Initial commit

This commit is contained in:
OpenCode Test
2025-12-24 10:50:10 -08:00
commit e1a64aa092
70 changed files with 5827 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
# `.agents/` — Subagent Briefs
This directory contains one Markdown brief per subagent referenced by `PLAN.md`. Each brief defines:
- the **assigned LLM model** to use,
- the subagents **mission**,
- responsibilities and guardrails,
- expected **deliverables** and **definition of done**.
These files are intended to be used by a human or an orchestration tool that can spawn specialized agents per task.
---
## Agent roster
| Agent | Model | File |
|---|---|---|
| `orchestrator` | `github-copilot/gpt-5.2` | `./.agents/orchestrator.md` |
| `backend-api` | `github-copilot/claude-sonnet-4.5` | `./.agents/backend-api.md` |
| `worker-media` | `github-copilot/claude-sonnet-4.5` | `./.agents/worker-media.md` |
| `frontend-ui` | `github-copilot/gpt-5.2` | `./.agents/frontend-ui.md` |
| `k8s-infra` | `github-copilot/claude-sonnet-4.5` | `./.agents/k8s-infra.md` |
| `qa-review` | `github-copilot/claude-haiku-4.5` | `./.agents/qa-review.md` |
---
## How to use
### 1) Pick tasks from `PLAN.md`
In `PLAN.md`, use the **Task breakdown (MVP)** section as the source of truth for what needs to be done.
### 2) Assign each task to the appropriate agent
- API/DB/presigning routes → `backend-api`
- Media processing and derived generation → `worker-media`
- UI tree + responsive layout → `frontend-ui`
- Helm/manifests/affinity/Tailscale ingress → `k8s-infra`
- Test plan + edge cases review → `qa-review`
- Cross-team coordination, contract alignment → `orchestrator`
### 3) Run agents in parallel where safe
Good parallel splits:
- `backend-api` and `worker-media` can implement in parallel if they agree on DB/job payloads.
- `frontend-ui` can proceed in parallel once API response shapes are agreed.
- `k8s-infra` can proceed in parallel once container ports/env vars are known.
### 4) Maintain contracts and avoid drift
Before an agent starts, the `orchestrator` should confirm:
- DB schema fields and enums
- MinIO key conventions
- API request/response shapes
- Worker job payloads and retry/idempotency rules
### 5) Update `PLAN.md` when decisions change
If any “Locked” decision changes (endpoints, prefixes, storage policy), update `PLAN.md` first, then propagate to agents.
---
## Conventions
- **Never delete/mutate** external archive objects under `originals/`.
- Presigned URLs returned to browsers must use **tailnet HTTPS** on `minio.<tailnet-fqdn>` to avoid mixed-content blocking.
- Prefer Pi-friendly defaults: low worker concurrency, avoid scheduling heavy pods on Pi 3.
+50
View File
@@ -0,0 +1,50 @@
# Agent: backend-api
**Model:** `github-copilot/claude-sonnet-4.5`
## Mission
Implement the web/API layer: ingestion endpoints, timeline aggregation endpoints, presigned URL generation, and DB schema/migrations.
## Primary Responsibilities
- Database schema:
- Implement `assets` and `imports` tables per `PLAN.md`.
- Add indexes and constraints.
- API endpoints (Next.js API routes or a dedicated Node API):
- Imports: create import, upload, scan-minio, status.
- Timeline aggregation: `GET /api/tree`.
- Asset listing: `GET /api/assets`.
- Presigned URLs: `GET /api/assets/:id/url?...`.
- MinIO client strategy:
- Internal endpoint: `http://minio:9000` for server-side operations.
- Public signing endpoint: `https://minio.<tailnet-fqdn>` for presigned URLs.
- Use **path-style** URLs.
- Enforce safety rules:
- Allowlisted scan prefix: `originals/` only.
- Never delete/mutate external originals.
## Inputs
- `PLAN.md` (schemas, API list, invariants)
- Env vars:
- `DATABASE_URL`, `REDIS_URL`
- `MINIO_INTERNAL_ENDPOINT`, `MINIO_PUBLIC_ENDPOINT_TS`
- MinIO credentials and bucket name
## Outputs / Deliverables
- Migrations and DB access layer.
- API route implementations with validated inputs.
- OpenAPI-like inline docs (lightweight) or route docs in code.
## Implementation Notes
- Be strict about request validation (zod or equivalent).
- Keep request handlers streaming-friendly for uploads.
- Ensure presigned URLs are generated for the tailnet endpoint to avoid mixed-content blocks.
## Error Handling Requirements
- Failed ingestion/scan should fail gracefully with actionable messages.
- Timeline endpoints must not break if some assets are `failed`.
## Definition of Done
- Manual smoke tests cover upload → process → timeline.
- Presigned URLs load media from `https://minio.<tailnet-fqdn>`.
- Pagination works for `GET /api/assets`.
- Aggregations return correct counts for year/month/day.
+49
View File
@@ -0,0 +1,49 @@
# Agent: frontend-ui
**Model:** `github-copilot/gpt-5.2`
## Mission
Build the dynamic, responsive, mobile-friendly UI: interactive timeline tree, browsing panels, viewer modal, and polished styling.
## Primary Responsibilities
- Timeline tree visualization:
- Year → Month → Day nodes.
- Expand/collapse.
- Orientation toggle (vertical/horizontal).
- Zoom/pan (touch + mouse).
- Responsive layout:
- Desktop: right-side inspector panel.
- Mobile: bottom sheet.
- Asset gallery:
- Virtualized list/grid for thumbnails.
- Placeholder tiles for failed assets.
- Media viewer:
- Images: display from presigned URLs.
- Videos: HTML5 `<video>` from presigned URL; show poster and error message when unsupported.
- Admin UI:
- Upload modal/page.
- Scan trigger and import progress display.
## Inputs
- API contract from backend (`/api/tree`, `/api/assets`, `/api/assets/:id/url`).
- Design tokens/colors and any brand guidelines (if provided).
## Outputs / Deliverables
- Page routes (`/`, `/admin`).
- Timeline tree component + supporting hooks.
- Responsive panels and viewer modal.
## UX Requirements
- Must work well on mobile browsers.
- Must not crash on partial failures.
- Provide loading skeletons for timeline and thumbnails.
## Performance Requirements
- Virtualize thumbnail panel.
- Memoize tree layout/render as much as practical.
## Definition of Done
- Timeline renders, expands/collapses, and flips orientation with stable state.
- Touch pan/zoom works.
- Viewer works for images and for playable videos.
- Failed/unsupported items render as placeholders without breaking flows.
+50
View File
@@ -0,0 +1,50 @@
# Agent: k8s-infra
**Model:** `github-copilot/claude-sonnet-4.5`
## Mission
Define and implement Kubernetes deployment artifacts for a Pi-based cluster with Longhorn, in-cluster MinIO, Redis/Postgres, and Tailscale ingress exposure.
## Primary Responsibilities
- Author Helm chart (preferred) or Kustomize manifests for:
- `web` Deployment + Service
- `worker` Deployment
- `redis` Deployment
- `postgres` StatefulSet + PVC (Longhorn)
- `minio` StatefulSet + PVC (Longhorn) in single-node mode
- CronJobs (at least `cleanup-staging`)
- Scheduling constraints:
- Pin heavy workloads to Pi 5 nodes using labels/affinity.
- Keep Pi 3 node unused for this app.
- Tailscale ingress resources:
- `app.<tailnet-fqdn>`
- `minio.<tailnet-fqdn>`
- `minio-console.<tailnet-fqdn>`
- Nginx ingress (optional LAN): provide values but keep tailnet as primary.
## Inputs
- Cluster facts:
- 2× Pi 5 8GB, 1× Pi 3 1GB
- Longhorn for PVC
- Insecure HTTP in-cluster registry
- Tailscale operator already deployed
- Service ports:
- MinIO S3: 9000
- MinIO console: 9001
## Outputs / Deliverables
- Deployable artifacts:
- `helm/` chart or `kustomize/` overlays
- values/examples for tailnet FQDN configuration
- Resource presets (requests/limits) sized for Pi hardware.
## Operational Requirements
- Ensure MinIO is reachable from tailnet clients for presigned URLs.
- Preserve Range requests for video playback.
- Provide env var plumbing for internal vs public MinIO endpoints.
## Definition of Done
- `helm install` (or equivalent) brings up all services on Pi 5 nodes.
- App and MinIO endpoints reachable via tailnet.
- PVCs created via Longhorn.
- CronJob cleanup runs and is safe (staging-only).
+47
View File
@@ -0,0 +1,47 @@
# Agent: orchestrator
**Model:** `github-copilot/gpt-5.2`
## Mission
Own planning, integration, and handoffs across all workstreams. Keep the system coherent: interfaces, acceptance criteria, sequencing, and risk management.
## Primary Responsibilities
- Maintain the canonical backlog derived from `PLAN.md`.
- Define and validate contracts between components:
- API endpoints and payloads.
- DB schema entities and invariants.
- MinIO object key conventions.
- Worker job payloads and retry/idempotency rules.
- Ensure Pi-cluster constraints are respected (no heavy workloads on Pi 3).
- Track and clearly separate MVP scope vs future features.
## Inputs
- `PLAN.md`
- Updated decisions from stakeholders.
- PRDs / diagrams / sample media sets (if provided).
## Outputs / Deliverables
- A prioritized task list with acceptance criteria per task.
- Interface specs (API/DB/job schemas) signed off by owners.
- Integration checklist and release checklist.
- Decisions log (if needed) capturing changes to locked assumptions.
## Coordination Protocol
- Before implementation begins, request each subagent to confirm:
- inputs required,
- assumptions,
- outputs, and
- test strategy.
- After each subagent completes a milestone, verify:
- API/DB/job contracts are consistent,
- any breaking changes are propagated.
## Guardrails
- Prefer minimal moving parts in MVP.
- Enforce external-archive policy: never delete/mutate `originals/`.
- Presigned URLs must be compatible with tailnet HTTPS (no mixed content).
## Definition of Done (per milestone)
- The milestones acceptance criteria are met.
- Interfaces are documented and consumed correctly by other components.
- Cross-cutting concerns checked: error handling, resource limits, and resiliency.
+41
View File
@@ -0,0 +1,41 @@
# Agent: qa-review
**Model:** `github-copilot/claude-haiku-4.5`
## Mission
Provide targeted QA, edge-case validation, and lightweight security/performance review to ensure the MVP is robust and doesnt regress.
## Primary Responsibilities
- Create a test plan focused on:
- Missing EXIF for photos.
- Conflicting/odd timestamps and timezone edge cases.
- Corrupt files.
- Unsupported video codecs/containers.
- Large file uploads and slow networks.
- Validate the “never break UI” principle:
- Failed assets render as placeholders.
- Timeline aggregation remains stable.
- Networking checks:
- Presigned URLs are HTTPS and use `minio.<tailnet-fqdn>`.
- Video seeking works (Range support through exposure path).
- Pi cluster constraints:
- Ensure worker concurrency and limits dont OOM.
## Inputs
- `PLAN.md`
- API contracts and any sample media set.
- k8s deployment notes (affinity/resources)
## Outputs / Deliverables
- A concise checklist of scenarios to validate.
- A list of high-risk areas with mitigation suggestions.
- Optional minimal automated tests if the repository has a test harness.
## Guardrails
- Focus on MVP-critical risks; avoid broad refactors.
- Flag any unsafe operations that might delete external originals.
## Definition of Done
- A prioritized QA checklist exists.
- Known edge cases have expected behavior documented.
- No blocker issues remain unaddressed.
+48
View File
@@ -0,0 +1,48 @@
# Agent: worker-media
**Model:** `github-copilot/claude-sonnet-4.5`
## Mission
Implement the media processing worker: scanning, metadata extraction, thumbnail/poster generation, canonical copy logic, and safe CronJobs.
## Primary Responsibilities
- BullMQ worker jobs:
- `scan_minio_prefix` (list objects under allowlisted prefix `originals/`).
- `process_asset` (extract metadata + generate derived assets).
- `copy_to_canonical` (copy-only into date-based canonical layout).
- Metadata extraction policy:
- Photos: EXIF DateTimeOriginal-first.
- Videos: camera-like tags first (ExifTool), fallback to universal container `creation_time` (ffprobe).
- Derived assets:
- Images: `thumbs/{assetId}/image_256.jpg` and `image_768.jpg` using `sharp`.
- Videos: `thumbs/{assetId}/poster_256.jpg` using ffmpeg frame extraction.
- Robustness:
- Never crash the worker loop due to a single bad file.
- Write `assets.status=failed` + `error_message` on failures.
- Resource constraints:
- Keep concurrency low (12) for Raspberry Pi.
## Inputs
- `PLAN.md` (job semantics, key layout)
- MinIO credentials + endpoints
- DB access + Redis queue
- Docker image must include required tools:
- ExifTool
- ffprobe/ffmpeg
## Outputs / Deliverables
- Worker runnable in k8s.
- Repeatable job behavior with idempotency considerations.
- Derived outputs in MinIO, referenced from DB.
## Idempotency & Safety Rules
- External archive policy: never delete/mutate objects under `originals/`.
- `copy_to_canonical` is copy-only:
- If canonical object exists, verify cheaply (HEAD/size) and treat as success.
- Staging cleanup CronJob may safely delete old `staging/` objects.
## Definition of Done
- Mixed media set (images + videos) processed end-to-end.
- Poster + thumbs appear in MinIO for ready assets.
- Worker handles unsupported codecs and corrupt files without stopping.
- Copies to canonical work for uploads, and can be enabled later for scans.
+6
View File
@@ -0,0 +1,6 @@
node_modules
**/node_modules
**/.next
**/dist
**/*.log
.git
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.next/
dist/
.DS_Store
.env
.env.*
+450
View File
@@ -0,0 +1,450 @@
# Timeline Media Library — Implementation Plan
This document defines the conception and execution plan for a **web app** that ingests photos/videos, extracts metadata (capture date), and displays media on a **visual timeline tree** that supports **vertical and horizontal** orientations. The app runs in a **Kubernetes** cluster (Raspberry Pi heterogenous nodes) with **Longhorn** PVCs, **in-cluster MinIO** for storage, and **Tailscale Ingress** for private HTTPS access.
This plan is written to be executed by multiple subagents (parallelizable workstreams) each using a **specific LLM model**.
---
## Goals (MVP)
- Index media (photos + videos) and extract capture date metadata.
- Render an interactive **timeline tree** (Year → Month → Day) with:
- Orientation toggle: **vertical/horizontal**.
- Zoom/pan (touch + mouse).
- Expand/collapse nodes.
- Provide a **mobile-friendly** UI with a bottom sheet details panel.
- Support **videos** (play original if supported; show poster + fallback message otherwise).
- Ingest sources:
- **Admin upload** (cross-browser; no folder APIs required).
- **Server-side scan** of MinIO prefix **`originals/`** (external archive).
- Use **presigned URLs** for media delivery directly from MinIO.
- Be resilient: broken/unsupported media should show placeholders and never break the app.
---
## Non-goals (MVP)
- Authentication/authorization (rely on tailnet perimeter for now).
- Location/map features.
- User edits (fix dates, tagging, etc.).
- Video transcoding (planned as future CronJob).
- Deduplication.
---
## Key Decisions (Locked)
### App identity
- App name: `porthole`
- Set the app name via environment variable: `APP_NAME=porthole`.
- Use `APP_NAME` everywhere (web + worker) via the shared config module so renaming is global.
- If the UI needs to display the name in the browser, also provide `NEXT_PUBLIC_APP_NAME` (either set explicitly or derived at build time from `APP_NAME`).
### Networking
- Tailnet clients access the app via **Tailscale Ingress HTTPS termination**.
- MinIO is reachable **over tailnet** via a dedicated FQDN:
- `https://minio.<tailnet-fqdn>` (S3 API)
- `https://minio-console.<tailnet-fqdn>` (MinIO console)
- App is reachable over tailnet:
- `https://app.<tailnet-fqdn>`
- Optional LAN ingress exists using `nip.io` and nginx ingress, but tailnet clients use Tailscale hostnames.
### Storage model
- **MinIO is the source of truth**.
- External archive objects under **`originals/`** are treated as **immutable**:
- The app **indexes in place**.
- The app **must never delete/mutate** external originals.
- Canonical managed library is **copy-only**, pure date layout:
- `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`
- Uploads are processed then stored in canonical by default.
### Presigned URL strategy
- Use **path-style presigned URLs** signed against:
- `MINIO_PUBLIC_ENDPOINT_TS=https://minio.<tailnet-fqdn>`
- Using HTTPS for MinIO on tailnet avoids mixed-content block when the app is served via HTTPS.
### Kubernetes constraints
- Cluster nodes: **2× Raspberry Pi 5 (8GB)** + **1× Raspberry Pi 3 B+ (1GB)**.
- Heavy pods must be pinned to Pi 5 nodes.
- Multi-arch images required (arm64 + amd64), built on a laptop and pushed to an in-cluster **insecure HTTP registry**.
### Metadata extraction
- **Photos**: camera-like EXIF first (`DateTimeOriginal`), then fallbacks.
- **Videos**: camera-like tags first (ExifTool QuickTime/vendor tags), fallback to universal container `creation_time`.
### Derived media
- Image thumbs: `image_256.jpg` and `image_768.jpg`.
- Video posters: only `poster_256.jpg` initially (CPU-friendly).
---
## Architecture
### Components
- **Web**: Next.js (UI + API)
- **Worker**: Node worker using BullMQ
- **Queue**: Redis
- **DB**: Postgres
- **Object store**: MinIO (in-cluster, single-node)
### Data flow
1. Ingestion (upload or scan) creates/updates DB asset records.
2. Worker extracts metadata and generates thumbs/posters.
3. UI queries aggregated timeline nodes and displays a tree.
4. UI fetches presigned URLs for rendering and playback.
---
## MinIO Object Layout (Single Bucket)
Example bucket: `media`.
- External archive (indexed in place):
- `originals/**`
- Upload staging (temporary):
- `staging/{importId}/{uuid}.{ext}`
- Canonical (copy only):
- `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`
- Derived thumbnails/posters:
- `thumbs/{assetId}/image_256.jpg`
- `thumbs/{assetId}/image_768.jpg`
- `thumbs/{assetId}/poster_256.jpg`
- Future derived video transcodes:
- `derived/video/{assetId}/...`
---
## Database Model (MVP)
### Table: `assets`
- `id` (UUID)
- `bucket` (text)
- `media_type` (`image` | `video`)
- `mime_type` (text)
- Keys:
- `source_key` (text, immutable)
- `active_key` (text)
- `canonical_key` (text, nullable)
- Time:
- `capture_ts_utc` (timestamptz)
- `capture_offset_minutes` (int, nullable)
- `date_confidence` (`camera` | `container` | `object_mtime` | `import_time`)
- Media fields:
- `width` (int, nullable)
- `height` (int, nullable)
- `rotation` (int, nullable)
- `duration_seconds` (int, nullable)
- Derived:
- `thumb_small_key` (text, nullable)
- `thumb_med_key` (text, nullable)
- `poster_key` (text, nullable)
- Processing:
- `status` (`new` | `processing` | `ready` | `failed`)
- `error_message` (text, nullable)
- `raw_tags_json` (jsonb, optional but recommended for debugging)
Indexes:
- `capture_ts_utc`, `status`, `media_type`
### Table: `imports`
- `id` (UUID)
- `type` (`upload` | `minio_scan` | `normalize_copy`)
- `status`
- `created_at`
- Optional counters for progress reporting.
---
## Worker Jobs (BullMQ)
### `scan_minio_prefix(importId, bucket, prefix)`
- Guardrails: only allow prefixes from allowlist, starting with `originals/`.
- Lists objects; upserts `assets` by `source_key`.
- Enqueues `process_asset(assetId)`.
### `process_asset(assetId)`
- Downloads object (stream or temp file).
- Extracts metadata:
- Photos: ExifTool EXIF chain.
- Videos: ExifTool first; ffprobe fallback for `creation_time` and technical metadata.
- Derived generation:
- Images: `sharp``image_256.jpg`, `image_768.jpg`.
- Videos: `ffmpeg` screenshot → `poster_256.jpg`.
- Updates DB status.
- Never throws errors that would crash the worker loop; failures are captured on the asset row.
### `copy_to_canonical(assetId)`
- Computes canonical key: `canonical/originals/YYYY/MM/DD/{assetId}.{origExt}`.
- Copy-only; never deletes `source_key` for external archive.
- Updates `canonical_key` and flips `active_key`.
---
## API (MVP)
### Admin ingestion
- `POST /api/imports` → create import batch
- `POST /api/imports/:id/upload` → upload media to `staging/` and enqueue processing
- `POST /api/imports/:id/scan-minio` → enqueue scan of allowlisted prefix
- `GET /api/imports/:id/status` → progress
### Timeline and browsing
- `GET /api/tree`
- params: `start`, `end`, `granularity=year|month|day`, filters: `mediaType`
- returns nodes with counts and sample thumbs
- `GET /api/assets`
- params: date-range + pagination + filters
- `GET /api/assets/:id/url?variant=original|thumb_small|thumb_med|poster`
- returns presigned URL pointing at `https://minio.<tailnet-fqdn>`
---
## Frontend UX/UI (MVP)
### Pages
- `/` Timeline tree
- `/admin` Admin tools (upload, scan, import status)
### Timeline tree
- SVG tree rendering with:
- Vertical/horizontal orientation toggle.
- Zoom/pan (touch supported).
- Expand/collapse nodes.
- Detail panel:
- Desktop: right-side panel.
- Mobile: bottom sheet.
- Virtualized thumbnail list.
### Viewer
- Image viewer modal.
- Video playback via HTML5 `<video>` on the presigned URL.
- If a video cant be played (codec/container): show poster + message.
### Resilience
- Any media with `status=failed` renders as a placeholder tile and does not break aggregation or layout.
---
## Kubernetes Deployment Plan (Pi-aware)
### Scheduling
- Label nodes:
- Pi 5 nodes: `node-class=compute`
- Pi 3 node: `node-class=tiny`
- Pin pods to Pi 5:
- `web`, `worker`, `minio`, `postgres`, `redis`
### Workloads
- `StatefulSet/minio` (single-node) + Longhorn PVC
- `StatefulSet/postgres` + Longhorn PVC
- `Deployment/redis`
- `Deployment/web`
- `Deployment/worker` (BullMQ concurrency 1)
- `CronJob/cleanup-staging` (optional; disabled by default)
### Exposure
- Tailscale Ingress (HTTPS termination):
- `app.<tailnet-fqdn>` → web service
- `minio.<tailnet-fqdn>` → MinIO S3 (9000)
- `minio-console.<tailnet-fqdn>` → MinIO console (9001)
- Optional LAN nginx ingress + MetalLB for `nip.io` hostnames.
### Ingress notes
- For uploads and media streaming, configure timeouts and body size to support “large but not gigantic” media.
- Ensure Range requests work for video playback.
---
## Build & Release (Multi-arch)
### Package manager
- Use **Bun** for installs and scripts (`bun install`, `bun run ...`).
- Avoid `npm`/`pnpm` in CI and docs unless required for a specific tool.
### Container build
- Build on laptop using Docker Buildx.
- Push `linux/arm64` and `linux/amd64` images to local in-cluster registry over **insecure HTTP**.
- Use Debian-slim Node base images for better ARM64 compatibility with `sharp` + ffmpeg.
---
## Execution Plan (Tasks + Subagents)
This plan is intended to be executed in parallel by multiple subagents. Each subagent uses a specific LLM model and follows its brief in `./.agents/`.
### Git commits (required during development)
- Treat each numbered task below as a **development phase**.
- At the end of each phase (or a meaningful sub-slice), create a **small, scoped git commit** that cleanly compiles/tests for that phase.
- Prefer **one commit per phase** when feasible; use multiple commits if a phase is large, but keep each commit independently reviewable.
- Commit messages should reference the phase, e.g. `task-4: worker pipeline metadata + thumbs`.
- Never commit secrets (credentials, tailnet hostnames, access keys); use `.env.example` / k8s `Secret` manifests or placeholders.
### Progress tracking (source of truth)
- `PLAN.md` is the **single source of truth** for project status.
- Keep the table below updated in every PR/merge/phase-end commit that changes scope or completes work.
- Exactly one task should be marked `in_progress` at a time.
| Task | Status | Notes |
|---|---|---|
| 1 — Repository scaffolding | completed | Bun workspace + shared config scaffold |
| 2 — Database schema + migrations | completed | assets/imports schema + migration runner |
| 3 — MinIO client + presigned URL strategy | completed | @tline/minio + presigned URL API route |
| 4 — Worker pipeline (process images/videos) | completed | process_asset + scan_minio_prefix implemented |
| 5 — Ingestion endpoints (upload + scan) | completed | imports create/upload/scan/status APIs |
| 6 — Canonical copy logic (uploads default) | completed | copy_to_canonical worker job + enqueue on uploads |
| 7 — Timeline aggregation API | completed | /api/tree implemented |
| 8 — Timeline tree frontend | completed | basic SVG tree + orientation toggle |
| 9 — Media panel + viewer | completed | day selection, asset list, preview + viewer |
| 10 — k8s deployment (Pi-aware) | completed | Helm chart + Tailscale ingress |
| 11 — QA + hardening | in_progress | Dockerfiles + MinIO Tailscale services added; pending deploy + end-to-end verification (Range, codec failures) |
- Entry point: `./.agents/README.md`
- Agent briefs:
- `./.agents/orchestrator.md`
- `./.agents/backend-api.md`
- `./.agents/worker-media.md`
- `./.agents/frontend-ui.md`
- `./.agents/k8s-infra.md`
- `./.agents/qa-review.md`
### Subagents and assigned model
| Subagent | Responsibility | LLM Model |
|---|---|---|
| `orchestrator` | backlog coordination, interfaces, acceptance criteria | `github-copilot/gpt-5.2` |
| `backend-api` | Next.js API routes, DB schema/migrations, presigned URL logic | `github-copilot/claude-sonnet-4.5` |
| `worker-media` | BullMQ worker, ExifTool/ffprobe/ffmpeg integration, thumbs/posters | `github-copilot/claude-sonnet-4.5` |
| `frontend-ui` | timeline tree rendering, responsive layout, virtualization, styling | `github-copilot/gpt-5.2` |
| `k8s-infra` | Helm/Kustomize, node affinity, MinIO/Postgres/Redis manifests, Tailscale ingress | `github-copilot/claude-sonnet-4.5` |
| `qa-review` | test plan, edge cases, security review, performance checks | `github-copilot/claude-haiku-4.5` |
> Note: the model names above are intentionally explicit. If your environment exposes different model IDs, replace them consistently.
### Task breakdown (MVP)
#### Task 1 — Repository scaffolding
- Define folder structure (apps/web, apps/worker, helm/).
- Add shared `config` module (env validation).
Owner: `orchestrator` (brief: `./.agents/orchestrator.md`, model: `github-copilot/gpt-5.2`)
#### Task 2 — Database schema + migrations
- Implement `assets`/`imports` schema.
- Add indexes.
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 3 — MinIO client + presigned URL strategy
- Implement internal client for cluster operations.
- Implement public-signing client for tailnet endpoint.
- Enforce path-style URLs.
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 4 — Worker pipeline (process images/videos)
- ExifTool extraction (photos + camera-like video fields).
- ffprobe technical metadata; fallback `creation_time`.
- `sharp` thumbs for images.
- ffmpeg poster extraction for videos.
- Robust failure handling updates DB.
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 5 — Ingestion endpoints (upload + scan)
- Admin upload endpoint: stream to `staging/`.
- Scan endpoint: enqueue `scan_minio_prefix` only for allowlisted prefix `originals/`.
- Import status endpoint.
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 6 — Canonical copy logic (uploads default)
- For uploads, copy to canonical date key, flip `active_key`.
- For scans, optional manual/cron copy.
Owner: `worker-media` (brief: `./.agents/worker-media.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 7 — Timeline aggregation API
- `GET /api/tree` for year/month/day rolling up counts.
- Select sample thumbs per node.
Owner: `backend-api` (brief: `./.agents/backend-api.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 8 — Timeline tree frontend
- Interactive tree with orientation toggle.
- Touch zoom/pan.
- Expand/collapse.
Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/gpt-5.2`)
#### Task 9 — Media panel + viewer
- Virtualized thumbnail list.
- Viewer modal for images.
- Video playback with poster fallback.
- Placeholder tiles for `failed` assets.
Owner: `frontend-ui` (brief: `./.agents/frontend-ui.md`, model: `github-copilot/gpt-5.2`)
#### Task 10 — k8s deployment (Pi-aware)
- Helm chart or Kustomize.
- Node affinity to Pi 5 nodes.
- Longhorn PVCs.
- Tailscale ingress resources for app/minio/console.
- CronJob cleanup staging.
Owner: `k8s-infra` (brief: `./.agents/k8s-infra.md`, model: `github-copilot/claude-sonnet-4.5`)
#### Task 11 — QA + hardening
- Edge case tests: missing EXIF, odd timezones, unsupported video codecs.
- Validate Range playback through ingress.
- Verify no UI crash on failed assets.
Owner: `qa-review` (brief: `./.agents/qa-review.md`, model: `github-copilot/claude-haiku-4.5`)
---
## Future Features (Tracked)
### Security / Access
- Authentication and authorization.
- Lightweight admin protection (shared secret header) before full auth.
### Media
- Video transcoding CronJob (H.264 MP4 and/or HLS) and “prefer derived” playback.
- Multiple poster/thumb sizes.
- Better codec support via transcode profiles.
### Organization
- User-defined albums and tags.
- Progressive enhancement for folder upload where supported.
- Bucket separation (`media` vs `derived`) or lifecycle policies.
### Metadata
- Location: GPS extraction + reverse geocoding + map UI.
- Metadata edits/overrides (fix dates, correct capture time), audit log.
### Performance / Scale
- Deduplication by hash.
- Smarter clustering (“moments”) within a day.
### Networking
- Routed LAN for tailnet clients (subnet router) and endpoint selection for presigned URLs.
### Delivery
- Move multi-arch builds from laptop to CI.
---
## Acceptance Criteria Summary
- Can scan `originals/` and show a populated timeline.
- Can upload media on mobile; it appears under canonical date layout.
- Timeline tree renders and remains responsive on mobile.
- Broken media shows placeholders; the app never crashes.
- Video playback works when codec supported; poster shown otherwise.
- Presigned URLs work over tailnet HTTPS against `minio.<tailnet-fqdn>`.
- k8s scheduling pins heavy workloads to Pi 5 nodes.
+221
View File
@@ -0,0 +1,221 @@
# porthole
Porthole: timeline media library (Next.js web + worker), backed by Postgres/Redis/MinIO.
## How to try it
- Create a values file (example minimal):
- set `secrets.postgres.password`
- set `secrets.minio.accessKeyId` + `secrets.minio.secretAccessKey`
- set `images.web.repository/tag` and `images.worker.repository/tag`
- set `global.tailscale.tailnetFQDN` (recommended), or set `app.minio.publicEndpointTs` (must be `https://minio.<tailnet-fqdn>`)
- Render locally: `helm template porthole helm/tline -f your-values.yaml --namespace porthole`
- Install (to `porthole` namespace): `helm upgrade --install porthole helm/tline -f your-values.yaml --namespace porthole`
## ArgoCD
A ready-to-apply ArgoCD `Application` manifest is included at `argocd/tline-application.yaml` (it deploys the Helm release name `porthole`).
Reference example (deploys into the `porthole` namespace; the Helm chart itself does not hardcode a namespace):
```yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: porthole
namespace: argocd
spec:
project: default
source:
repoURL: git@gitea-gitea-ssh.taildb3494.ts.net:will/porthole.git
targetRevision: main
path: helm/tline
helm:
releaseName: porthole
valueFiles:
- values.yaml
# - values-porthole.yaml
# Alternative to valueFiles: set a few values inline.
# parameters:
# - name: global.tailscale.tailnetFQDN
# value: tailxyz.ts.net
# - name: images.web.repository
# value: registry.lan:5000/tline-web
# - name: images.web.tag
# value: dev
destination:
server: https://kubernetes.default.svc
namespace: porthole
# Optional: if you use image pull secrets, you can set them via values files
# or inline Helm parameters.
# source:
# helm:
# parameters:
# - name: imagePullSecrets[0]
# value: my-registry-secret
# - name: registrySecret.create
# value: "true"
# - name: registrySecret.server
# value: registry.lan:5000
# - name: registrySecret.username
# value: your-user
# - name: registrySecret.password
# value: your-pass
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
- ApplyOutOfSyncOnly=true
```
## Notes
- MinIO bucket creation: the app does not auto-create buckets. You can either create the bucket yourself, or enable the Helm hook job:
- `jobs.ensureBucket.enabled=true`
- Staging cleanup: disabled by default; enable with:
- `cronjobs.cleanupStaging.enabled=true`
## Build + push images (multi-arch)
This repo is a Bun monorepo, but container builds use Docker Buildx.
- Assumptions:
- You have an **in-cluster registry** reachable over **insecure HTTP** (example: `registry.lan:5000`).
- Your Docker daemon is configured to allow that registry as an insecure registry.
- Create/use a buildx builder (one-time):
- `docker buildx create --name tline --use`
- Build + push **web** (Next standalone):
- `REGISTRY=registry.lan:5000 TAG=dev`
- `docker buildx build --platform linux/amd64,linux/arm64 -f apps/web/Dockerfile -t "$REGISTRY/tline-web:$TAG" --push .`
- Notes:
- The Dockerfile uses `bun install --frozen-lockfile` and copies all workspace `package.json` files first to keep Bun from mutating `bun.lock`.
- Runtime entrypoint comes from Next standalone output (the image runs `node app/apps/web/server.js`).
- Build + push **worker** (includes `ffmpeg` + `exiftool`):
- `REGISTRY=registry.lan:5000 TAG=dev`
- `docker buildx build --platform linux/amd64,linux/arm64 -f apps/worker/Dockerfile -t "$REGISTRY/tline-worker:$TAG" --push .`
- Notes:
- The Dockerfile uses `bun install --frozen-lockfile --production` and also copies all workspace `package.json` files first for stable `workspace:*` resolution.
- Then set Helm values:
- `images.web.repository: registry.lan:5000/tline-web`
- `images.web.tag: dev`
- `images.worker.repository: registry.lan:5000/tline-worker`
- `images.worker.tag: dev`
### Private registry auth (optional)
If your registry requires auth, you can either:
- reference an existing Secret via `imagePullSecrets`, or
- have the chart create a `kubernetes.io/dockerconfigjson` Secret via `registrySecret`.
Example values:
```yaml
# Option A: reference an existing secret
imagePullSecrets:
- my-registry-secret
# Option B: create a secret from values (stores creds in values)
registrySecret:
create: true
server: "registry.lan:5000"
username: "your-user"
password: "your-pass"
email: "you@example.com"
```
## MinIO exposure (Tailscale)
MinIO S3 URLs must be signed against `https://minio.<tailnet-fqdn>`.
You can expose MinIO over tailnet either via:
- **Tailscale Ingress** (default), or
- **Tailscale LoadBalancer Service** (often more reliable for streaming/Range)
Example values (LoadBalancer for S3 + console):
```yaml
global:
tailscale:
tailnetFQDN: "tailxyz.ts.net"
minio:
tailscaleServiceS3:
enabled: true
hostnameLabel: minio
tailscaleServiceConsole:
enabled: true
hostnameLabel: minio-console
# Optional: if you prefer explicit override instead of deriving from tailnetFQDN
# app:
# minio:
# publicEndpointTs: "https://minio.tailxyz.ts.net"
```
## Example values (Pi cluster)
This chart assumes you label nodes like:
- Pi 5 nodes: `node-class=compute`
- Pi 3 node: `node-class=tiny`
The default scheduling in `helm/tline/values.yaml` pins heavy pods to `node-class=compute`.
Example `values.yaml` you can start from:
```yaml
secrets:
postgres:
password: "change-me"
minio:
accessKeyId: "minioadmin"
secretAccessKey: "minioadmin"
images:
web:
repository: registry.lan:5000/tline-web
tag: dev
worker:
repository: registry.lan:5000/tline-worker
tag: dev
global:
tailscale:
tailnetFQDN: "tailxyz.ts.net"
# Optional, but common for Pi clusters (Longhorn default shown as example)
# global:
# storageClass: longhorn
minio:
# Prefer LB Services for streaming/Range reliability
tailscaleServiceS3:
enabled: true
hostnameLabel: minio
tailscaleServiceConsole:
enabled: true
hostnameLabel: minio-console
jobs:
ensureBucket:
enabled: true
# Optional staging cleanup (never touches originals/**)
# cronjobs:
# cleanupStaging:
# enabled: true
# olderThanDays: 7
```
## Quick checks
- Range support through ingress (expect `206`):
- `curl -sS -D- -H 'Range: bytes=0-1023' "$(curl -sS https://app.<tailnet-fqdn>/api/assets/<assetId>/url?variant=original | jq -r .url)" -o /dev/null`
+38
View File
@@ -0,0 +1,38 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1.3.3 AS deps
WORKDIR /app
# Workspace manifests (copy all workspace package.json files so Bun
# can resolve workspace:* deps without mutating the lockfile).
COPY package.json bun.lock tsconfig.base.json ./
COPY apps/web/package.json ./apps/web/package.json
COPY apps/worker/package.json ./apps/worker/package.json
COPY packages/config/package.json ./packages/config/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/minio/package.json ./packages/minio/package.json
COPY packages/queue/package.json ./packages/queue/package.json
RUN bun install --frozen-lockfile --ignore-scripts
FROM deps AS builder
WORKDIR /app
COPY apps/web ./apps/web
COPY packages ./packages
# Build Next standalone output
RUN bun run --cwd apps/web build
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
# Next standalone output contains its own node_modules tree.
# With outputFileTracingRoot set, standalone output nests under the basename
# of that tracing root ("app" inside this Docker build).
COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./app/apps/web/.next/static
EXPOSE 3000
CMD ["node", "app/apps/web/server.js"]
+8
View File
@@ -0,0 +1,8 @@
export default function AdminPage() {
return (
<main style={{ padding: 16 }}>
<h1 style={{ marginTop: 0 }}>Admin</h1>
<p>Upload + scan tools will live here.</p>
</main>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { presignGetObjectUrl } from "@tline/minio";
export const runtime = "nodejs";
const paramsSchema = z.object({
id: z.string().uuid()
});
const variantSchema = z.enum(["original", "thumb_small", "thumb_med", "poster"]);
export async function GET(
request: Request,
context: { params: Promise<{ id: string }> }
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const params = paramsParsed.data;
const url = new URL(request.url);
const variantParsed = variantSchema.safeParse(url.searchParams.get("variant") ?? "original");
if (!variantParsed.success) {
return Response.json(
{ error: "invalid_query", issues: variantParsed.error.issues },
{ status: 400 },
);
}
const variant = variantParsed.data;
const db = getDb();
const rows = await db<
{
bucket: string;
active_key: string;
thumb_small_key: string | null;
thumb_med_key: string | null;
poster_key: string | null;
mime_type: string;
}[]
>`
select bucket, active_key, thumb_small_key, thumb_med_key, poster_key, mime_type
from assets
where id = ${params.id}
limit 1
`;
const asset = rows[0];
if (!asset) {
return Response.json({ error: "not_found" }, { status: 404 });
}
const key =
variant === "original"
? asset.active_key
: variant === "thumb_small"
? asset.thumb_small_key
: variant === "thumb_med"
? asset.thumb_med_key
: asset.poster_key;
if (!key) {
return Response.json(
{ error: "variant_not_available", variant },
{ status: 404 }
);
}
// Hint the browser; especially helpful for Range playback.
const responseContentType = variant === "original" ? asset.mime_type : "image/jpeg";
const responseContentDisposition =
variant === "original" && asset.mime_type.startsWith("video/") ? "inline" : undefined;
const signed = await presignGetObjectUrl({
bucket: asset.bucket,
key,
responseContentType,
responseContentDisposition,
});
return Response.json(signed, {
headers: {
"Cache-Control": "no-store"
}
});
}
+114
View File
@@ -0,0 +1,114 @@
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const querySchema = z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
mediaType: z.enum(["image", "video"]).optional(),
status: z.enum(["new", "processing", "ready", "failed"]).optional(),
limit: z.coerce.number().int().positive().max(200).default(60),
cursor: z.string().uuid().optional(),
cursorTs: z.string().datetime().optional(),
})
.strict();
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const parsed = querySchema.safeParse({
start: url.searchParams.get("start") ?? undefined,
end: url.searchParams.get("end") ?? undefined,
mediaType: url.searchParams.get("mediaType") ?? undefined,
status: url.searchParams.get("status") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
cursorTs: url.searchParams.get("cursorTs") ?? undefined,
});
if (!parsed.success) {
return Response.json(
{ error: "invalid_query", issues: parsed.error.issues },
{ status: 400 },
);
}
const query = parsed.data;
const start = query.start ? new Date(query.start) : null;
const end = query.end ? new Date(query.end) : null;
const db = getDb();
// Cursor pagination: (capture_ts_utc, id) > (cursorTs, cursor)
const cursorTs = query.cursorTs ? new Date(query.cursorTs) : null;
const cursorId = query.cursor ?? null;
const rows = await db<
{
id: string;
bucket: string;
media_type: "image" | "video";
mime_type: string;
active_key: string;
capture_ts_utc: string | null;
date_confidence: string | null;
width: number | null;
height: number | null;
rotation: number | null;
duration_seconds: number | null;
thumb_small_key: string | null;
thumb_med_key: string | null;
poster_key: string | null;
status: "new" | "processing" | "ready" | "failed";
error_message: string | null;
}[]
>`
select
id,
bucket,
media_type,
mime_type,
active_key,
capture_ts_utc,
date_confidence,
width,
height,
rotation,
duration_seconds,
thumb_small_key,
thumb_med_key,
poster_key,
status,
error_message
from assets
where true
and capture_ts_utc is not null
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
and (${query.status ?? null}::asset_status is null or status = ${query.status ?? null}::asset_status)
and (
${cursorId}::uuid is null
or ${cursorTs}::timestamptz is null
or (capture_ts_utc, id) > (${cursorTs}::timestamptz, ${cursorId}::uuid)
)
order by capture_ts_utc asc nulls last, id asc
limit ${query.limit}
`;
const nextCursor = rows.length > 0 ? rows[rows.length - 1] : null;
return Response.json({
start: start ? start.toISOString() : null,
end: end ? end.toISOString() : null,
items: rows,
next:
nextCursor && nextCursor.capture_ts_utc
? { cursor: nextCursor.id, cursorTs: nextCursor.capture_ts_utc }
: null,
});
}
+3
View File
@@ -0,0 +1,3 @@
export function GET() {
return Response.json({ ok: true });
}
@@ -0,0 +1,66 @@
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket } from "@tline/minio";
import { enqueueScanMinioPrefix } from "@tline/queue";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const bodySchema = z
.object({
bucket: z.string().min(1).optional(),
prefix: z.string().min(1).default("originals/"),
})
.strict();
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const params = paramsParsed.data;
const bodyJson = await request.json().catch(() => ({}));
const body = bodySchema.parse(bodyJson);
const bucket = body.bucket ?? getMinioBucket();
const db = getDb();
const rows = await db<
{
id: string;
}[]
>`
select id
from imports
where id = ${params.id}
limit 1
`;
const imp = rows[0];
if (!imp) {
return Response.json({ error: "not_found" }, { status: 404 });
}
await enqueueScanMinioPrefix({
importId: imp.id,
bucket,
prefix: body.prefix,
});
await db`
update imports
set status = 'queued'
where id = ${imp.id}
`;
return Response.json({ ok: true, importId: imp.id, bucket, prefix: body.prefix });
}
@@ -0,0 +1,65 @@
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const params = paramsParsed.data;
const db = getDb();
const [imp] = await db<
{
id: string;
type: string;
status: string;
created_at: string;
total_count: number | null;
processed_count: number | null;
failed_count: number | null;
}[]
>`
select id, type, status, created_at, total_count, processed_count, failed_count
from imports
where id = ${params.id}
limit 1
`;
if (!imp) {
return Response.json({ error: "not_found" }, { status: 404 });
}
const counts = await db<
{
total: number;
ready: number;
failed: number;
processing: number;
new_count: number;
}[]
>`
select
count(*)::int as total,
count(*) filter (where status = 'ready')::int as ready,
count(*) filter (where status = 'failed')::int as failed,
count(*) filter (where status = 'processing')::int as processing,
count(*) filter (where status = 'new')::int as new_count
from assets
where source_key like ${`staging/${imp.id}/%`}
`;
return Response.json({ ...imp, asset_counts: counts[0] ?? null });
}
@@ -0,0 +1,108 @@
import { randomUUID } from "crypto";
import { Readable } from "stream";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { PutObjectCommand } from "@aws-sdk/client-s3";
import { z } from "zod";
import { getDb } from "@tline/db";
import { getMinioBucket, getMinioInternalClient } from "@tline/minio";
import { enqueueProcessAsset } from "@tline/queue";
export const runtime = "nodejs";
const paramsSchema = z.object({ id: z.string().uuid() });
const contentTypeMediaMap: Array<{
match: (ct: string) => boolean;
mediaType: "image" | "video";
}> = [
{ match: (ct) => ct.startsWith("image/"), mediaType: "image" },
{ match: (ct) => ct.startsWith("video/"), mediaType: "video" },
];
function inferMediaTypeFromContentType(ct: string): "image" | "video" | null {
const found = contentTypeMediaMap.find((m) => m.match(ct));
return found?.mediaType ?? null;
}
function inferExtFromContentType(ct: string): string {
const parts = ct.split("/");
const ext = parts[1] ?? "bin";
return ext.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase() || "bin";
}
export async function POST(
request: Request,
context: { params: Promise<{ id: string }> },
): Promise<Response> {
const rawParams = await context.params;
const paramsParsed = paramsSchema.safeParse(rawParams);
if (!paramsParsed.success) {
return Response.json(
{ error: "invalid_params", issues: paramsParsed.error.issues },
{ status: 400 },
);
}
const params = paramsParsed.data;
const contentType = request.headers.get("content-type") ?? "application/octet-stream";
const mediaType = inferMediaTypeFromContentType(contentType);
if (!mediaType) {
return Response.json({ error: "unsupported_content_type", contentType }, { status: 400 });
}
const bucket = getMinioBucket();
const ext = inferExtFromContentType(contentType);
const objectId = randomUUID();
const key = `staging/${params.id}/${objectId}.${ext}`;
const db = getDb();
const [imp] = await db<{ id: string }[]>`
select id
from imports
where id = ${params.id}
limit 1
`;
if (!imp) {
return Response.json({ error: "import_not_found" }, { status: 404 });
}
if (!request.body) {
return Response.json({ error: "missing_body" }, { status: 400 });
}
const s3 = getMinioInternalClient();
const bodyStream = Readable.fromWeb(request.body as unknown as NodeReadableStream);
await s3.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: bodyStream,
ContentType: contentType,
}),
);
const rows = await db<
{
id: string;
status: "new" | "processing" | "ready" | "failed";
}[]
>`
insert into assets (bucket, media_type, mime_type, source_key, active_key)
values (${bucket}, ${mediaType}, ${contentType}, ${key}, ${key})
on conflict (bucket, source_key)
do update set active_key = excluded.active_key
returning id, status
`;
const asset = rows[0];
if (!asset) {
return Response.json({ error: "asset_insert_failed" }, { status: 500 });
}
await enqueueProcessAsset({ assetId: asset.id });
return Response.json({ ok: true, importId: imp.id, assetId: asset.id, bucket, key });
}
+37
View File
@@ -0,0 +1,37 @@
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const bodySchema = z
.object({
type: z.enum(["upload", "minio_scan"]).default("upload"),
})
.strict();
export async function POST(request: Request): Promise<Response> {
const bodyJson = await request.json().catch(() => ({}));
const body = bodySchema.parse(bodyJson);
const db = getDb();
const rows = await db<
{
id: string;
type: "upload" | "minio_scan";
status: string;
created_at: string;
}[]
>`
insert into imports (type, status)
values (${body.type}, 'new')
returning id, type, status, created_at
`;
const created = rows[0];
if (!created) {
return Response.json({ error: "insert_failed" }, { status: 500 });
}
return Response.json(created);
}
+137
View File
@@ -0,0 +1,137 @@
import { z } from "zod";
import { getDb } from "@tline/db";
export const runtime = "nodejs";
const querySchema = z
.object({
start: z.string().datetime().optional(),
end: z.string().datetime().optional(),
granularity: z.enum(["year", "month", "day"]).default("day"),
mediaType: z.enum(["image", "video"]).optional(),
includeFailed: z.enum(["0", "1"]).default("0").transform((v) => v === "1"),
limit: z.coerce.number().int().positive().max(500).default(200),
})
.strict();
type Granularity = z.infer<typeof querySchema>["granularity"];
function sqlGroupExpr(granularity: Granularity, alias: string) {
const col = `${alias}.capture_ts_utc`;
if (granularity === "year") return `date_trunc('year', ${col})`;
if (granularity === "month") return `date_trunc('month', ${col})`;
return `date_trunc('day', ${col})`;
}
export async function GET(request: Request): Promise<Response> {
const url = new URL(request.url);
const parsed = querySchema.safeParse({
start: url.searchParams.get("start") ?? undefined,
end: url.searchParams.get("end") ?? undefined,
granularity: url.searchParams.get("granularity") ?? undefined,
mediaType: url.searchParams.get("mediaType") ?? undefined,
includeFailed: url.searchParams.get("includeFailed") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return Response.json(
{ error: "invalid_query", issues: parsed.error.issues },
{ status: 400 },
);
}
const query = parsed.data;
const start = query.start ? new Date(query.start) : null;
const end = query.end ? new Date(query.end) : null;
const db = getDb();
// Note: capture_ts_utc can be null (unprocessed). Those rows are excluded.
const groupExprFiltered = sqlGroupExpr(query.granularity, "filtered");
const groupExprF = sqlGroupExpr(query.granularity, "f");
const rows = await db<
{
bucket: string;
group_ts: string;
count_total: number;
count_ready: number;
sample_asset_id: string | null;
sample_thumb_small_key: string | null;
sample_thumb_med_key: string | null;
sample_poster_key: string | null;
sample_active_key: string | null;
sample_status: string | null;
sample_media_type: "image" | "video" | null;
}[]
>`
with filtered as (
select
id,
bucket,
media_type,
status,
capture_ts_utc,
active_key,
thumb_small_key,
thumb_med_key,
poster_key
from assets
where capture_ts_utc is not null
and (${start}::timestamptz is null or capture_ts_utc >= ${start}::timestamptz)
and (${end}::timestamptz is null or capture_ts_utc < ${end}::timestamptz)
and (${query.mediaType ?? null}::media_type is null or media_type = ${query.mediaType ?? null}::media_type)
and (
${query.includeFailed}::boolean = true
or status <> 'failed'
)
),
grouped as (
select
bucket,
${db.unsafe(groupExprFiltered)} as group_ts,
count(*)::int as count_total,
count(*) filter (where status = 'ready')::int as count_ready
from filtered
group by bucket, ${db.unsafe(groupExprFiltered)}
order by group_ts desc
limit ${query.limit}
)
select
g.bucket,
to_char(g.group_ts AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') as group_ts,
g.count_total,
g.count_ready,
s.id as sample_asset_id,
s.thumb_small_key as sample_thumb_small_key,
s.thumb_med_key as sample_thumb_med_key,
s.poster_key as sample_poster_key,
s.active_key as sample_active_key,
s.status as sample_status,
s.media_type as sample_media_type
from grouped g
left join lateral (
select *
from filtered f
where f.bucket = g.bucket
and ${db.unsafe(groupExprF)} = g.group_ts
and f.status = 'ready'
order by f.capture_ts_utc asc
limit 1
) s on true
order by g.group_ts desc
`;
return Response.json({
granularity: query.granularity,
start: start ? start.toISOString() : null,
end: end ? end.toISOString() : null,
mediaType: query.mediaType ?? null,
includeFailed: query.includeFailed,
nodes: rows,
});
}
+291
View File
@@ -0,0 +1,291 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type Asset = {
id: string;
media_type: "image" | "video";
mime_type: string;
capture_ts_utc: string | null;
thumb_small_key: string | null;
thumb_med_key: string | null;
poster_key: string | null;
status: "new" | "processing" | "ready" | "failed";
error_message: string | null;
};
type AssetsResponse = {
items: Asset[];
};
type SignedUrlResponse = {
url: string;
expiresSeconds: number;
};
type PreviewUrlState = Record<string, string | undefined>;
function startOfDayUtc(iso: string) {
const d = new Date(iso);
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0));
}
function endOfDayUtc(iso: string) {
const start = startOfDayUtc(iso);
return new Date(start.getTime() + 24 * 60 * 60 * 1000);
}
export function MediaPanel(props: { selectedDayIso: string | null }) {
const [assets, setAssets] = useState<Asset[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [previews, setPreviews] = useState<PreviewUrlState>({});
const [viewer, setViewer] = useState<{
asset: Asset;
url: string;
variant: "original" | "thumb_med" | "poster";
} | null>(null);
const [viewerError, setViewerError] = useState<string | null>(null);
const [videoFallback, setVideoFallback] = useState<{ posterUrl: string | null } | null>(null);
const range = useMemo(() => {
if (!props.selectedDayIso) return null;
const start = startOfDayUtc(props.selectedDayIso);
const end = endOfDayUtc(props.selectedDayIso);
return { start, end };
}, [props.selectedDayIso]);
useEffect(() => {
let cancelled = false;
async function load() {
if (!range) {
setAssets(null);
return;
}
try {
setError(null);
setAssets(null);
const qs = new URLSearchParams({
start: range.start.toISOString(),
end: range.end.toISOString(),
limit: "120",
});
const res = await fetch(`/api/assets?${qs.toString()}`, { cache: "no-store" });
if (!res.ok) throw new Error(`assets_fetch_failed:${res.status}`);
const json = (await res.json()) as AssetsResponse;
if (!cancelled) {
setAssets(json.items);
setPreviews({});
}
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
}
void load();
return () => {
cancelled = true;
};
}, [range]);
async function loadSignedUrl(assetId: string, variant: "original" | "thumb_small" | "thumb_med" | "poster") {
const res = await fetch(`/api/assets/${assetId}/url?variant=${variant}`, {
cache: "no-store",
});
if (!res.ok) throw new Error(`presign_failed:${res.status}`);
const json = (await res.json()) as SignedUrlResponse;
return json.url;
}
async function openViewer(asset: Asset) {
if (asset.status === "failed") {
setViewerError(`${asset.id}: ${asset.error_message ?? "asset_failed"}`);
setViewer(null);
return;
}
setViewerError(null);
setVideoFallback(null);
const variant: "original" | "thumb_med" | "poster" = "original";
const url = await loadSignedUrl(asset.id, variant);
setViewer({ asset, url, variant });
}
return (
<div style={{ display: "grid", gap: 12 }}>
<div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between" }}>
<strong>Media</strong>
<span style={{ color: "#666", fontSize: 12 }}>
{props.selectedDayIso ? startOfDayUtc(props.selectedDayIso).toISOString().slice(0, 10) : "(select a day)"}
</span>
</div>
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
{!assets && props.selectedDayIso && !error ? (
<div style={{ color: "#666" }}>Loading assets</div>
) : null}
{assets ? (
<div style={{ display: "grid", gap: 8 }}>
{assets.length === 0 ? <div style={{ color: "#666" }}>No assets.</div> : null}
{assets.map((a) => (
<button
key={a.id}
type="button"
onClick={() => void openViewer(a)}
onPointerEnter={() => {
if (previews[a.id] !== undefined) return;
const variant = a.media_type === "image" ? "thumb_small" : "poster";
const promise = loadSignedUrl(a.id, variant).catch(() => undefined);
void promise.then((url) => {
setPreviews((prev) => ({ ...prev, [a.id]: url }));
});
}}
style={{
textAlign: "left",
padding: 10,
border: "1px solid #ddd",
borderRadius: 8,
background: "white",
}}
>
<div style={{ display: "grid", gridTemplateColumns: "72px 1fr", gap: 10, alignItems: "center" }}>
<div
style={{
width: 72,
height: 72,
borderRadius: 8,
background: "#f2f2f2",
border: "1px solid #eee",
overflow: "hidden",
display: "grid",
placeItems: "center",
color: "#888",
fontSize: 12,
}}
>
{previews[a.id] ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={previews[a.id]} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<span>{a.media_type}</span>
)}
</div>
<div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<span>
{a.media_type} · {a.status}
</span>
<span style={{ color: "#666", fontSize: 12 }}>{a.id.slice(0, 8)}</span>
</div>
{a.status === "failed" && a.error_message ? (
<div style={{ color: "#b00", fontSize: 12, marginTop: 6 }}>{a.error_message}</div>
) : null}
</div>
</div>
</button>
))}
</div>
) : null}
{viewer || viewerError ? (
<div
role="dialog"
aria-modal="true"
onClick={() => {
setViewer(null);
setViewerError(null);
setVideoFallback(null);
}}
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
display: "grid",
placeItems: "center",
padding: 16,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "min(1000px, 98vw)",
maxHeight: "90vh",
overflow: "auto",
background: "white",
borderRadius: 12,
padding: 12,
display: "grid",
gap: 12,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
<strong>{viewer ? `${viewer.asset.media_type} (${viewer.variant})` : "Viewer"}</strong>
<button
type="button"
onClick={() => {
setViewer(null);
setViewerError(null);
setVideoFallback(null);
}}
>
Close
</button>
</div>
{viewer ? (
<>
{viewer.asset.media_type === "image" ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={viewer.url}
alt={viewer.asset.id}
style={{ width: "100%", height: "auto" }}
onError={() => setViewerError("image_load_failed")}
/>
) : (
<video
src={viewer.url}
controls
style={{ width: "100%" }}
poster={videoFallback?.posterUrl ?? undefined}
onError={() => {
setViewerError("video_playback_failed");
if (videoFallback !== null) return;
setVideoFallback({ posterUrl: null });
void loadSignedUrl(viewer.asset.id, "poster")
.then((posterUrl) => setVideoFallback({ posterUrl }))
.catch(() => setVideoFallback({ posterUrl: null }));
}}
/>
)}
{viewerError ? (
<div style={{ color: "#b00", fontSize: 12 }}>
{viewerError}
{viewer.asset.media_type === "video" ? " (try a different browser/codec)" : null}
</div>
) : null}
<div style={{ color: "#666", fontSize: 12 }}>{viewer.asset.id}</div>
</>
) : (
<div style={{ color: "#b00" }}>{viewerError ?? "unknown_error"}</div>
)}
</div>
</div>
) : null}
</div>
);
}
+368
View File
@@ -0,0 +1,368 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
type Granularity = "year" | "month" | "day";
type TreeNode = {
id: string;
label: string;
ts: string; // ISO string from API
countTotal: number;
countReady: number;
children?: TreeNode[];
};
type ApiTreeRow = {
bucket: string;
group_ts: string;
count_total: number;
count_ready: number;
};
type ApiTreeResponse = {
granularity: Granularity;
start: string | null;
end: string | null;
nodes: ApiTreeRow[];
};
type Orientation = "vertical" | "horizontal";
type ExpandedState = Record<string, boolean>;
function monthKey(d: Date) {
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
}
function dayKey(d: Date) {
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}
function yearKey(d: Date) {
return `${d.getUTCFullYear()}`;
}
function buildHierarchy(dayRows: ApiTreeRow[]): TreeNode[] {
const years = new Map<string, TreeNode>();
const months = new Map<string, TreeNode>();
for (const row of dayRows) {
const date = new Date(row.group_ts);
const year = yearKey(date);
const month = monthKey(date);
const day = dayKey(date);
const yMapKey = `${row.bucket}:${year}`;
const mMapKey = `${row.bucket}:${month}`;
let yNode = years.get(yMapKey);
if (!yNode) {
yNode = {
id: `y:${year}:${row.bucket}`,
label: year,
ts: new Date(Date.UTC(Number(year), 0, 1)).toISOString(),
countTotal: 0,
countReady: 0,
children: [],
};
years.set(yMapKey, yNode);
}
let mNode = months.get(mMapKey);
if (!mNode) {
const [yy, mm] = month.split("-");
mNode = {
id: `m:${month}:${row.bucket}`,
label: `${yy}-${mm}`,
ts: new Date(Date.UTC(Number(yy), Number(mm) - 1, 1)).toISOString(),
countTotal: 0,
countReady: 0,
children: [],
};
months.set(mMapKey, mNode);
yNode.children!.push(mNode);
}
const dNode: TreeNode = {
id: `d:${day}:${row.bucket}`,
label: day,
ts: row.group_ts,
countTotal: row.count_total,
countReady: row.count_ready,
};
mNode.children!.push(dNode);
// rollups
mNode.countTotal += row.count_total;
mNode.countReady += row.count_ready;
yNode.countTotal += row.count_total;
yNode.countReady += row.count_ready;
}
// Sort ascending within each parent
const yearNodes = Array.from(years.values()).sort((a, b) => a.label.localeCompare(b.label));
for (const y of yearNodes) {
y.children = (y.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
for (const m of y.children) {
m.children = (m.children ?? []).sort((a, b) => a.label.localeCompare(b.label));
}
}
return yearNodes;
}
function gatherVisible(
roots: TreeNode[],
expanded: ExpandedState,
): Array<{ node: TreeNode; depth: number; parentId: string | null }> {
const out: Array<{ node: TreeNode; depth: number; parentId: string | null }> = [];
function walk(nodes: TreeNode[], depth: number, parentId: string | null) {
for (const n of nodes) {
out.push({ node: n, depth, parentId });
const isExpanded = expanded[n.id] ?? depth < 1; // default expand years
if (n.children?.length && isExpanded) {
walk(n.children, depth + 1, n.id);
}
}
}
walk(roots, 0, null);
return out;
}
export function TimelineTree(props: { onSelectDay?: (dayIso: string) => void }) {
const [orientation, setOrientation] = useState<Orientation>("vertical");
const [expanded, setExpanded] = useState<ExpandedState>({});
const [rows, setRows] = useState<ApiTreeRow[] | null>(null);
const [error, setError] = useState<string | null>(null);
// simple pan/zoom via viewBox
const svgRef = useRef<SVGSVGElement | null>(null);
const [viewBox, setViewBox] = useState({ x: 0, y: 0, w: 1200, h: 800 });
const dragState = useRef<{
active: boolean;
startX: number;
startY: number;
startViewBox: typeof viewBox;
} | null>(null);
useEffect(() => {
let cancelled = false;
async function load() {
try {
setError(null);
const res = await fetch("/api/tree?granularity=day&limit=500&includeFailed=1", {
cache: "no-store",
});
if (!res.ok) throw new Error(`tree_fetch_failed:${res.status}`);
const json = (await res.json()) as ApiTreeResponse;
if (!cancelled) setRows(json.nodes);
} catch (e) {
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
}
}
void load();
return () => {
cancelled = true;
};
}, []);
const roots = useMemo(() => (rows ? buildHierarchy(rows) : []), [rows]);
const visible = useMemo(() => gatherVisible(roots, expanded), [roots, expanded]);
const layout = useMemo(() => {
const nodeGap = 56;
const depthGap = 180;
const positions = new Map<string, { x: number; y: number }>();
for (let i = 0; i < visible.length; i++) {
const { node, depth } = visible[i];
const primary = i * nodeGap;
const secondary = depth * depthGap;
const x = orientation === "vertical" ? secondary : primary;
const y = orientation === "vertical" ? primary : secondary;
positions.set(node.id, { x, y });
}
// compute bounds
let maxX = 0;
let maxY = 0;
for (const p of positions.values()) {
maxX = Math.max(maxX, p.x);
maxY = Math.max(maxY, p.y);
}
const padding = 120;
const w = maxX + padding * 2;
const h = maxY + padding * 2;
return { positions, w, h, padding };
}, [visible, orientation]);
useEffect(() => {
// reset viewBox when layout changes
setViewBox((vb) => ({ ...vb, w: Math.max(layout.w, 1200), h: Math.max(layout.h, 800) }));
}, [layout.w, layout.h]);
function toggleNode(id: string) {
setExpanded((prev) => ({ ...prev, [id]: !(prev[id] ?? false) }));
}
function onPointerDown(e: React.PointerEvent<SVGSVGElement>) {
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
dragState.current = {
active: true,
startX: e.clientX,
startY: e.clientY,
startViewBox: viewBox,
};
}
function onPointerMove(e: React.PointerEvent<SVGSVGElement>) {
if (!dragState.current?.active) return;
const dx = e.clientX - dragState.current.startX;
const dy = e.clientY - dragState.current.startY;
// Convert pixels to viewBox units roughly; this is intentionally simple.
const scaleX = viewBox.w / 900;
const scaleY = viewBox.h / 600;
setViewBox({
...viewBox,
x: dragState.current.startViewBox.x - dx * scaleX,
y: dragState.current.startViewBox.y - dy * scaleY,
});
}
function onPointerUp(e: React.PointerEvent<SVGSVGElement>) {
dragState.current = null;
try {
(e.currentTarget as SVGSVGElement).releasePointerCapture(e.pointerId);
} catch {
// ignore
}
}
function onWheel(e: React.WheelEvent<SVGSVGElement>) {
e.preventDefault();
const factor = e.deltaY < 0 ? 0.9 : 1.1;
const mouseX = e.clientX;
const mouseY = e.clientY;
const rect = e.currentTarget.getBoundingClientRect();
const px = (mouseX - rect.left) / rect.width;
const py = (mouseY - rect.top) / rect.height;
const newW = Math.max(300, Math.min(5000, viewBox.w * factor));
const newH = Math.max(300, Math.min(5000, viewBox.h * factor));
const newX = viewBox.x + (viewBox.w - newW) * px;
const newY = viewBox.y + (viewBox.h - newH) * py;
setViewBox({ x: newX, y: newY, w: newW, h: newH });
}
return (
<div style={{ display: "grid", gap: 12 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<strong>Timeline</strong>
<button
type="button"
onClick={() => setOrientation((o) => (o === "vertical" ? "horizontal" : "vertical"))}
>
Orientation: {orientation}
</button>
<button type="button" onClick={() => setViewBox({ x: 0, y: 0, w: 1200, h: 800 })}>
Reset view
</button>
{rows ? <span style={{ color: "#666" }}>{rows.length} day nodes</span> : null}
</div>
{error ? <div style={{ color: "#b00" }}>Error: {error}</div> : null}
{!rows && !error ? <div style={{ color: "#666" }}>Loading tree</div> : null}
<div
style={{
border: "1px solid #ddd",
borderRadius: 8,
overflow: "hidden",
height: 600,
}}
>
<svg
ref={svgRef}
viewBox={`${viewBox.x} ${viewBox.y} ${viewBox.w} ${viewBox.h}`}
width="100%"
height="100%"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onWheel={onWheel}
style={{ touchAction: "none", background: "#fafafa" }}
>
<g transform={`translate(${layout.padding} ${layout.padding})`}>
{/* edges */}
{visible.map(({ node, parentId }) => {
if (!parentId) return null;
const p = layout.positions.get(parentId);
const c = layout.positions.get(node.id);
if (!p || !c) return null;
return (
<line
key={`${parentId}->${node.id}`}
x1={p.x}
y1={p.y}
x2={c.x}
y2={c.y}
stroke="#bbb"
strokeWidth={2}
/>
);
})}
{/* nodes */}
{visible.map(({ node }) => {
const pos = layout.positions.get(node.id);
if (!pos) return null;
const hasChildren = Boolean(node.children?.length);
const isExpanded = expanded[node.id] ?? node.id.startsWith("y:");
const isDay = node.id.startsWith("d:");
const clickCursor = hasChildren || isDay ? "pointer" : "default";
return (
<g
key={node.id}
transform={`translate(${pos.x} ${pos.y})`}
style={{ cursor: clickCursor }}
onClick={() => {
if (hasChildren) toggleNode(node.id);
if (isDay) props.onSelectDay?.(node.ts);
}}
>
<circle r={12} fill={hasChildren ? "#1f6feb" : "#666"} />
<text x={20} y={5} fontSize={14} fill="#111">
{node.label} ({node.countReady}/{node.countTotal})
{hasChildren ? (isExpanded ? " ▼" : " ▶") : ""}
</text>
</g>
);
})}
</g>
</svg>
</div>
<div style={{ color: "#666", fontSize: 12 }}>
Drag to pan, mouse wheel to zoom. Click years/months to expand.
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
import { getAppName } from "@tline/config";
export const metadata = {
title: getAppName()
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body style={{ margin: 0, fontFamily: "system-ui, sans-serif" }}>{children}</body>
</html>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import Link from "next/link";
import { getAppName } from "@tline/config";
import { useState } from "react";
import { MediaPanel } from "./components/MediaPanel";
import { TimelineTree } from "./components/TimelineTree";
export default function HomePage() {
const [selectedDayIso, setSelectedDayIso] = useState<string | null>(null);
return (
<main style={{ padding: 16, display: "grid", gap: 16 }}>
<header>
<h1 style={{ marginTop: 0 }}>{getAppName()}</h1>
<ul>
<li>
<Link href="/admin">Admin</Link>
</li>
<li>
<a href="/api/healthz">API health</a>
</li>
</ul>
</header>
<div
style={{
display: "grid",
gridTemplateColumns: "2fr 1fr",
gap: 16,
alignItems: "start",
}}
>
<TimelineTree onSelectDay={setSelectedDayIso} />
<MediaPanel selectedDayIso={selectedDayIso} />
</div>
</main>
);
}
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+13
View File
@@ -0,0 +1,13 @@
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
outputFileTracingRoot: path.join(__dirname, "../../.."),
reactStrictMode: true,
};
export default nextConfig;
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@tline/web",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@tline/config": "workspace:*",
"@tline/db": "workspace:*",
"@tline/minio": "workspace:*",
"@tline/queue": "workspace:*",
"@aws-sdk/client-s3": "^3.899.0",
"next": "15.5.3",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
}
}
+26
View File
@@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"types": [
"bun-types"
],
"plugins": [
{
"name": "next"
}
],
"allowJs": true,
"incremental": true,
"isolatedModules": true
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+35
View File
@@ -0,0 +1,35 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1.3.3 AS deps
WORKDIR /app
# Workspace manifests (copy all workspace package.json files so Bun
# can resolve workspace:* deps without mutating the lockfile).
COPY package.json bun.lock tsconfig.base.json ./
COPY apps/web/package.json ./apps/web/package.json
COPY apps/worker/package.json ./apps/worker/package.json
COPY packages/config/package.json ./packages/config/package.json
COPY packages/db/package.json ./packages/db/package.json
COPY packages/minio/package.json ./packages/minio/package.json
COPY packages/queue/package.json ./packages/queue/package.json
RUN bun install --frozen-lockfile --production --ignore-scripts
FROM oven/bun:1.3.3 AS runner
WORKDIR /app
# Media tooling for worker pipeline
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg libimage-exiftool-perl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/package.json ./package.json
COPY --from=deps /app/bun.lock ./bun.lock
COPY apps/worker ./apps/worker
COPY packages ./packages
CMD ["bun", "--cwd", "apps/worker", "run", "start"]
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@tline/worker",
"version": "0.0.0",
"private": true,
"type": "module",
"dependencies": {
"@tline/config": "workspace:*",
"@tline/db": "workspace:*",
"@tline/minio": "workspace:*",
"@tline/queue": "workspace:*",
"@aws-sdk/client-s3": "^3.899.0",
"bullmq": "^5.61.0",
"sharp": "^0.33.5"
},
"scripts": {
"dev": "bun run src/index.ts",
"start": "bun run src/index.ts"
}
}
+58
View File
@@ -0,0 +1,58 @@
import { getAppName } from "@tline/config";
import { Worker, type Job } from "bullmq";
import { closeQueue, getQueueEnv, getQueueName, getRedis } from "@tline/queue";
import { closeDb } from "@tline/db";
import {
handleCopyToCanonical,
handleProcessAsset,
handleScanMinioPrefix
} from "./jobs";
console.log(`[${getAppName()}] worker boot`);
const env = getQueueEnv();
const queueName = getQueueName();
const connection = getRedis();
try {
await connection.connect();
} catch (err) {
console.error(`[${getAppName()}] redis connect failed`, { err, redisUrl: env.REDIS_URL });
process.exit(1);
}
const worker = new Worker(
queueName,
async (job: Job) => {
if (job.name === "scan_minio_prefix") return handleScanMinioPrefix(job.data);
if (job.name === "process_asset") return handleProcessAsset(job.data);
if (job.name === "copy_to_canonical") return handleCopyToCanonical(job.data);
throw new Error(`Unknown job: ${job.name}`);
},
{
connection,
concurrency: 1
}
);
worker.on("failed", (job: Job | undefined, err: Error) => {
console.error(`[${getAppName()}] job failed`, { jobId: job?.id, name: job?.name, err });
});
worker.on("completed", (job: Job) => {
console.log(`[${getAppName()}] job completed`, { jobId: job.id, name: job.name });
});
async function shutdown(signal: string) {
console.log(`[${getAppName()}] shutting down`, { signal });
await Promise.allSettled([worker.close(), closeDb()]);
await Promise.allSettled([closeQueue()]);
process.exit(0);
}
process.on("SIGINT", () => void shutdown("SIGINT"));
process.on("SIGTERM", () => void shutdown("SIGTERM"));
+616
View File
@@ -0,0 +1,616 @@
import { spawn } from "child_process";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { createWriteStream, createReadStream } from "fs";
import { Readable } from "stream";
import sharp from "sharp";
import {
CopyObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand
} from "@aws-sdk/client-s3";
import { getDb } from "@tline/db";
import { getMinioInternalClient } from "@tline/minio";
import {
copyToCanonicalPayloadSchema,
enqueueCopyToCanonical,
enqueueProcessAsset,
processAssetPayloadSchema,
scanMinioPrefixPayloadSchema,
} from "@tline/queue";
const allowedScanPrefixes = ["originals/"] as const;
function assertAllowedScanPrefix(prefix: string) {
if (allowedScanPrefixes.some((allowed) => prefix.startsWith(allowed))) return;
throw new Error(`scan prefix not allowed: ${prefix}`);
}
function getExtensionLower(key: string) {
const dot = key.lastIndexOf(".");
if (dot === -1) return "";
return key.slice(dot + 1).toLowerCase();
}
function inferMedia(
key: string,
): { mediaType: "image" | "video"; mimeType: string } | null {
const ext = getExtensionLower(key);
if (["jpg", "jpeg"].includes(ext))
return { mediaType: "image", mimeType: "image/jpeg" };
if (ext === "png") return { mediaType: "image", mimeType: "image/png" };
if (ext === "gif") return { mediaType: "image", mimeType: "image/gif" };
if (ext === "webp") return { mediaType: "image", mimeType: "image/webp" };
if (ext === "heic") return { mediaType: "image", mimeType: "image/heic" };
if (ext === "heif") return { mediaType: "image", mimeType: "image/heif" };
if (ext === "mov") return { mediaType: "video", mimeType: "video/quicktime" };
if (ext === "mp4") return { mediaType: "video", mimeType: "video/mp4" };
if (ext === "m4v") return { mediaType: "video", mimeType: "video/x-m4v" };
if (ext === "mkv")
return { mediaType: "video", mimeType: "video/x-matroska" };
return null;
}
async function listAllObjectKeys(input: { bucket: string; prefix: string }) {
const s3 = getMinioInternalClient();
const keys: string[] = [];
let continuationToken: string | undefined;
do {
const res = await s3.send(
new ListObjectsV2Command({
Bucket: input.bucket,
Prefix: input.prefix,
ContinuationToken: continuationToken,
}),
);
for (const obj of res.Contents ?? []) {
if (!obj.Key) continue;
keys.push(obj.Key);
}
continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
} while (continuationToken);
return keys;
}
export async function handleScanMinioPrefix(raw: unknown) {
const payload = scanMinioPrefixPayloadSchema.parse(raw);
assertAllowedScanPrefix(payload.prefix);
const keys = await listAllObjectKeys({
bucket: payload.bucket,
prefix: payload.prefix,
});
const db = getDb();
let processed = 0;
let skipped = 0;
let enqueued = 0;
for (const key of keys) {
if (key.endsWith("/")) {
skipped++;
continue;
}
const inferred = inferMedia(key);
if (!inferred) {
skipped++;
continue;
}
const rows = await db<
{
id: string;
status: "new" | "processing" | "ready" | "failed";
}[]
>`
insert into assets (bucket, media_type, mime_type, source_key, active_key)
values (${payload.bucket}, ${inferred.mediaType}, ${inferred.mimeType}, ${key}, ${key})
on conflict (bucket, source_key)
do update
set media_type = excluded.media_type,
mime_type = excluded.mime_type,
active_key = excluded.active_key
returning id, status
`;
processed++;
const [asset] = rows;
if (!asset) continue;
if (asset.status === "new" || asset.status === "failed") {
await enqueueProcessAsset({ assetId: asset.id });
enqueued++;
}
}
return {
ok: true,
importId: payload.importId,
bucket: payload.bucket,
scannedPrefix: payload.prefix,
found: keys.length,
processed,
skipped,
enqueued,
};
}
function streamToFile(stream: Readable, filePath: string): Promise<void> {
return new Promise((resolve, reject) => {
const writeStream = createWriteStream(filePath);
stream.pipe(writeStream);
writeStream.on("finish", resolve);
writeStream.on("error", reject);
});
}
async function runCommand(cmd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const proc = spawn(cmd, args);
let stdout = "";
let stderr = "";
proc.stdout.on("data", (data) => {
stdout += data.toString();
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
});
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
} else {
reject(new Error(`${cmd} failed with code ${code}: ${stderr}`));
}
});
proc.on("error", reject);
});
}
async function uploadObject(input: {
bucket: string;
key: string;
filePath: string;
contentType?: string;
}): Promise<void> {
const s3 = getMinioInternalClient();
await s3.send(
new PutObjectCommand({
Bucket: input.bucket,
Key: input.key,
Body: createReadStream(input.filePath),
ContentType: input.contentType,
}),
);
}
async function getObjectLastModified(input: { bucket: string; key: string }): Promise<Date | null> {
const s3 = getMinioInternalClient();
const res = await s3.send(new HeadObjectCommand({ Bucket: input.bucket, Key: input.key }));
return res.LastModified ?? null;
}
function parseExifDate(dateStr: string | undefined): Date | null {
if (!dateStr) return null;
const s = dateStr.trim();
// ExifTool commonly emits: "YYYY:MM:DD HH:MM:SS", sometimes with fractional seconds and/or tz.
const m = s.match(
/^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:\d{2}))?$/,
);
if (m) {
const [, y, mo, d, hh, mm, ss, frac, tz] = m;
// If tz missing, prefer deterministic UTC over server-local interpretation.
const iso = `${y}-${mo}-${d}T${hh}:${mm}:${ss}${frac ?? ""}${tz ?? "Z"}`;
const date = new Date(iso);
return isNaN(date.getTime()) ? null : date;
}
const date = new Date(s);
return isNaN(date.getTime()) ? null : date;
}
function isPlausibleCaptureTs(date: Date) {
const ts = date.getTime();
if (!Number.isFinite(ts)) return false;
const year = date.getUTCFullYear();
// Guard against bogus container/default dates; allow up to 24h in future.
return year >= 1971 && ts <= Date.now() + 24 * 60 * 60 * 1000;
}
function inferExtFromKey(key: string): string {
const ext = getExtensionLower(key);
return ext || "bin";
}
function pad2(n: number) {
return String(n).padStart(2, "0");
}
function utcDateParts(date: Date) {
const y = date.getUTCFullYear();
const m = date.getUTCMonth() + 1;
const d = date.getUTCDate();
return { y, m, d };
}
export async function handleProcessAsset(raw: unknown) {
const payload = processAssetPayloadSchema.parse(raw);
const db = getDb();
const s3 = getMinioInternalClient();
await db`
update assets
set status = 'processing', error_message = null
where id = ${payload.assetId}
and status in ('new', 'failed')
`;
try {
const [asset] = await db<
{
id: string;
bucket: string;
active_key: string;
media_type: "image" | "video";
mime_type: string;
created_at: Date;
}[]
>`
select id, bucket, active_key, media_type, mime_type, created_at
from assets
where id = ${payload.assetId}
`;
if (!asset) {
throw new Error(`Asset not found: ${payload.assetId}`);
}
const tempDir = await mkdtemp(join(tmpdir(), "tline-process-"));
try {
const containerExt = asset.mime_type.split("/")[1] ?? "bin";
const inputPath = join(tempDir, `input.${containerExt}`);
const getRes = await s3.send(
new GetObjectCommand({
Bucket: asset.bucket,
Key: asset.active_key,
}),
);
if (!getRes.Body) throw new Error("Empty response body from S3");
await streamToFile(getRes.Body as Readable, inputPath);
const updates: Record<string, unknown> = {
capture_ts_utc: null,
date_confidence: null,
width: null,
height: null,
rotation: null,
duration_seconds: null,
thumb_small_key: null,
thumb_med_key: null,
poster_key: null,
raw_tags_json: null
};
let rawTags: Record<string, unknown> = {};
let captureTs: Date | null = null;
let dateConfidence:
| "camera"
| "container"
| "object_mtime"
| "import_time"
| null = null;
async function tryReadExifTags(): Promise<Record<string, unknown>> {
try {
const exifOutput = await runCommand("exiftool", ["-j", inputPath]);
const exifData = JSON.parse(exifOutput);
if (Array.isArray(exifData) && exifData.length > 0) {
const first = exifData[0];
if (first && typeof first === "object") {
return first as Record<string, unknown>;
}
}
return {};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { exiftool_error: message };
}
}
function maybeSetCaptureDateFromTags(tags: Record<string, unknown>) {
if (captureTs) return;
// ExifTool uses different fields across image/video vendors.
const dateFields = [
"DateTimeOriginal",
"CreateDate",
"ModifyDate",
"MediaCreateDate",
"TrackCreateDate",
"CreationDate",
"GPSDateTime",
] as const;
for (const field of dateFields) {
const val = tags[field] as string | undefined;
if (!val) continue;
const parsed = parseExifDate(val);
if (parsed && isPlausibleCaptureTs(parsed)) {
captureTs = parsed;
dateConfidence = "camera";
return;
}
}
}
async function applyObjectMtimeFallback() {
if (captureTs) return;
try {
const mtime = await getObjectLastModified({
bucket: asset.bucket,
key: asset.active_key,
});
if (!mtime) return;
if (!isPlausibleCaptureTs(mtime)) return;
captureTs = mtime;
dateConfidence = "object_mtime";
rawTags = { ...rawTags, object_last_modified: mtime.toISOString() };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
rawTags = { ...rawTags, object_last_modified_error: message };
}
}
if (asset.media_type === "image") {
rawTags = await tryReadExifTags();
maybeSetCaptureDateFromTags(rawTags);
await applyObjectMtimeFallback();
if (rawTags.ImageWidth !== undefined) updates.width = Number(rawTags.ImageWidth);
if (rawTags.ImageHeight !== undefined) updates.height = Number(rawTags.ImageHeight);
if (rawTags.Rotation !== undefined) updates.rotation = Number(rawTags.Rotation);
const imgMeta = await sharp(inputPath).metadata();
if (updates.width === null && imgMeta.width) updates.width = imgMeta.width;
if (updates.height === null && imgMeta.height) updates.height = imgMeta.height;
const thumb256Path = join(tempDir, "thumb_256.jpg");
const thumb768Path = join(tempDir, "thumb_768.jpg");
await sharp(inputPath)
.rotate()
.resize(256, 256, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumb256Path);
await sharp(inputPath)
.rotate()
.resize(768, 768, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toFile(thumb768Path);
const thumb256Key = `thumbs/${asset.id}/image_256.jpg`;
const thumb768Key = `thumbs/${asset.id}/image_768.jpg`;
await uploadObject({
bucket: asset.bucket,
key: thumb256Key,
filePath: thumb256Path,
contentType: "image/jpeg",
});
await uploadObject({
bucket: asset.bucket,
key: thumb768Key,
filePath: thumb768Path,
contentType: "image/jpeg",
});
updates.thumb_small_key = thumb256Key;
updates.thumb_med_key = thumb768Key;
} else if (asset.media_type === "video") {
rawTags = await tryReadExifTags();
maybeSetCaptureDateFromTags(rawTags);
const ffprobeOutput = await runCommand("ffprobe", [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height,duration",
"-show_entries",
"format_tags=creation_time",
"-of",
"json",
inputPath
]);
const ffprobeData = JSON.parse(ffprobeOutput);
if (!captureTs && ffprobeData.format?.tags?.creation_time) {
const ts = new Date(ffprobeData.format.tags.creation_time);
if (!isNaN(ts.getTime()) && isPlausibleCaptureTs(ts)) {
captureTs = ts;
dateConfidence = "container";
}
}
await applyObjectMtimeFallback();
if (ffprobeData.streams?.[0]) {
const stream = ffprobeData.streams[0];
if (stream.width) updates.width = Number(stream.width);
if (stream.height) updates.height = Number(stream.height);
if (stream.duration)
updates.duration_seconds = Math.round(Number(stream.duration));
}
rawTags = { ...rawTags, ffprobe: ffprobeData };
const posterPath = join(tempDir, "poster_256.jpg");
await runCommand("ffmpeg", [
"-i",
inputPath,
"-vf",
"scale=256:256:force_original_aspect_ratio=decrease",
"-vframes",
"1",
"-q:v",
"2",
"-y",
posterPath
]);
const posterKey = `thumbs/${asset.id}/poster_256.jpg`;
await uploadObject({
bucket: asset.bucket,
key: posterKey,
filePath: posterPath,
contentType: "image/jpeg",
});
updates.poster_key = posterKey;
}
if (asset.media_type === "video" && typeof updates.poster_key !== "string") {
throw new Error("poster generation did not produce output");
}
if (
asset.media_type === "image" &&
(typeof updates.thumb_small_key !== "string" || typeof updates.thumb_med_key !== "string")
) {
throw new Error("thumb generation did not produce output");
}
if (!captureTs) {
captureTs = new Date(asset.created_at);
dateConfidence = "import_time";
rawTags = {
...rawTags,
capture_date_fallback: "import_time",
};
}
updates.capture_ts_utc = captureTs;
updates.date_confidence = dateConfidence;
updates.raw_tags_json = rawTags;
await db`
update assets
set ${db(
updates,
"capture_ts_utc",
"date_confidence",
"width",
"height",
"rotation",
"duration_seconds",
"thumb_small_key",
"thumb_med_key",
"poster_key",
"raw_tags_json"
)}, status = 'ready', error_message = null
where id = ${asset.id}
`;
// Only uploads (staging/*) are copied into canonical by default.
if (asset.active_key.startsWith("staging/")) {
await enqueueCopyToCanonical({ assetId: asset.id });
}
return { ok: true };
} finally {
await rm(tempDir, { recursive: true, force: true });
}
} catch (err) {
const message = err instanceof Error ? err.message : "unknown_error";
await db`
update assets
set status = 'failed', error_message = ${message}
where id = ${payload.assetId}
`;
throw err;
}
}
export async function handleCopyToCanonical(raw: unknown) {
const payload = copyToCanonicalPayloadSchema.parse(raw);
const db = getDb();
const s3 = getMinioInternalClient();
const [asset] = await db<
{
id: string;
bucket: string;
source_key: string;
active_key: string;
canonical_key: string | null;
capture_ts_utc: Date | null;
}[]
>`
select id, bucket, source_key, active_key, canonical_key, capture_ts_utc
from assets
where id = ${payload.assetId}
limit 1
`;
if (!asset) throw new Error(`Asset not found: ${payload.assetId}`);
// Canonical layout is date-based; if we don't have a date yet, do nothing.
// This job can be retried later after metadata extraction improves.
if (!asset.capture_ts_utc) {
return { ok: true, assetId: asset.id, skipped: "missing_capture_ts" };
}
// Never copy external archive originals by default.
if (asset.source_key.startsWith("originals/")) {
return { ok: true, assetId: asset.id, skipped: "external_archive" };
}
const ext = inferExtFromKey(asset.source_key);
const { y, m, d } = utcDateParts(new Date(asset.capture_ts_utc));
const canonicalKey = `canonical/originals/${y}/${pad2(m)}/${pad2(d)}/${asset.id}.${ext}`;
// Idempotency: if already canonicalized, don't redo work.
if (asset.canonical_key === canonicalKey && asset.active_key === canonicalKey) {
return { ok: true, assetId: asset.id, canonicalKey, already: true };
}
await s3.send(
new CopyObjectCommand({
Bucket: asset.bucket,
Key: canonicalKey,
CopySource: `${asset.bucket}/${asset.active_key}`,
MetadataDirective: "COPY",
}),
);
await db`
update assets
set canonical_key = ${canonicalKey}, active_key = ${canonicalKey}
where id = ${asset.id}
`;
return { ok: true, assetId: asset.id, canonicalKey };
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*.ts", "../../packages/*/src/**/*.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: porthole
namespace: argocd
spec:
project: default
source:
repoURL: git@gitea-ssh.gitea.svc:will/porthole.git
targetRevision: main
path: helm/tline
helm:
releaseName: porthole
valueFiles:
- values.yaml
# - values-porthole.yaml
destination:
server: https://kubernetes.default.svc
namespace: porthole
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=false
- ApplyOutOfSyncOnly=true
+693
View File
@@ -0,0 +1,693 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "tline",
"dependencies": {
"zod": "^4.2.1",
},
"devDependencies": {
"@types/node": "^20.19.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"bun-types": "^1.3.5",
"eslint": "^9.39.2",
"prettier": "^3.7.4",
"typescript": "^5.9.3",
},
},
"apps/web": {
"name": "@tline/web",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.899.0",
"@tline/config": "workspace:*",
"@tline/db": "workspace:*",
"@tline/minio": "workspace:*",
"@tline/queue": "workspace:*",
"next": "15.5.3",
"react": "19.2.0",
"react-dom": "19.2.0",
},
},
"apps/worker": {
"name": "@tline/worker",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.899.0",
"@tline/config": "workspace:*",
"@tline/db": "workspace:*",
"@tline/minio": "workspace:*",
"@tline/queue": "workspace:*",
"bullmq": "^5.61.0",
"sharp": "^0.33.5",
},
},
"packages/config": {
"name": "@tline/config",
"version": "0.0.0",
},
"packages/db": {
"name": "@tline/db",
"version": "0.0.0",
"dependencies": {
"postgres": "^3.4.7",
"zod": "^4.2.1",
},
},
"packages/minio": {
"name": "@tline/minio",
"version": "0.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.899.0",
"@aws-sdk/s3-request-presigner": "^3.899.0",
"zod": "^4.2.1",
},
},
"packages/queue": {
"name": "@tline/queue",
"version": "0.0.0",
"dependencies": {
"bullmq": "^5.61.0",
"ioredis": "^5.8.0",
"zod": "^4.2.1",
},
},
},
"packages": {
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
"@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="],
"@aws-crypto/sha1-browser": ["@aws-crypto/sha1-browser@5.2.0", "", { "dependencies": { "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg=="],
"@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
"@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
"@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.958.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-node": "3.958.0", "@aws-sdk/middleware-bucket-endpoint": "3.957.0", "@aws-sdk/middleware-expect-continue": "3.957.0", "@aws-sdk/middleware-flexible-checksums": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-location-constraint": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/middleware-ssec": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/eventstream-serde-browser": "^4.2.7", "@smithy/eventstream-serde-config-resolver": "^4.3.7", "@smithy/eventstream-serde-node": "^4.2.7", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-blob-browser": "^4.2.8", "@smithy/hash-node": "^4.2.7", "@smithy/hash-stream-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/md5-js": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/util-waiter": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-ol8Sw37AToBWb6PjRuT/Wu40SrrZSA0N4F7U3yTkjUNX0lirfO1VFLZ0hZtZplVJv8GNPITbiczxQ8VjxESXxg=="],
"@aws-sdk/client-sso": ["@aws-sdk/client-sso@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-6qNCIeaMzKzfqasy2nNRuYnMuaMebCcCPP4J2CVGkA8QYMbIVKPlkn9bpB20Vxe6H/r3jtCCLQaOJjVTx/6dXg=="],
"@aws-sdk/core": ["@aws-sdk/core@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/xml-builder": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrZgDnF1lQZv75a52nFWs6MExihJF2GZB6ETZRqr6jMwhrk2kbJPUtvgbifwcL7AYmVqHQDJBrR/MqkwwFCpiw=="],
"@aws-sdk/crc64-nvme": ["@aws-sdk/crc64-nvme@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-qSwSfI+qBU9HDsd6/4fM9faCxYJx2yDuHtj+NVOQ6XYDWQzFab/hUdwuKZ77Pi6goLF1pBZhJ2azaC2w7LbnTA=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-475mkhGaWCr+Z52fOOVb/q2VHuNvqEDixlYIkeaO6xJ6t9qR0wpLt4hOQaR6zR1wfZV0SlE7d8RErdYq/PByog=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-stream": "^4.5.8", "tslib": "^2.6.2" } }, "sha512-8dS55QHRxXgJlHkEYaCGZIhieCs9NU1HU1BcqQ4RfUdSsfRdxxktqUKgCnBnOOn0oD3PPA8cQOCAVgIyRb3Rfw=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/credential-provider-env": "3.957.0", "@aws-sdk/credential-provider-http": "3.957.0", "@aws-sdk/credential-provider-login": "3.958.0", "@aws-sdk/credential-provider-process": "3.957.0", "@aws-sdk/credential-provider-sso": "3.958.0", "@aws-sdk/credential-provider-web-identity": "3.958.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-u7twvZa1/6GWmPBZs6DbjlegCoNzNjBsMS/6fvh5quByYrcJr/uLd8YEr7S3UIq4kR/gSnHqcae7y2nL2bqZdg=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-sDwtDnBSszUIbzbOORGh5gmXGl9aK25+BHb4gb1aVlqB+nNL2+IUEJA62+CE55lXSH8qXF90paivjK8tOHTwPA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.958.0", "", { "dependencies": { "@aws-sdk/credential-provider-env": "3.957.0", "@aws-sdk/credential-provider-http": "3.957.0", "@aws-sdk/credential-provider-ini": "3.958.0", "@aws-sdk/credential-provider-process": "3.957.0", "@aws-sdk/credential-provider-sso": "3.958.0", "@aws-sdk/credential-provider-web-identity": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-vdoZbNG2dt66I7EpN3fKCzi6fp9xjIiwEA/vVVgqO4wXCGw8rKPIdDUus4e13VvTr330uQs2W0UNg/7AgtquEQ=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/KIz9kadwbeLy6SKvT79W81Y+hb/8LMDyeloA2zhouE28hmne+hLn0wNCQXAAupFFlYOAtZR2NTBs7HBAReJlg=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.958.0", "", { "dependencies": { "@aws-sdk/client-sso": "3.958.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/token-providers": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-CBYHJ5ufp8HC4q+o7IJejCUctJXWaksgpmoFpXerbjAso7/Fg7LLUu9inXVOxlHKLlvYekDXjIUBXDJS2WYdgg=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-dgnvwjMq5Y66WozzUzxNkCFap+umHUtqMMKlr8z/vl9NYMLem/WUbWNpFFOVFWquXikc+ewtpBMR4KEDXfZ+KA=="],
"@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iczcn/QRIBSpvsdAS/rbzmoBpleX1JBjXvCynMbDceVLBIcVrwT1hXECrhtIC2cjh4HaLo9ClAbiOiWuqt+6MA=="],
"@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-AlbK3OeVNwZZil0wlClgeI/ISlOt/SPUxBsIns876IFaVu/Pj3DgImnYhpcJuFRek4r4XM51xzIaGQXM6GDHGg=="],
"@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.957.0", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/crc64-nvme": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/is-array-buffer": "^4.2.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-iJpeVR5V8se1hl2pt+k8bF/e9JO4KWgPCMjg8BtRspNtKIUGy7j6msYvbDixaKZaF2Veg9+HoYcOhwnZumjXSA=="],
"@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-BBgKawVyfQZglEkNTuBBdC3azlyqNXsvvN4jPkWAiNYcY0x1BasaJFl+7u/HisfULstryweJq/dAvIZIxzlZaA=="],
"@aws-sdk/middleware-location-constraint": ["@aws-sdk/middleware-location-constraint@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-y8/W7TOQpmDJg/fPYlqAhwA4+I15LrS7TwgUEoxogtkD8gfur9wFMRLT8LCyc9o4NMEcAnK50hSb4+wB0qv6tQ=="],
"@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-w1qfKrSKHf9b5a8O76yQ1t69u6NWuBjr5kBX+jRWFx/5mu6RLpqERXRpVJxfosbep7k3B+DSB5tZMZ82GKcJtQ=="],
"@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-D2H/WoxhAZNYX+IjkKTdOhOkWQaK0jjJrDBj56hKjU5c9ltQiaX/1PqJ4dfjHntEshJfu0w+E6XJ+/6A6ILBBA=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-arn-parser": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-5B2qY2nR2LYpxoQP0xUum5A1UNvH2JQpLHDH1nWFNF/XetV7ipFHksMxPNhtJJ6ARaWhQIDXfOUj0jcnkJxXUg=="],
"@aws-sdk/middleware-ssec": ["@aws-sdk/middleware-ssec@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-qwkmrK0lizdjNt5qxl4tHYfASh8DFpHXM1iDVo+qHe+zuslfMqQEGRkzxS8tJq/I+8F0c6v3IKOveKJAfIvfqQ=="],
"@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.957.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@smithy/core": "^3.20.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-50vcHu96XakQnIvlKJ1UoltrFODjsq2KvtTgHiPFteUS884lQnK5VC/8xd1Msz/1ONpLMzdCVproCQqhDTtMPQ=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.958.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.957.0", "@aws-sdk/middleware-host-header": "3.957.0", "@aws-sdk/middleware-logger": "3.957.0", "@aws-sdk/middleware-recursion-detection": "3.957.0", "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/region-config-resolver": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-endpoints": "3.957.0", "@aws-sdk/util-user-agent-browser": "3.957.0", "@aws-sdk/util-user-agent-node": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/core": "^3.20.0", "@smithy/fetch-http-handler": "^5.3.8", "@smithy/hash-node": "^4.2.7", "@smithy/invalid-dependency": "^4.2.7", "@smithy/middleware-content-length": "^4.2.7", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-retry": "^4.4.17", "@smithy/middleware-serde": "^4.2.8", "@smithy/middleware-stack": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/node-http-handler": "^4.4.7", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-body-length-node": "^4.2.1", "@smithy/util-defaults-mode-browser": "^4.3.16", "@smithy/util-defaults-mode-node": "^4.2.19", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-/KuCcS8b5TpQXkYOrPLYytrgxBhv81+5pChkOlhegbeHttjM69pyUpQVJqyfDM/A7wPLnDrzCAnk4zaAOkY0Nw=="],
"@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/config-resolver": "^4.4.5", "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-V8iY3blh8l2iaOqXWW88HbkY5jDoWjH56jonprG/cpyqqCnprvpMUZWPWYJoI8rHRf2bqzZeql1slxG6EnKI7A=="],
"@aws-sdk/s3-request-presigner": ["@aws-sdk/s3-request-presigner@3.958.0", "", { "dependencies": { "@aws-sdk/signature-v4-multi-region": "3.957.0", "@aws-sdk/types": "3.957.0", "@aws-sdk/util-format-url": "3.957.0", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/protocol-http": "^5.3.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-bFKsofead/fl3lyhdES+aNo+MZ+qv1ixSPSsF8O1oj6/KgGE0t1UH9AHw2vPq6iSQMTeEuyV0F5pC+Ns40kBgA=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.957.0", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/protocol-http": "^5.3.7", "@smithy/signature-v4": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-t6UfP1xMUigMMzHcb7vaZcjv7dA2DQkk9C/OAP1dKyrE0vb4lFGDaTApi17GN6Km9zFxJthEMUbBc7DL0hq1Bg=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.958.0", "", { "dependencies": { "@aws-sdk/core": "3.957.0", "@aws-sdk/nested-clients": "3.958.0", "@aws-sdk/types": "3.957.0", "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-UCj7lQXODduD1myNJQkV+LYcGYJ9iiMggR8ow8Hva1g3A/Na5imNXzz6O67k7DAee0TYpy+gkNw+SizC6min8Q=="],
"@aws-sdk/types": ["@aws-sdk/types@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-wzWC2Nrt859ABk6UCAVY/WYEbAd7FjkdrQL6m24+tfmWYDNRByTJ9uOgU/kw9zqLCAwb//CPvrJdhqjTznWXAg=="],
"@aws-sdk/util-arn-parser": ["@aws-sdk/util-arn-parser@3.957.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Aj6m+AyrhWyg8YQ4LDPg2/gIfGHCEcoQdBt5DeSFogN5k9mmJPOJ+IAmNSWmWRjpOxEy6eY813RNDI6qS97M0g=="],
"@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-endpoints": "^3.2.7", "tslib": "^2.6.2" } }, "sha512-xwF9K24mZSxcxKS3UKQFeX/dPYkEps9wF1b+MGON7EvnbcucrJGyQyK1v1xFPn1aqXkBTFi+SZaMRx5E5YCVFw=="],
"@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-Yyo/tlc0iGFGTPPkuxub1uRAv6XrnVnvSNjslZh5jIYA8GZoeEFPgJa3Qdu0GUS/YwoK8GOLnnaL9h/eH5LDJQ=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.957.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nhmgKHnNV9K+i9daumaIz8JTLsIIML9PE/HUks5liyrjUzenjW/aHoc7WJ9/Td/gPZtayxFnXQSJRb/fDlBuJw=="],
"@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.957.0", "", { "dependencies": { "@aws-sdk/types": "3.957.0", "@smithy/types": "^4.11.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-exueuwxef0lUJRnGaVkNSC674eAiWU07ORhxBnevFFZEKisln+09Qrtw823iyv5I1N8T+wKfh95xvtWQrNKNQw=="],
"@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.957.0", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "3.957.0", "@aws-sdk/types": "3.957.0", "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-ycbYCwqXk4gJGp0Oxkzf2KBeeGBdTxz559D41NJP8FlzSej1Gh7Rk40Zo6AyTfsNWkrl/kVi1t937OIzC5t+9Q=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.957.0", "", { "dependencies": { "@smithy/types": "^4.11.0", "fast-xml-parser": "5.2.5", "tslib": "^2.6.2" } }, "sha512-Ai5iiQqS8kJ5PjzMhWcLKN0G2yasAkvpnPlq2EnqlIMdB48HsizElt62qcktdxp4neRMyGkFq4NzgmDbXnhRiA=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.2", "", {}, "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
"@next/env": ["@next/env@15.5.3", "", {}, "sha512-RSEDTRqyihYXygx/OJXwvVupfr9m04+0vH8vyy0HfZ7keRto6VX9BbEk0J2PUk0VGy6YhklJUSrgForov5F9pw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-nzbHQo69+au9wJkGKTU9lP7PXv0d1J5ljFpvb+LnEomLtSbJkbZyEs6sbF3plQmiOB2l9OBtN2tNSvCH1nQ9Jg=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-w83w4SkOOhekJOcA5HBvHyGzgV1W/XvOfpkrxIse4uPWhYTTRwtGEM4v/jiXwNSJvfRvah0H8/uTLBKRXlef8g=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-+m7pfIs0/yvgVu26ieaKrifV8C8yiLe7jVp9SpcIzg7XmyyNE7toC1fy5IOQozmr6kWl/JONC51osih2RyoXRw=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-u3PEIzuguSenoZviZJahNLgCexGFhso5mxWCrrIMdvpZn6lkME5vc/ADZG8UUk5K1uWRy4hqSFECrON6UKQBbQ=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-lDtOOScYDZxI2BENN9m0pfVPJDSuUkAD1YXSvlJF0DKwZt0WlA7T7o3wrcEr4Q+iHYGzEaVuZcsIbCps4K27sA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-9vWVUnsx9PrY2NwdVRJ4dUURAQ8Su0sLRPqcCCxtX5zIQUBES12eRVHq6b70bbfaVaxIDGJN2afHui0eDm+cLg=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-1CU20FZzY9LFQigRi6jM45oJMU3KziA5/sSG+dXeVaTm661snQP6xu3ykGxxwU5sLG3sh14teO/IOEPVsQMRfA=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-JMoLAq3n3y5tKXPQwCK5c+6tmwkuFDa2XAxz8Wm4+IVthdBZdZGh+lmiLUHg9f9IDwIQpUjp+ysd6OkYTyZRZw=="],
"@smithy/abort-controller": ["@smithy/abort-controller@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw=="],
"@smithy/chunked-blob-reader": ["@smithy/chunked-blob-reader@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA=="],
"@smithy/chunked-blob-reader-native": ["@smithy/chunked-blob-reader-native@4.2.1", "", { "dependencies": { "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ=="],
"@smithy/config-resolver": ["@smithy/config-resolver@4.4.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "@smithy/util-config-provider": "^4.2.0", "@smithy/util-endpoints": "^3.2.7", "@smithy/util-middleware": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg=="],
"@smithy/core": ["@smithy/core@3.20.0", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.8", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-body-length-browser": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-stream": "^4.5.8", "@smithy/util-utf8": "^4.2.0", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ=="],
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.7", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.7", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-ujzPk8seYoDBmABDE5YqlhQZAXLOrtxtJLrbhHMKjBoG5b4dK4i6/mEU+6/7yXIAkqOO8sJ6YxZl+h0QQ1IJ7g=="],
"@smithy/eventstream-serde-config-resolver": ["@smithy/eventstream-serde-config-resolver@4.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-x7BtAiIPSaNaWuzm24Q/mtSkv+BrISO/fmheiJ39PKRNH3RmH2Hph/bUKSOBOBC9unqfIYDhKTHwpyZycLGPVQ=="],
"@smithy/eventstream-serde-node": ["@smithy/eventstream-serde-node@4.2.7", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-roySCtHC5+pQq5lK4be1fZ/WR6s/AxnPaLfCODIPArtN2du8s5Ot4mKVK3pPtijL/L654ws592JHJ1PbZFF6+A=="],
"@smithy/eventstream-serde-universal": ["@smithy/eventstream-serde-universal@4.2.7", "", { "dependencies": { "@smithy/eventstream-codec": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-QVD+g3+icFkThoy4r8wVFZMsIP08taHVKjE6Jpmz8h5CgX/kk6pTODq5cht0OMtcapUx+xrPzUTQdA+TmO0m1g=="],
"@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "tslib": "^2.6.2" } }, "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg=="],
"@smithy/hash-blob-browser": ["@smithy/hash-blob-browser@4.2.8", "", { "dependencies": { "@smithy/chunked-blob-reader": "^5.2.0", "@smithy/chunked-blob-reader-native": "^4.2.1", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-07InZontqsM1ggTCPSRgI7d8DirqRrnpL7nIACT4PW0AWrgDiHhjGZzbAE5UtRSiU0NISGUYe7/rri9ZeWyDpw=="],
"@smithy/hash-node": ["@smithy/hash-node@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw=="],
"@smithy/hash-stream-node": ["@smithy/hash-stream-node@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZQVoAwNYnFMIbd4DUc517HuwNelJUY6YOzwqrbcAgCnVn+79/OK7UjwA93SPpdTOpKDVkLIzavWm/Ck7SmnDPQ=="],
"@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ=="],
"@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ=="],
"@smithy/md5-js": ["@smithy/md5-js@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Wv6JcUxtOLTnxvNjDnAiATUsk8gvA6EeS8zzHig07dotpByYsLot+m0AaQEniUBjx97AC41MQR4hW0baraD1Xw=="],
"@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.7", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg=="],
"@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.1", "", { "dependencies": { "@smithy/core": "^3.20.0", "@smithy/middleware-serde": "^4.2.8", "@smithy/node-config-provider": "^4.3.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "@smithy/url-parser": "^4.2.7", "@smithy/util-middleware": "^4.2.7", "tslib": "^2.6.2" } }, "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg=="],
"@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.17", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/protocol-http": "^5.3.7", "@smithy/service-error-classification": "^4.2.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-retry": "^4.2.7", "@smithy/uuid": "^1.1.0", "tslib": "^2.6.2" } }, "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg=="],
"@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.8", "", { "dependencies": { "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w=="],
"@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw=="],
"@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.7", "", { "dependencies": { "@smithy/property-provider": "^4.2.7", "@smithy/shared-ini-file-loader": "^4.4.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw=="],
"@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.7", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/querystring-builder": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ=="],
"@smithy/property-provider": ["@smithy/property-provider@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA=="],
"@smithy/protocol-http": ["@smithy/protocol-http@5.3.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA=="],
"@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "@smithy/util-uri-escape": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg=="],
"@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w=="],
"@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0" } }, "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA=="],
"@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.2", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg=="],
"@smithy/signature-v4": ["@smithy/signature-v4@5.3.7", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-middleware": "^4.2.7", "@smithy/util-uri-escape": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg=="],
"@smithy/smithy-client": ["@smithy/smithy-client@4.10.2", "", { "dependencies": { "@smithy/core": "^3.20.0", "@smithy/middleware-endpoint": "^4.4.1", "@smithy/middleware-stack": "^4.2.7", "@smithy/protocol-http": "^5.3.7", "@smithy/types": "^4.11.0", "@smithy/util-stream": "^4.5.8", "tslib": "^2.6.2" } }, "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g=="],
"@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="],
"@smithy/url-parser": ["@smithy/url-parser@4.2.7", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg=="],
"@smithy/util-base64": ["@smithy/util-base64@4.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ=="],
"@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg=="],
"@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA=="],
"@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew=="],
"@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q=="],
"@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.16", "", { "dependencies": { "@smithy/property-provider": "^4.2.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ=="],
"@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.19", "", { "dependencies": { "@smithy/config-resolver": "^4.4.5", "@smithy/credential-provider-imds": "^4.2.7", "@smithy/node-config-provider": "^4.3.7", "@smithy/property-provider": "^4.2.7", "@smithy/smithy-client": "^4.10.2", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA=="],
"@smithy/util-endpoints": ["@smithy/util-endpoints@3.2.7", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg=="],
"@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw=="],
"@smithy/util-middleware": ["@smithy/util-middleware@4.2.7", "", { "dependencies": { "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w=="],
"@smithy/util-retry": ["@smithy/util-retry@4.2.7", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg=="],
"@smithy/util-stream": ["@smithy/util-stream@4.5.8", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.8", "@smithy/node-http-handler": "^4.4.7", "@smithy/types": "^4.11.0", "@smithy/util-base64": "^4.3.0", "@smithy/util-buffer-from": "^4.2.0", "@smithy/util-hex-encoding": "^4.2.0", "@smithy/util-utf8": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w=="],
"@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA=="],
"@smithy/util-utf8": ["@smithy/util-utf8@4.2.0", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw=="],
"@smithy/util-waiter": ["@smithy/util-waiter@4.2.7", "", { "dependencies": { "@smithy/abort-controller": "^4.2.7", "@smithy/types": "^4.11.0", "tslib": "^2.6.2" } }, "sha512-vHJFXi9b7kUEpHWUCY3Twl+9NPOZvQ0SAi+Ewtn48mbiJk4JY9MZmKQjGB4SCvVb9WPiSphZJYY6RIbs+grrzw=="],
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tline/config": ["@tline/config@workspace:packages/config"],
"@tline/db": ["@tline/db@workspace:packages/db"],
"@tline/minio": ["@tline/minio@workspace:packages/minio"],
"@tline/queue": ["@tline/queue@workspace:packages/queue"],
"@tline/web": ["@tline/web@workspace:apps/web"],
"@tline/worker": ["@tline/worker@workspace:apps/worker"],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="],
"@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"bullmq": ["bullmq@5.66.2", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.8.2", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.3", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-0PrkpIakIntkBcPLltPIRWdLC1FTLUa/VhJkmEfobb5YUQjoUwJdmmf7HX+o/vMonS5048JpP+abf9lVRUFEjA=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001761", "", {}, "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-xml-parser": ["fast-xml-parser@5.2.5", "", { "dependencies": { "strnum": "^2.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@15.5.3", "", { "dependencies": { "@next/env": "15.5.3", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.3", "@next/swc-darwin-x64": "15.5.3", "@next/swc-linux-arm64-gnu": "15.5.3", "@next/swc-linux-arm64-musl": "15.5.3", "@next/swc-linux-x64-gnu": "15.5.3", "@next/swc-linux-x64-musl": "15.5.3", "@next/swc-win32-arm64-msvc": "15.5.3", "@next/swc-win32-x64-msvc": "15.5.3", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-r/liNAx16SQj4D+XH/oI1dlpv9tdKJ6cONYPwwcCC46f2NjpaRWY+EKCzULfgQYV6YKXjHBchff2IZBSlZmJNw=="],
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="],
"react-dom": ["react-dom@19.2.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"bun-types/@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="],
"next/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
"next/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
"next/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
"next/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
"next/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
"next/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
"next/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
"next/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
"next/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
"next/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
"next/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
"next/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
"next/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
"next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
"next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
"next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
"next/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
"@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
}
}
+6
View File
@@ -0,0 +1,6 @@
apiVersion: v2
name: tline
description: Timeline media library (porthole)
type: application
version: 0.1.0
appVersion: "0.0.0"
+151
View File
@@ -0,0 +1,151 @@
{{- define "tline.name" -}}
{{- default .Chart.Name .Values.global.nameOverride | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "tline.fullname" -}}
{{- if .Values.global.fullnameOverride -}}
{{- .Values.global.fullnameOverride | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name (include "tline.name" .) | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "tline.labels" -}}
app.kubernetes.io/name: {{ include "tline.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }}
{{- end -}}
{{- define "tline.selectorLabels" -}}
app.kubernetes.io/name: {{ include "tline.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "tline.componentName" -}}
{{- printf "%s-%s" (include "tline.fullname" .) .component | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "tline.storageClass" -}}
{{- $sc := .storageClass | default "" -}}
{{- if $sc -}}
{{- $sc -}}
{{- else if .Values.global.storageClass -}}
{{- .Values.global.storageClass -}}
{{- else -}}
{{- "" -}}
{{- end -}}
{{- end -}}
{{- define "tline.affinity" -}}
{{- $class := .schedulingClass | default "compute" -}}
{{- $sched := index .Values.scheduling $class -}}
{{- if $sched.affinity -}}
{{- toYaml $sched.affinity -}}
{{- end -}}
{{- end -}}
{{- define "tline.tolerations" -}}
{{- $class := .schedulingClass | default "compute" -}}
{{- $sched := index .Values.scheduling $class -}}
{{- if $sched.tolerations -}}
{{- toYaml $sched.tolerations -}}
{{- end -}}
{{- end -}}
{{- define "tline.secretName" -}}
{{- if .Values.secrets.existingSecret -}}
{{- .Values.secrets.existingSecret -}}
{{- else -}}
{{- printf "%s-secrets" (include "tline.fullname" .) -}}
{{- end -}}
{{- end -}}
{{- define "tline.databaseUrl" -}}
{{- if .Values.app.databaseUrl -}}
{{- .Values.app.databaseUrl -}}
{{- else if not .Values.postgres.enabled -}}
{{- fail "app.databaseUrl is required when postgres.enabled=false" -}}
{{- else if .Values.secrets.existingSecret -}}
{{- fail "app.databaseUrl is required when secrets.existingSecret is set (password not available in values)" -}}
{{- else -}}
{{- $svc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") -}}
{{- printf "postgres://%s:%s@%s:%d/%s" .Values.secrets.postgres.user .Values.secrets.postgres.password $svc (.Values.postgres.service.port | int) .Values.secrets.postgres.database -}}
{{- end -}}
{{- end -}}
{{- define "tline.redisUrl" -}}
{{- if .Values.app.redisUrl -}}
{{- .Values.app.redisUrl -}}
{{- else if not .Values.redis.enabled -}}
{{- fail "app.redisUrl is required when redis.enabled=false" -}}
{{- else -}}
{{- $svc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "redis") -}}
{{- printf "redis://%s:%d" $svc (.Values.redis.service.port | int) -}}
{{- end -}}
{{- end -}}
{{- define "tline.minioInternalEndpoint" -}}
{{- if .Values.app.minio.internalEndpoint -}}
{{- .Values.app.minio.internalEndpoint -}}
{{- else if not .Values.minio.enabled -}}
{{- fail "app.minio.internalEndpoint is required when minio.enabled=false" -}}
{{- else -}}
{{- $svc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
{{- printf "http://%s:%d" $svc (.Values.minio.service.s3Port | int) -}}
{{- end -}}
{{- end -}}
{{- define "tline.minioPublicEndpointTs" -}}
{{- if .Values.app.minio.publicEndpointTs -}}
{{- .Values.app.minio.publicEndpointTs -}}
{{- else if and .Values.global.tailscale.enabled .Values.global.tailscale.tailnetFQDN -}}
{{- $label := .Values.minio.ingressS3.hostnameLabel -}}
{{- if .Values.minio.tailscaleServiceS3.enabled -}}
{{- $label = .Values.minio.tailscaleServiceS3.hostnameLabel -}}
{{- end -}}
{{- printf "https://%s.%s" $label .Values.global.tailscale.tailnetFQDN -}}
{{- else -}}
{{- fail "app.minio.publicEndpointTs is required (or set global.tailscale.tailnetFQDN to derive it)" -}}
{{- end -}}
{{- end -}}
{{- define "tline.image" -}}
{{- $repo := .repository | default "" -}}
{{- $tag := .tag | default "" -}}
{{- if or (not $repo) (not $tag) -}}
{{- fail "image repository and tag must be set" -}}
{{- end -}}
{{- printf "%s:%s" $repo $tag -}}
{{- end -}}
{{- define "tline.migrateImage" -}}
{{- $repo := .Values.jobs.migrate.image.repository | default .Values.images.worker.repository -}}
{{- $tag := .Values.jobs.migrate.image.tag | default .Values.images.worker.tag -}}
{{- $policy := .Values.jobs.migrate.image.pullPolicy | default .Values.images.worker.pullPolicy -}}
{{- if or (not $repo) (not $tag) -}}
{{- fail "migrate image repository/tag must be set (jobs.migrate.image or images.worker)" -}}
{{- end -}}
{{- dict "image" (printf "%s:%s" $repo $tag) "pullPolicy" $policy | toYaml -}}
{{- end -}}
{{- define "tline.registrySecretName" -}}
{{- if .Values.registrySecret.name -}}
{{- .Values.registrySecret.name -}}
{{- else -}}
{{- printf "%s-registry" (include "tline.fullname" .) -}}
{{- end -}}
{{- end -}}
{{- define "tline.imagePullSecrets" -}}
{{- $secrets := .Values.imagePullSecrets | default (list) -}}
{{- if .Values.registrySecret.create -}}
{{- $secrets = append $secrets (include "tline.registrySecretName" .) -}}
{{- end -}}
{{- if gt (len $secrets) 0 -}}
imagePullSecrets:
{{- range $name := $secrets }}
- name: {{ $name | quote }}
{{- end -}}
{{- end -}}
{{- end -}}
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
labels:
{{ include "tline.labels" . | indent 4 }}
data:
APP_NAME: {{ .Values.app.name | quote }}
NEXT_PUBLIC_APP_NAME: {{ .Values.app.name | quote }}
QUEUE_NAME: {{ .Values.app.queueName | quote }}
DATABASE_URL: {{ include "tline.databaseUrl" . | quote }}
REDIS_URL: {{ include "tline.redisUrl" . | quote }}
MINIO_INTERNAL_ENDPOINT: {{ include "tline.minioInternalEndpoint" . | quote }}
MINIO_PUBLIC_ENDPOINT_TS: {{ include "tline.minioPublicEndpointTs" . | quote }}
MINIO_REGION: {{ .Values.app.minio.region | quote }}
MINIO_BUCKET: {{ .Values.app.minio.bucket | quote }}
MINIO_PRESIGN_EXPIRES_SECONDS: {{ .Values.app.minio.presignExpiresSeconds | quote }}
@@ -0,0 +1,64 @@
{{- if and .Values.cronjobs.cleanupStaging.enabled .Values.minio.enabled -}}
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "cleanup-staging") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: cleanup-staging
spec:
schedule: {{ .Values.cronjobs.cleanupStaging.schedule | quote }}
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 12 }}
app.kubernetes.io/component: cleanup-staging
spec:
restartPolicy: Never
{{ include "tline.imagePullSecrets" . | indent 10 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 12 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 12 }}
{{- end }}
containers:
- name: cleanup
image: {{ printf "%s:%s" .Values.cronjobs.cleanupStaging.image.repository .Values.cronjobs.cleanupStaging.image.tag | quote }}
imagePullPolicy: {{ .Values.cronjobs.cleanupStaging.image.pullPolicy }}
command:
- sh
- -c
- |
set -eu
echo "Configuring mc alias..."
{{- $minioSvc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
{{- $minioEndpoint := printf "http://%s:%d" $minioSvc (.Values.minio.service.s3Port | int) -}}
mc alias set local {{ $minioEndpoint | quote }} "$MINIO_ACCESS_KEY_ID" "$MINIO_SECRET_ACCESS_KEY"
echo "Removing staged objects older than {{ .Values.cronjobs.cleanupStaging.olderThanDays }}d..."
mc rm --recursive --force --older-than "{{ .Values.cronjobs.cleanupStaging.olderThanDays }}d" "local/{{ .Values.app.minio.bucket }}/staging" || true
env:
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_SECRET_ACCESS_KEY
resources:
{{ toYaml .Values.cronjobs.cleanupStaging.resources | indent 16 }}
{{- end }}
@@ -0,0 +1,120 @@
{{- if and .Values.global.tailscale.enabled .Values.web.enabled .Values.web.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: web
annotations:
{{- if .Values.global.tailscale.proxyGroup.enabled }}
tailscale.com/proxy-group: {{ .Values.global.tailscale.proxyGroup.name | quote }}
{{- end }}
{{- if .Values.web.ingress.funnel }}
tailscale.com/funnel: "true"
{{- end }}
{{- if .Values.web.ingress.tags }}
tailscale.com/tags: {{ join "," .Values.web.ingress.tags | quote }}
{{- end }}
{{- with .Values.web.ingress.extraAnnotations }}
{{ toYaml . | indent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.global.tailscale.ingressClassName }}
tls:
- hosts:
- {{ .Values.web.ingress.hostnameLabel | quote }}
rules:
- host: {{ .Values.web.ingress.hostnameLabel | quote }}
http:
paths:
- path: {{ .Values.web.ingress.path }}
pathType: {{ .Values.web.ingress.pathType }}
backend:
service:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
port:
number: {{ .Values.web.service.port }}
{{ end }}
{{- $disableMinioS3Ingress := and .Values.minio.tailscaleServiceS3.enabled (.Values.minio.ingressS3DisabledWhenTailscaleService | default true) -}}
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.ingressS3.enabled (not $disableMinioS3Ingress) -}}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: minio
annotations:
{{- if .Values.global.tailscale.proxyGroup.enabled }}
tailscale.com/proxy-group: {{ .Values.global.tailscale.proxyGroup.name | quote }}
{{- end }}
{{- if .Values.minio.ingressS3.funnel }}
tailscale.com/funnel: "true"
{{- end }}
{{- if .Values.minio.ingressS3.tags }}
tailscale.com/tags: {{ join "," .Values.minio.ingressS3.tags | quote }}
{{- end }}
{{- with .Values.minio.ingressS3.extraAnnotations }}
{{ toYaml . | indent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.global.tailscale.ingressClassName }}
tls:
- hosts:
- {{ .Values.minio.ingressS3.hostnameLabel | quote }}
rules:
- host: {{ .Values.minio.ingressS3.hostnameLabel | quote }}
http:
paths:
- path: {{ .Values.minio.ingressS3.path }}
pathType: {{ .Values.minio.ingressS3.pathType }}
backend:
service:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
port:
number: {{ .Values.minio.service.s3Port }}
{{ end }}
{{- $disableMinioConsoleIngress := and .Values.minio.tailscaleServiceConsole.enabled (.Values.minio.ingressConsoleDisabledWhenTailscaleService | default true) -}}
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.ingressConsole.enabled (not $disableMinioConsoleIngress) -}}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio-console") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: minio
annotations:
{{- if .Values.global.tailscale.proxyGroup.enabled }}
tailscale.com/proxy-group: {{ .Values.global.tailscale.proxyGroup.name | quote }}
{{- end }}
{{- if .Values.minio.ingressConsole.funnel }}
tailscale.com/funnel: "true"
{{- end }}
{{- if .Values.minio.ingressConsole.tags }}
tailscale.com/tags: {{ join "," .Values.minio.ingressConsole.tags | quote }}
{{- end }}
{{- with .Values.minio.ingressConsole.extraAnnotations }}
{{ toYaml . | indent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.global.tailscale.ingressClassName }}
tls:
- hosts:
- {{ .Values.minio.ingressConsole.hostnameLabel | quote }}
rules:
- host: {{ .Values.minio.ingressConsole.hostnameLabel | quote }}
http:
paths:
- path: {{ .Values.minio.ingressConsole.path }}
pathType: {{ .Values.minio.ingressConsole.pathType }}
backend:
service:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
port:
number: {{ .Values.minio.service.consolePort }}
{{- end }}
@@ -0,0 +1,64 @@
{{- if and .Values.jobs.ensureBucket.enabled .Values.minio.enabled -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "ensure-bucket") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: ensure-bucket
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-20"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 2
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: ensure-bucket
spec:
restartPolicy: Never
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: ensure-bucket
image: {{ printf "%s:%s" .Values.jobs.ensureBucket.image.repository .Values.jobs.ensureBucket.image.tag | quote }}
imagePullPolicy: {{ .Values.jobs.ensureBucket.image.pullPolicy }}
command:
- sh
- -c
- |
set -eu
echo "Configuring mc alias..."
{{- $minioSvc := include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") -}}
{{- $minioEndpoint := printf "http://%s:%d" $minioSvc (.Values.minio.service.s3Port | int) -}}
mc alias set local {{ $minioEndpoint | quote }} "$MINIO_ACCESS_KEY_ID" "$MINIO_SECRET_ACCESS_KEY"
echo "Ensuring bucket exists: {{ .Values.app.minio.bucket }}"
mc mb --ignore-existing "local/{{ .Values.app.minio.bucket }}"
# Never mutate or delete originals/**. This job only ensures the bucket exists.
env:
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_SECRET_ACCESS_KEY
resources:
{{ toYaml .Values.jobs.ensureBucket.resources | indent 12 }}
{{- end }}
+51
View File
@@ -0,0 +1,51 @@
{{- if .Values.jobs.migrate.enabled -}}
apiVersion: batch/v1
kind: Job
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "migrate") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: migrate
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook-weight": "-10"
"helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded
spec:
backoffLimit: 3
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: migrate
spec:
restartPolicy: Never
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: migrate
{{- $img := fromYaml (include "tline.migrateImage" .) }}
image: {{ $img.image | quote }}
imagePullPolicy: {{ $img.pullPolicy }}
command:
- bun
- run
- packages/db/src/migrate.ts
envFrom:
- configMapRef:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: POSTGRES_PASSWORD
{{- end }}
+107
View File
@@ -0,0 +1,107 @@
{{- if .Values.minio.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: minio
spec:
type: ClusterIP
ports:
- name: s3
port: {{ .Values.minio.service.s3Port }}
targetPort: s3
- name: console
port: {{ .Values.minio.service.consolePort }}
targetPort: console
selector:
{{ include "tline.selectorLabels" . | indent 4 }}
app.kubernetes.io/component: minio
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: minio
spec:
serviceName: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio") }}
replicas: 1
selector:
matchLabels:
{{ include "tline.selectorLabels" . | indent 6 }}
app.kubernetes.io/component: minio
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: minio
spec:
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.minio.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: minio
image: {{ printf "%s:%s" .Values.images.minio.repository .Values.images.minio.tag | quote }}
imagePullPolicy: {{ .Values.images.minio.pullPolicy }}
args:
- server
- /data
- "--console-address=:{{ .Values.minio.service.consolePort }}"
ports:
- name: s3
containerPort: 9000
- name: console
containerPort: 9001
env:
- name: MINIO_ROOT_USER
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_ACCESS_KEY_ID
- name: MINIO_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_SECRET_ACCESS_KEY
readinessProbe:
httpGet:
path: /minio/health/ready
port: s3
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /minio/health/live
port: s3
initialDelaySeconds: 20
periodSeconds: 10
resources:
{{ toYaml .Values.minio.resources | indent 12 }}
volumeMounts:
- name: data
mountPath: /data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
{{- $sc := include "tline.storageClass" (dict "Values" .Values "storageClass" .Values.minio.storage.storageClass) -}}
{{- if $sc }}
storageClassName: {{ $sc | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.minio.storage.size | quote }}
{{- end }}
+100
View File
@@ -0,0 +1,100 @@
{{- if .Values.postgres.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") }}
labels:
{{ include "tline.labels" . | indent 4 }}
spec:
type: ClusterIP
ports:
- name: postgres
port: {{ .Values.postgres.service.port }}
targetPort: postgres
selector:
{{ include "tline.selectorLabels" . | indent 4 }}
app.kubernetes.io/component: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: postgres
spec:
serviceName: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "postgres") }}
replicas: 1
selector:
matchLabels:
{{ include "tline.selectorLabels" . | indent 6 }}
app.kubernetes.io/component: postgres
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: postgres
spec:
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.postgres.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: postgres
image: {{ printf "%s:%s" .Values.images.postgres.repository .Values.images.postgres.tag | quote }}
imagePullPolicy: {{ .Values.images.postgres.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_USER
value: {{ .Values.secrets.postgres.user | quote }}
- name: POSTGRES_DB
value: {{ .Values.secrets.postgres.database | quote }}
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: POSTGRES_PASSWORD
readinessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
exec:
command:
- sh
- -c
- pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB"
initialDelaySeconds: 20
periodSeconds: 10
resources:
{{ toYaml .Values.postgres.resources | indent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes:
- ReadWriteOnce
{{- $sc := include "tline.storageClass" (dict "Values" .Values "storageClass" .Values.postgres.storage.storageClass) -}}
{{- if $sc }}
storageClassName: {{ $sc | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.storage.size | quote }}
{{- end }}
+57
View File
@@ -0,0 +1,57 @@
{{- if .Values.redis.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "redis") }}
labels:
{{ include "tline.labels" . | indent 4 }}
spec:
type: ClusterIP
ports:
- name: redis
port: {{ .Values.redis.service.port }}
targetPort: redis
selector:
{{ include "tline.selectorLabels" . | indent 4 }}
app.kubernetes.io/component: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "redis") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
selector:
matchLabels:
{{ include "tline.selectorLabels" . | indent 6 }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: redis
spec:
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.redis.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.redis.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: redis
image: {{ printf "%s:%s" .Values.images.redis.repository .Values.images.redis.tag | quote }}
imagePullPolicy: {{ .Values.images.redis.pullPolicy }}
ports:
- name: redis
containerPort: 6379
resources:
{{ toYaml .Values.redis.resources | indent 12 }}
{{- end }}
+32
View File
@@ -0,0 +1,32 @@
{{- if not .Values.secrets.existingSecret -}}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "tline.secretName" . }}
labels:
{{ include "tline.labels" . | indent 4 }}
type: Opaque
data:
POSTGRES_PASSWORD: {{ required "secrets.postgres.password is required" .Values.secrets.postgres.password | b64enc }}
MINIO_ACCESS_KEY_ID: {{ required "secrets.minio.accessKeyId is required" .Values.secrets.minio.accessKeyId | b64enc }}
MINIO_SECRET_ACCESS_KEY: {{ required "secrets.minio.secretAccessKey is required" .Values.secrets.minio.secretAccessKey | b64enc }}
{{- end }}
{{- if .Values.registrySecret.create -}}
---
apiVersion: v1
kind: Secret
metadata:
name: {{ include "tline.registrySecretName" . }}
labels:
{{ include "tline.labels" . | indent 4 }}
type: kubernetes.io/dockerconfigjson
{{ $server := required "registrySecret.server is required" .Values.registrySecret.server -}}
{{ $user := .Values.registrySecret.username | default "" -}}
{{ $pass := required "registrySecret.password is required" .Values.registrySecret.password -}}
{{ $email := .Values.registrySecret.email | default "" -}}
{{ $auth := printf "%s:%s" $user $pass | b64enc -}}
{{ $cfg := dict "auths" (dict $server (dict "username" $user "password" $pass "email" $email "auth" $auth)) -}}
data:
.dockerconfigjson: {{ $cfg | toJson | b64enc }}
{{- end }}
@@ -0,0 +1,27 @@
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.tailscaleServiceConsole.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio-ts-console") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: minio
annotations:
tailscale.com/hostname: {{ .Values.minio.tailscaleServiceConsole.hostnameLabel | quote }}
{{- if .Values.minio.tailscaleServiceConsole.tags }}
tailscale.com/tags: {{ join "," .Values.minio.tailscaleServiceConsole.tags | quote }}
{{- end }}
{{- with .Values.minio.tailscaleServiceConsole.extraAnnotations }}
{{ toYaml . | indent 4 }}
{{- end }}
spec:
type: LoadBalancer
loadBalancerClass: tailscale
ports:
- name: console
port: 443
targetPort: console
selector:
{{ include "tline.selectorLabels" . | indent 4 }}
app.kubernetes.io/component: minio
{{- end }}
@@ -0,0 +1,27 @@
{{- if and .Values.global.tailscale.enabled .Values.minio.enabled .Values.minio.tailscaleServiceS3.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "minio-ts-s3") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: minio
annotations:
tailscale.com/hostname: {{ .Values.minio.tailscaleServiceS3.hostnameLabel | quote }}
{{- if .Values.minio.tailscaleServiceS3.tags }}
tailscale.com/tags: {{ join "," .Values.minio.tailscaleServiceS3.tags | quote }}
{{- end }}
{{- with .Values.minio.tailscaleServiceS3.extraAnnotations }}
{{ toYaml . | indent 4 }}
{{- end }}
spec:
type: LoadBalancer
loadBalancerClass: tailscale
ports:
- name: s3
port: 443
targetPort: s3
selector:
{{ include "tline.selectorLabels" . | indent 4 }}
app.kubernetes.io/component: minio
{{- end }}
+89
View File
@@ -0,0 +1,89 @@
{{- if .Values.web.enabled -}}
apiVersion: v1
kind: Service
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: web
spec:
type: ClusterIP
ports:
- name: http
port: {{ .Values.web.service.port }}
targetPort: http
selector:
{{ include "tline.selectorLabels" . | indent 4 }}
app.kubernetes.io/component: web
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "web") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: web
spec:
replicas: {{ .Values.web.replicas }}
selector:
matchLabels:
{{ include "tline.selectorLabels" . | indent 6 }}
app.kubernetes.io/component: web
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: web
spec:
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.web.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.web.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: web
image: {{ include "tline.image" (dict "repository" .Values.images.web.repository "tag" .Values.images.web.tag) | quote }}
imagePullPolicy: {{ .Values.images.web.pullPolicy }}
ports:
- name: http
containerPort: 3000
envFrom:
- configMapRef:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: POSTGRES_PASSWORD
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_SECRET_ACCESS_KEY
readinessProbe:
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 5
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/healthz
port: http
initialDelaySeconds: 20
periodSeconds: 10
resources:
{{ toYaml .Values.web.resources | indent 12 }}
{{- end }}
+57
View File
@@ -0,0 +1,57 @@
{{- if .Values.worker.enabled -}}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "worker") }}
labels:
{{ include "tline.labels" . | indent 4 }}
app.kubernetes.io/component: worker
spec:
replicas: {{ .Values.worker.replicas }}
selector:
matchLabels:
{{ include "tline.selectorLabels" . | indent 6 }}
app.kubernetes.io/component: worker
template:
metadata:
labels:
{{ include "tline.selectorLabels" . | indent 8 }}
app.kubernetes.io/component: worker
spec:
{{ include "tline.imagePullSecrets" . | indent 6 }}
{{- $aff := include "tline.affinity" (dict "Values" .Values "schedulingClass" .Values.worker.schedulingClass) }}
{{- if $aff }}
affinity:
{{ $aff | indent 8 }}
{{- end }}
{{- $tols := include "tline.tolerations" (dict "Values" .Values "schedulingClass" .Values.worker.schedulingClass) }}
{{- if $tols }}
tolerations:
{{ $tols | indent 8 }}
{{- end }}
containers:
- name: worker
image: {{ include "tline.image" (dict "repository" .Values.images.worker.repository "tag" .Values.images.worker.tag) | quote }}
imagePullPolicy: {{ .Values.images.worker.pullPolicy }}
envFrom:
- configMapRef:
name: {{ include "tline.componentName" (dict "Values" .Values "Chart" .Chart "Release" .Release "component" "config") }}
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: POSTGRES_PASSWORD
- name: MINIO_ACCESS_KEY_ID
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_ACCESS_KEY_ID
- name: MINIO_SECRET_ACCESS_KEY
valueFrom:
secretKeyRef:
name: {{ include "tline.secretName" . }}
key: MINIO_SECRET_ACCESS_KEY
resources:
{{ toYaml .Values.worker.resources | indent 12 }}
{{- end }}
+254
View File
@@ -0,0 +1,254 @@
global:
nameOverride: ""
fullnameOverride: ""
storageClass: ""
tailscale:
enabled: true
ingressClassName: tailscale
tailnetFQDN: "" # e.g. tailxyz.ts.net
proxyGroup:
enabled: false
create: false
name: ingress-proxies
tags: []
scheduling:
compute:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: node-class
operator: In
values:
- compute
tolerations: []
app:
name: porthole
queueName: tline
# Optional overrides when bringing your own services.
databaseUrl: "" # defaults to in-chart Postgres when empty
redisUrl: "" # defaults to in-chart Redis when empty
minio:
bucket: media
region: us-east-1
presignExpiresSeconds: 900
publicEndpointTs: "" # optional if global.tailscale.tailnetFQDN is set (defaults to https://minio.<tailnet-fqdn>)
internalEndpoint: "" # defaults to in-chart MinIO when empty
secrets:
existingSecret: "" # if set, chart will not create secrets
postgres:
user: tline
password: "" # REQUIRED if not using existingSecret
database: tline
minio:
accessKeyId: "" # REQUIRED if not using existingSecret
secretAccessKey: "" # REQUIRED if not using existingSecret
images:
web:
repository: ""
tag: ""
pullPolicy: IfNotPresent
worker:
repository: ""
tag: ""
pullPolicy: IfNotPresent
postgres:
repository: postgres
tag: "16"
pullPolicy: IfNotPresent
redis:
repository: redis
tag: "7"
pullPolicy: IfNotPresent
minio:
repository: minio/minio
tag: RELEASE.2024-01-16T16-07-38Z
pullPolicy: IfNotPresent
# Optional: secrets used for pulling container images.
# Each entry is a Secret name in the same namespace.
imagePullSecrets: []
# Optional: create a docker-registry Secret from values.
# This is convenient for private/local deployments, but stores credentials in values.
registrySecret:
create: false
name: "" # defaults to <release>-<chart>-registry
server: "" # e.g. registry.lan:5000
username: ""
password: ""
email: ""
web:
enabled: true
replicas: 1
schedulingClass: compute
service:
port: 3000
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: 1000m
memory: 1Gi
ingress:
enabled: true
hostnameLabel: app
path: /
pathType: Prefix
tags: []
funnel: false
extraAnnotations: {}
worker:
enabled: true
replicas: 1
schedulingClass: compute
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 2000m
memory: 2Gi
postgres:
enabled: true
schedulingClass: compute
service:
port: 5432
storage:
size: 20Gi
storageClass: "" # defaults to global.storageClass
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: 1500m
memory: 2Gi
redis:
enabled: true
schedulingClass: compute
service:
port: 6379
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 300m
memory: 512Mi
minio:
enabled: true
schedulingClass: compute
service:
s3Port: 9000
consolePort: 9001
# Optional: expose MinIO S3 API via a Tailscale LoadBalancer Service
# instead of (or in addition to) Tailscale Ingress.
# This can be more reliable for streaming / Range requests depending on
# Tailscale operator + cluster behavior.
tailscaleServiceS3:
enabled: false
hostnameLabel: minio
tags: []
extraAnnotations: {}
tailscaleServiceConsole:
enabled: false
hostnameLabel: minio-console
tags: []
extraAnnotations: {}
# When true, disable the MinIO S3 Ingress when S3 service is enabled.
ingressS3DisabledWhenTailscaleService: true
# When true, disable the MinIO console Ingress when console service is enabled.
ingressConsoleDisabledWhenTailscaleService: true
storage:
size: 200Gi
storageClass: "" # defaults to global.storageClass
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1500m
memory: 2Gi
ingressS3:
enabled: true
hostnameLabel: minio
path: /
pathType: Prefix
tags: []
funnel: false
extraAnnotations: {}
ingressConsole:
enabled: true
hostnameLabel: minio-console
path: /
pathType: Prefix
tags: []
funnel: false
extraAnnotations: {}
jobs:
ensureBucket:
enabled: false
image:
repository: minio/mc
tag: RELEASE.2024-01-16T16-07-38Z
pullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 300m
memory: 256Mi
migrate:
enabled: true
image:
repository: "" # defaults to images.worker.repository when empty
tag: "" # defaults to images.worker.tag when empty
pullPolicy: "" # defaults to images.worker.pullPolicy when empty
cronjobs:
cleanupStaging:
enabled: false
schedule: "0 4 * * *"
# Remove objects under `staging/` older than this many days.
# This CronJob must never touch `originals/`.
olderThanDays: 14
image:
repository: minio/mc
tag: RELEASE.2024-01-16T16-07-38Z
pullPolicy: IfNotPresent
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 300m
memory: 256Mi
+111
View File
@@ -0,0 +1,111 @@
{
"$schema": "https://opencode.ai/config.json",
"agent": {
"orchestrator": {
"description": "Backlog coordination, contracts, and acceptance criteria",
"mode": "primary",
"model": "github-copilot/gpt-5.2",
"temperature": 0.2,
"prompt": "You are the orchestrator for this repo. Keep PLAN.md as the source of truth. Align DB/API/worker/UI contracts, surface inconsistencies, and produce concise checklists and decisions. Prefer minimal MVP scope. Enforce: never delete/mutate originals/ and presigned URLs must target MINIO_PUBLIC_ENDPOINT_TS with path-style URLs.",
"tools": {
"edit": false,
"write": false
},
"permission": {
"edit": "deny",
"bash": {
"git status": "allow",
"git diff*": "allow",
"git log*": "allow",
"*": "ask"
},
"webfetch": "allow"
}
},
"backend-api": {
"description": "Next.js API routes, DB schema/migrations, MinIO presigned URL logic",
"mode": "primary",
"model": "github-copilot/claude-sonnet-4.5",
"temperature": 0.3,
"prompt": "You implement backend/API work for the timeline media library per PLAN.md. Be strict about request validation (zod), implement correct presigned URL behavior (path-style, MINIO_PUBLIC_ENDPOINT_TS), and respect the external originals policy (never delete/mutate originals/). Prefer small, reviewable changes.",
"tools": {
"bash": true,
"read": true,
"grep": true,
"glob": true,
"list": true,
"edit": true,
"write": true
}
},
"worker-media": {
"description": "BullMQ worker, ExifTool/ffprobe/ffmpeg integration, thumbs/posters",
"mode": "primary",
"model": "github-copilot/claude-sonnet-4.5",
"temperature": 0.3,
"prompt": "You implement the worker/media pipeline per PLAN.md: scan_minio_prefix, process_asset, copy_to_canonical. Be robust: never crash the worker loop; update assets.status=failed and error_message on failures. Keep concurrency low (Pi-friendly). Never delete/mutate originals/; canonical copy is copy-only.",
"tools": {
"bash": true,
"read": true,
"grep": true,
"glob": true,
"list": true,
"edit": true,
"write": true
}
},
"frontend-ui": {
"description": "Timeline tree rendering, responsive layout, virtualization, styling",
"mode": "primary",
"model": "github-copilot/gpt-5.2",
"temperature": 0.4,
"prompt": "You implement the frontend UI per PLAN.md using Next.js App Router. Build the interactive timeline tree (orientation toggle, zoom/pan, expand/collapse), mobile-friendly layout (bottom sheet), virtualized thumbnails, and viewer modal. The UI must never crash on failed assets; render placeholders.",
"tools": {
"bash": true,
"read": true,
"grep": true,
"glob": true,
"list": true,
"edit": true,
"write": true
}
},
"k8s-infra": {
"description": "Helm/Kustomize, node affinity, MinIO/Postgres/Redis manifests, Tailscale ingress",
"mode": "primary",
"model": "github-copilot/claude-sonnet-4.5",
"temperature": 0.3,
"prompt": "You implement Pi-aware Kubernetes deployment artifacts per PLAN.md. Pin heavy pods to Pi 5 nodes via affinity/labels, provision Longhorn PVCs for Postgres/MinIO, and configure Tailscale ingress for app/minio/console. Ensure video Range requests work. Never include secrets; use placeholders and document required env vars.",
"tools": {
"bash": true,
"read": true,
"grep": true,
"glob": true,
"list": true,
"edit": true,
"write": true
}
},
"qa-review": {
"description": "Test plan, edge cases, security review, performance checks",
"mode": "primary",
"model": "github-copilot/claude-haiku-4.5",
"temperature": 0.2,
"prompt": "You perform targeted QA review per PLAN.md. Focus on edge cases (missing EXIF, timezones, corrupt files, unsupported codecs) and resilience (UI never crashes). Check presigned URLs are HTTPS and use MINIO_PUBLIC_ENDPOINT_TS; confirm Range video playback through ingress. Suggest minimal tests if a harness exists.",
"tools": {
"edit": false,
"write": false
},
"permission": {
"edit": "deny",
"bash": {
"git status": "allow",
"git diff*": "allow",
"git log*": "allow",
"*": "ask"
},
"webfetch": "deny"
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "tline",
"private": true,
"version": "0.0.0",
"description": "Timeline media library (porthole)",
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"typecheck": "bunx tsc -p packages/config/tsconfig.json --noEmit && bunx tsc -p packages/db/tsconfig.json --noEmit && bunx tsc -p packages/minio/tsconfig.json --noEmit && bunx tsc -p packages/queue/tsconfig.json --noEmit && bunx tsc -p apps/worker/tsconfig.json --noEmit && bunx tsc -p apps/web/tsconfig.json --noEmit",
"lint": "bunx eslint .",
"format": "bunx prettier . --check"
},
"devDependencies": {
"@types/node": "^20.19.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"bun-types": "^1.3.5",
"eslint": "^9.39.2",
"prettier": "^3.7.4",
"typescript": "^5.9.3"
},
"dependencies": {
"zod": "^4.2.1"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@tline/config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod";
const envSchema = z.object({
APP_NAME: z.string().min(1).default("porthole"),
NEXT_PUBLIC_APP_NAME: z.string().min(1).optional()
});
let cachedEnv: z.infer<typeof envSchema> | undefined;
export function getEnv() {
if (cachedEnv) return cachedEnv;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid environment variables: ${parsed.error.message}`);
}
cachedEnv = parsed.data;
return cachedEnv;
}
export function getAppName() {
const env = getEnv();
return env.NEXT_PUBLIC_APP_NAME ?? env.APP_NAME;
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}
+88
View File
@@ -0,0 +1,88 @@
-- Task 2 (MVP): assets/imports schema
CREATE EXTENSION IF NOT EXISTS pgcrypto;
DO $$ BEGIN
CREATE TYPE media_type AS ENUM ('image', 'video');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE asset_status AS ENUM ('new', 'processing', 'ready', 'failed');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE date_confidence AS ENUM ('camera', 'container', 'object_mtime', 'import_time');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
DO $$ BEGIN
CREATE TYPE import_type AS ENUM ('upload', 'minio_scan', 'normalize_copy');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
CREATE TABLE IF NOT EXISTS imports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
type import_type NOT NULL,
status text NOT NULL DEFAULT 'new',
created_at timestamptz NOT NULL DEFAULT now(),
total_count int,
processed_count int,
failed_count int
);
CREATE TABLE IF NOT EXISTS assets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
bucket text NOT NULL,
media_type media_type NOT NULL,
mime_type text NOT NULL,
source_key text NOT NULL,
active_key text NOT NULL,
canonical_key text,
capture_ts_utc timestamptz,
capture_offset_minutes int,
date_confidence date_confidence,
width int,
height int,
rotation int,
duration_seconds double precision,
thumb_small_key text,
thumb_med_key text,
poster_key text,
status asset_status NOT NULL DEFAULT 'new',
error_message text,
raw_tags_json jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS assets_source_key_idx ON assets (source_key);
CREATE INDEX IF NOT EXISTS assets_capture_ts_idx ON assets (capture_ts_utc);
CREATE INDEX IF NOT EXISTS assets_status_idx ON assets (status);
CREATE INDEX IF NOT EXISTS assets_media_type_idx ON assets (media_type);
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$ BEGIN
CREATE TRIGGER assets_set_updated_at
BEFORE UPDATE ON assets
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
@@ -0,0 +1,16 @@
-- Task 2 follow-up: align schema with PLAN.md
-- 1) duration_seconds should be int (seconds)
ALTER TABLE assets
ALTER COLUMN duration_seconds
TYPE int
USING (
CASE
WHEN duration_seconds IS NULL THEN NULL
ELSE round(duration_seconds)::int
END
);
-- 2) source_key uniqueness should be per-bucket
DROP INDEX IF EXISTS assets_source_key_idx;
CREATE UNIQUE INDEX IF NOT EXISTS assets_bucket_source_key_uidx ON assets (bucket, source_key);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@tline/db",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"dependencies": {
"postgres": "^3.4.7",
"zod": "^4.2.1"
},
"scripts": {
"migrate": "bun run src/migrate.ts"
}
}
+23
View File
@@ -0,0 +1,23 @@
import postgres, { type Sql } from "postgres";
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().min(1)
});
let cachedDb: Sql | undefined;
export function getDb() {
if (cachedDb) return cachedDb;
const env = envSchema.parse(process.env);
cachedDb = postgres(env.DATABASE_URL);
return cachedDb;
}
export async function closeDb() {
if (!cachedDb) return;
const db = cachedDb;
cachedDb = undefined;
await db.end({ timeout: 5 });
}
+43
View File
@@ -0,0 +1,43 @@
import { readdir } from "node:fs/promises";
import { readFile } from "node:fs/promises";
import path from "node:path";
import postgres from "postgres";
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().min(1)
});
async function main() {
const env = envSchema.parse(process.env);
const sql = postgres(env.DATABASE_URL, { max: 1 });
try {
await sql`CREATE TABLE IF NOT EXISTS schema_migrations (id text primary key, applied_at timestamptz not null default now())`;
const migrationsDir = path.join(import.meta.dir, "..", "migrations");
const files = (await readdir(migrationsDir)).filter((f) => f.endsWith(".sql")).sort();
for (const file of files) {
const already = await sql<{ id: string }[]>`SELECT id FROM schema_migrations WHERE id = ${file}`;
if (already.length > 0) continue;
const contents = await readFile(path.join(migrationsDir, file), "utf8");
await sql.begin(async (tx) => {
await tx.unsafe(contents);
await tx`INSERT INTO schema_migrations (id) VALUES (${file})`;
});
// eslint-disable-next-line no-console
console.log(`Applied migration ${file}`);
}
} finally {
await sql.end({ timeout: 5 });
}
}
main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exitCode = 1;
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*.ts", "migrations/**/*.sql"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@tline/minio",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"dependencies": {
"@aws-sdk/client-s3": "^3.899.0",
"@aws-sdk/s3-request-presigner": "^3.899.0",
"zod": "^4.2.1"
}
}
+97
View File
@@ -0,0 +1,97 @@
import "server-only";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { z } from "zod";
const envSchema = z.object({
MINIO_INTERNAL_ENDPOINT: z.string().url().optional(),
MINIO_PUBLIC_ENDPOINT_TS: z.string().url().optional(),
MINIO_ACCESS_KEY_ID: z.string().min(1),
MINIO_SECRET_ACCESS_KEY: z.string().min(1),
MINIO_REGION: z.string().min(1).default("us-east-1"),
MINIO_BUCKET: z.string().min(1).default("media"),
MINIO_PRESIGN_EXPIRES_SECONDS: z.coerce.number().int().positive().default(900)
});
type MinioEnv = z.infer<typeof envSchema>;
let cachedEnv: MinioEnv | undefined;
let cachedInternal: S3Client | undefined;
let cachedPublic: S3Client | undefined;
export function getMinioEnv(): MinioEnv {
if (cachedEnv) return cachedEnv;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid MinIO env: ${parsed.error.message}`);
}
cachedEnv = parsed.data;
return cachedEnv;
}
export function getMinioBucket() {
return getMinioEnv().MINIO_BUCKET;
}
export function getMinioInternalClient(): S3Client {
if (cachedInternal) return cachedInternal;
const env = getMinioEnv();
if (!env.MINIO_INTERNAL_ENDPOINT) {
throw new Error("MINIO_INTERNAL_ENDPOINT is required for internal MinIO client");
}
cachedInternal = new S3Client({
region: env.MINIO_REGION,
endpoint: env.MINIO_INTERNAL_ENDPOINT,
forcePathStyle: true,
credentials: {
accessKeyId: env.MINIO_ACCESS_KEY_ID,
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY
}
});
return cachedInternal;
}
export function getMinioPublicSigningClient(): S3Client {
if (cachedPublic) return cachedPublic;
const env = getMinioEnv();
if (!env.MINIO_PUBLIC_ENDPOINT_TS) {
throw new Error("MINIO_PUBLIC_ENDPOINT_TS is required for presigned URL generation");
}
cachedPublic = new S3Client({
region: env.MINIO_REGION,
endpoint: env.MINIO_PUBLIC_ENDPOINT_TS,
forcePathStyle: true,
credentials: {
accessKeyId: env.MINIO_ACCESS_KEY_ID,
secretAccessKey: env.MINIO_SECRET_ACCESS_KEY
}
});
return cachedPublic;
}
export async function presignGetObjectUrl(input: {
bucket: string;
key: string;
expiresSeconds?: number;
responseContentType?: string;
responseContentDisposition?: string;
}) {
const env = getMinioEnv();
const s3 = getMinioPublicSigningClient();
const command = new GetObjectCommand({
Bucket: input.bucket,
Key: input.key,
ResponseContentType: input.responseContentType,
ResponseContentDisposition: input.responseContentDisposition,
});
const expiresIn = input.expiresSeconds ?? env.MINIO_PRESIGN_EXPIRES_SECONDS;
const url = await getSignedUrl(s3, command, { expiresIn });
return { url, expiresSeconds: expiresIn };
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@tline/queue",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"dependencies": {
"bullmq": "^5.61.0",
"ioredis": "^5.8.0",
"zod": "^4.2.1"
}
}
+128
View File
@@ -0,0 +1,128 @@
import { z } from "zod";
import { Queue } from "bullmq";
import IORedis from "ioredis";
const envSchema = z.object({
REDIS_URL: z.string().min(1).default("redis://localhost:6379"),
QUEUE_NAME: z.string().min(1).default("tline")
});
export const jobNameSchema = z.enum([
"scan_minio_prefix",
"process_asset",
"copy_to_canonical"
]);
export type QueueJobName = z.infer<typeof jobNameSchema>;
export const scanMinioPrefixPayloadSchema = z
.object({
importId: z.string().uuid(),
bucket: z.string().min(1),
prefix: z.string().min(1)
})
.strict();
export const processAssetPayloadSchema = z
.object({
assetId: z.string().uuid()
})
.strict();
export const copyToCanonicalPayloadSchema = z
.object({
assetId: z.string().uuid()
})
.strict();
export const payloadByJobNameSchema = z.discriminatedUnion("name", [
z.object({ name: z.literal("scan_minio_prefix"), payload: scanMinioPrefixPayloadSchema }),
z.object({ name: z.literal("process_asset"), payload: processAssetPayloadSchema }),
z.object({ name: z.literal("copy_to_canonical"), payload: copyToCanonicalPayloadSchema })
]);
export type ScanMinioPrefixPayload = z.infer<typeof scanMinioPrefixPayloadSchema>;
export type ProcessAssetPayload = z.infer<typeof processAssetPayloadSchema>;
export type CopyToCanonicalPayload = z.infer<typeof copyToCanonicalPayloadSchema>;
type QueueEnv = z.infer<typeof envSchema>;
let cachedEnv: QueueEnv | undefined;
let cachedRedis: IORedis | undefined;
let cachedQueue: Queue | undefined;
export function getQueueEnv(): QueueEnv {
if (cachedEnv) return cachedEnv;
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
throw new Error(`Invalid queue env: ${parsed.error.message}`);
}
cachedEnv = parsed.data;
return cachedEnv;
}
export function getQueueName() {
return getQueueEnv().QUEUE_NAME;
}
export function getRedis() {
if (cachedRedis) return cachedRedis;
const env = getQueueEnv();
cachedRedis = new IORedis(env.REDIS_URL, {
lazyConnect: true,
maxRetriesPerRequest: null
});
cachedRedis.on("error", () => {});
return cachedRedis;
}
export function getQueue() {
if (cachedQueue) return cachedQueue;
getQueueEnv();
cachedQueue = new Queue(getQueueName(), {
connection: getRedis()
});
return cachedQueue;
}
export async function closeQueue() {
await Promise.all([
cachedQueue?.close(),
cachedRedis?.quit().catch(() => cachedRedis?.disconnect())
]);
cachedQueue = undefined;
cachedRedis = undefined;
}
export async function enqueueScanMinioPrefix(input: ScanMinioPrefixPayload) {
const payload = scanMinioPrefixPayloadSchema.parse(input);
const queue = getQueue();
return queue.add("scan_minio_prefix", payload, {
attempts: 3,
backoff: { type: "exponential", delay: 1000 }
});
}
export async function enqueueProcessAsset(input: ProcessAssetPayload) {
const payload = processAssetPayloadSchema.parse(input);
const queue = getQueue();
return queue.add("process_asset", payload, {
attempts: 3,
backoff: { type: "exponential", delay: 1000 }
});
}
export async function enqueueCopyToCanonical(input: CopyToCanonicalPayload) {
const payload = copyToCanonicalPayloadSchema.parse(input);
const queue = getQueue();
return queue.add("copy_to_canonical", payload, {
attempts: 3,
backoff: { type: "exponential", delay: 1000 }
});
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun-types"]
},
"include": ["src/**/*.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "preserve",
"resolveJsonModule": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@tline/*": ["packages/*/src/index.ts"]
}
}
}