18 KiB
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
Technology Stack
Languages
- Go 1.22 — Backend API server, all
internal/andcmd/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.sumpresent - npm —
frontend/package-lock.jsonpresent (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/Dockerfileandfrontend/Dockerfile
Key Dependencies
github.com/labstack/echo/v4v4.12.0 — HTTP routing, middleware, JSON bindinggithub.com/jackc/pgx/v5v5.5.5 — PostgreSQL driver and connection poolgithub.com/jackc/pgx/v5/pgxpool— Connection pool (used ininternal/db/db.go)log/slog(stdlib) — Structured loggingreact^18.3.1 +react-dom^18.3.1 — UI renderingreact-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 tofrontend/dist/ - Backend currently copies
frontend/dist/into binary container (seebackend/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
ummdatabase - Partitioned
mediatable (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 viaschema_migrationstable
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, notrouters.go - Use
_test.gosuffix 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
Requestsuffix —createMediaRequest,updateMediaRequest - Response DTOs: PascalCase with
Responsesuffix —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
omitemptyon 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: trueenablednoUncheckedIndexedAccess: true— must check array/object accessexactOptionalPropertyTypes: true— optional properties must be explicitlyundefinedverbatimModuleSyntax: true— useimport typefor type-only importsjsx: "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/deleteAPIthrowErroron 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.Infofor startup, shutdown, migration events - Use
slog.Errorfor all failures before returning HTTP errors - Use
slog.Warnfor recoverable issues - Key-value pairs use string keys:
"error","id","port","query" - Never use
fmt.Printlnorlog.Println— always useslog
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}withhttp.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 NULLin WHERE clauses for soft-deleted tables (media,media_files) - Hard delete for
blocklist,download_queue(use status changes instead) - Use
parsePagination(c)to extractpageandpage_sizefrom query params - Default: page 1, page_size 50, max 100
- Return
paginatedResponsestruct withdata,total,page,page_size,total_pages
NULL Handling
- Use
sql.NullString,sql.NullInt64,sql.NullTimefor nullable columns - Check
.Validthen dereference:if origTitle.Valid { m.OriginalTitle = &origTitle.String } - For
json.RawMessagenullable fields, use[]bytescanner 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→Configstruct,Load()functioninternal/db→DBstruct,New()constructor,MigrationsFSembedded FSinternal/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
*Configstruct — no global state, passed explicitly - Use Vite env:
import.meta.env.VITE_API_URL - Empty string fallback means same-origin API calls
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 hase.Static("/", "frontend/dist")as a fallback - Handler-per-function pattern: each API endpoint is a standalone function returning
echo.HandlerFuncvia 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.gowhich callsapi.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.DBpool access),pgx/v5for scanning - Used by: Router layer via closure:
g.GET("/media", listMedia(database)) - Purpose: Connection pool management, migration runner
- Location:
internal/db/db.go - Contains:
DBstruct wrappingpgxpool.Pool,New()constructor,RunMigrations()method - Depends on:
pgx/v5/pgxpool, embeddedmigrations/*.sqlfiles - Used by: All handler functions,
cmd/server/main.go - Purpose: Environment variable loading with defaults
- Location:
internal/config/config.go - Contains:
Configstruct withDatabaseURL,QdrantURL,OllamaURL,Port - Depends on:
osstandard 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
useStateper component. No global state store (no Redux/Zustand). Each page fetches data independently viauseEffect.
Key Abstractions
- Purpose: Inject
*db.DBdependency 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.DBand returns a closure
- 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 bothpgx.Rowandpgx.Rowscan be passed - Purpose: Construct parameterized SQL with variable WHERE clauses
- Examples:
buildMediaWhere()inmedia.go, inline insearchMedia,listQueue - Pattern: Maintain
idxcounter, build[]stringconditions,[]interface{}args, format with$Nplaceholders
- Purpose: Ship SQL migrations inside the compiled binary
- Example:
internal/db/db.go:17—//go:embed migrations/*.sql - Pattern:
embed.FSread 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
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 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-quickfor small fixes, doc updates, and ad-hoc tasks/gsd-debugfor investigation and bug fixing/gsd-execute-phasefor planned phase work
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
Developer Profile
Profile not yet configured. Run
/gsd-profile-userto generate your developer profile. This section is managed bygenerate-OpenCode-profile-- do not edit manually.