Sync from /srv/compose/unified-media-manager
This commit is contained in:
326
AGENTS.md
Normal file
326
AGENTS.md
Normal file
@@ -0,0 +1,326 @@
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**Unified Media Manager (UMM)**
|
||||
|
||||
A single Go + React service that replaces the entire \*arr stack (Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Jellyseerr, Bazarr, Recyclarr, Analyzarr — 12 containers) with one unified media management platform. UMM handles media acquisition, download management, quality tracking, and library organization for movies, TV series, music, books, audiobooks, and podcasts.
|
||||
|
||||
**Core Value:** Media downloads and imports correctly — searching indexers, grabbing releases, downloading via SABnzbd/qBittorrent, and importing completed files to the library with proper naming and hardlinks. If this works, the arr stack is replaceable.
|
||||
|
||||
### Constraints
|
||||
|
||||
- **Tech stack**: Go 1.22+ (Echo v4), PostgreSQL 16, React 18 + TypeScript + Tailwind — already chosen and in use
|
||||
- **Network**: Backend must share Gluetun's network namespace for VPN-routed indexer/download traffic
|
||||
- **Filesystem**: Hardlinking requires download dir and media library on same filesystem (TrueNAS NFS)
|
||||
- **Compatibility**: File naming conventions must match existing library structure so Jellyfin/Navidrome/Calibre continue working
|
||||
- **Protocol**: Must support Torznab/Newznab XML APIs and Cardigann YAML definitions for indexer compatibility
|
||||
- **Existing services preserved**: Gluetun, SABnzbd, qBittorrent, FlareSolverr, Tdarr — UMM replaces acquisition/management layer only
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
<!-- GSD:stack-start source:codebase/STACK.md -->
|
||||
## Technology Stack
|
||||
|
||||
## Languages
|
||||
- Go 1.22 — Backend API server, all `internal/` and `cmd/` packages
|
||||
- TypeScript 5.4+ — Frontend React application in `frontend/src/`
|
||||
- SQL (PostgreSQL dialect) — Database schema in `internal/db/migrations/`
|
||||
- YAML — Docker Compose config (`docker-compose.yml`)
|
||||
- Nginx config — Frontend reverse proxy (`frontend/nginx.conf`)
|
||||
## Runtime
|
||||
- Backend: Linux container (Alpine 3.19), compiled with `CGO_ENABLED=0`
|
||||
- Frontend: Node.js 20-alpine (build), Nginx Alpine (serve)
|
||||
- Docker + Docker Compose v3.8
|
||||
- Go Modules — `go.mod` + `go.sum` present
|
||||
- npm — `frontend/package-lock.json` present (lockfile committed)
|
||||
## Frameworks
|
||||
- Echo v4.12.0 — Go HTTP framework for REST API (`github.com/labstack/echo/v4`)
|
||||
- React 18.3.1 — Frontend UI library
|
||||
- React Router DOM 6.23.1 — Client-side routing
|
||||
- Tailwind CSS 3.4.4 — Utility-first CSS framework
|
||||
- No testing framework detected — no test files, no test config
|
||||
- Vite 5.3.1 — Frontend build tool and dev server
|
||||
- @vitejs/plugin-react 4.3.0 — Vite React plugin
|
||||
- PostCSS 8.4.38 + Autoprefixer 10.4.19 — CSS processing pipeline
|
||||
- Multi-stage Docker builds — `backend/Dockerfile` and `frontend/Dockerfile`
|
||||
## Key Dependencies
|
||||
- `github.com/labstack/echo/v4` v4.12.0 — HTTP routing, middleware, JSON binding
|
||||
- `github.com/jackc/pgx/v5` v5.5.5 — PostgreSQL driver and connection pool
|
||||
- `github.com/jackc/pgx/v5/pgxpool` — Connection pool (used in `internal/db/db.go`)
|
||||
- `log/slog` (stdlib) — Structured logging
|
||||
- `react` ^18.3.1 + `react-dom` ^18.3.1 — UI rendering
|
||||
- `react-router-dom` ^6.23.1 — SPA routing
|
||||
- PostgreSQL 16 (external) — Primary database, shared instance at `postgres-shared:5432`
|
||||
- Nginx (Alpine) — Frontend static file server + API reverse proxy
|
||||
- Traefik (external) — TLS termination and routing at `umm.local.tophermayor.com`
|
||||
## Configuration
|
||||
- Config loaded from env vars in `internal/config/config.go`
|
||||
- `DATABASE_URL` — PostgreSQL connection string (default: `postgres://localhost:5432/unified_media_manager?sslmode=disable`)
|
||||
- `QDRANT_URL` — Qdrant vector DB URL (default: `http://localhost:6333`)
|
||||
- `OLLAMA_URL` — Ollama AI service URL (default: `http://localhost:11434`)
|
||||
- `PORT` — Backend HTTP port (default: `8084`)
|
||||
- `VITE_API_URL` — Frontend API base URL (build-time, default: `http://umm.local.tophermayor.com`)
|
||||
- Backend: `go build -o /umm ./cmd/server/` via multi-stage Dockerfile
|
||||
- Frontend: `tsc && vite build` → output to `frontend/dist/`
|
||||
- Backend currently copies `frontend/dist/` into binary container (see `backend/Dockerfile:13`)
|
||||
## Platform Requirements
|
||||
- Go 1.22+ SDK
|
||||
- Node.js 20+
|
||||
- Docker + Docker Compose
|
||||
- PostgreSQL 16 accessible (or use Docker default)
|
||||
- Docker host with external network `proxy-net`
|
||||
- PostgreSQL instance (shared, `postgres-shared:5432`)
|
||||
- Traefik reverse proxy on same Docker network
|
||||
- Gluetun VPN container (planned: backend will use `network_mode: service:gluetun`)
|
||||
- NFS mount from TrueNAS for media files (`/mnt/truenas/mediadata:/data`)
|
||||
## Database Schema
|
||||
- 3 custom enum types: `MEDIA_TYPE`, `MEDIA_STATUS`, `QUEUE_STATUS`
|
||||
- 24 tables total across single `umm` database
|
||||
- Partitioned `media` table (9 partitions by media type)
|
||||
- Partitioned `download_history` (time-range partitioning, 90-day retention)
|
||||
- Full-text search via GIN index on `media.title`
|
||||
- JSONB columns for flexible metadata (`external_ids`, `metadata`, `images`, `quality`)
|
||||
- Migration system: embedded SQL files in `internal/db/migrations/`, tracked via `schema_migrations` table
|
||||
<!-- GSD:stack-end -->
|
||||
|
||||
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||
## Conventions
|
||||
|
||||
## Project Overview
|
||||
## Naming Patterns
|
||||
### Go (Backend)
|
||||
- Use short, lowercase, single-word package names: `api`, `db`, `config`
|
||||
- Package `internal/api/` contains all HTTP handlers in a single package (no sub-packages)
|
||||
- Package `internal/db/` contains database pool, migrations, and embedded SQL files
|
||||
- Package `internal/config/` contains environment config loading
|
||||
- One domain entity per file: `media.go`, `queue.go`, `blocklist.go`, `indexers.go`, `dashboard.go`, `health.go`
|
||||
- File names are lowercase, singular: `router.go`, not `routers.go`
|
||||
- Use `_test.go` suffix for tests (none exist yet)
|
||||
- Handler factory functions use the pattern `func <verb><Entity>(database *db.DB) echo.HandlerFunc` — e.g., `listMedia`, `createMedia`, `updateMedia`, `deleteMedia`
|
||||
- Private helper functions use camelCase: `parsePagination`, `buildMediaWhere`, `scanMedia`, `scanMediaRows`
|
||||
- Scanner functions accept an interface: `func scanQueueItem(scanner interface{ Scan(...interface{}) error })`
|
||||
- Constants for column lists: `const queueColumns = \`id, media_id, ...\``
|
||||
- Local variables: camelCase — `pageSize`, `totalPages`, `setClauses`
|
||||
- Struct fields: PascalCase — `TotalPages`, `PageSize`, `ReleaseTitle`
|
||||
- Abbreviations stay uppercase: `ID`, `URL`, `API` — e.g., `MediaID`, `ReleaseURL`, `APIKey`
|
||||
- Exported structs: PascalCase — `Media`, `QueueItem`, `BlocklistItem`, `Indexer`, `DashboardStats`
|
||||
- Request DTOs: PascalCase with `Request` suffix — `createMediaRequest`, `updateMediaRequest`
|
||||
- Response DTOs: PascalCase with `Response` suffix — `mediaDetailResponse`, `paginatedResponse`
|
||||
- Use unexported structs for request/response types that don't leave the package
|
||||
- JSON tags use snake_case: `` `json:"media_type"` ``, `` `json:"release_title"` ``
|
||||
- Use `omitempty` on nullable/optional fields: `` `json:"original_title,omitempty"` ``
|
||||
- Required fields never have `omitempty`
|
||||
### TypeScript (Frontend)
|
||||
- PascalCase for components: `Dashboard.tsx`, `Library.tsx`, `Queue.tsx`
|
||||
- camelCase for utilities: `client.ts`
|
||||
- Directories: lowercase — `pages/`, `api/`, `components/`
|
||||
- Default exports for page components: `export default function Dashboard()`
|
||||
- Functional components only (no class components)
|
||||
- One component per file
|
||||
- camelCase: `search`, `typeFilter`, `fetchAPI`
|
||||
- PascalCase for types/interfaces: `DashboardData`
|
||||
- Generic type parameter: `<T>` in API functions
|
||||
- Generic fetch wrappers in `frontend/src/api/client.ts`
|
||||
- Three functions: `fetchAPI<T>`, `postAPI<T>`, `deleteAPI<T>`
|
||||
## Code Style
|
||||
### Go Formatting & Linting
|
||||
- Use `gofmt` / `goimports` — standard Go formatting
|
||||
- Tabs for indentation (Go standard)
|
||||
- No linter config detected (no `.golangci.yml`)
|
||||
- Follow standard Go conventions
|
||||
- Grouped imports: stdlib first, then project imports, then third-party
|
||||
- Error wrapping with `fmt.Errorf("verb: %w", err)` — never raw error return
|
||||
- Struct literal field alignment not enforced but generally consistent
|
||||
### TypeScript Formatting & Linting
|
||||
- No Prettier config detected
|
||||
- 2-space indentation
|
||||
- Single quotes for strings (JSX attributes use double quotes in some places)
|
||||
- Trailing commas not enforced
|
||||
- No ESLint config detected in the project (only in node_modules)
|
||||
- `strict: true` enabled
|
||||
- `noUncheckedIndexedAccess: true` — must check array/object access
|
||||
- `exactOptionalPropertyTypes: true` — optional properties must be explicitly `undefined`
|
||||
- `verbatimModuleSyntax: true` — use `import type` for type-only imports
|
||||
- `jsx: "react-jsx"` — automatic JSX runtime (no need to import React in every file)
|
||||
- `isolatedModules: true` — Vite compatibility
|
||||
## Import Organization
|
||||
### Go
|
||||
### TypeScript
|
||||
- None configured — use relative paths: `'../api/client'`, `'./pages/Dashboard'`
|
||||
## Error Handling
|
||||
### Go Backend
|
||||
- Always return `map[string]string{"error": "message"}` for error JSON
|
||||
- Use appropriate HTTP status codes:
|
||||
- Wrap errors with context using `fmt.Errorf("verb: %w", err)`
|
||||
- Log with `slog.Error("message", "key", value, "error", err)` before returning HTTP error
|
||||
- Check `tag.RowsAffected()` after UPDATE/DELETE for not-found detection
|
||||
- Every handler creates a context with timeout: `context.WithTimeout(c.Request().Context(), <duration>)`
|
||||
- Read operations: 10-second timeout
|
||||
- Write operations: 5-second timeout
|
||||
- Indexer test: 15-second timeout
|
||||
- Always `defer cancel()` immediately after creating context
|
||||
### Frontend
|
||||
- `fetchAPI`/`postAPI`/`deleteAPI` throw `Error` on non-OK responses: `throw new Error(\`API error: ${res.status}\`)`
|
||||
- Currently swallowing errors in components: `.catch(() => {})` — this needs improvement
|
||||
- No global error boundary or toast notification system
|
||||
## Logging
|
||||
- Use `slog.Info` for startup, shutdown, migration events
|
||||
- Use `slog.Error` for all failures before returning HTTP errors
|
||||
- Use `slog.Warn` for recoverable issues
|
||||
- Key-value pairs use string keys: `"error"`, `"id"`, `"port"`, `"query"`
|
||||
- Never use `fmt.Println` or `log.Println` — always use `slog`
|
||||
## Comments
|
||||
- Public functions: no doc comments in current codebase (should be added)
|
||||
- Inline comments for SQL query construction: `// addCol helper for dynamic UPDATE`
|
||||
- Column constant definitions explain the SQL: `const mediaColumns = \`...\``
|
||||
- No file-level comments
|
||||
- No Go doc comments on exported types or functions — this is a gap
|
||||
- No JSDoc/TSDoc on frontend functions
|
||||
- No inline documentation for complex SQL queries
|
||||
## Function Design
|
||||
### Go Handlers
|
||||
- Always return `c.JSON(statusCode, body)` — never return raw values
|
||||
- Success responses use typed structs or `map[string]string{"status": "updated"}`
|
||||
- Creation responses return `map[string]int64{"id": id}` with `http.StatusCreated`
|
||||
### Frontend Components
|
||||
## Database Query Conventions
|
||||
### Query Building
|
||||
- Define column list constants at file scope: `const queueColumns = \`id, media_id, ...\``
|
||||
- Use `fmt.Sprintf("SELECT %s FROM table", columns)` to compose queries
|
||||
- Use `deleted_at IS NULL` in WHERE clauses for soft-deleted tables (`media`, `media_files`)
|
||||
- Hard delete for `blocklist`, `download_queue` (use status changes instead)
|
||||
- Use `parsePagination(c)` to extract `page` and `page_size` from query params
|
||||
- Default: page 1, page_size 50, max 100
|
||||
- Return `paginatedResponse` struct with `data`, `total`, `page`, `page_size`, `total_pages`
|
||||
### NULL Handling
|
||||
- Use `sql.NullString`, `sql.NullInt64`, `sql.NullTime` for nullable columns
|
||||
- Check `.Valid` then dereference: `if origTitle.Valid { m.OriginalTitle = &origTitle.String }`
|
||||
- For `json.RawMessage` nullable fields, use `[]byte` scanner then convert
|
||||
## CSS/Styling Conventions
|
||||
- Background: `bg-gray-950` (page), `bg-gray-900` (nav/containers), `bg-gray-800` (inputs)
|
||||
- Text: `text-white` (primary), `text-gray-400` (secondary), `text-gray-500` (placeholder)
|
||||
- Accent: `text-indigo-400`, `focus:border-indigo-500`
|
||||
- Status colors: `bg-blue-600`, `bg-green-600`, `bg-red-600`, `bg-orange-600`, etc.
|
||||
## Module Design
|
||||
### Go Exports
|
||||
- `internal/config` → `Config` struct, `Load()` function
|
||||
- `internal/db` → `DB` struct, `New()` constructor, `MigrationsFS` embedded FS
|
||||
- `internal/api` → `NewRouter()` function (the only entry point into the API layer)
|
||||
### Frontend Exports
|
||||
## Configuration Conventions
|
||||
- Read from environment variables via `os.Getenv()`
|
||||
- Provide sensible defaults when env vars are unset
|
||||
- Return a `*Config` struct — no global state, passed explicitly
|
||||
- Use Vite env: `import.meta.env.VITE_API_URL`
|
||||
- Empty string fallback means same-origin API calls
|
||||
<!-- GSD:conventions-end -->
|
||||
|
||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||
## Architecture
|
||||
|
||||
## Pattern Overview
|
||||
- Backend serves both API (`/api/*`) and static frontend assets from the same Echo server
|
||||
- Frontend is a separate Docker container with nginx that proxies `/api/*` to backend — but backend also has `e.Static("/", "frontend/dist")` as a fallback
|
||||
- Handler-per-function pattern: each API endpoint is a standalone function returning `echo.HandlerFunc` via closure over `*db.DB`
|
||||
- No service/repository layer — handlers directly execute SQL against `pgxpool.Pool`
|
||||
- Database migrations embedded at compile time via `go:embed`
|
||||
- PostgreSQL partitioned tables used for multi-tenant media types and time-bounded history
|
||||
## Layers
|
||||
- Purpose: Route registration, middleware, request parsing, response serialization
|
||||
- Location: `internal/api/router.go`
|
||||
- Contains: Echo route group definitions, CORS config, pagination helpers
|
||||
- Depends on: Echo v4 framework, `internal/config`, `internal/db`
|
||||
- Used by: `cmd/server/main.go` which calls `api.NewRouter()`
|
||||
- Purpose: Per-resource CRUD handlers — business logic and SQL queries are inline
|
||||
- Location: `internal/api/*.go` (one file per domain: `media.go`, `queue.go`, `blocklist.go`, `indexers.go`, `dashboard.go`, `health.go`)
|
||||
- Contains: Type structs (request/response), scan helpers, handler functions
|
||||
- Depends on: `internal/db` (for `*db.DB` pool access), `pgx/v5` for scanning
|
||||
- Used by: Router layer via closure: `g.GET("/media", listMedia(database))`
|
||||
- Purpose: Connection pool management, migration runner
|
||||
- Location: `internal/db/db.go`
|
||||
- Contains: `DB` struct wrapping `pgxpool.Pool`, `New()` constructor, `RunMigrations()` method
|
||||
- Depends on: `pgx/v5/pgxpool`, embedded `migrations/*.sql` files
|
||||
- Used by: All handler functions, `cmd/server/main.go`
|
||||
- Purpose: Environment variable loading with defaults
|
||||
- Location: `internal/config/config.go`
|
||||
- Contains: `Config` struct with `DatabaseURL`, `QdrantURL`, `OllamaURL`, `Port`
|
||||
- Depends on: `os` standard library only
|
||||
- Used by: `cmd/server/main.go`, `internal/api/health.go`
|
||||
- Purpose: User interface for media management
|
||||
- Location: `frontend/src/`
|
||||
- Contains: React components, API client wrapper, Tailwind-styled pages
|
||||
- Depends on: React 18, react-router-dom 6, Tailwind CSS
|
||||
- Used by: End users via browser; served by nginx container or backend static fallback
|
||||
## Data Flow
|
||||
- Backend: Stateless — all state in PostgreSQL. No in-memory caching, no sessions.
|
||||
- Frontend: Local `useState` per component. No global state store (no Redux/Zustand). Each page fetches data independently via `useEffect`.
|
||||
## Key Abstractions
|
||||
- Purpose: Inject `*db.DB` dependency into handlers without global state
|
||||
- Examples: `listMedia(database *db.DB) echo.HandlerFunc`, `dashboard(database *db.DB) echo.HandlerFunc`
|
||||
- Pattern: Every handler is a function that takes `*db.DB` and returns a closure
|
||||
```go
|
||||
```
|
||||
- Purpose: Standardized envelope for all list endpoints
|
||||
- Examples: Used in `listMedia`, `searchMedia`, `searchMissing`, `searchUpgrades`, `listQueue`, `listBlocklist`
|
||||
- Pattern: `{data: [], total: N, page: N, page_size: N, total_pages: N}`
|
||||
- Purpose: Reusable row scanning for types that appear in both single and multi-row queries
|
||||
- Examples: `scanBlocklistItem(scanner interface{ Scan(...interface{}) error })`, `scanQueueItem(scanner)`
|
||||
- Pattern: Accept `interface{ Scan(...interface{}) error }` so both `pgx.Row` and `pgx.Rows` can be passed
|
||||
- Purpose: Construct parameterized SQL with variable WHERE clauses
|
||||
- Examples: `buildMediaWhere()` in `media.go`, inline in `searchMedia`, `listQueue`
|
||||
- Pattern: Maintain `idx` counter, build `[]string` conditions, `[]interface{}` args, format with `$N` placeholders
|
||||
```go
|
||||
```
|
||||
- Purpose: Ship SQL migrations inside the compiled binary
|
||||
- Example: `internal/db/db.go:17` — `//go:embed migrations/*.sql`
|
||||
- Pattern: `embed.FS` read at runtime, sorted lexicographically, applied transactionally
|
||||
## Entry Points
|
||||
- Location: `cmd/server/main.go`
|
||||
- Triggers: Docker container start (`CMD ["./umm"]`)
|
||||
- Responsibilities:
|
||||
- Location: `frontend/vite.config.ts`
|
||||
- Triggers: `npm run dev` (development only)
|
||||
- Responsibilities: Vite dev server on port 3000 with HMR
|
||||
- Location: `frontend/Dockerfile`
|
||||
- Triggers: Docker container start (nginx)
|
||||
- Responsibilities: Serves built SPA from `/usr/share/nginx/html`, proxies `/api/*` to backend
|
||||
## Error Handling
|
||||
- Handlers return `c.JSON(http.StatusN, map[string]string{"error": "message"})` for errors
|
||||
- Database errors logged via `slog.Error()` with context fields then returned as HTTP 500
|
||||
- Validation errors return HTTP 400 with descriptive message
|
||||
- Not-found returns HTTP 404 after checking `tag.RowsAffected() == 0`
|
||||
- Scan errors in row iterators are logged and skipped (`continue`) rather than failing the whole request
|
||||
- Migration failures are logged as warnings and don't stop server startup (`main.go:33`)
|
||||
- All handlers use `context.WithTimeout` (5–15s) with deferred cancel
|
||||
- No centralized error middleware
|
||||
- No structured error types or error codes
|
||||
- No request-level error logging correlation (no request IDs)
|
||||
## Cross-Cutting Concerns
|
||||
<!-- GSD:architecture-end -->
|
||||
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
|
||||
No project skills found. Add skills to any of: `.OpenCode/skills/`, `.agents/skills/`, `.cursor/skills/`, or `.github/skills/` with a `SKILL.md` index file.
|
||||
<!-- GSD:skills-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## GSD Workflow Enforcement
|
||||
|
||||
Before using edit, write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
|
||||
|
||||
Use these entry points:
|
||||
- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks
|
||||
- `/gsd-debug` for investigation and bug fixing
|
||||
- `/gsd-execute-phase` for planned phase work
|
||||
|
||||
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
|
||||
<!-- GSD:workflow-end -->
|
||||
|
||||
|
||||
|
||||
<!-- GSD:profile-start -->
|
||||
## Developer Profile
|
||||
|
||||
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-OpenCode-profile` -- do not edit manually.
|
||||
<!-- GSD:profile-end -->
|
||||
Reference in New Issue
Block a user