Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
# Dependencies
frontend/node_modules/
# Build output
frontend/dist/
server
# TypeScript compiled output (Vite handles compilation)
frontend/src/**/*.js
frontend/src/**/*.js.map
frontend/src/**/*.d.ts
frontend/src/**/*.d.ts.map
frontend/vite.config.js
frontend/vite.config.d.ts
frontend/vite.config.d.ts.map
frontend/vite.config.js.map
# Config files
SPEC.md

326
AGENTS.md Normal file
View 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` (515s) 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 -->

19
backend/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go get github.com/jackc/pgx/v5@v5.5.5 && go mod tidy
RUN CGO_ENABLED=0 GOOS=linux go build -o /umm ./cmd/server/
RUN CGO_ENABLED=1 GOOS=linux go build -o /umm-migrate ./cmd/migrate/
FROM alpine:3.19
RUN apk --no-cache add ca-certificates wget ffmpeg
WORKDIR /app
COPY --from=builder /umm .
COPY --from=builder /umm-migrate .
COPY internal/db/migrations/ ./internal/db/migrations/
EXPOSE 8084
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD wget -qO- http://localhost:8084/health/live || exit 1
CMD ["./umm"]

73
cmd/migrate/main.go Normal file
View File

@@ -0,0 +1,73 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/migrate"
)
func main() {
// Flags for each arr database path
sonarr := flag.String("sonarr", "", "Path to Sonarr SQLite database")
radarr := flag.String("radarr", "", "Path to Radarr SQLite database")
sonarrAnime := flag.String("sonarr-anime", "", "Path to Sonarr-anime SQLite database")
radarrAnime := flag.String("radarr-anime", "", "Path to Radarr-anime SQLite database")
lidarr := flag.String("lidarr", "", "Path to Lidarr SQLite database")
readarr := flag.String("readarr", "", "Path to Readarr SQLite database")
prowlarr := flag.String("prowlarr", "", "Path to Prowlarr SQLite database")
databaseURL := flag.String("database-url", "", "PostgreSQL connection string (or set DATABASE_URL env)")
flag.Parse()
if *databaseURL == "" {
*databaseURL = os.Getenv("DATABASE_URL")
}
if *databaseURL == "" {
*databaseURL = "postgres://bear:bear123@postgres-shared:5432/umm?sslmode=disable"
}
slog.Info("starting UMM arr data migration tool")
// Connect to PostgreSQL
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
database, err := db.New(ctx, *databaseURL)
cancel()
if err != nil {
slog.Error("failed to connect to database", "error", err)
os.Exit(1)
}
defer database.Close()
// Run schema migrations to ensure tables exist
if err := database.RunMigrations(context.Background(), db.MigrationsFS); err != nil {
slog.Error("failed to run migrations", "error", err)
os.Exit(1)
}
sources := migrate.ArrSources{
Sonarr: *sonarr,
Radarr: *radarr,
SonarrAnime: *sonarrAnime,
RadarrAnime: *radarrAnime,
Lidarr: *lidarr,
Readarr: *readarr,
Prowlarr: *prowlarr,
}
m := migrate.NewMigrator(database, sources)
report, err := m.Run(context.Background())
if err != nil {
slog.Error("migration failed", "error", err)
fmt.Println()
fmt.Println(report.String())
os.Exit(1)
}
fmt.Println()
fmt.Println(report.String())
}

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
version: "3.8"
services:
backend:
build:
context: .
dockerfile: backend/Dockerfile
network_mode: "container:gluetun"
environment:
PORT: "8084"
DATABASE_URL: ${DATABASE_URL:-postgres://bear:bear123@postgres-shared:5432/umm?sslmode=disable}
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
OLLAMA_URL: ${OLLAMA_URL:-http://192.168.50.84:11434}
FRONTEND_URL: ${FRONTEND_URL:-http://umm.local.tophermayor.com}
ADMIN_API_KEY: ${ADMIN_API_KEY:-03136aebde4bd783ae6ada1abdc8debbd11e3fe89380d87d}
TMDB_API_KEY: ${TMDB_API_KEY:-}
DOWNLOAD_DIR: /data/completed
volumes:
- /mnt/truenas/mediadata:/data
# Arr SQLite databases for migration (read-only)
- /home/bear/homelab/ubuntu/media-stack/sonarr:/data/arr-configs/sonarr:ro
- /home/bear/homelab/ubuntu/media-stack/radarr:/data/arr-configs/radarr:ro
- /home/bear/homelab/ubuntu/media-stack/sonarr-anime:/data/arr-configs/sonarr-anime:ro
- /home/bear/homelab/ubuntu/media-stack/radarr-anime:/data/arr-configs/radarr-anime:ro
- /home/bear/homelab/ubuntu/media-stack/lidarr:/data/arr-configs/lidarr:ro
- /home/bear/homelab/ubuntu/media-stack/readarr:/data/arr-configs/readarr:ro
- /home/bear/homelab/ubuntu/media-stack/prowlarr:/data/arr-configs/prowlarr:ro
restart: unless-stopped
depends_on:
- frontend
frontend:
build:
context: frontend
dockerfile: Dockerfile
environment:
VITE_API_URL: ${VITE_API_URL:-http://umm.local.tophermayor.com}
networks:
- proxy-net
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.umm.rule=Host(`umm.local.tophermayor.com`)"
- "traefik.http.routers.umm.entrypoints=websecure"
- "traefik.http.services.umm.loadbalancer.server.port=3000"
networks:
proxy-net:
external: true

301
docs/UX-FLOWS.md Normal file
View File

@@ -0,0 +1,301 @@
# UMM UX Flows — Implemented & Tested
**Last verified:** 2026-04-22 (deployed build, backend healthy)
## Navigation
| Route | Page | Nav Label |
|-------|------|-----------|
| `/` | Dashboard | Dashboard |
| `/library` | Library | Library |
| `/library/:type/:id` | Media Detail | (via Library click) |
| `/discover` | Discover | Discover |
| `/calendar` | Calendar | Calendar |
| `/queue` | Download Queue | Queue |
| `/search` | Search Indexers | Search |
| `/activity` | Activity | Activity |
| `/requests` | Requests | Requests |
| `/blocklist` | Blocklist | Blocklist |
| `/settings` | Settings | Settings |
---
## Page-by-Page Flows
### Dashboard (`/`)
**API:** `GET /api/dashboard` (200 OK)
- 8 stat cards: Movies, TV Series, Music, Books, Active Downloads, Missing, Upgrades Available, Total Storage
- Auto-fetches on mount
- Skeleton loading while fetching
- Error banner with retry on failure
### Library (`/library`)
**API:** `GET /api/media?page=1&page_size=50` (200 OK)
- Search input with 300ms debounce
- Type filter: All, Movie, Series, Music, Book, Audiobook, Podcast, Photo, Other
- Status filter: All, Available, Unavailable, Downloading, Failed
- Paginated table (50 per page)
- Each row: Title (click → detail), Type, Status badge, Quality, Monitor toggle
- Monitor toggle: optimistic update, rollback on error with toast
- Empty state messages for no results vs empty library
### Media Detail (`/library/:type/:id`)
**API:** `GET /api/media/:type/:id/detail` (200 OK)
5 tabs (4 for non-series):
**Overview tab:**
- Poster image (or letter placeholder)
- Title, year, original title
- Status badge, monitored indicator
- TMDB rating, genres
- Quality profile name
- Current quality / desired quality
- Synopsis text
**Search tab:**
- Auto-searches indexers for media title
- Results via `ReleaseSearchResults` component
- Sortable by quality, size, seeders, age
**Files tab:**
- File table: name, quality, size, source, codec, subtitles
- Per-file subtitle badges (language code, SDH, Forced, source)
- "Search" button per file → subtitle search modal
- "Download" button per subtitle result
- "Extract All Subtitles" button (extracts embedded subs)
**Episodes tab (series only):**
- Episodes grouped by season
- Columns: episode #, title, status, monitored, has file, quality
**History tab:**
- Timeline with color-coded badges: Grab, Import, Download, Failed, Upgrade, Blocked, Error
- Relative timestamps (Xm ago, Xh ago, Xd ago)
**Header actions:**
- "Refresh Metadata" button → `POST /api/media/:type/:id/refresh-metadata`
- "Delete" button → confirmation modal → `DELETE /api/media/:type/:id` → redirect to Library
### Discover (`/discover`)
**API:** `GET /api/discover/trending?type=movie&page=1` (401 — requires API key auth)
**API:** `GET /api/discover/popular?type=movie&page=1` (401 — requires API key auth)
- Trending / Popular tab toggle
- Movie / Series type filter
- Poster grid with hover overlay (title, year, rating, "Add to Library" button)
- Green checkmark badge for items already in library
- "Add to Library" → `POST /api/discover/add` → toast feedback
- Paginated (prev/next, up to page 10)
**Note:** Requires API key authentication. Frontend does not currently send API keys, so this flow will 401 in production.
### Calendar (`/calendar`)
**API:** `GET /api/calendar?month=2026-04` (200 OK)
- Monthly grid (6 rows × 7 cols)
- Navigation: prev/next month, "Today" button
- Events color-coded by type: movie (indigo), series (emerald), music (amber), audiobook (rose), podcast (violet)
- Click event → navigate to media detail
- "Today" date highlighted with indigo ring
- "+N more" for days with >3 events
### Download Queue (`/queue`)
**API:** `GET /api/queue` (200 OK)
- Tabs: All, Downloading, Pending, Failed, Imported, History
- Auto-refresh every 5 seconds
- Per-item: release title, status badge, download client, quality, size
- Progress bar for downloading items
- Error message display for failed items
- Actions: Cancel (with confirmation modal), Retry, Retry All Failed, Clear Completed
- "Check for Completed Downloads" → `POST /api/imports/trigger`
- Import History tab with pagination (`GET /api/imports/history`)
### Search Indexers (`/search`)
**API:** `GET /api/releases/search?query=...` (500 — "no enabled indexers available" when none configured)
- Free-text search across all enabled indexers
- Sortable columns: Quality, Title, Size, Indexer, Seeders, Age
- "Select & Grab" button per result → opens media selector modal
- Modal: debounced library search
- Select media item → `POST /api/releases/grab` → toast
- Empty state for no query, no results, loading skeleton
**Note:** Returns 500 when no indexers are configured. This is expected behavior.
### Activity (`/activity`)
**API:** `GET /api/activity?page=1&page_size=50` (200 OK)
- Paginated event log (50 per page)
- Type filter dropdown: All, Grabs, Imports, Downloads, Failures, Upgrades, Safety Blocks, Errors
- Color-coded badges: Grab (blue), Import/Download (green), Failed/Error (red), Upgrade (purple), Blocked (orange)
- Relative timestamps
### Requests (`/requests`)
**API:** `GET /api/requests?page=1&page_size=20` (401 — requires API key auth)
- Filter tabs: All, Pending, Approved, Fulfilled, Rejected
- "+ New Request" modal:
- Title (required), Type (7 options), Year, Quality Profile dropdown, Root Folder dropdown
- Submit → `POST /api/requests`
- Per-request card: title, year, status badge, requester, time ago, quality profile, root folder
- Actions for pending: Approve (green) → `PUT /api/requests/:id/approve`, Reject (red, with confirmation) → `PUT /api/requests/:id/reject`
- Withdraw link for pending/approved → `DELETE /api/requests/:id`
**Note:** Requires API key authentication. Frontend does not currently send API keys.
### Blocklist (`/blocklist`)
**API:** `GET /api/blocklist?page=1&page_size=50` (200 OK)
- Paginated table (50 per page)
- Checkboxes for bulk selection (select all toggle)
- Per-row: release title, indexer, quality, reason, date, delete button
- Bulk "Delete Selected" → `DELETE /api/blocklist/:id` per item
- "Clear Expired" → `DELETE /api/blocklist/expired`
- "Clear All" → confirmation modal → `DELETE /api/blocklist`
### Settings (`/settings`)
8 sections in 2-column grid layout:
#### Notifications
**API:** `GET /api/notifications/channels` (200 OK)
- List channels with enable/disable dot, name, type badge (webhook/telegram), config preview
- Inline edit: name, URL/bot token/chat ID, event type checkboxes (8 types), save/cancel/test/delete
- Add channel: name, type selector, URL or bot token + chat ID, event type checkboxes
- Test button → `POST /api/notifications/channels/:id/test`
#### Indexers
**API:** `GET /api/indexers` (200 OK)
- List with enable/disable dot, name, implementation badge (torznab/newznab/cardigann), URL
- Inline edit: name, URL, API key (masked), enable toggle, save/cancel/test/delete
- Add indexer: name, implementation selector (Newznab, Torznab, Cardigann)
- Cardigann: YAML textarea, validate button → `POST /api/indexers/validate-cardigann`, settings fields from definition
- Test → `POST /api/indexers/:id/test`
#### Download Clients
**API:** `GET /api/download-clients` (200 OK)
- List with enable/disable dot, name, implementation, URL
- Inline edit: name, URL, API key, category, priority, enable toggle, save/cancel/test/delete
- Add: name, implementation (SABnzbd, qBittorrent), URL, API key
- Test → `POST /api/download-clients/:id/test`
#### Quality Profiles
**API:** `GET /api/quality-profiles` (200 OK)
- List with name, media type badges
- Inline edit: name, cutoff quality, allowed qualities (comma-sep), save/cancel/delete
- Add: name, media types (comma-sep), cutoff quality, allowed qualities
#### Root Folders
**API:** `GET /api/root-folders` (200 OK)
- List with path, media type, free space
- Inline edit: path, media type (delete + re-create pattern), save/cancel/delete
- Add: path, media type
#### Tags
**API:** `GET /api/tags` (200 OK — fixed)
- List with name, color swatch
- Inline edit: name, color, save/cancel/delete
- Add: name, color (hex)
#### Tasks
**API:** `GET /api/workers` (401 — requires API key auth, different from `/api/tasks`)
- Scheduled tasks list with name, cron expression, enabled toggle, last run, next run
- Enable/disable toggle per task
**Note:** Frontend calls `/api/tasks` but router registers `/api/workers`. May result in 404.
#### Metadata
**API:** `POST /api/media/refresh-all` (200 OK, no auth required)
- Description text about refresh scope
- "Refresh All Metadata" button → confirmation modal → POST
- Loading state while refreshing
---
## Shared Components
| Component | Used By | Purpose |
|-----------|---------|---------|
| `Toast` | All pages | Success/error feedback (top-right, auto-dismiss) |
| `ConfirmModal` | Queue, Blocklist, Media Detail, Settings, Requests | Confirmation for destructive/batch actions |
| `Pagination` | Library, Queue, Activity, Blocklist, Requests | Page navigation with total/pages display |
| `StatusBadge` | Library, Queue, Media Detail, Requests | Color-coded status labels |
| `ErrorBanner` | All pages | Error display with retry button |
| `Loading` | Dashboard, Queue, Activity, Blocklist, Settings | Skeleton/spinner loading states |
| `ReleaseSearchResults` | Media Detail, Search | Release result table with grab actions |
---
## API Test Results (2026-04-22)
### Working (200 OK)
| Endpoint | Status | Notes |
|----------|--------|-------|
| `GET /health/live` | 200 | `{"status":"alive"}` |
| `GET /api/dashboard` | 200 | Stats returned, all zeros (empty DB) |
| `GET /api/media` | 200 | Paginated, empty |
| `GET /api/queue` | 200 | Empty |
| `GET /api/blocklist` | 200 | Paginated, empty |
| `GET /api/activity` | 200 | Paginated, empty |
| `GET /api/indexers` | 200 | Empty |
| `GET /api/download-clients` | 200 | Empty |
| `GET /api/quality-profiles` | 200 | Empty |
| `GET /api/root-folders` | 200 | Empty |
| `GET /api/tags` | 200 | Empty (fixed: removed `created_at` column ref) |
| `GET /api/notifications/channels` | 200 | Empty |
| `GET /api/calendar` | 200 | Empty events |
| `GET /api/search` | 200 | Empty results |
| `GET /api/imports/history` | 200 | Empty |
| `POST /api/media/refresh-all` | 200 | Triggers refresh |
### Auth-Required (401 Unauthorized)
| Endpoint | Notes |
|----------|-------|
| `GET /api/requests` | API key auth middleware |
| `GET /api/discover/trending` | API key auth middleware |
| `GET /api/discover/popular` | API key auth middleware |
| `GET /api/workers` | API key auth middleware |
**Root cause:** Frontend `client.ts` does not send `X-API-Key` header or `api_key` query param. These flows (Requests, Discover, Tasks) will 401 in production unless auth is configured or the frontend is updated to pass API keys.
### Expected Errors (500)
| Endpoint | Error | Cause |
|----------|-------|-------|
| `GET /api/releases/search` | "no enabled indexers available" | No indexers configured yet |
---
## Known Issues
1. **Auth-protected endpoints unreachable from frontend** — Requests, Discover, and Tasks pages will show errors because the API client doesn't pass API keys. The `newAPIKeyAuth` middleware requires `X-API-Key` header or `api_key` query param.
2. **Tasks page route mismatch** — Frontend calls `/api/tasks` but router registers workers at `/api/workers`. Needs alignment.
3. **No indexers configured** — Release search (used by Search page and Media Detail Search tab) returns 500. Expected until indexers are added via Settings.
4. **Frontend CORS** — CORS is configured to allow `cfg.FrontendURL` only. Requests from other origins will be rejected.

12
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unified Media Manager</title>
</head>
<body class="bg-gray-950 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

20
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,20 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
gzip on;
gzip_types application/json;
gzip_min_length 256;
gzip_vary on;
location /api/ {
proxy_pass http://gluetun:8084/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

2746
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "umm-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.99.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.23.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.3.1"
}
}

76
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,76 @@
import { Routes, Route, NavLink } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import { QueryProvider } from './api/queryClient'
import { ToastProvider } from './components/Toast'
import Loading from './components/Loading'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Library = lazy(() => import('./pages/Library'))
const Discover = lazy(() => import('./pages/Discover'))
const Calendar = lazy(() => import('./pages/Calendar'))
const MediaDetail = lazy(() => import('./pages/MediaDetail'))
const Queue = lazy(() => import('./pages/Queue'))
const Requests = lazy(() => import('./pages/Requests'))
const Activity = lazy(() => import('./pages/Activity'))
const Blocklist = lazy(() => import('./pages/Blocklist'))
const Settings = lazy(() => import('./pages/Settings'))
const Search = lazy(() => import('./pages/Search'))
const navItems = [
{ to: '/', label: 'Dashboard' },
{ to: '/library', label: 'Library' },
{ to: '/discover', label: 'Discover' },
{ to: '/calendar', label: 'Calendar' },
{ to: '/queue', label: 'Queue' },
{ to: '/search', label: 'Search' },
{ to: '/activity', label: 'Activity' },
{ to: '/requests', label: 'Requests' },
{ to: '/blocklist', label: 'Blocklist' },
{ to: '/settings', label: 'Settings' },
]
export default function App() {
return (
<QueryProvider>
<ToastProvider>
<div className="min-h-screen bg-gray-950">
<a href="#main-content" className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-indigo-600 focus:text-white focus:px-4 focus:py-2 focus:rounded focus:outline-none focus:ring-2 focus:ring-indigo-400">
Skip to content
</a>
<nav className="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center gap-6">
<h1 className="text-xl font-bold text-indigo-400">UMM</h1>
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
`text-sm font-medium ${isActive ? 'text-indigo-400' : 'text-gray-400 hover:text-gray-200'}`
}
>
{item.label}
</NavLink>
))}
</nav>
<main id="main-content" className="p-6">
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/library" element={<Library />} />
<Route path="/discover" element={<Discover />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/library/:type/:id" element={<MediaDetail />} />
<Route path="/queue" element={<Queue />} />
<Route path="/search" element={<Search />} />
<Route path="/activity" element={<Activity />} />
<Route path="/requests" element={<Requests />} />
<Route path="/blocklist" element={<Blocklist />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</main>
</div>
</ToastProvider>
</QueryProvider>
)
}

View File

@@ -0,0 +1,64 @@
const API = import.meta.env.VITE_API_URL || ''
function getAPIKey(): string | null {
return localStorage.getItem('umm_api_key')
}
function authHeaders(): Record<string, string> {
const key = getAPIKey()
if (key) return { 'X-API-Key': key }
return {}
}
function jsonHeaders(): Record<string, string> {
return { 'Content-Type': 'application/json', ...authHeaders() }
}
export function setAPIKey(key: string) {
localStorage.setItem('umm_api_key', key)
}
export function clearAPIKey() {
localStorage.removeItem('umm_api_key')
}
export function hasAPIKey(): boolean {
return !!getAPIKey()
}
export async function fetchAPI<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, { ...init, headers: { ...authHeaders(), ...(init?.headers || {}) } })
if (res.status === 401 && path.startsWith('/api/requests')) {
throw new Error('Authentication required. Please set your API key in Settings.')
}
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function postAPI<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: jsonHeaders(),
body: JSON.stringify(body),
...init,
})
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function putAPI<T>(path: string, body: unknown, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, {
method: 'PUT',
headers: jsonHeaders(),
body: JSON.stringify(body),
...init,
})
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}
export async function deleteAPI<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API}${path}`, { method: 'DELETE', headers: authHeaders(), ...init })
if (!res.ok) throw new Error(`API error: ${res.status}`)
return res.json()
}

View File

@@ -0,0 +1,22 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
gcTime: 5 * 60 * 1000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
export function QueryProvider({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}
export { queryClient }

View File

@@ -0,0 +1,85 @@
import { useEffect, useRef, type ReactNode } from 'react'
interface ConfirmModalProps {
open: boolean
title: string
message: string
onConfirm: () => void
onCancel: () => void
destructive?: boolean
confirmLabel?: string
children?: ReactNode
}
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
export default function ConfirmModal({ open, title, message, onConfirm, onCancel, destructive, confirmLabel, children }: ConfirmModalProps) {
const dialogRef = useRef<HTMLDivElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel()
return
}
if (e.key === 'Tab' && dialogRef.current) {
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first.focus()
}
}
}
}
document.addEventListener('keydown', handleKey)
cancelRef.current?.focus()
return () => document.removeEventListener('keydown', handleKey)
}, [open, onCancel])
if (!open) return null
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel} role="presentation">
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
aria-describedby="confirm-modal-desc"
className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
onClick={e => e.stopPropagation()}
>
<h3 id="confirm-modal-title" className="text-xl font-semibold mb-2 text-gray-100">{title}</h3>
<p id="confirm-modal-desc" className="text-gray-400 text-sm mb-4">{message}</p>
{children}
<div className="flex gap-3 justify-end">
<button ref={cancelRef} onClick={onCancel} className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400">
Cancel
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm rounded font-semibold min-h-[36px] ${
destructive ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-500 hover:bg-indigo-400 text-white'
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400`}
>
{confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
export default function ErrorBanner({ error, onRetry }: { error: string; onRetry?: () => void }) {
return (
<div className="bg-red-900/30 border border-red-800 rounded-lg p-4 mb-4">
<p className="text-red-400 text-sm">{error}</p>
{onRetry && (
<button onClick={onRetry} className="text-red-300 text-xs underline mt-1">
Retry
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="flex items-center justify-center py-12">
<div className="animate-spin h-6 w-6 border-2 border-indigo-400 border-t-transparent rounded-full" />
</div>
)
}

View File

@@ -0,0 +1,52 @@
export default function Pagination({ total, page, pageSize, onPageChange }: {
total: number
page: number
pageSize: number
onPageChange: (p: number) => void
}) {
const totalPages = Math.ceil(total / pageSize)
if (totalPages <= 1) return null
const pages: number[] = []
const maxVisible = 7
let start = Math.max(1, page - Math.floor(maxVisible / 2))
const end = Math.min(totalPages, start + maxVisible - 1)
start = Math.max(1, end - maxVisible + 1)
for (let i = start; i <= end; i++) {
pages.push(i)
}
return (
<div className="flex items-center justify-between py-4">
<span className="text-sm text-gray-500">{total} items</span>
<div className="flex gap-1">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
aria-label="Previous page"
className="px-3 py-1 text-sm rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-40"
>
&lt;
</button>
{pages.map(p => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`px-3 py-1 text-sm rounded ${p === page ? 'bg-indigo-400 text-gray-950' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
>
{p}
</button>
))}
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
aria-label="Next page"
className="px-3 py-1 text-sm rounded bg-gray-800 text-gray-400 hover:bg-gray-700 disabled:opacity-40"
>
&gt;
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,218 @@
import { useState } from 'react'
import { postAPI } from '../api/client'
import { useToast } from './Toast'
interface SearchResult {
title: string
guid: string
size: number
pub_date: string
indexer_name: string
indexer_priority: number
quality: {
title: string
resolution: string
source: string
video_codec: string
release_group: string
parse_warning: boolean
}
quality_tier: {
name: string
rank: number
resolution: string
} | null
seeders: number
peers: number
category: string
download_url: string
source_indexers: string[]
}
interface GrabPayload {
download_url: string
title: string
media_type: string
quality: SearchResult['quality']
indexer_name: string
media_id: number
}
interface ReleaseSearchResultsProps {
results: SearchResult[]
mediaId: number
mediaType: string
loading?: boolean
}
type SortCol = 'quality' | 'size' | 'seeders' | 'age'
type SortDir = 'asc' | 'desc'
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return `${(bytes / 1024).toFixed(0)} KB`
}
function formatAge(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
return `${months}mo`
}
export function ReleaseSearchResults({ results, mediaId, mediaType, loading }: ReleaseSearchResultsProps) {
const [sortCol, setSortCol] = useState<SortCol>('quality')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [grabbing, setGrabbing] = useState<Set<string>>(new Set())
const { showToast } = useToast()
function handleSort(col: SortCol) {
if (sortCol === col) {
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
}
const sorted = [...results].sort((a, b) => {
let cmp = 0
switch (sortCol) {
case 'quality':
cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999)
break
case 'size':
cmp = a.size - b.size
break
case 'seeders':
cmp = a.seeders - b.seeders
break
case 'age':
cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime()
break
}
return sortDir === 'asc' ? cmp : -cmp
})
async function handleGrab(result: SearchResult) {
setGrabbing(prev => new Set(prev).add(result.guid))
try {
await postAPI<{ queue_id: number }>('/api/releases/grab', {
download_url: result.download_url,
title: result.title,
media_type: mediaType,
quality: result.quality,
indexer_name: result.indexer_name,
media_id: mediaId,
} satisfies GrabPayload)
showToast(`✓ Grabbed "${result.title}"`)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
showToast(`✗ Failed to grab: ${message}`)
} finally {
setGrabbing(prev => {
const next = new Set(prev)
next.delete(result.guid)
return next
})
}
}
function SortHeader({ col, label }: { col: SortCol; label: string }) {
const active = sortCol === col
return (
<button
onClick={() => handleSort(col)}
className={`text-left text-xs font-semibold uppercase tracking-wide transition-colors ${
active ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-400 hover:text-gray-200'
}`}
>
{label} {active && (sortDir === 'asc' ? '▲' : '▼')}
</button>
)
}
if (loading) {
return (
<div className="animate-pulse space-y-3">
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
</div>
)
}
if (results.length === 0) {
return <div className="text-gray-500 text-sm text-center py-12">No releases found</div>
}
return (
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3"><SortHeader col="quality" label="Quality" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Title</th>
<th className="px-4 py-3"><SortHeader col="size" label="Size" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Indexer</th>
<th className="px-4 py-3"><SortHeader col="seeders" label="Seeders" /></th>
<th className="px-4 py-3"><SortHeader col="age" label="Age" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide w-20">Action</th>
</tr>
</thead>
<tbody>
{sorted.map(result => {
const isGrabbing = grabbing.has(result.guid)
const noUrl = !result.download_url
return (
<tr key={result.guid} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm">
{result.quality_tier ? (
<span className="text-gray-200">{result.quality_tier.name}</span>
) : result.quality.resolution || result.quality.source ? (
<span className="text-gray-200">{result.quality.resolution} {result.quality.source}</span>
) : (
<span className="text-gray-500">Unknown</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-200 max-w-lg truncate" title={result.title}>
{result.title}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(result.size)}</td>
<td className="px-4 py-3 text-sm text-gray-400">{result.indexer_name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className={result.seeders > 0 ? 'text-green-400' : ''}>{result.seeders}</span>
{' / '}
<span>{result.peers}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatAge(result.pub_date)}</td>
<td className="px-4 py-3">
<button
disabled={isGrabbing || noUrl}
onClick={() => handleGrab(result)}
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGrabbing ? 'Grabbing...' : noUrl ? 'N/A' : 'Grab'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
export default ReleaseSearchResults

View File

@@ -0,0 +1,23 @@
const statusColors: Record<string, string> = {
pending: 'bg-yellow-900/30 text-yellow-400',
approved: 'bg-blue-900/30 text-blue-400',
searching: 'bg-cyan-900/30 text-cyan-400',
downloading: 'bg-orange-900/30 text-orange-400',
fulfilled: 'bg-green-900/30 text-green-400',
failed: 'bg-red-900/30 text-red-400',
available: 'bg-green-900/30 text-green-400',
unavailable: 'bg-red-900/30 text-red-400',
imported: 'bg-green-900/30 text-green-400',
completed: 'bg-green-900/30 text-green-400',
rejected: 'bg-red-900/30 text-red-400',
}
export default function StatusBadge({ status }: { status: string }) {
const colorClass = statusColors[status] ?? 'bg-gray-800 text-gray-400'
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${colorClass}`} aria-label={status}>
{status}
</span>
)
}

View File

@@ -0,0 +1,32 @@
import { createContext, useContext, useState, useCallback } from 'react'
import type { ReactNode } from 'react'
interface ToastContextValue {
showToast: (message: string) => void
}
const ToastContext = createContext<ToastContextValue>({ showToast: () => {} })
export function useToast(): ToastContextValue {
return useContext(ToastContext)
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<string | null>(null)
const showToast = useCallback((message: string) => {
setToast(message)
setTimeout(() => setToast(null), 3000)
}, [])
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{toast && (
<div role="status" aria-live="polite" className="fixed bottom-4 right-4 bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 shadow-lg z-50">
<p className="text-sm text-gray-200">{toast}</p>
</div>
)}
</ToastContext.Provider>
)
}

22
frontend/src/index.css Normal file
View File

@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 2px;
}
:focus:not(:focus-visible) {
outline: none;
}
@media (max-width: 768px) {
input, select, textarea {
font-size: 16px !important;
}
}

15
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,160 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI } from '../api/client'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
interface ActivityEvent {
id: number
event_type: string
media_id: number | null
media_type: string | null
title: string
description: string | null
data: Record<string, unknown>
created_at: string
}
interface PaginatedResponse {
data: ActivityEvent[]
total: number
page: number
page_size: number
total_pages: number
}
const PAGE_SIZE = 50
function eventTypeBadge(type: string): { label: string; className: string } {
switch (type) {
case 'grab':
return { label: 'Grab', className: 'bg-blue-600' }
case 'import':
return { label: 'Import', className: 'bg-green-600' }
case 'download_complete':
return { label: 'Download', className: 'bg-green-600' }
case 'download_failed':
return { label: 'Failed', className: 'bg-red-600' }
case 'quality_upgrade':
return { label: 'Upgrade', className: 'bg-purple-600' }
case 'safety_block':
return { label: 'Blocked', className: 'bg-orange-600' }
case 'error':
return { label: 'Error', className: 'bg-red-600' }
default:
return { label: 'Info', className: 'bg-gray-600' }
}
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
export default function Activity() {
const [events, setEvents] = useState<ActivityEvent[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [typeFilter, setTypeFilter] = useState('')
const fetchActivity = useCallback(() => {
setLoading(true)
setError(null)
let url = `/api/activity?page=${page}&page_size=${PAGE_SIZE}`
if (typeFilter) url += `&event_type=${typeFilter}`
fetchAPI<PaginatedResponse>(url)
.then(res => {
setEvents(res.data ?? [])
setTotal(res.total)
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [page, typeFilter])
useEffect(() => {
fetchActivity()
}, [fetchActivity])
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Activity</h2>
<select
value={typeFilter}
onChange={e => { setTypeFilter(e.target.value); setPage(1) }}
className="bg-gray-800 border border-gray-700 text-gray-200 rounded px-3 py-2 text-sm"
>
<option value="">All Events</option>
<option value="grab">Grabs</option>
<option value="import">Imports</option>
<option value="download_complete">Downloads</option>
<option value="download_failed">Failures</option>
<option value="quality_upgrade">Upgrades</option>
<option value="safety_block">Safety Blocks</option>
<option value="error">Errors</option>
</select>
</div>
{error && <ErrorBanner error={error} onRetry={fetchActivity} />}
{loading ? (
<Loading />
) : events.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No activity events</div>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Media</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Time</th>
</tr>
</thead>
<tbody>
{events.map(event => {
const badge = eventTypeBadge(event.event_type)
return (
<tr key={event.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
{badge.label}
</span>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-100 truncate max-w-md">{event.title}</p>
{event.description && (
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{event.media_id != null ? `${event.media_type} #${event.media_id}` : '—'}
</td>
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(event.created_at)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,243 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI, deleteAPI } from '../api/client'
import { useToast } from '../components/Toast'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
import ConfirmModal from '../components/ConfirmModal'
interface BlocklistItem {
id: number
release_title: string
source_title: string | null
quality: { resolution?: string } | null
indexer: string | null
protocol: string
size: number | null
message: string | null
block_reason: string
created_at: string
}
interface PaginatedResponse {
data: BlocklistItem[]
total: number
page: number
page_size: number
total_pages: number
}
const PAGE_SIZE = 50
export default function Blocklist() {
const { showToast } = useToast()
const [items, setItems] = useState<BlocklistItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [clearAllOpen, setClearAllOpen] = useState(false)
const [deleting, setDeleting] = useState(false)
const fetchBlocklist = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<PaginatedResponse>(`/api/blocklist?page=${page}&page_size=${PAGE_SIZE}`)
.then(res => {
setItems(res.data ?? [])
setTotal(res.total)
setSelected(new Set())
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [page])
useEffect(() => {
fetchBlocklist()
}, [fetchBlocklist])
const toggleSelect = (id: number) => {
setSelected(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const toggleSelectAll = () => {
if (selected.size === items.length) {
setSelected(new Set())
} else {
setSelected(new Set(items.map(i => i.id)))
}
}
const deleteSelected = async () => {
setDeleting(true)
let failed = 0
for (const id of selected) {
try {
await deleteAPI(`/api/blocklist/${id}`)
} catch {
failed++
}
}
showToast(failed ? `Deleted ${selected.size - failed} items, ${failed} failed` : `Deleted ${selected.size} items`)
setDeleting(false)
fetchBlocklist()
}
const clearExpired = async () => {
try {
await deleteAPI('/api/blocklist/expired')
showToast('Expired entries cleared')
fetchBlocklist()
} catch {
showToast('Failed to clear expired entries')
}
}
const clearAll = async () => {
try {
await deleteAPI('/api/blocklist')
showToast('All entries cleared')
fetchBlocklist()
} catch {
showToast('Failed to clear blocklist')
}
setClearAllOpen(false)
}
const deleteOne = async (id: number) => {
try {
await deleteAPI(`/api/blocklist/${id}`)
setItems(items.filter(i => i.id !== id))
setTotal(t => t - 1)
showToast('Entry removed')
} catch {
showToast('Failed to remove entry')
}
}
const formatTimeAgo = (dateStr: string): string => {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Blocklist</h2>
<div className="flex gap-2">
<button onClick={clearExpired} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Clear Expired
</button>
<button onClick={() => setClearAllOpen(true)} className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Clear All
</button>
</div>
</div>
{error && <ErrorBanner error={error} onRetry={fetchBlocklist} />}
{loading ? (
<Loading />
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No blocked releases</div>
) : (
<>
{selected.size > 0 && (
<div className="mb-4 flex items-center gap-4">
<span className="text-sm text-gray-400">{selected.size} selected</span>
<button
onClick={deleteSelected}
disabled={deleting}
className="bg-red-600 hover:bg-red-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px] disabled:opacity-50"
>
Delete Selected
</button>
</div>
)}
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3 w-10">
<span className="sr-only">
<input
type="checkbox"
checked={selected.size === items.length && items.length > 0}
onChange={toggleSelectAll}
className="rounded bg-gray-800 border-gray-600"
aria-label="Select all"
/>
</span>
</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Release</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Indexer</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Quality</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Reason</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Date</th>
<th className="w-10 px-4 py-3" />
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className={`border-b border-gray-800/50 hover:bg-gray-800/30 ${selected.has(item.id) ? 'bg-gray-800/20' : ''}`}>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selected.has(item.id)}
onChange={() => toggleSelect(item.id)}
className="rounded bg-gray-800 border-gray-600"
aria-label={`Select ${item.release_title}`}
/>
</td>
<td className="px-4 py-3">
<p className="text-sm text-gray-100 truncate max-w-md">{item.release_title}</p>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{item.indexer ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">{item.quality?.resolution ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{item.block_reason}</td>
<td className="px-4 py-3 text-xs text-gray-500">{formatTimeAgo(item.created_at)}</td>
<td className="px-4 py-3">
<button onClick={() => deleteOne(item.id)} aria-label="Remove from blocklist" className="text-red-400 hover:text-red-300 text-xs min-h-[36px]">
×
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)}
<ConfirmModal
open={clearAllOpen}
title="Clear Blocklist"
message="Remove all blocked releases? This cannot be undone."
onConfirm={clearAll}
onCancel={() => setClearAllOpen(false)}
destructive
/>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { fetchAPI } from '../api/client'
import ErrorBanner from '../components/ErrorBanner'
interface CalendarEvent {
id: number
media_type: string
title: string
date: string
year: number | null
status: string
poster_url: string
}
interface CalendarResponse {
data: CalendarEvent[]
month: string
}
const typeColors: Record<string, { bg: string; border: string; text: string }> = {
movie: { bg: 'bg-indigo-600/20', border: 'border-l-indigo-500', text: 'text-indigo-300' },
series: { bg: 'bg-emerald-600/20', border: 'border-l-emerald-500', text: 'text-emerald-300' },
music: { bg: 'bg-amber-600/20', border: 'border-l-amber-500', text: 'text-amber-300' },
audiobook: { bg: 'bg-rose-600/20', border: 'border-l-rose-500', text: 'text-rose-300' },
podcast: { bg: 'bg-violet-600/20', border: 'border-l-violet-500', text: 'text-violet-300' },
}
const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December',
]
export default function Calendar() {
const navigate = useNavigate()
const now = useMemo(() => new Date(), [])
const [year, setYear] = useState(now.getFullYear())
const [month, setMonth] = useState(now.getMonth())
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const monthParam = `${year}-${String(month + 1).padStart(2, '0')}`
const fetchData = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<CalendarResponse>(`/api/calendar?month=${monthParam}`)
.then(res => {
setEvents(res.data ?? [])
})
.catch(err => setError(err.message || 'Failed to load calendar events'))
.finally(() => setLoading(false))
}, [monthParam])
useEffect(() => {
fetchData()
}, [fetchData])
const goToPrevMonth = () => {
if (month === 0) {
setMonth(11)
setYear(y => y - 1)
} else {
setMonth(m => m - 1)
}
}
const goToNextMonth = () => {
if (month === 11) {
setMonth(0)
setYear(y => y + 1)
} else {
setMonth(m => m + 1)
}
}
const goToToday = () => {
setYear(now.getFullYear())
setMonth(now.getMonth())
}
const handleEventClick = (event: CalendarEvent) => {
navigate(`/library/${event.media_type}/${event.id}`)
}
// Calendar grid calculations
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const today = new Date()
const isCurrentMonth = today.getFullYear() === year && today.getMonth() === month
const todayDate = today.getDate()
// Group events by day
const eventsByDay = useMemo(() => {
const map = new Map<number, CalendarEvent[]>()
for (const event of events) {
const day = parseInt(event.date.split('-')[2] ?? '0', 10)
if (day > 0) {
const existing = map.get(day) ?? []
existing.push(event)
map.set(day, existing)
}
}
return map
}, [events])
// Build the 42-cell grid (6 rows × 7 cols)
const cells: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
cells.push(null)
}
for (let d = 1; d <= daysInMonth; d++) {
cells.push(d)
}
while (cells.length < 42) {
cells.push(null)
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-semibold text-gray-100">Calendar</h2>
<p className="text-gray-500 text-sm">Upcoming release dates for monitored media</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={goToToday}
className="px-3 py-1.5 text-sm font-medium bg-gray-800 border border-gray-700 rounded-lg text-gray-300 hover:bg-gray-700 transition-colors"
>
Today
</button>
<div className="flex items-center gap-2">
<button
onClick={goToPrevMonth}
aria-label="Previous month"
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-gray-100 font-medium min-w-[140px] text-center">
{monthNames[month]} {year}
</span>
<button
onClick={goToNextMonth}
aria-label="Next month"
className="p-1.5 rounded-lg bg-gray-800 border border-gray-700 text-gray-400 hover:text-gray-200 hover:bg-gray-700 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
</div>
{error && <ErrorBanner error={error} onRetry={fetchData} />}
{/* Day-of-week headers */}
<div className="grid grid-cols-7 gap-1 mb-1">
{dayLabels.map(d => (
<div key={d} className="text-center text-xs font-medium text-gray-500 py-2">
{d}
</div>
))}
</div>
{loading ? (
<div className="grid grid-cols-7 gap-1">
{Array.from({ length: 42 }).map((_, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2 animate-pulse">
<div className="h-3 w-6 bg-gray-800 rounded" />
</div>
))}
</div>
) : (
<div className="grid grid-cols-7 gap-1">
{cells.map((day, idx) => {
if (day === null) {
return <div key={`empty-${idx}`} className="min-h-[100px]" />
}
const dayEvents = eventsByDay.get(day) ?? []
const isToday = isCurrentMonth && day === todayDate
const maxVisible = 3
return (
<div
key={`day-${day}`}
className="bg-gray-900 border border-gray-800 rounded-lg min-h-[100px] p-2"
>
<span
className={`text-xs font-medium ${
isToday
? 'text-indigo-400 ring-1 ring-indigo-500 rounded-full w-5 h-5 flex items-center justify-center'
: 'text-gray-500'
}`}
>
{day}
</span>
<div className="mt-1 space-y-0.5">
{dayEvents.slice(0, maxVisible).map(event => {
const colors = typeColors[event.media_type] ?? { bg: 'bg-gray-600/20', border: 'border-l-gray-500', text: 'text-gray-300' }
return (
<button
key={event.id}
onClick={() => handleEventClick(event)}
className={`w-full text-left text-xs px-1.5 py-0.5 rounded border-l-2 ${colors.bg} ${colors.border} ${colors.text} hover:brightness-125 transition-all line-clamp-1 cursor-pointer`}
title={event.title}
>
{event.title}
</button>
)
})}
{dayEvents.length > maxVisible && (
<span className="text-xs text-gray-500 pl-1">
+{dayEvents.length - maxVisible} more
</span>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI } from '../api/client'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
interface DashboardStats {
total_media: number
monitored: number
unavailable: number
available: number
quality_upgrades: number
queue_pending: number
queue_downloading: number
queue_failed: number
blocklist_count: number
blocklist_expired: number
indexers_enabled: number
recent_downloads: number
media_by_type: Record<string, number>
storage_by_type: Record<string, number>
}
export default function Dashboard() {
const [data, setData] = useState<DashboardStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const fetchDashboard = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<DashboardStats>('/api/dashboard')
.then(setData)
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [])
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
if (error) {
return (
<div>
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Dashboard</h2>
<ErrorBanner error={error} onRetry={fetchDashboard} />
</div>
)
}
const cards = [
{ label: 'Movies', value: data?.media_by_type?.movie ?? 0, color: 'bg-blue-600' },
{ label: 'TV Series', value: data?.media_by_type?.series ?? 0, color: 'bg-green-600' },
{ label: 'Music', value: data?.media_by_type?.music ?? 0, color: 'bg-purple-600' },
{ label: 'Books', value: data?.media_by_type?.book ?? 0, color: 'bg-teal-600' },
{ label: 'Active Downloads', value: data?.queue_downloading ?? 0, color: 'bg-orange-600' },
{ label: 'Missing', value: data?.unavailable ?? 0, color: 'bg-red-600' },
{ label: 'Upgrades Available', value: data?.quality_upgrades ?? 0, color: 'bg-cyan-600' },
{ label: 'Total Storage', value: data ? formatStorage(data.storage_by_type) : '—', color: 'bg-gray-600' },
]
return (
<div>
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Dashboard</h2>
{loading && !data ? (
<Loading />
) : (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{cards.map(card => (
<div key={card.label} className={`${card.color} rounded-lg p-4`}>
<p className="text-sm text-gray-200 font-normal">{card.label}</p>
{loading ? (
<div className="mt-2 h-8 w-16 bg-white/10 animate-pulse rounded" />
) : (
<p className="text-2xl font-semibold mt-1">{card.value}</p>
)}
</div>
))}
</div>
)}
</div>
)
}
function formatStorage(storageByType: Record<string, number>): string {
const total = Object.values(storageByType).reduce((sum, v) => sum + v, 0)
const tb = total / 1e12
if (tb >= 1) return `${tb.toFixed(1)} TB`
const gb = total / 1e9
return `${gb.toFixed(1)} GB`
}

View File

@@ -0,0 +1,207 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ErrorBanner from '../components/ErrorBanner'
interface DiscoverItem {
tmdb_id: number
title: string
year: number | null
media_type: string
overview: string
poster_url: string
backdrop_url: string
vote_average: number
in_library: boolean
}
interface DiscoverResponse {
data: DiscoverItem[]
page: number
}
export default function Discover() {
const { showToast } = useToast()
const [items, setItems] = useState<DiscoverItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [mediaType, setMediaType] = useState<string>('movie')
const [tab, setTab] = useState<string>('trending')
const [page, setPage] = useState(1)
const [adding, setAdding] = useState<Set<number>>(new Set())
const fetchData = useCallback(() => {
setLoading(true)
setError(null)
fetchAPI<DiscoverResponse>(`/api/discover/${tab}?type=${mediaType}&page=${page}`)
.then(res => {
setItems(res.data ?? [])
})
.catch(err => setError(err.message || 'Failed to load discover content'))
.finally(() => setLoading(false))
}, [tab, mediaType, page])
useEffect(() => {
fetchData()
}, [fetchData])
const handleMediaTypeChange = (value: string) => {
setMediaType(value)
setPage(1)
}
const handleTabChange = (value: string) => {
setTab(value)
setPage(1)
}
const handleAddToLibrary = async (item: DiscoverItem) => {
setAdding(prev => new Set(prev).add(item.tmdb_id))
try {
const result = await postAPI<{ id: number; existing?: boolean }>(
'/api/discover/add',
{ tmdb_id: item.tmdb_id, media_type: item.media_type }
)
setItems(prev =>
prev.map(i => i.tmdb_id === item.tmdb_id ? { ...i, in_library: true } : i)
)
if (result.existing) {
showToast(`"${item.title}" is already in your library`)
} else {
showToast(`Added "${item.title}" to library`)
}
} catch {
showToast(`Failed to add "${item.title}" to library`)
} finally {
setAdding(prev => {
const next = new Set(prev)
next.delete(item.tmdb_id)
return next
})
}
}
return (
<div>
<h2 className="text-2xl font-semibold mb-2 text-gray-100">Discover</h2>
<p className="text-gray-500 text-sm mb-6">Browse trending and popular content from TMDB</p>
<div className="flex gap-3 mb-6">
<div className="flex rounded-lg overflow-hidden border border-gray-700">
<button
onClick={() => handleTabChange('trending')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === 'trending' ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200'
}`}
>
Trending
</button>
<button
onClick={() => handleTabChange('popular')}
className={`px-4 py-2 text-sm font-medium transition-colors ${
tab === 'popular' ? 'bg-indigo-600 text-white' : 'bg-gray-800 text-gray-400 hover:text-gray-200'
}`}
>
Popular
</button>
</div>
<select
value={mediaType}
onChange={e => handleMediaTypeChange(e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
>
<option value="movie">Movies</option>
<option value="series">Series</option>
</select>
</div>
{error && <ErrorBanner error={error} onRetry={fetchData} />}
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden animate-pulse">
<div className="aspect-[2/3] bg-gray-800" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No results found</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
{items.map(item => (
<div
key={item.tmdb_id}
className="group relative bg-gray-900 border border-gray-800 rounded-lg overflow-hidden hover:border-gray-700 transition-colors"
>
{item.poster_url ? (
<img
src={item.poster_url}
alt={item.title}
className="w-full aspect-[2/3] object-cover"
loading="lazy"
/>
) : (
<div className="w-full aspect-[2/3] bg-gray-800 flex items-center justify-center">
<span className="text-3xl text-gray-400 font-bold">
{item.title.charAt(0).toUpperCase()}
</span>
</div>
)}
{item.in_library && (
<div className="absolute top-2 right-2 bg-green-600 rounded-full w-6 h-6 flex items-center justify-center text-white text-xs font-bold z-10">
</div>
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity flex flex-col justify-end p-3">
<p className="text-white text-sm font-medium leading-tight mb-1 line-clamp-2">{item.title}</p>
<div className="flex items-center gap-2 text-xs text-gray-300 mb-2">
{item.year && <span>{item.year}</span>}
<span> {item.vote_average.toFixed(1)}</span>
</div>
{item.in_library ? (
<button
disabled
className="bg-green-800 text-green-300 text-xs px-3 py-1.5 rounded font-medium cursor-not-allowed"
>
In Library
</button>
) : (
<button
onClick={() => handleAddToLibrary(item)}
disabled={adding.has(item.tmdb_id)}
className="bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-800 disabled:cursor-wait text-white text-xs px-3 py-1.5 rounded font-medium transition-colors"
>
{adding.has(item.tmdb_id) ? 'Adding...' : 'Add to Library'}
</button>
)}
</div>
</div>
))}
</div>
<div className="flex items-center justify-center gap-4 mt-8">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 transition-colors"
>
Previous
</button>
<span className="text-sm text-gray-400">Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={page >= 10}
className="px-4 py-2 bg-gray-800 border border-gray-700 rounded-lg text-sm text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-700 transition-colors"
>
Next
</button>
</div>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,225 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { fetchAPI, putAPI } from '../api/client'
import { useToast } from '../components/Toast'
import StatusBadge from '../components/StatusBadge'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
interface MediaItem {
id: number
media_type: string
title: string
year: number | null
status: string
monitored: boolean
current_quality: { resolution?: string } | null
}
interface PaginatedResponse {
data: MediaItem[]
total: number
page: number
page_size: number
total_pages: number
}
const MEDIA_TYPES = ['all', 'movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'photo', 'other']
const STATUS_FILTERS = ['all', 'available', 'unavailable', 'downloading', 'failed']
const PAGE_SIZE = 50
export default function Library() {
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
const { showToast } = useToast()
const [items, setItems] = useState<MediaItem[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const q = searchParams.get('q') ?? ''
const typeFilter = searchParams.get('type') ?? 'all'
const statusFilter = searchParams.get('status') ?? 'all'
const page = Number(searchParams.get('page') ?? '1')
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fetchMedia = useCallback(() => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
if (q) params.set('q', q)
if (typeFilter !== 'all') params.set('type', typeFilter)
if (statusFilter !== 'all') params.set('status', statusFilter)
params.set('page', String(page))
params.set('page_size', String(PAGE_SIZE))
fetchAPI<PaginatedResponse>(`/api/media?${params}`)
.then(res => {
setItems(res.data ?? [])
setTotal(res.total)
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [q, typeFilter, statusFilter, page])
useEffect(() => {
fetchMedia()
}, [fetchMedia])
const updateSearch = (key: string, value: string) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
if (value && value !== 'all') {
next.set(key, value)
} else {
next.delete(key)
}
if (key !== 'page') next.set('page', '1')
return next
})
}
const handleSearchInput = (value: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => updateSearch('q', value), 300)
}
const toggleMonitored = async (item: MediaItem) => {
const prev = item.monitored
setItems(items.map(i => i.id === item.id ? { ...i, monitored: !prev } : i))
try {
await putAPI(`/api/media/${item.media_type}/${item.id}`, { monitored: !prev })
} catch {
setItems(items.map(i => i.id === item.id ? { ...i, monitored: prev } : i))
showToast('Failed to update monitoring status')
}
}
const totalPages = Math.ceil(total / PAGE_SIZE)
return (
<div>
<h2 className="text-2xl font-semibold mb-6 text-gray-100">Library</h2>
<div className="flex gap-4 mb-6">
<label htmlFor="lib-search" className="sr-only">Search media</label>
<input
id="lib-search"
type="text"
placeholder="Search across all media..."
defaultValue={q}
onChange={e => handleSearchInput(e.target.value)}
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 placeholder-gray-500 text-sm"
/>
<select
value={typeFilter}
onChange={e => updateSearch('type', e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
>
{MEDIA_TYPES.map(t => (
<option key={t} value={t}>{t === 'all' ? 'All Types' : t.charAt(0).toUpperCase() + t.slice(1)}</option>
))}
</select>
<select
value={statusFilter}
onChange={e => updateSearch('status', e.target.value)}
className="bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 text-sm"
>
{STATUS_FILTERS.map(s => (
<option key={s} value={s}>{s === 'all' ? 'All Statuses' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
</div>
{error && <ErrorBanner error={error} onRetry={fetchMedia} />}
{loading && !items.length ? (
<div className="space-y-2">
{[1, 2, 3].map(i => (
<div key={i} className="bg-gray-900 border border-gray-800 rounded-lg p-4 animate-pulse">
<div className="h-4 bg-gray-800 rounded w-1/3 mb-2" />
<div className="h-3 bg-gray-800 rounded w-1/5" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">
{q || typeFilter !== 'all' || statusFilter !== 'all'
? 'No media found matching your search'
: 'Your library is empty. Add media or request new content.'}
</div>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-20">Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Status</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Quality</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">Monitor</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr
key={item.id}
tabIndex={0}
role="link"
aria-label={`View ${item.title}`}
className="border-b border-gray-800/50 hover:bg-gray-800/30 cursor-pointer"
onClick={() => navigate(`/library/${item.media_type}/${item.id}`)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); navigate(`/library/${item.media_type}/${item.id}`) } }}
>
<td className="px-4 py-3">
<span className="text-sm text-gray-100 font-normal hover:text-indigo-400">{item.title}</span>
{item.year && <span className="text-xs text-gray-500 ml-2">{item.year}</span>}
</td>
<td className="px-4 py-3">
<span className="text-xs text-gray-400 capitalize">{item.media_type}</span>
</td>
<td className="px-4 py-3">
<StatusBadge status={item.status} />
</td>
<td className="px-4 py-3">
<span className="text-sm text-gray-400">
{item.current_quality?.resolution ?? '—'}
</span>
</td>
<td className="px-4 py-3 text-center">
<button
onClick={e => { e.stopPropagation(); toggleMonitored(item) }}
role="switch"
aria-checked={item.monitored}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-gray-900 ${
item.monitored ? 'bg-indigo-500' : 'bg-gray-700'
}`}
>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-white transition-transform ${
item.monitored ? 'translate-x-4' : 'translate-x-1'
}`} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={p => setSearchParams(prev => { const n = new URLSearchParams(prev); n.set('page', String(p)); return n })}
/>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,757 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { fetchAPI, postAPI, deleteAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ConfirmModal from '../components/ConfirmModal'
import StatusBadge from '../components/StatusBadge'
import ErrorBanner from '../components/ErrorBanner'
import ReleaseSearchResults from '../components/ReleaseSearchResults'
interface Media {
id: number
media_type: string
title: string
sort_title: string
original_title: string | null
overview: string | null
year: number | null
status: string
monitored: boolean
external_ids: Record<string, string>
metadata: Record<string, unknown>
images: Array<{ url: string; type: string; width: number; height: number }>
quality_profile_id: number | null
current_quality: { resolution?: string; source?: string } | null
desired_quality: { resolution?: string; source?: string } | null
quality_upgrade_needed: boolean
added_at: string
updated_at: string
}
interface MediaFile {
id: number
media_id: number
path: string
original_path: string | null
file_name: string
file_size: number
quality: { resolution?: string; source?: string; codec?: string } | null
codec: string | null
resolution: string | null
source: string | null
transcode_status: string
created_at: string
}
interface MediaRelation {
id: number
parent_id: number
child_id: number
relation: string
position: number | null
season: number | null
}
interface MediaDetail {
media: Media
files: MediaFile[]
relations: MediaRelation[]
}
interface QualityProfileInfo {
id: number
name: string
cutoff_quality: Record<string, unknown>
allowed_qualities: Record<string, unknown>[]
}
interface SubtitleInfo {
file_name: string
language: string
language_code: string
hi: boolean
forced: boolean
source: string
}
interface FileWithSubtitles extends MediaFile {
subtitles: SubtitleInfo[]
}
interface EpisodeInfo {
media_id: number
title: string
season: number
episode: number
status: string
monitored: boolean
air_date: string | null
has_file: boolean
quality: { resolution?: string } | null
}
interface MediaHistoryItem {
id: number
event_type: string
title: string
description: string | null
data: Record<string, unknown>
created_at: string
}
interface FullMediaDetail {
media: MediaDetail
quality_profile: QualityProfileInfo | null
files_with_subtitles: FileWithSubtitles[]
episodes: EpisodeInfo[]
history: MediaHistoryItem[]
}
interface ReleaseSearchResult {
title: string
guid: string
size: number
pub_date: string
indexer_name: string
indexer_priority: number
quality: {
title: string
resolution: string
source: string
video_codec: string
release_group: string
parse_warning: boolean
}
quality_tier: { name: string; rank: number; resolution: string } | null
seeders: number
peers: number
category: string
download_url: string
source_indexers: string[]
}
interface SubtitleSearchResult {
id: string
file_name: string
language: string
language_code: string
hi: boolean
forced: boolean
download_count: number
release_name: string
provider: string
}
function eventTypeBadge(type: string): { label: string; className: string } {
switch (type) {
case 'grab':
return { label: 'Grab', className: 'bg-blue-600' }
case 'import':
return { label: 'Import', className: 'bg-green-600' }
case 'download_complete':
return { label: 'Download', className: 'bg-green-600' }
case 'download_failed':
return { label: 'Failed', className: 'bg-red-600' }
case 'quality_upgrade':
return { label: 'Upgrade', className: 'bg-purple-600' }
case 'safety_block':
return { label: 'Blocked', className: 'bg-orange-600' }
case 'error':
return { label: 'Error', className: 'bg-red-600' }
default:
return { label: 'Info', className: 'bg-gray-600' }
}
}
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return `${(bytes / 1024).toFixed(0)} KB`
}
type TabKey = 'overview' | 'search' | 'files' | 'episodes' | 'history'
export default function MediaDetail() {
const { type, id } = useParams<{ type: string; id: string }>()
const navigate = useNavigate()
const [detail, setDetail] = useState<FullMediaDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<TabKey>('overview')
const [searchResults, setSearchResults] = useState<ReleaseSearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState<string | null>(null)
const { showToast } = useToast()
const [subtitleSearchFile, setSubtitleSearchFile] = useState<FileWithSubtitles | null>(null)
const [subtitleResults, setSubtitleResults] = useState<SubtitleSearchResult[]>([])
const [subtitleSearchLoading, setSubtitleSearchLoading] = useState(false)
const [subtitleDownloading, setSubtitleDownloading] = useState<Set<string>>(new Set())
const [extracting, setExtracting] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
useEffect(() => {
if (!type || !id) return
setLoading(true)
setError(null)
fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
.then(res => setDetail(res))
.catch(err => setError(err.message || 'Failed to load media detail'))
.finally(() => setLoading(false))
}, [type, id])
useEffect(() => {
if (!type || !id || activeTab !== 'search') return
const media = detail?.media.media
if (!media) return
setSearchLoading(true)
setSearchError(null)
const query = encodeURIComponent(media.title)
fetchAPI<{ data: ReleaseSearchResult[]; total: number }>(`/api/releases/search?query=${query}&media_type=${media.media_type}`)
.then(res => setSearchResults(res.data ?? []))
.catch(err => setSearchError(err.message || 'Search failed'))
.finally(() => setSearchLoading(false))
}, [activeTab, type, id, detail?.media.media])
const searchSubtitles = async (file: FileWithSubtitles) => {
if (!type || !id) return
setSubtitleSearchFile(file)
setSubtitleSearchLoading(true)
setSubtitleResults([])
try {
const res = await fetchAPI<{ data: SubtitleSearchResult[] }>(
`/api/media/${type}/${id}/subtitles/search?languages=en`
)
setSubtitleResults(res.data ?? [])
} catch {
showToast('Failed to search subtitles')
} finally {
setSubtitleSearchLoading(false)
}
}
const downloadSub = async (result: SubtitleSearchResult) => {
if (!type || !id) return
setSubtitleDownloading(prev => new Set(prev).add(result.id))
try {
await postAPI(`/api/media/${type}/${id}/subtitles/download`, {
subtitle_id: result.id,
language_code: result.language_code,
hi: result.hi,
forced: result.forced,
})
showToast(`✓ Downloaded ${result.language} subtitle`)
// Refresh media detail to show new subtitle
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
setDetail(refreshed)
setSubtitleResults([])
setSubtitleSearchFile(null)
} catch {
showToast('Failed to download subtitle')
} finally {
setSubtitleDownloading(prev => {
const next = new Set(prev)
next.delete(result.id)
return next
})
}
}
const extractSubs = async () => {
if (!type || !id) return
setExtracting(true)
try {
await postAPI(`/api/media/${type}/${id}/subtitles/extract`, {})
showToast('✓ Subtitles extracted')
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
setDetail(refreshed)
} catch {
showToast('Failed to extract subtitles')
} finally {
setExtracting(false)
}
}
const handleRefreshMetadata = async () => {
if (!type || !id) return
setRefreshing(true)
try {
await postAPI(`/api/media/${type}/${id}/refresh-metadata`, {})
showToast('✓ Metadata refreshed')
const refreshed = await fetchAPI<FullMediaDetail>(`/api/media/${type}/${id}/detail`)
setDetail(refreshed)
} catch {
showToast('Failed to refresh metadata')
} finally {
setRefreshing(false)
}
}
const handleDeleteMedia = async () => {
if (!type || !id) return
setShowDeleteModal(false)
try {
await deleteAPI(`/api/media/${type}/${id}`)
showToast('✓ Media deleted')
navigate('/library')
} catch {
showToast('Failed to delete media')
}
}
if (loading) {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-800 rounded w-1/3" />
<div className="h-64 bg-gray-800 rounded" />
</div>
)
}
if (error) {
return <ErrorBanner error={error} onRetry={() => window.location.reload()} />
}
if (!detail) return null
const media = detail.media.media
const posterImage = detail.media.media.images?.find(i => i.type === 'poster')
const isSeries = media.media_type === 'series'
const metadata = media.metadata as Record<string, unknown> | null
const tmdbRating = (metadata?.tmdb_rating as number) ?? null
const genres = (metadata?.genres as string[]) ?? []
const totalSize = detail.files_with_subtitles.reduce((sum, f) => sum + f.file_size, 0)
const tabs: { key: TabKey; label: string }[] = [
{ key: 'overview', label: 'Overview' },
{ key: 'search', label: 'Search' },
{ key: 'files', label: 'Files' },
...(isSeries ? [{ key: 'episodes' as TabKey, label: 'Episodes' }] : []),
{ key: 'history', label: 'History' },
]
// Group episodes by season
const seasons: Record<number, EpisodeInfo[]> = {}
if (isSeries && detail.episodes) {
for (const ep of detail.episodes) {
if (!seasons[ep.season]) seasons[ep.season] = []
seasons[ep.season].push(ep)
}
}
return (
<div>
{/* Header */}
<div className="flex items-center gap-4 mb-6">
<button
onClick={() => navigate('/library')}
className="text-sm text-indigo-400 hover:text-indigo-300 transition-colors"
>
Back to Library
</button>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-semibold text-gray-100">{media.title}</h2>
<span className="px-2 py-0.5 rounded text-xs font-medium bg-gray-800 text-gray-400 capitalize">
{media.media_type}
</span>
</div>
<div className="ml-auto flex items-center gap-3">
<button
onClick={handleRefreshMetadata}
disabled={refreshing}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
>
{refreshing ? 'Refreshing...' : '↻ Refresh Metadata'}
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="text-sm text-red-400 hover:text-red-300 px-2 min-h-[36px]"
>
Delete
</button>
<span className="text-xs text-gray-500">
{detail.files_with_subtitles.length} file{detail.files_with_subtitles.length !== 1 ? 's' : ''} · {formatFileSize(totalSize)}
</span>
</div>
</div>
{/* Tab navigation */}
<div className="flex gap-6 border-b border-gray-800 mb-6">
{tabs.map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`pb-3 text-sm font-medium transition-colors ${
activeTab === tab.key
? 'border-b-2 border-indigo-400 text-indigo-400'
: 'text-gray-400 hover:text-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Overview Tab */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Poster */}
<div>
{posterImage ? (
<img
src={posterImage.url}
alt={media.title}
className="w-full max-w-xs rounded-lg shadow-lg"
/>
) : (
<div className="w-full max-w-xs aspect-[2/3] bg-gray-800 rounded-lg flex items-center justify-center">
<span className="text-4xl text-gray-500 font-bold">
{media.title.charAt(0).toUpperCase()}
</span>
</div>
)}
</div>
{/* Info */}
<div className="lg:col-span-2 space-y-4">
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold text-white">{media.title}</h1>
{media.year && (
<span className="px-2 py-0.5 rounded text-sm bg-gray-800 text-gray-300">{media.year}</span>
)}
</div>
{media.original_title && media.original_title !== media.title && (
<p className="text-sm text-gray-500">{media.original_title}</p>
)}
<div className="flex items-center gap-3">
<StatusBadge status={media.status} />
<span className={`text-xs px-2 py-0.5 rounded ${media.monitored ? 'bg-indigo-900/30 text-indigo-400' : 'bg-gray-800 text-gray-500'}`}>
{media.monitored ? 'Monitored' : 'Not Monitored'}
</span>
</div>
{tmdbRating != null && (
<p className="text-sm text-gray-300">
{tmdbRating.toFixed(1)} / 10
</p>
)}
{genres.length > 0 && (
<div className="flex flex-wrap gap-2">
{genres.map(g => (
<span key={g} className="px-2 py-0.5 rounded text-xs bg-gray-800 text-gray-400">{g}</span>
))}
</div>
)}
<div className="text-sm text-gray-400">
<span className="font-medium text-gray-300">Quality Profile:</span>{' '}
{detail.quality_profile?.name ?? <span className="text-gray-500">None configured</span>}
</div>
{(media.current_quality || media.desired_quality) && (
<div className="flex gap-4 text-sm">
{media.current_quality && (
<span className="text-gray-400">
Current: <span className="text-gray-200">{media.current_quality.resolution ?? 'Unknown'}</span>
</span>
)}
{media.desired_quality && (
<span className="text-gray-400">
Desired: <span className="text-gray-200">{media.desired_quality.resolution ?? 'Unknown'}</span>
</span>
)}
</div>
)}
{media.overview && (
<div className="mt-4">
<p className="text-gray-300 leading-relaxed">{media.overview}</p>
</div>
)}
</div>
</div>
)}
{/* Search Tab */}
{activeTab === 'search' && (
<div>
<div className="flex items-center justify-between mb-4">
<p className="text-sm text-gray-400">
Searching indexers for "<span className="text-gray-200">{media.title}</span>"
</p>
{searchResults.length > 0 && (
<p className="text-xs text-gray-500">{searchResults.length} result{searchResults.length !== 1 ? 's' : ''}</p>
)}
</div>
{searchError ? (
<ErrorBanner error={searchError} onRetry={() => setActiveTab('search')} />
) : (
<ReleaseSearchResults
results={searchResults}
mediaId={Number(id)}
mediaType={media.media_type}
loading={searchLoading}
/>
)}
</div>
)}
{/* Files Tab */}
{activeTab === 'files' && (
detail.files_with_subtitles.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No imported files yet</div>
) : (
<div>
<div className="flex justify-end mb-3">
<button
onClick={extractSubs}
disabled={extracting}
className="bg-gray-700 hover:bg-gray-600 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
>
{extracting ? 'Extracting...' : 'Extract All Subtitles'}
</button>
</div>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">File Name</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Quality</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Size</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Source</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Codec</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Subtitles</th>
</tr>
</thead>
<tbody>
{detail.files_with_subtitles.map(file => (
<tr key={file.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm text-gray-200 max-w-md truncate">{file.file_name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
{file.quality?.resolution ?? file.resolution ?? 'Unknown'}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(file.file_size)}</td>
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{file.source ?? '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">{file.codec ?? '—'}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{file.subtitles && file.subtitles.length > 0 ? (
<div className="flex flex-wrap gap-1">
{file.subtitles.map((sub, idx) => (
<span key={idx} className="bg-gray-700 rounded px-2 py-0.5 text-xs text-gray-300 flex items-center gap-1">
{sub.language_code.toUpperCase()}
{sub.hi && <span className="text-yellow-400">SDH</span>}
{sub.forced && <span className="text-blue-400">F</span>}
<span className="text-gray-500 text-[10px]">{sub.source === 'extracted' ? 'EX' : 'DL'}</span>
</span>
))}
</div>
) : (
<span className="text-gray-600 text-xs">None</span>
)}
<button
onClick={() => searchSubtitles(file)}
className="text-xs text-indigo-400 hover:text-indigo-300 ml-1 whitespace-nowrap"
>
Search
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{subtitleSearchFile && (
<div className="mt-4 bg-gray-900 border border-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-semibold text-gray-200">
Subtitles for &ldquo;{subtitleSearchFile.file_name}&rdquo;
</h4>
<button
onClick={() => { setSubtitleSearchFile(null); setSubtitleResults([]) }}
className="text-gray-500 hover:text-gray-300 text-sm"
>
Close
</button>
</div>
{subtitleSearchLoading ? (
<div className="animate-pulse space-y-2">
<div className="h-8 bg-gray-800 rounded" />
<div className="h-8 bg-gray-800 rounded" />
</div>
) : subtitleResults.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-8">No subtitles found</div>
) : (
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase">Language</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase">Release</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-20">Downloads</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-24">Source</th>
<th className="text-left px-3 py-2 text-xs font-semibold text-gray-400 uppercase w-20">Action</th>
</tr>
</thead>
<tbody>
{subtitleResults.map(result => {
const isDownloading = subtitleDownloading.has(result.id)
return (
<tr key={result.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-3 py-2 text-sm text-gray-200">
{result.language}
{result.hi && <span className="text-yellow-400 ml-1 text-xs">SDH</span>}
{result.forced && <span className="text-blue-400 ml-1 text-xs">Forced</span>}
</td>
<td className="px-3 py-2 text-sm text-gray-400 max-w-xs truncate" title={result.release_name}>
{result.release_name || '—'}
</td>
<td className="px-3 py-2 text-sm text-gray-400">{result.download_count}</td>
<td className="px-3 py-2 text-sm text-gray-400">{result.provider}</td>
<td className="px-3 py-2">
<button
onClick={() => downloadSub(result)}
disabled={isDownloading}
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{isDownloading ? '...' : 'Download'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
)}
</div>
)
)}
{/* Episodes Tab */}
{activeTab === 'episodes' && isSeries && (
Object.keys(seasons).length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No episode information available</div>
) : (
<div className="space-y-6">
{Object.keys(seasons)
.map(Number)
.sort((a, b) => a - b)
.map(seasonNum => (
<div key={seasonNum}>
<h3 className="text-lg font-semibold text-gray-200 mb-3">Season {seasonNum}</h3>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">#</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Status</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">Monitor</th>
<th className="text-center px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-16">File</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Quality</th>
</tr>
</thead>
<tbody>
{seasons[seasonNum].map(ep => (
<tr key={ep.media_id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm text-gray-400">{ep.episode}</td>
<td className="px-4 py-3 text-sm text-gray-200">{ep.title}</td>
<td className="px-4 py-3">
<StatusBadge status={ep.status} />
</td>
<td className="px-4 py-3 text-center">
{ep.monitored ? (
<span className="text-green-400"></span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-center">
{ep.has_file ? (
<span className="text-green-400"></span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-400">
{ep.quality?.resolution ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))}
</div>
)
)}
{/* History Tab */}
{activeTab === 'history' && (
detail.history.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No history recorded for this item</div>
) : (
<div className="space-y-0">
{detail.history.map(event => {
const badge = eventTypeBadge(event.event_type)
return (
<div
key={event.id}
className="flex gap-4 py-3 border-l-2 border-gray-800 pl-4 ml-2 hover:border-gray-600 transition-colors"
>
<div className="flex-shrink-0">
<span className={`inline-block px-2 py-0.5 rounded text-xs font-semibold text-white ${badge.className}`}>
{badge.label}
</span>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-200 truncate">{event.title}</p>
{event.description && (
<p className="text-xs text-gray-500 mt-0.5">{event.description}</p>
)}
</div>
<div className="flex-shrink-0 text-xs text-gray-500">
{formatTimeAgo(event.created_at)}
</div>
</div>
)
})}
</div>
)
)}
<ConfirmModal
open={showDeleteModal}
title="Delete Media"
message={`Are you sure you want to delete "${media.title}"? This will remove the media item and its associated files from the library. Files on disk will not be deleted.`}
confirmLabel="Delete"
destructive
onConfirm={handleDeleteMedia}
onCancel={() => setShowDeleteModal(false)}
/>
</div>
)
}

View File

@@ -0,0 +1,333 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { fetchAPI, deleteAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import StatusBadge from '../components/StatusBadge'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
import ConfirmModal from '../components/ConfirmModal'
import Pagination from '../components/Pagination'
interface QueueItem {
id: number
media_id: number
release_title: string
indexer: string
download_client: string
quality: { resolution?: string } | null
size: number | null
status: string
progress: number
error_message: string | null
protocol: string
created_at: string
updated_at: string
}
interface ImportReport {
imported: number
skipped: number
errors: number
results: ImportResult[]
}
interface ImportResult {
media_id: number
media_type: string
source_path: string
dest_path: string
file_size: number
quality: string
status: string
}
interface ImportHistoryItem {
id: number
media_id: number
media_type: string
action: string
release_title: string
quality: string
created_at: string
}
const TABS = ['all', 'downloading', 'pending', 'failed', 'imported', 'history'] as const
export default function Queue() {
const { showToast } = useToast()
const [items, setItems] = useState<QueueItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [activeTab, setActiveTab] = useState<string>('all')
const [cancelTarget, setCancelTarget] = useState<QueueItem | null>(null)
const [clearAllConfirm, setClearAllConfirm] = useState(false)
const [importing, setImporting] = useState(false)
const [importHistory, setImportHistory] = useState<ImportHistoryItem[]>([])
const [historyPage, setHistoryPage] = useState(1)
const [historyTotal, setHistoryTotal] = useState(0)
const historyPageSize = 50
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fetchQueue = useCallback(() => {
setError(null)
const params = new URLSearchParams()
if (activeTab !== 'all') params.set('status', activeTab)
fetchAPI<{ data: QueueItem[] }>(`/api/queue?${params}`)
.then(res => setItems(res.data ?? []))
.catch(err => {
setError(err.message || 'Something went wrong. Please try again.')
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
})
.finally(() => setLoading(false))
}, [activeTab])
useEffect(() => {
setLoading(true)
fetchQueue()
intervalRef.current = setInterval(fetchQueue, 5000)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [fetchQueue])
const fetchImportHistory = useCallback(() => {
fetchAPI<{ data: ImportHistoryItem[]; total: number; page: number; page_size: number; total_pages: number }>(`/api/imports/history?page=${historyPage}&page_size=${historyPageSize}`)
.then(res => {
setImportHistory(res.data ?? [])
setHistoryTotal(res.total)
})
.catch(() => {
setImportHistory([])
setHistoryTotal(0)
})
}, [historyPage])
useEffect(() => {
if (activeTab === 'history') {
fetchImportHistory()
}
}, [activeTab, fetchImportHistory])
const cancelItem = async (item: QueueItem) => {
try {
await deleteAPI(`/api/queue/${item.id}`)
setItems(items.filter(i => i.id !== item.id))
showToast('Download cancelled')
} catch {
showToast('Failed to cancel download')
}
setCancelTarget(null)
}
const retryItem = async (item: QueueItem) => {
try {
await postAPI(`/api/queue/${item.id}/retry`, {})
showToast('Retrying download')
fetchQueue()
} catch {
showToast('Failed to retry download')
}
}
const retryAllFailed = async () => {
try {
await postAPI('/api/queue/retry-failed', {})
showToast('Retrying all failed downloads')
fetchQueue()
} catch {
showToast('Failed to retry downloads')
}
}
const clearCompleted = async () => {
try {
await postAPI('/api/queue/clear', {})
showToast('Completed downloads cleared')
fetchQueue()
} catch {
showToast('Failed to clear downloads')
}
}
const triggerImport = async () => {
setImporting(true)
try {
const report = await postAPI<ImportReport>('/api/imports/trigger', {})
showToast(`Import complete: ${report.imported} imported, ${report.skipped} skipped, ${report.errors} errors`)
fetchQueue()
if (activeTab === 'history') fetchImportHistory()
} catch {
showToast('Failed to trigger import')
} finally {
setImporting(false)
}
}
const formatSize = (bytes: number | null): string => {
if (!bytes) return '—'
const gb = bytes / 1e9
return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / 1e6).toFixed(1)} MB`
}
const failedCount = items.filter(i => i.status === 'failed').length
const completedCount = items.filter(i => i.status === 'imported').length
return (
<div>
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Download Queue</h2>
<div className="flex gap-2">
<button
onClick={triggerImport}
disabled={importing}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-4 py-2 rounded text-sm font-semibold min-h-[36px]"
>
{importing ? 'Importing...' : 'Check for Completed Downloads'}
</button>
{completedCount > 0 && (
<button onClick={clearCompleted} className="bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Clear Completed
</button>
)}
{failedCount > 0 && (
<button onClick={retryAllFailed} className="bg-gray-700 hover:bg-gray-600 px-4 py-2 rounded text-sm font-semibold min-h-[36px]">
Retry All Failed
</button>
)}
</div>
</div>
<div className="flex gap-1 mb-4">
{TABS.map(tab => (
<button
key={tab}
onClick={() => { setActiveTab(tab); setLoading(true) }}
className={`px-4 py-2 text-sm rounded capitalize ${activeTab === tab ? 'bg-indigo-400 text-gray-950' : 'bg-gray-800 text-gray-400 hover:bg-gray-700'}`}
>
{tab}
</button>
))}
</div>
{error && <ErrorBanner error={error} onRetry={fetchQueue} />}
{loading ? (
<Loading />
) : items.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No active downloads</div>
) : (
<div className="space-y-2">
{items.map(item => (
<div key={item.id} className="bg-gray-900 border border-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex-1 min-w-0 mr-4">
<p className="text-sm text-gray-100 truncate">{item.release_title}</p>
<div className="flex items-center gap-3 mt-1">
<StatusBadge status={item.status} />
<span className="text-xs text-gray-500">{item.download_client}</span>
{item.quality?.resolution && (
<span className="text-xs text-gray-400">{item.quality.resolution}</span>
)}
<span className="text-xs text-gray-500">{formatSize(item.size)}</span>
</div>
</div>
<div className="flex items-center gap-2">
{item.status === 'failed' && (
<button onClick={() => retryItem(item)} className="text-xs text-indigo-400 hover:text-indigo-300 min-h-[36px] px-2">
Retry
</button>
)}
{(item.status === 'downloading' || item.status === 'pending' || item.status === 'failed') && (
<button
onClick={() => {
if (item.status === 'downloading') {
setCancelTarget(item)
} else {
cancelItem(item)
}
}}
className="text-xs text-red-400 hover:text-red-300 min-h-[36px] px-2"
>
Cancel
</button>
)}
</div>
</div>
{(item.status === 'downloading' || item.status === 'pending') && (
<div className="w-full bg-gray-800 rounded-full h-1.5">
<div
className="bg-indigo-400 h-1.5 rounded-full transition-all duration-500"
style={{ width: `${Math.min(100, item.progress * 100)}%` }}
/>
</div>
)}
{item.status === 'downloading' && (
<p className="text-xs text-gray-500 mt-1">{(item.progress * 100).toFixed(0)}%</p>
)}
{item.error_message && (
<p className="text-xs text-red-400 mt-1">{item.error_message}</p>
)}
</div>
))}
</div>
)}
{activeTab === 'history' && (
<div>
{importHistory.length === 0 ? (
<div className="text-gray-500 text-center py-20 text-sm">No import history yet</div>
) : (
<>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase">Release Title</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-28">Media Type</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-24">Quality</th>
<th className="text-left px-4 py-3 text-xs font-semibold text-gray-400 uppercase w-40">Imported At</th>
</tr>
</thead>
<tbody>
{importHistory.map(item => (
<tr key={item.id} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm text-gray-200 max-w-md truncate">{item.release_title || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400 capitalize">{item.media_type}</td>
<td className="px-4 py-3 text-sm text-gray-400">{item.quality || '—'}</td>
<td className="px-4 py-3 text-sm text-gray-400">{new Date(item.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination total={historyTotal} page={historyPage} pageSize={historyPageSize} onPageChange={setHistoryPage} />
</>
)}
</div>
)}
<ConfirmModal
open={!!cancelTarget}
title="Cancel Download"
message={`Cancel "${cancelTarget?.release_title ?? ''}"? This will remove it from the queue.`}
onConfirm={() => cancelTarget && cancelItem(cancelTarget)}
onCancel={() => setCancelTarget(null)}
destructive
confirmLabel="Cancel Download"
/>
<ConfirmModal
open={clearAllConfirm}
title="Clear All Completed"
message="Remove all completed downloads from the queue?"
onConfirm={() => { clearCompleted(); setClearAllConfirm(false) }}
onCancel={() => setClearAllConfirm(false)}
destructive
/>
</div>
)
}

View File

@@ -0,0 +1,454 @@
import { useEffect, useState, useCallback } from 'react'
import { fetchAPI, postAPI, putAPI, deleteAPI } from '../api/client'
import { useToast } from '../components/Toast'
import Pagination from '../components/Pagination'
import ErrorBanner from '../components/ErrorBanner'
import Loading from '../components/Loading'
import ConfirmModal from '../components/ConfirmModal'
import StatusBadge from '../components/StatusBadge'
interface RequestItem {
id: number
media_id: number
title: string
media_type: string
year: number | null
quality_profile_id: number | null
quality_profile_name: string | null
root_folder_id: number | null
root_folder_path: string | null
status: string
requested_by: string
notes: string | null
created_at: string
updated_at: string
}
interface PaginatedResponse {
data: RequestItem[]
total: number
page: number
page_size: number
total_pages: number
}
interface QualityProfile {
id: number
name: string
}
interface RootFolder {
id: number
path: string
media_type: string
}
const PAGE_SIZE = 20
const FILTER_TABS = [
{ value: '', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'fulfilled', label: 'Fulfilled' },
{ value: 'rejected', label: 'Rejected' },
]
const MEDIA_TYPES = ['movie', 'series', 'music', 'book', 'audiobook', 'podcast', 'other']
function formatTimeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d ago`
const months = Math.floor(days / 30)
return `${months}mo ago`
}
export default function Requests() {
const { showToast } = useToast()
const [requests, setRequests] = useState<RequestItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// New request modal
const [showNewModal, setShowNewModal] = useState(false)
const [newTitle, setNewTitle] = useState('')
const [newType, setNewType] = useState('movie')
const [newYear, setNewYear] = useState('')
const [newQualityId, setNewQualityId] = useState<number | ''>('')
const [newRootFolderId, setNewRootFolderId] = useState<number | ''>('')
const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
// Reject confirmation
const [rejectTarget, setRejectTarget] = useState<RequestItem | null>(null)
// Dropdown data
const [qualityProfiles, setQualityProfiles] = useState<QualityProfile[]>([])
const [rootFolders, setRootFolders] = useState<RootFolder[]>([])
const fetchRequests = useCallback(() => {
setLoading(true)
setError(null)
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
if (statusFilter) params.set('status', statusFilter)
fetchAPI<PaginatedResponse>(`/api/requests?${params}`)
.then(res => {
setRequests(res.data ?? [])
setTotal(res.total)
})
.catch(err => setError(err.message || 'Something went wrong. Please try again.'))
.finally(() => setLoading(false))
}, [page, statusFilter])
useEffect(() => {
fetchRequests()
}, [fetchRequests])
// Fetch dropdown data on mount
useEffect(() => {
fetchAPI<{ data: QualityProfile[] }>('/api/quality-profiles?page=1&page_size=100')
.then(res => setQualityProfiles(res.data ?? []))
.catch(() => {})
fetchAPI<{ data: RootFolder[] }>('/api/root-folders?page=1&page_size=100')
.then(res => setRootFolders(res.data ?? []))
.catch(() => {})
}, [])
const handleFilterChange = (value: string) => {
setStatusFilter(value)
setPage(1)
}
const openNewModal = () => {
setNewTitle('')
setNewType('movie')
setNewYear('')
setNewQualityId('')
setNewRootFolderId('')
setFormError(null)
setShowNewModal(true)
}
const submitNewRequest = async () => {
if (!newTitle.trim()) {
setFormError('Title is required')
return
}
setSubmitting(true)
setFormError(null)
try {
const body: Record<string, unknown> = {
title: newTitle.trim(),
media_type: newType,
}
if (newYear) body.year = parseInt(newYear, 10)
if (newQualityId !== '') body.quality_profile_id = newQualityId
if (newRootFolderId !== '') body.root_folder_id = newRootFolderId
await postAPI('/api/requests', body)
showToast('Request submitted')
setShowNewModal(false)
fetchRequests()
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to submit request'
setFormError(msg)
} finally {
setSubmitting(false)
}
}
const approveRequest = async (req: RequestItem) => {
try {
await putAPI(`/api/requests/${req.id}/approve`, {})
showToast('Request approved — searching for release')
fetchRequests()
} catch {
showToast('Failed to approve request')
}
}
const rejectRequest = async () => {
if (!rejectTarget) return
try {
await putAPI(`/api/requests/${rejectTarget.id}/reject`, {})
showToast('Request rejected')
fetchRequests()
} catch {
showToast('Failed to reject request')
}
setRejectTarget(null)
}
const withdrawRequest = async (req: RequestItem) => {
try {
await deleteAPI(`/api/requests/${req.id}`)
showToast('Request withdrawn')
fetchRequests()
} catch {
showToast('Failed to withdraw request')
}
}
// Filter root folders by selected media type
const filteredFolders = rootFolders.filter(f => f.media_type === newType)
return (
<div>
{/* Header */}
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-semibold text-gray-100">Requests</h2>
<button
onClick={openNewModal}
className="bg-indigo-400 hover:bg-indigo-500 px-4 py-2 rounded text-sm font-semibold text-gray-950 min-h-[36px]"
>
+ New Request
</button>
</div>
{/* Filter tabs */}
<div className="flex gap-2 mb-6">
{FILTER_TABS.map(tab => (
<button
key={tab.value}
onClick={() => handleFilterChange(tab.value)}
className={`px-4 py-2 rounded text-sm font-medium min-h-[36px] ${
statusFilter === tab.value
? 'bg-indigo-400 text-gray-950'
: 'bg-gray-800 text-gray-400 hover:bg-gray-700'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Error */}
{error && <ErrorBanner error={error} onRetry={fetchRequests} />}
{/* Loading */}
{loading ? (
<Loading />
) : requests.length === 0 ? (
/* Empty */
<div className="text-gray-500 text-center py-20 text-sm">
{statusFilter
? `No ${statusFilter} requests`
: 'No requests yet. Click + New Request to get started.'}
</div>
) : (
<>
{/* Request cards */}
<div className="space-y-3">
{requests.map(req => (
<div
key={req.id}
className="bg-gray-900 border border-gray-800 rounded-lg p-4"
>
{/* Header row */}
<div className="flex items-start justify-between mb-2">
<h3 className="text-xl font-semibold text-gray-100">
{req.title}
{req.year ? (
<span className="text-gray-400 font-normal"> ({req.year})</span>
) : null}
</h3>
<StatusBadge status={req.status} />
</div>
{/* Meta row */}
<div className="text-sm text-gray-400 mb-3">
<span>Requested by: {req.requested_by}</span>
<span className="mx-2">&bull;</span>
<span>{formatTimeAgo(req.created_at)}</span>
{req.quality_profile_name && (
<>
<span className="mx-2">&bull;</span>
<span>Quality: {req.quality_profile_name}</span>
</>
)}
{req.root_folder_path && (
<>
<span className="mx-2">&bull;</span>
<span>Root: {req.root_folder_path}</span>
</>
)}
</div>
{/* Actions row */}
{req.status === 'pending' && (
<div className="flex items-center gap-3">
<button
onClick={() => approveRequest(req)}
className="bg-green-600 hover:bg-green-700 text-sm font-medium px-4 py-2 rounded min-h-[36px]"
aria-label="Approve request"
>
Approve
</button>
<button
onClick={() => setRejectTarget(req)}
className="bg-red-600 hover:bg-red-700 text-sm font-medium px-4 py-2 rounded min-h-[36px]"
aria-label="Reject request"
>
Reject
</button>
</div>
)}
{/* Withdraw link for own pending/approved requests */}
{(req.status === 'pending' || req.status === 'approved') && (
<button
onClick={() => withdrawRequest(req)}
className="text-xs text-gray-500 hover:text-gray-300 mt-2"
>
Withdraw
</button>
)}
</div>
))}
</div>
{/* Pagination */}
<Pagination
total={total}
page={page}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
</>
)}
{/* New Request Modal */}
{showNewModal && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowNewModal(false)}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-semibold text-gray-100">New Request</h3>
<button onClick={() => setShowNewModal(false)} className="text-gray-500 hover:text-gray-300 text-lg">
&times;
</button>
</div>
{formError && (
<div className="bg-red-900/30 border border-red-800 rounded-lg p-3 mb-4">
<p className="text-red-400 text-sm">{formError}</p>
</div>
)}
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm text-gray-400 mb-1">Title</label>
<input
type="text"
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
placeholder="Movie or show title"
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
autoFocus
/>
</div>
{/* Type */}
<div>
<label className="block text-sm text-gray-400 mb-1">Type</label>
<select
value={newType}
onChange={e => {
setNewType(e.target.value)
setNewRootFolderId('')
}}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
>
{MEDIA_TYPES.map(t => (
<option key={t} value={t}>{t.charAt(0).toUpperCase() + t.slice(1)}</option>
))}
</select>
</div>
{/* Year */}
<div>
<label className="block text-sm text-gray-400 mb-1">Year</label>
<input
type="number"
value={newYear}
onChange={e => setNewYear(e.target.value)}
placeholder="2024"
min="1900"
max="2099"
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
/>
</div>
{/* Quality Profile */}
<div>
<label className="block text-sm text-gray-400 mb-1">Quality Profile</label>
<select
value={newQualityId}
onChange={e => setNewQualityId(e.target.value ? parseInt(e.target.value, 10) : '')}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
>
<option value="">Any</option>
{qualityProfiles.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* Root Folder */}
<div>
<label className="block text-sm text-gray-400 mb-1">Root Folder</label>
<select
value={newRootFolderId}
onChange={e => setNewRootFolderId(e.target.value ? parseInt(e.target.value, 10) : '')}
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-100 focus:border-indigo-500 focus:outline-none"
>
<option value="">Default</option>
{filteredFolders.map(f => (
<option key={f.id} value={f.id}>{f.path}</option>
))}
</select>
</div>
</div>
{/* Modal actions */}
<div className="flex gap-3 justify-end mt-6">
<button
onClick={() => setShowNewModal(false)}
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 min-h-[36px]"
>
Cancel
</button>
<button
onClick={submitNewRequest}
disabled={submitting}
className="px-4 py-2 text-sm bg-indigo-500 hover:bg-indigo-400 rounded font-semibold text-white min-h-[36px] disabled:opacity-50"
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</div>
</div>
)}
{/* Reject Confirmation Modal */}
<ConfirmModal
open={!!rejectTarget}
title="Reject Request"
message={`Reject "${rejectTarget?.title}"? The requester will be notified.`}
onConfirm={rejectRequest}
onCancel={() => setRejectTarget(null)}
destructive
confirmLabel="Reject"
/>
</div>
)
}

View File

@@ -0,0 +1,427 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { fetchAPI, postAPI } from '../api/client'
import { useToast } from '../components/Toast'
import ErrorBanner from '../components/ErrorBanner'
interface MediaItem {
id: number
media_type: string
title: string
year: number | null
status: string
monitored: boolean
}
interface SearchResult {
title: string
guid: string
size: number
pub_date: string
indexer_name: string
indexer_priority: number
quality: {
title: string
resolution: string
source: string
video_codec: string
release_group: string
parse_warning: boolean
}
quality_tier: {
name: string
rank: number
resolution: string
} | null
seeders: number
peers: number
category: string
download_url: string
source_indexers: string[]
}
type SortCol = 'quality' | 'size' | 'seeders' | 'age'
type SortDir = 'asc' | 'desc'
function formatFileSize(bytes: number): string {
if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return `${(bytes / 1024).toFixed(0)} KB`
}
function formatAge(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime()
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'now'
if (minutes < 60) return `${minutes}m`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
if (days < 30) return `${days}d`
const months = Math.floor(days / 30)
return `${months}mo`
}
function extractSearchHint(title: string): string {
// Take the first few meaningful words from a release title as a media search hint
const cleaned = title.replace(/[.\-]+/g, ' ').trim()
const words = cleaned.split(/\s+/)
// Take up to 3 words, but stop at common quality/year markers
const stopWords = new Set(['720p', '1080p', '2160p', '4k', 'bluray', 'webrip', 'webdl', 'hdtv', 'dvdrip', 'x264', 'x265', 'hevc', 'aac', 'dts'])
const hint: string[] = []
for (const word of words.slice(0, 6)) {
if (stopWords.has(word.toLowerCase())) break
if (/^\d{4}$/.test(word)) break
hint.push(word)
if (hint.length >= 3) break
}
return hint.join(' ') || title.slice(0, 30)
}
export default function Search() {
// Search state
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [hasSearched, setHasSearched] = useState(false)
// Sorting
const [sortCol, setSortCol] = useState<SortCol>('quality')
const [sortDir, setSortDir] = useState<SortDir>('asc')
// Media selector modal state
const [modalOpen, setModalOpen] = useState(false)
const [pendingResult, setPendingResult] = useState<SearchResult | null>(null)
const [mediaQuery, setMediaQuery] = useState('')
const [mediaItems, setMediaItems] = useState<MediaItem[]>([])
const [mediaLoading, setMediaLoading] = useState(false)
// Grab tracking
const [grabbing, setGrabbing] = useState<Set<string>>(new Set())
const { showToast } = useToast()
const mediaSearchRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const modalInputRef = useRef<HTMLInputElement>(null)
function handleSort(col: SortCol) {
if (sortCol === col) {
setSortDir(prev => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortCol(col)
setSortDir('asc')
}
}
const sorted = [...results].sort((a, b) => {
let cmp = 0
switch (sortCol) {
case 'quality':
cmp = (a.quality_tier?.rank ?? 999) - (b.quality_tier?.rank ?? 999)
break
case 'size':
cmp = a.size - b.size
break
case 'seeders':
cmp = a.seeders - b.seeders
break
case 'age':
cmp = new Date(a.pub_date).getTime() - new Date(b.pub_date).getTime()
break
}
return sortDir === 'asc' ? cmp : -cmp
})
async function doSearch() {
const trimmed = query.trim()
if (!trimmed) return
setLoading(true)
setHasSearched(true)
setError(null)
try {
const res = await fetchAPI<{ data: SearchResult[]; total: number }>(
'/api/releases/search?query=' + encodeURIComponent(trimmed)
)
setResults(res.data ?? [])
} catch (err) {
const message = err instanceof Error ? err.message : 'Search failed'
setError(message)
} finally {
setLoading(false)
}
}
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault()
doSearch()
}
// Media selector: open modal
function openMediaSelector(result: SearchResult) {
setPendingResult(result)
const hint = extractSearchHint(result.title)
setMediaQuery(hint)
setMediaItems([])
setModalOpen(true)
}
// Fetch media items when modal query changes (debounced)
const fetchMedia = useCallback(async (q: string) => {
if (!q.trim()) {
setMediaItems([])
return
}
setMediaLoading(true)
try {
const res = await fetchAPI<{ data: MediaItem[]; total: number }>(
'/api/search?q=' + encodeURIComponent(q) + '&page_size=20'
)
setMediaItems(res.data ?? [])
} catch {
setMediaItems([])
} finally {
setMediaLoading(false)
}
}, [])
// Debounce media search input
useEffect(() => {
if (!modalOpen) return
if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current)
mediaSearchRef.current = setTimeout(() => {
fetchMedia(mediaQuery)
}, 300)
return () => {
if (mediaSearchRef.current) clearTimeout(mediaSearchRef.current)
}
}, [mediaQuery, modalOpen, fetchMedia])
// Focus modal input when modal opens
useEffect(() => {
if (modalOpen) {
// Small delay to let modal render
setTimeout(() => modalInputRef.current?.focus(), 50)
}
}, [modalOpen])
async function handleGrabWithMedia(item: MediaItem) {
if (!pendingResult) return
setGrabbing(prev => new Set(prev).add(pendingResult.guid))
try {
await postAPI<{ queue_id: number }>('/api/releases/grab', {
download_url: pendingResult.download_url,
title: pendingResult.title,
media_type: item.media_type,
quality: pendingResult.quality,
indexer_name: pendingResult.indexer_name,
media_id: item.id,
})
showToast(`✓ Grabbed "${pendingResult.title}"`)
setModalOpen(false)
setPendingResult(null)
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'
showToast(`✗ Failed to grab: ${message}`)
} finally {
if (pendingResult) {
setGrabbing(prev => {
const next = new Set(prev)
next.delete(pendingResult.guid)
return next
})
}
}
}
function closeModal() {
setModalOpen(false)
setPendingResult(null)
setMediaItems([])
setMediaQuery('')
}
function SortHeader({ col, label }: { col: SortCol; label: string }) {
const active = sortCol === col
return (
<button
onClick={() => handleSort(col)}
className={`text-left text-xs font-semibold uppercase tracking-wide transition-colors ${
active ? 'text-indigo-400 hover:text-indigo-300' : 'text-gray-400 hover:text-gray-200'
}`}
>
{label} {active && (sortDir === 'asc' ? '▲' : '▼')}
</button>
)
}
return (
<div>
<h2 className="text-2xl font-semibold text-gray-100 mb-6">Search Indexers</h2>
{/* Search input */}
<form onSubmit={handleSearchSubmit} className="mb-6">
<div className="flex gap-3 max-w-2xl">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search all indexers..."
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-3 w-full outline-none transition-colors"
/>
<button
type="submit"
disabled={loading || !query.trim()}
className="bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-semibold transition-colors"
>
{loading ? 'Searching...' : 'Search'}
</button>
</div>
<p className="text-gray-500 text-xs mt-2">Search across all enabled indexers for any release</p>
</form>
{/* Results area */}
{!hasSearched && (
<div className="text-center py-20 text-gray-500">
<p className="text-lg">Enter a search query to find releases</p>
</div>
)}
{hasSearched && loading && (
<div className="animate-pulse space-y-3">
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
<div className="h-10 bg-gray-800 rounded" />
</div>
)}
{hasSearched && error && !loading && (
<ErrorBanner error={error} onRetry={doSearch} />
)}
{hasSearched && !loading && !error && results.length === 0 && (
<div className="text-gray-500 text-center py-12">No releases found</div>
)}
{hasSearched && !loading && !error && results.length > 0 && (
<div>
<p className="text-gray-400 text-sm mb-3">{results.length} release{results.length !== 1 ? 's' : ''} found</p>
<div className="bg-gray-900 border border-gray-800 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-gray-800">
<th className="px-4 py-3"><SortHeader col="quality" label="Quality" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Title</th>
<th className="px-4 py-3"><SortHeader col="size" label="Size" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide">Indexer</th>
<th className="px-4 py-3"><SortHeader col="seeders" label="Seeders" /></th>
<th className="px-4 py-3"><SortHeader col="age" label="Age" /></th>
<th className="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wide w-28">Action</th>
</tr>
</thead>
<tbody>
{sorted.map(result => {
const isGrabbing = grabbing.has(result.guid)
const noUrl = !result.download_url
return (
<tr key={result.guid} className="border-b border-gray-800/50 hover:bg-gray-800/30">
<td className="px-4 py-3 text-sm">
{result.quality_tier ? (
<span className="text-gray-200">{result.quality_tier.name}</span>
) : result.quality.resolution || result.quality.source ? (
<span className="text-gray-200">{result.quality.resolution} {result.quality.source}</span>
) : (
<span className="text-gray-500">Unknown</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-200 max-w-lg truncate" title={result.title}>
{result.title}
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatFileSize(result.size)}</td>
<td className="px-4 py-3 text-sm text-gray-400">{result.indexer_name}</td>
<td className="px-4 py-3 text-sm text-gray-400">
<span className={result.seeders > 0 ? 'text-green-400' : ''}>{result.seeders}</span>
{' / '}
<span>{result.peers}</span>
</td>
<td className="px-4 py-3 text-sm text-gray-400">{formatAge(result.pub_date)}</td>
<td className="px-4 py-3">
<button
disabled={isGrabbing || noUrl}
onClick={() => openMediaSelector(result)}
className="px-3 py-1 text-xs font-semibold rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-50 disabled:cursor-not-allowed"
>
{isGrabbing ? 'Grabbing...' : noUrl ? 'N/A' : 'Select & Grab'}
</button>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)}
{/* Media selector modal */}
{modalOpen && pendingResult && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={closeModal}>
<div className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
<h3 className="text-lg font-semibold text-gray-100 mb-1">Select Media Item</h3>
<p className="text-sm text-gray-400 mb-4">
Associate "<span className="text-gray-200">{pendingResult.title}</span>" with a media item in your library
</p>
{/* Media search input */}
<input
ref={modalInputRef}
type="text"
value={mediaQuery}
onChange={e => setMediaQuery(e.target.value)}
placeholder="Search your library..."
className="bg-gray-800 border border-gray-700 focus:border-indigo-500 text-white rounded-lg px-4 py-2.5 w-full mb-3 text-sm outline-none transition-colors"
/>
{/* Media items list */}
<div className="max-h-64 overflow-y-auto space-y-1">
{mediaLoading && mediaItems.length === 0 && (
<div className="text-gray-500 text-sm text-center py-6">Searching library...</div>
)}
{!mediaLoading && mediaItems.length === 0 && mediaQuery.trim() && (
<div className="text-gray-500 text-sm text-center py-6">No matching media items found</div>
)}
{mediaItems.map(item => {
const isGrabbingItem = grabbing.has(pendingResult.guid)
return (
<button
key={item.id}
disabled={isGrabbingItem}
onClick={() => handleGrabWithMedia(item)}
className="w-full text-left px-3 py-2.5 rounded hover:bg-gray-800 transition-colors disabled:opacity-50"
>
<span className="text-sm text-gray-200">{item.title}</span>
<span className="text-xs text-gray-500 ml-2">
{item.media_type}{item.year ? ` · ${item.year}` : ''}
</span>
</button>
)
})}
</div>
{/* Modal actions */}
<div className="flex justify-end gap-3 mt-4 pt-3 border-t border-gray-800">
<button
onClick={closeModal}
className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}

23
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: { port: 3000, host: true },
build: { outDir: 'dist' },
})

35
go.mod Normal file
View File

@@ -0,0 +1,35 @@
module github.com/TopherMayor/unified-media-manager
go 1.25.0
require github.com/labstack/echo/v4 v4.12.0
require (
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.5.0 // indirect
)
require (
github.com/PuerkitoBio/goquery v1.12.0
github.com/dustin/go-humanize v1.0.1
github.com/jackc/pgx/v5 v5.5.5
github.com/mattn/go-sqlite3 v1.14.42
github.com/robfig/cron/v3 v3.0.1
golang.org/x/sync v0.20.0
gopkg.in/yaml.v3 v3.0.1
)

135
go.sum Normal file
View File

@@ -0,0 +1,135 @@
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

48
internal/api/activity.go Normal file
View File

@@ -0,0 +1,48 @@
package api
import (
"context"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listActivity(svc *service.ActivityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
var mediaID *int64
if mid := c.QueryParam("media_id"); mid != "" {
if id, err := strconv.ParseInt(mid, 10, 64); err == nil {
mediaID = &id
}
}
events, total, err := svc.List(ctx, service.ActivityFilters{
EventType: c.QueryParam("event_type"),
MediaID: mediaID,
MediaType: c.QueryParam("media_type"),
Page: page,
PageSize: pageSize,
})
if err != nil {
slog.Error("failed to list activity", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: events,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}

107
internal/api/blocklist.go Normal file
View File

@@ -0,0 +1,107 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listBlocklist(svc *service.BlocklistService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
items, total, err := svc.List(ctx, service.BlocklistFilters{
Page: page,
PageSize: pageSize,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}
func deleteBlocklistItem(svc *service.BlocklistService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "blocklist item not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}
func clearBlocklist(svc *service.BlocklistService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
cleared, err := svc.Clear(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]int64{"cleared": cleared})
}
}
func clearExpiredBlocklist(svc *service.BlocklistService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
cleared, err := svc.ClearExpired(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]int64{"cleared": cleared})
}
}
func addBlocklistItem(svc *service.BlocklistService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req service.AddBlocklistRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.ReleaseTitle == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "release_title is required"})
}
id, err := svc.Add(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}

45
internal/api/calendar.go Normal file
View File

@@ -0,0 +1,45 @@
package api
import (
"context"
"log/slog"
"net/http"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
type calendarResponse struct {
Data []service.CalendarEvent `json:"data"`
Month string `json:"month"`
}
func listCalendarEvents(svc *service.CalendarService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
monthParam := c.QueryParam("month")
if monthParam == "" {
now := time.Now()
monthParam = now.Format("2006-01")
}
parsed, err := time.Parse("2006-01", monthParam)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "month must be in YYYY-MM format"})
}
events, err := svc.EventsByMonth(ctx, parsed.Year(), parsed.Month())
if err != nil {
slog.Error("calendar events failed", "month", monthParam, "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch calendar events"})
}
return c.JSON(http.StatusOK, calendarResponse{
Data: events,
Month: monthParam,
})
}
}

24
internal/api/dashboard.go Normal file
View File

@@ -0,0 +1,24 @@
package api
import (
"context"
"net/http"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func dashboard(svc *service.DashboardService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
stats, err := svc.Stats(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, stats)
}
}

130
internal/api/discover.go Normal file
View File

@@ -0,0 +1,130 @@
package api
import (
"context"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
type discoverResponse struct {
Data []service.DiscoverItem `json:"data"`
Page int `json:"page"`
}
type addFromDiscoverRequest struct {
TMDBID int `json:"tmdb_id"`
MediaType string `json:"media_type"`
}
type addFromDiscoverResponse struct {
ID int64 `json:"id"`
Existing bool `json:"existing,omitempty"`
}
func listTrending(svc *service.DiscoverService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
mediaType := c.QueryParam("type")
if mediaType == "" {
mediaType = "movie"
}
if mediaType != "movie" && mediaType != "series" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "type must be 'movie' or 'series'"})
}
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
if page > 10 {
page = 10
}
items, err := svc.Trending(ctx, mediaType, page)
if err != nil {
slog.Error("list trending failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch trending content"})
}
return c.JSON(http.StatusOK, discoverResponse{
Data: items,
Page: page,
})
}
}
func listPopular(svc *service.DiscoverService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
mediaType := c.QueryParam("type")
if mediaType == "" {
mediaType = "movie"
}
if mediaType != "movie" && mediaType != "series" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "type must be 'movie' or 'series'"})
}
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
if page > 10 {
page = 10
}
items, err := svc.Popular(ctx, mediaType, page)
if err != nil {
slog.Error("list popular failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch popular content"})
}
return c.JSON(http.StatusOK, discoverResponse{
Data: items,
Page: page,
})
}
}
func addFromDiscover(svc *service.DiscoverService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
var req addFromDiscoverRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if req.TMDBID <= 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "tmdb_id must be a positive integer"})
}
if req.MediaType != "movie" && req.MediaType != "series" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_type must be 'movie' or 'series'"})
}
id, existing, err := svc.AddToLibrary(ctx, req.TMDBID, req.MediaType)
if err != nil {
slog.Error("add from discover failed", "tmdb_id", req.TMDBID, "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to add item to library"})
}
status := http.StatusCreated
if existing {
status = http.StatusOK
}
return c.JSON(status, addFromDiscoverResponse{
ID: id,
Existing: existing,
})
}
}

View File

@@ -0,0 +1,119 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listDownloadClients(svc *service.DownloadClientService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
items, err := svc.List(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
}
func createDownloadClient(svc *service.DownloadClientService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req service.CreateDownloadClientRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.Name == "" || req.URL == "" || req.Implementation == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name, url, and implementation are required"})
}
if req.Implementation != "sabnzbd" && req.Implementation != "qbittorrent" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "implementation must be sabnzbd or qbittorrent"})
}
id, err := svc.Create(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func updateDownloadClient(svc *service.DownloadClientService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req service.UpdateDownloadClientRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.Update(ctx, id, req); err != nil {
if err.Error() == "no fields to update" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err.Error() == "download client not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "updated"})
}
}
func deleteDownloadClient(svc *service.DownloadClientService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "download client not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}
func testDownloadClient(svc *service.DownloadClientService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
result, err := svc.Test(ctx, id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "download client not found"})
}
return c.JSON(http.StatusOK, result)
}
}

77
internal/api/health.go Normal file
View File

@@ -0,0 +1,77 @@
package api
import (
"context"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/labstack/echo/v4"
)
func healthLive(c echo.Context) error {
return c.JSON(http.StatusOK, map[string]string{"status": "alive"})
}
func healthReady(database *db.DB, cfg *config.Config) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
checks := map[string]string{}
allReady := true
if err := database.Ping(ctx); err != nil {
checks["database"] = "unhealthy: " + err.Error()
allReady = false
slog.Error("readiness check failed", "component", "database", "error", err)
} else {
checks["database"] = "healthy"
}
if err := checkHTTPService(ctx, cfg.QdrantURL+"/healthz"); err != nil {
checks["qdrant"] = "unhealthy: " + err.Error()
allReady = false
slog.Error("readiness check failed", "component", "qdrant", "error", err)
} else {
checks["qdrant"] = "healthy"
}
if err := checkHTTPService(ctx, cfg.OllamaURL+"/api/version"); err != nil {
checks["ollama"] = "unhealthy: " + err.Error()
allReady = false
slog.Error("readiness check failed", "component", "ollama", "error", err)
} else {
checks["ollama"] = "healthy"
}
status := "healthy"
code := http.StatusOK
if !allReady {
status = "degraded"
code = http.StatusServiceUnavailable
}
return c.JSON(code, map[string]interface{}{"status": status, "checks": checks})
}
}
func checkHTTPService(ctx context.Context, url string) error {
client := &http.Client{Timeout: 3 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
resp.Body.Close()
if resp.StatusCode >= 500 {
return fmt.Errorf("server returned %d", resp.StatusCode)
}
return nil
}

83
internal/api/import.go Normal file
View File

@@ -0,0 +1,83 @@
package api
import (
"context"
"net/http"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
type importHistoryItem struct {
ID int64 `json:"id"`
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
Action string `json:"action"`
ReleaseTitle string `json:"release_title"`
Quality string `json:"quality"`
CreatedAt string `json:"created_at"`
}
func triggerImport(svc *service.ImportService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
report, err := svc.ProcessCompleted(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, report)
}
}
func listImportHistory(svc *service.ImportService, database *db.DB) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
var total int
err := database.Pool.QueryRow(ctx,
"SELECT COUNT(*) FROM download_history WHERE action = 'import'").Scan(&total)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
offset := (page - 1) * pageSize
rows, err := database.Pool.Query(ctx,
`SELECT id, media_id, media_type, action, release_title, quality, created_at
FROM download_history WHERE action = 'import'
ORDER BY created_at DESC LIMIT $1 OFFSET $2`, pageSize, offset)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
defer rows.Close()
var items []importHistoryItem
for rows.Next() {
var item importHistoryItem
var quality []byte
var createdAt time.Time
if err := rows.Scan(&item.ID, &item.MediaID, &item.MediaType, &item.Action,
&item.ReleaseTitle, &quality, &createdAt); err != nil {
continue
}
item.Quality = string(quality)
item.CreatedAt = createdAt.Format(time.RFC3339)
items = append(items, item)
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}

218
internal/api/indexers.go Normal file
View File

@@ -0,0 +1,218 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/cardigann"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listIndexers(svc *service.IndexerService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
items, err := svc.List(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
}
func createIndexer(svc *service.IndexerService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req service.CreateIndexerRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.Name == "" || req.Implementation == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name and implementation are required"})
}
// Cardigann indexers get URL from YAML definition; others require explicit URL
if req.Implementation != "cardigann" && req.URL == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "url is required"})
}
id, err := svc.Create(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func updateIndexer(svc *service.IndexerService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req service.UpdateIndexerRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.Update(ctx, id, req); err != nil {
if err.Error() == "no fields to update" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err.Error() == "indexer not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "updated"})
}
}
func deleteIndexer(svc *service.IndexerService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "indexer not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}
func testIndexer(svc *service.IndexerService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
result, err := svc.Test(ctx, id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "indexer not found"})
}
return c.JSON(http.StatusOK, result)
}
}
func indexerStats(svc *service.IndexerService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
result, err := svc.Stats(ctx, id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "indexer not found"})
}
return c.JSON(http.StatusOK, result)
}
}
type validateCardigannRequest struct {
YAML string `json:"yaml"`
}
type validateCardigannResponse struct {
Valid bool `json:"valid"`
Definition *cardigannDefinitionResponse `json:"definition,omitempty"`
Error string `json:"error,omitempty"`
Warnings []string `json:"warnings,omitempty"`
}
type cardigannDefinitionResponse struct {
Site string `json:"site"`
Name string `json:"name"`
Settings []cardigannSettingsFieldResponse `json:"settings"`
HasLogin bool `json:"has_login"`
}
type cardigannSettingsFieldResponse struct {
Name string `json:"name"`
Type string `json:"type"`
Label string `json:"label"`
}
func validateCardigannDefinition() echo.HandlerFunc {
return func(c echo.Context) error {
var req validateCardigannRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.YAML == "" {
return c.JSON(http.StatusBadRequest, validateCardigannResponse{
Valid: false,
Error: "yaml field is required",
})
}
// Threat model T-10-01: Validate YAML size limit (512KB max)
if len(req.YAML) > 512*1024 {
return c.JSON(http.StatusBadRequest, validateCardigannResponse{
Valid: false,
Error: "YAML definition exceeds maximum size of 512KB",
})
}
def, err := cardigann.ParseDefinition([]byte(req.YAML))
if err != nil {
return c.JSON(http.StatusOK, validateCardigannResponse{
Valid: false,
Error: err.Error(),
})
}
warnings := cardigann.ValidateDefinition(def)
settings := make([]cardigannSettingsFieldResponse, 0, len(def.Settings))
for _, s := range def.Settings {
settings = append(settings, cardigannSettingsFieldResponse{
Name: s.Name,
Type: s.Type,
Label: s.Label,
})
}
return c.JSON(http.StatusOK, validateCardigannResponse{
Valid: true,
Definition: &cardigannDefinitionResponse{
Site: def.Site,
Name: def.Name,
Settings: settings,
HasLogin: def.Login.Path != "" || len(def.Login.Inputs) > 0,
},
Warnings: warnings,
})
}
}

214
internal/api/media.go Normal file
View File

@@ -0,0 +1,214 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listMedia(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
items, total, err := svc.List(ctx, service.MediaFilters{
MediaType: c.QueryParam("type"),
Status: c.QueryParam("status"),
Monitored: c.QueryParam("monitored"),
Page: page,
PageSize: pageSize,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}
func getMedia(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
detail, err := svc.GetByID(ctx, id, c.Param("type"))
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media not found"})
}
return c.JSON(http.StatusOK, detail)
}
}
func createMedia(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req service.CreateMediaRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.Title == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "title is required"})
}
if req.MediaType == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_type is required"})
}
id, err := svc.Create(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func updateMedia(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req service.UpdateMediaRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.Update(ctx, id, c.Param("type"), req); err != nil {
if err.Error() == "no fields to update" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err.Error() == "media not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "updated"})
}
}
func deleteMedia(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id, c.Param("type")); err != nil {
if err.Error() == "media not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}
func searchMedia(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
items, total, err := svc.Search(ctx, service.MediaFilters{
Query: c.QueryParam("q"),
MediaType: c.QueryParam("type"),
Status: c.QueryParam("status"),
Tag: c.QueryParam("tag"),
Page: page,
PageSize: pageSize,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}
func searchMissing(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
items, total, err := svc.SearchMissing(ctx, service.MediaFilters{
MediaType: c.QueryParam("type"),
Page: page,
PageSize: pageSize,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}
func searchUpgrades(svc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
items, total, err := svc.SearchUpgrades(ctx, service.MediaFilters{
MediaType: c.QueryParam("type"),
Page: page,
PageSize: pageSize,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}

View File

@@ -0,0 +1,43 @@
package api
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func getFullMediaDetail(svc *service.MediaDetailService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
mediaType := c.Param("type")
if mediaType == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media type is required"})
}
idStr := c.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil || id <= 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media id"})
}
detail, err := svc.GetFullDetail(ctx, id, mediaType)
if err != nil {
if err.Error() == "get media detail: get media: no rows in result set" ||
strings.Contains(err.Error(), "not found") {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media not found"})
}
slog.Error("get full media detail failed", "id", id, "type", mediaType, "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch media detail"})
}
return c.JSON(http.StatusOK, detail)
}
}

69
internal/api/metadata.go Normal file
View File

@@ -0,0 +1,69 @@
package api
import (
"context"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func refreshMetadata(svc *service.MetadataService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
mediaType := c.Param("type")
if mediaType == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media type required"})
}
if err := svc.RefreshMetadata(ctx, id, mediaType); err != nil {
slog.Error("refresh metadata failed", "error", err, "media_id", id)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "refreshed"})
}
}
func refreshAllMetadata(svc *service.MetadataService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 60*time.Second)
defer cancel()
if err := svc.RefreshAllMetadata(ctx); err != nil {
slog.Error("refresh all metadata failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "refreshing_all"})
}
}
func serveImage(imageDir string) echo.HandlerFunc {
return func(c echo.Context) error {
mediaType := c.Param("type")
filename := c.Param("filename")
filePath := filepath.Join(imageDir, mediaType, filename)
cleanPath := filepath.Clean(filePath)
cleanBase := filepath.Clean(imageDir)
if cleanPath == "" || len(cleanPath) < len(cleanBase) || cleanPath[:len(cleanBase)] != cleanBase {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
}
http.ServeFile(c.Response(), c.Request(), filePath)
return nil
}
}

View File

@@ -0,0 +1,212 @@
package api
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listNotificationChannels(svc *service.NotificationService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
channels, err := svc.ListChannels(ctx)
if err != nil {
slog.Error("failed to list notification channels", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list channels"})
}
if channels == nil {
channels = []service.NotificationChannel{}
}
return c.JSON(http.StatusOK, channels)
}
}
type createNotificationChannelRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Config json.RawMessage `json:"config"`
Enabled *bool `json:"enabled,omitempty"`
EventTypes []string `json:"event_types"`
}
func createNotificationChannel(svc *service.NotificationService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req createNotificationChannelRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
name := strings.TrimSpace(req.Name)
if len(name) < 3 || len(name) > 50 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name must be 3-50 characters"})
}
if req.Type != "webhook" && req.Type != "telegram" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "type must be webhook or telegram"})
}
if req.Config == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "config is required"})
}
id, err := svc.CreateChannel(ctx, name, req.Type, req.Config)
if err != nil {
slog.Error("failed to create notification channel", "error", err, "name", name, "type", req.Type)
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if len(req.EventTypes) > 0 {
if subErr := svc.UpdateSubscriptions(ctx, id, req.EventTypes); subErr != nil {
slog.Error("failed to set subscriptions", "error", subErr, "channel_id", id)
}
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
type updateNotificationChannelRequest struct {
Name *string `json:"name,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Config json.RawMessage `json:"config,omitempty"`
EventTypes []string `json:"event_types,omitempty"`
}
func updateNotificationChannel(svc *service.NotificationService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req updateNotificationChannelRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if err := svc.UpdateChannel(ctx, id, req.Name, req.Enabled, req.Config); err != nil {
slog.Error("failed to update notification channel", "error", err, "id", id)
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
if req.EventTypes != nil {
if subErr := svc.UpdateSubscriptions(ctx, id, req.EventTypes); subErr != nil {
slog.Error("failed to update subscriptions", "error", subErr, "id", id)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to update subscriptions"})
}
}
return c.JSON(http.StatusOK, map[string]string{"status": "updated"})
}
}
func deleteNotificationChannel(svc *service.NotificationService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.DeleteChannel(ctx, id); err != nil {
slog.Error("failed to delete notification channel", "error", err, "id", id)
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}
func testNotificationChannel(svc *service.NotificationService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
ch, err := svc.GetChannelWithConfig(ctx, id)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "channel not found"})
}
var configMap map[string]interface{}
json.Unmarshal(ch.Config, &configMap)
var deliverErr error
switch ch.Type {
case "webhook":
webhookURL, _ := configMap["url"].(string)
deliverErr = svc.DeliverWebhook(ctx, webhookURL, map[string]interface{}{
"test": true,
"message": "Test notification from UMM",
})
case "telegram":
botToken, _ := configMap["bot_token"].(string)
chatID, _ := configMap["chat_id"].(string)
deliverErr = svc.DeliverTelegram(ctx, botToken, chatID, "🔔 Test notification from UMM")
}
if deliverErr != nil {
slog.Error("notification test failed", "channel", ch.Name, "type", ch.Type)
return c.JSON(http.StatusOK, map[string]interface{}{
"success": false,
"error": "delivery failed",
})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"success": true,
})
}
}
func listNotificationQueue(svc *service.NotificationService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
status := c.QueryParam("status")
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
entries, total, err := svc.ListQueue(ctx, status, page, pageSize)
if err != nil {
slog.Error("failed to list notification queue", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to list queue"})
}
if entries == nil {
entries = []service.QueueEntry{}
}
totalPages := (total + pageSize - 1) / pageSize
return c.JSON(http.StatusOK, paginatedResponse{
Data: entries,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
}

114
internal/api/quality.go Normal file
View File

@@ -0,0 +1,114 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listQualityProfiles(svc *service.QualityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
items, err := svc.List(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{"data": items})
}
}
type createQualityProfileRequest struct {
Name string `json:"name"`
MediaTypes []string `json:"media_types"`
CutoffQuality string `json:"cutoff_quality"`
AllowedQualities []string `json:"allowed_qualities"`
}
func createQualityProfile(svc *service.QualityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req createQualityProfileRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.Name == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name is required"})
}
if len(req.MediaTypes) == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_types is required"})
}
if req.CutoffQuality == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cutoff_quality is required"})
}
if service.GetTierByName(req.CutoffQuality) == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid cutoff_quality tier name"})
}
id, err := svc.Create(ctx, req.Name, req.MediaTypes, req.CutoffQuality, req.AllowedQualities)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func updateQualityProfile(svc *service.QualityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var req service.UpdateQualityProfileRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err := svc.Update(ctx, id, req); err != nil {
if err.Error() == "no fields to update" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if err.Error() == "quality profile not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "updated"})
}
}
func deleteQualityProfile(svc *service.QualityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "quality profile not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}

129
internal/api/queue.go Normal file
View File

@@ -0,0 +1,129 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listQueue(svc *service.QueueService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
items, total, err := svc.List(ctx, service.QueueFilters{
Status: c.QueryParam("status"),
Page: page,
PageSize: pageSize,
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}
func deleteQueueItem(svc *service.QueueService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "queue item not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "cancelled"})
}
}
func batchDeleteQueue(svc *service.QueueService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
var req service.QueueBatchDeleteRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
cancelled, err := svc.BatchDelete(ctx, req)
if err != nil {
if err.Error() == "must provide status, batch_id, or ids" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]int64{"cancelled": cancelled})
}
}
func clearQueue(svc *service.QueueService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
cleared, err := svc.Clear(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]int64{"cleared": cleared})
}
}
func retryQueueItem(svc *service.QueueService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Retry(ctx, id); err != nil {
if err.Error() == "queue item not found or not failed" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "retried"})
}
}
func retryFailedQueue(svc *service.QueueService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
retried, err := svc.RetryFailed(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]int64{"retried": retried})
}
}

161
internal/api/requests.go Normal file
View File

@@ -0,0 +1,161 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listRequests(reqSvc *service.RequestService, userSvc *service.UserService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
user := c.Get("user").(*service.User)
page, pageSize := service.ParsePagination(c.QueryParam("page"), c.QueryParam("page_size"))
filters := service.RequestFilters{
Status: c.QueryParam("status"),
Page: page,
PageSize: pageSize,
}
// Non-admin users can only see their own requests
if user.Role != "admin" {
userID := user.ID
filters.RequestedBy = &userID
}
items, total, err := reqSvc.List(ctx, filters)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: items,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: service.CalcTotalPages(total, pageSize),
})
}
}
func createRequest(reqSvc *service.RequestService, userSvc *service.UserService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
user := c.Get("user").(*service.User)
var req service.CreateRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.Title == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "title is required"})
}
id, err := reqSvc.Create(ctx, req, user.Role, user.ID)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func requestStats(reqSvc *service.RequestService, userSvc *service.UserService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
stats, err := reqSvc.Stats(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, stats)
}
}
func approveRequest(reqSvc *service.RequestService, userSvc *service.UserService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
user := c.Get("user").(*service.User)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var body struct {
Notes string `json:"notes"`
}
_ = c.Bind(&body)
if err := reqSvc.Approve(ctx, id, user.ID, body.Notes); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "approved"})
}
}
func rejectRequest(reqSvc *service.RequestService, userSvc *service.UserService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
user := c.Get("user").(*service.User)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
var body struct {
Notes string `json:"notes"`
}
_ = c.Bind(&body)
if err := reqSvc.Reject(ctx, id, user.ID, body.Notes); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "rejected"})
}
}
func withdrawRequest(reqSvc *service.RequestService, userSvc *service.UserService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
user := c.Get("user").(*service.User)
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := reqSvc.Withdraw(ctx, id, user.ID); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "withdrawn"})
}
}

View File

@@ -0,0 +1,67 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listRootFolders(svc *service.RootFolderService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
folders, err := svc.List(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if folders == nil {
folders = []service.RootFolder{}
}
return c.JSON(http.StatusOK, folders)
}
}
func createRootFolder(svc *service.RootFolderService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req service.CreateRootFolderRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
id, err := svc.Create(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func deleteRootFolder(svc *service.RootFolderService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "root folder not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}

220
internal/api/router.go Normal file
View File

@@ -0,0 +1,220 @@
package api
import (
"net/http"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/TopherMayor/unified-media-manager/internal/worker"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
type Services struct {
DB *db.DB
Media *service.MediaService
Queue *service.QueueService
Indexer *service.IndexerService
Blocklist *service.BlocklistService
Dashboard *service.DashboardService
Quality *service.QualityService
DownloadClient *service.DownloadClientService
Search *service.SearchService
Import *service.ImportService
Metadata *service.MetadataService
Subtitle *service.SubtitleService
RootFolder *service.RootFolderService
Tag *service.TagService
Scheduler *worker.Scheduler
User *service.UserService
Activity *service.ActivityService
Safety *service.SafetyService
Request *service.RequestService
Notification *service.NotificationService
Discover *service.DiscoverService
MediaDetail *service.MediaDetailService
Calendar *service.CalendarService
}
func NewRouter(cfg *config.Config, svc *Services) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{cfg.FrontendURL},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
}))
e.Use(cacheControlMiddleware())
e.GET("/health/live", healthLive)
e.GET("/health/ready", healthReady(svc.DB, cfg))
g := e.Group("/api")
g.GET("/media", listMedia(svc.Media))
g.GET("/media/:type/:id", getMedia(svc.Media))
g.GET("/media/:type/:id/detail", getFullMediaDetail(svc.MediaDetail))
g.POST("/media", createMedia(svc.Media))
g.PUT("/media/:type/:id", updateMedia(svc.Media))
g.DELETE("/media/:type/:id", deleteMedia(svc.Media))
g.GET("/search", searchMedia(svc.Media))
g.GET("/search/missing", searchMissing(svc.Media))
g.GET("/search/upgrades", searchUpgrades(svc.Media))
g.GET("/queue", listQueue(svc.Queue))
g.DELETE("/queue/:id", deleteQueueItem(svc.Queue))
g.DELETE("/queue/batch", batchDeleteQueue(svc.Queue))
g.POST("/queue/clear", clearQueue(svc.Queue))
g.POST("/queue/:id/retry", retryQueueItem(svc.Queue))
g.POST("/queue/retry-failed", retryFailedQueue(svc.Queue))
g.GET("/blocklist", listBlocklist(svc.Blocklist))
g.DELETE("/blocklist/:id", deleteBlocklistItem(svc.Blocklist))
g.DELETE("/blocklist", clearBlocklist(svc.Blocklist))
g.DELETE("/blocklist/expired", clearExpiredBlocklist(svc.Blocklist))
g.POST("/blocklist", addBlocklistItem(svc.Blocklist))
g.GET("/indexers", listIndexers(svc.Indexer))
g.POST("/indexers", createIndexer(svc.Indexer))
g.POST("/indexers/validate-cardigann", validateCardigannDefinition())
g.PUT("/indexers/:id", updateIndexer(svc.Indexer))
g.DELETE("/indexers/:id", deleteIndexer(svc.Indexer))
g.POST("/indexers/:id/test", testIndexer(svc.Indexer))
g.GET("/indexers/:id/stats", indexerStats(svc.Indexer))
g.GET("/dashboard", dashboard(svc.Dashboard))
g.GET("/activity", listActivity(svc.Activity))
g.GET("/quality-profiles", listQualityProfiles(svc.Quality))
g.POST("/quality-profiles", createQualityProfile(svc.Quality))
g.PUT("/quality-profiles/:id", updateQualityProfile(svc.Quality))
g.DELETE("/quality-profiles/:id", deleteQualityProfile(svc.Quality))
g.GET("/download-clients", listDownloadClients(svc.DownloadClient))
g.POST("/download-clients", createDownloadClient(svc.DownloadClient))
g.PUT("/download-clients/:id", updateDownloadClient(svc.DownloadClient))
g.DELETE("/download-clients/:id", deleteDownloadClient(svc.DownloadClient))
g.POST("/download-clients/:id/test", testDownloadClient(svc.DownloadClient))
g.GET("/releases/search", searchReleases(svc.Search))
g.POST("/releases/grab", grabRelease(svc.Search, svc.DownloadClient, svc.Queue, svc.Safety, svc.Activity))
g.POST("/imports/trigger", triggerImport(svc.Import))
g.GET("/imports/history", listImportHistory(svc.Import, svc.DB))
g.POST("/media/:type/:id/refresh-metadata", refreshMetadata(svc.Metadata))
g.POST("/media/refresh-all", refreshAllMetadata(svc.Metadata))
g.GET("/images/:type/:filename", serveImage(cfg.ImageDir))
g.GET("/media/:type/:id/subtitles/search", searchSubtitles(svc.Subtitle, svc.Media))
g.POST("/media/:type/:id/subtitles/download", downloadSubtitle(svc.Subtitle, svc.Media))
g.POST("/media/:type/:id/subtitles/extract", extractSubtitles(svc.Subtitle, svc.Media))
g.GET("/root-folders", listRootFolders(svc.RootFolder))
g.POST("/root-folders", createRootFolder(svc.RootFolder))
g.DELETE("/root-folders/:id", deleteRootFolder(svc.RootFolder))
g.GET("/tags", listTags(svc.Tag))
g.POST("/tags", createTag(svc.Tag))
g.DELETE("/tags/:id", deleteTag(svc.Tag))
if svc.Scheduler != nil {
g.GET("/workers", listWorkers(svc.Scheduler))
g.GET("/workers/:name/history", workerHistory(svc.Scheduler))
g.PUT("/workers/:name", updateWorker(svc.Scheduler))
g.POST("/workers/:name/trigger", triggerWorker(svc.Scheduler))
}
// Notification routes
g.GET("/notifications/channels", listNotificationChannels(svc.Notification))
g.POST("/notifications/channels", createNotificationChannel(svc.Notification))
g.PUT("/notifications/channels/:id", updateNotificationChannel(svc.Notification))
g.DELETE("/notifications/channels/:id", deleteNotificationChannel(svc.Notification))
g.POST("/notifications/channels/:id/test", testNotificationChannel(svc.Notification))
g.GET("/notifications/queue", listNotificationQueue(svc.Notification))
// Discover routes
if svc.Discover != nil {
g.GET("/discover/trending", listTrending(svc.Discover))
g.GET("/discover/popular", listPopular(svc.Discover))
g.POST("/discover/add", addFromDiscover(svc.Discover))
}
// Calendar route
g.GET("/calendar", listCalendarEvents(svc.Calendar))
// Request routes — protected by API key auth
apiKeyAuth := newAPIKeyAuth(svc.User)
g.GET("/requests", listRequests(svc.Request, svc.User), apiKeyAuth)
g.POST("/requests", createRequest(svc.Request, svc.User), apiKeyAuth)
g.GET("/requests/stats", requestStats(svc.Request, svc.User), apiKeyAuth)
g.PUT("/requests/:id/approve", approveRequest(svc.Request, svc.User), apiKeyAuth)
g.PUT("/requests/:id/reject", rejectRequest(svc.Request, svc.User), apiKeyAuth)
g.DELETE("/requests/:id", withdrawRequest(svc.Request, svc.User), apiKeyAuth)
return e
}
type paginatedResponse struct {
Data interface{} `json:"data"`
Total int `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
func newAPIKeyAuth(userSvc *service.UserService) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
key := c.Request().Header.Get("X-API-Key")
if key == "" {
key = c.QueryParam("api_key")
}
if key == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "API key required"})
}
user, err := userSvc.GetUserByAPIKey(c.Request().Context(), key)
if err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid API key"})
}
c.Set("user", user)
return next(c)
}
}
}
func cacheControlMiddleware() echo.MiddlewareFunc {
shortCache := map[string]bool{
"/api/quality-profiles": true,
"/api/download-clients": true,
"/api/root-folders": true,
"/api/tags": true,
"/api/indexers": true,
"/api/workers": true,
}
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
if err != nil {
return err
}
path := c.Request().URL.Path
if c.Request().Method == http.MethodGet {
if shortCache[path] {
c.Response().Header().Set("Cache-Control", "max-age=60")
} else if path == "/api/dashboard" {
c.Response().Header().Set("Cache-Control", "max-age=30")
} else if path == "/api/calendar" || path == "/api/activity" {
c.Response().Header().Set("Cache-Control", "max-age=15")
}
}
return nil
}
}
}

137
internal/api/search.go Normal file
View File

@@ -0,0 +1,137 @@
package api
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func searchReleases(svc *service.SearchService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
query := c.QueryParam("query")
if query == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "query parameter is required"})
}
mediaType := c.QueryParam("media_type")
var indexerIDs []int64
if ids := c.QueryParam("indexer_ids"); ids != "" {
for _, idStr := range strings.Split(ids, ",") {
idStr = strings.TrimSpace(idStr)
if id, err := strconv.ParseInt(idStr, 10, 64); err == nil {
indexerIDs = append(indexerIDs, id)
}
}
}
req := service.SearchRequest{
Query: query,
MediaType: mediaType,
IndexerIDs: indexerIDs,
}
results, err := svc.Search(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"data": results,
"total": len(results),
})
}
}
func grabRelease(svc *service.SearchService, dcSvc *service.DownloadClientService, queueSvc *service.QueueService, safetySvc *service.SafetyService, activitySvc *service.ActivityService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
var req service.GrabRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
if req.DownloadURL == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "download_url is required"})
}
if req.Title == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "title is required"})
}
if req.MediaID == 0 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media_id is required"})
}
// Safety check: block dangerous file extensions before download
if safetySvc != nil {
block := safetySvc.Check(req.Title, req.DownloadURL)
if block != nil {
if activitySvc != nil {
activitySvc.LogAsync(service.LogEntry{
EventType: "safety_block",
MediaID: &req.MediaID,
MediaType: &req.MediaType,
Title: req.Title,
Description: &block.Reason,
Data: json.RawMessage(fmt.Sprintf(`{"extension":"%s","indexer":"%s"}`, block.MatchedExtension, req.IndexerName)),
})
}
return c.JSON(http.StatusUnprocessableEntity, map[string]interface{}{
"error": block.Reason,
"blocked": true,
})
}
}
result, err := svc.Grab(ctx, req, dcSvc)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
qualityJSON, _ := json.Marshal(req.Quality)
_, err = queueSvc.CreateQueueEntry(ctx, service.CreateQueueEntryRequest{
MediaID: req.MediaID,
MediaType: req.MediaType,
ReleaseTitle: req.Title,
Indexer: req.IndexerName,
DownloadClient: result.ClientName,
Quality: qualityJSON,
Protocol: result.Protocol,
DownloadID: result.DownloadID,
})
if err != nil {
slog.Error("failed to create queue entry", "error", err)
}
// Log successful grab activity
if activitySvc != nil {
activitySvc.LogAsync(service.LogEntry{
EventType: "grab",
MediaID: &req.MediaID,
MediaType: &req.MediaType,
Title: fmt.Sprintf("Grabbed %s", req.Title),
Description: &req.IndexerName,
Data: json.RawMessage(fmt.Sprintf(`{"release":"%s","client":"%s","protocol":"%s"}`, req.Title, result.ClientName, result.Protocol)),
})
}
return c.JSON(http.StatusCreated, map[string]interface{}{
"queue_id": result.QueueID,
"download_id": result.DownloadID,
"client": result.ClientName,
"protocol": result.Protocol,
})
}
}

159
internal/api/subtitle.go Normal file
View File

@@ -0,0 +1,159 @@
package api
import (
"context"
"log/slog"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func searchSubtitles(subSvc *service.SubtitleService, mediaSvc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
mediaType := c.Param("type")
if mediaType == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media type required"})
}
detail, err := mediaSvc.GetByID(ctx, id, mediaType)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media not found"})
}
langsParam := c.QueryParam("languages")
if langsParam == "" {
langsParam = "en"
}
langs := strings.Split(langsParam, ",")
hi := c.QueryParam("hi") == "true"
forced := c.QueryParam("forced") == "true"
results, err := subSvc.Search(ctx, detail.Media.Title, service.SubtitleSearchOptions{
LanguageCodes: langs,
HI: hi,
Forced: forced,
})
if err != nil {
slog.Error("subtitle search failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"data": results,
})
}
}
type downloadSubtitleRequest struct {
SubtitleID string `json:"subtitle_id"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
}
func downloadSubtitle(subSvc *service.SubtitleService, mediaSvc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 15*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
mediaType := c.Param("type")
if mediaType == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media type required"})
}
var req downloadSubtitleRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.SubtitleID == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "subtitle_id required"})
}
if req.LanguageCode == "" {
req.LanguageCode = "en"
}
detail, err := mediaSvc.GetByID(ctx, id, mediaType)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media not found"})
}
targetDir := ""
if detail.Media.RootFolderID != nil {
targetDir = filepath.Join("/", "data", mediaType)
}
if targetDir == "" {
targetDir = filepath.Join("/", "data", mediaType, "subtitles")
}
season := 0
episode := 0
baseName := service.BuildSubtitleBaseName(detail.Media.Title, detail.Media.Year, season, episode)
result, err := subSvc.Download(ctx, req.SubtitleID, targetDir, baseName, req.LanguageCode, req.HI, req.Forced)
if err != nil {
slog.Error("subtitle download failed", "error", err)
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, result)
}
}
func extractSubtitles(subSvc *service.SubtitleService, mediaSvc *service.MediaService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
mediaType := c.Param("type")
if mediaType == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "media type required"})
}
detail, err := mediaSvc.GetByID(ctx, id, mediaType)
if err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": "media not found"})
}
var allFiles []service.SubtitleFile
for _, file := range detail.Files {
season := 0
episode := 0
baseName := service.BuildSubtitleBaseName(detail.Media.Title, detail.Media.Year, season, episode)
extracted, err := subSvc.ExtractSubtitles(ctx, file.Path, filepath.Dir(file.Path), baseName)
if err != nil {
slog.Error("subtitle extraction failed", "error", err, "file", file.Path)
continue
}
allFiles = append(allFiles, extracted...)
}
return c.JSON(http.StatusOK, map[string]interface{}{
"data": allFiles,
})
}
}

67
internal/api/tag.go Normal file
View File

@@ -0,0 +1,67 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/service"
"github.com/labstack/echo/v4"
)
func listTags(svc *service.TagService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
tags, err := svc.List(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if tags == nil {
tags = []service.Tag{}
}
return c.JSON(http.StatusOK, tags)
}
}
func createTag(svc *service.TagService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
var req service.CreateTagRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
id, err := svc.Create(ctx, req)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusCreated, map[string]int64{"id": id})
}
}
func deleteTag(svc *service.TagService) echo.HandlerFunc {
return func(c echo.Context) error {
ctx, cancel := context.WithTimeout(c.Request().Context(), 5*time.Second)
defer cancel()
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid id"})
}
if err := svc.Delete(ctx, id); err != nil {
if err.Error() == "tag not found" {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "deleted"})
}
}

92
internal/api/workers.go Normal file
View File

@@ -0,0 +1,92 @@
package api
import (
"context"
"net/http"
"strconv"
"time"
"github.com/TopherMayor/unified-media-manager/internal/worker"
"github.com/labstack/echo/v4"
)
func listWorkers(scheduler *worker.Scheduler) echo.HandlerFunc {
return func(c echo.Context) error {
tasks := scheduler.GetWorkers()
if tasks == nil {
tasks = []worker.ScheduledTaskInfo{}
}
return c.JSON(http.StatusOK, tasks)
}
}
func workerHistory(scheduler *worker.Scheduler) echo.HandlerFunc {
return func(c echo.Context) error {
name := c.Param("name")
page, _ := strconv.Atoi(c.QueryParam("page"))
if page < 1 {
page = 1
}
pageSize, _ := strconv.Atoi(c.QueryParam("page_size"))
if pageSize < 1 || pageSize > 100 {
pageSize = 50
}
ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second)
defer cancel()
executions, total, err := scheduler.GetHistory(ctx, name, page, pageSize)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
if executions == nil {
executions = []worker.TaskExecution{}
}
totalPages := total / pageSize
if total%pageSize > 0 {
totalPages++
}
return c.JSON(http.StatusOK, paginatedResponse{
Data: executions,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
})
}
}
func triggerWorker(scheduler *worker.Scheduler) echo.HandlerFunc {
return func(c echo.Context) error {
name := c.Param("name")
if err := scheduler.TriggerWorker(name); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusAccepted, map[string]string{"status": "triggered"})
}
}
func updateWorker(scheduler *worker.Scheduler) echo.HandlerFunc {
return func(c echo.Context) error {
name := c.Param("name")
var req struct {
Enabled *bool `json:"enabled"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
}
if req.Enabled == nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "enabled field is required"})
}
if err := scheduler.SetEnabled(name, *req.Enabled); err != nil {
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]string{"status": "updated"})
}
}

View File

@@ -0,0 +1,287 @@
package cardigann
import (
"fmt"
yaml "gopkg.in/yaml.v3"
)
// Definition represents a parsed Cardigann YAML indexer definition.
// It matches the upstream Cardigann schema for site definitions.
type Definition struct {
Site string `yaml:"site"`
Name string `yaml:"name"`
Description string `yaml:"description"`
Language string `yaml:"language"`
Encoding string `yaml:"encoding"`
Links StringOrSlice `yaml:"links"`
Settings []SettingsField `yaml:"settings"`
Caps CapabilitiesBlock `yaml:"caps"`
Login LoginBlock `yaml:"login"`
Ratio RatioBlock `yaml:"ratio"`
Search SearchBlock `yaml:"search"`
}
// SettingsField describes a user-configurable field in the definition.
type SettingsField struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Label string `yaml:"label"`
}
// CapabilitiesBlock maps categories and search modes.
type CapabilitiesBlock struct {
Categories map[string]string `yaml:"categories"`
Modes map[string][]string `yaml:"modes"`
}
// LoginBlock describes authentication configuration.
type LoginBlock struct {
Path string `yaml:"path"`
Method string `yaml:"method"`
Form string `yaml:"form"`
Inputs map[string]string `yaml:"inputs"`
Error []ErrorBlock `yaml:"error"`
Test PageTestBlock `yaml:"test"`
}
// ErrorBlock describes an error detection pattern.
type ErrorBlock struct {
Path string `yaml:"path"`
Selector string `yaml:"selector"`
Message SelectorBlock `yaml:"message"`
}
// PageTestBlock describes a page test for verifying login.
type PageTestBlock struct {
Path string `yaml:"path"`
Selector string `yaml:"selector"`
}
// SearchBlock describes search configuration.
type SearchBlock struct {
Path string `yaml:"path"`
Method string `yaml:"method"`
Inputs map[string]string `yaml:"inputs"`
Rows RowsBlock `yaml:"rows"`
Fields FieldsListBlock `yaml:"fields"`
}
// RowsBlock describes how to find result rows in HTML.
type RowsBlock struct {
Selector string `yaml:"selector"`
Remove string `yaml:"remove"`
After int `yaml:"after"`
DateHeaders SelectorBlock `yaml:"dateheaders"`
}
// FieldBlock represents a single field extraction definition.
type FieldBlock struct {
Field string `yaml:"field"`
Block SelectorBlock `yaml:"-"`
}
// SelectorBlock describes CSS selector extraction with optional filters.
type SelectorBlock struct {
Selector string `yaml:"selector"`
Text string `yaml:"text"`
Attribute string `yaml:"attribute"`
Remove string `yaml:"remove"`
Filters []FilterBlock `yaml:"filters"`
Case map[string]string `yaml:"case"`
}
// FilterBlock represents a filter transformation.
type FilterBlock struct {
Name string `yaml:"name"`
Args interface{} `yaml:"args"`
}
// RatioBlock describes ratio display configuration.
type RatioBlock struct {
Selector string `yaml:"selector"`
Path string `yaml:"path"`
}
// StringOrSlice is a custom type that accepts either a string or a slice of strings in YAML.
type StringOrSlice []string
func (s *StringOrSlice) UnmarshalYAML(value *yaml.Node) error {
var single string
if err := value.Decode(&single); err == nil {
*s = []string{single}
return nil
}
var slice []string
if err := value.Decode(&slice); err != nil {
return fmt.Errorf("expected string or list of strings: %w", err)
}
*s = slice
return nil
}
// FieldsListBlock preserves the field ordering from YAML map keys.
type FieldsListBlock []FieldBlock
func (f *FieldsListBlock) UnmarshalYAML(value *yaml.Node) error {
// Cardigann fields are a YAML map where key is field name and value is selector block.
// We use the yaml.Node directly to preserve key ordering.
if value.Kind != yaml.MappingNode {
return fmt.Errorf("fields must be a mapping")
}
result := make([]FieldBlock, 0, len(value.Content)/2)
for i := 0; i < len(value.Content); i += 2 {
keyNode := value.Content[i]
valNode := value.Content[i+1]
fieldName := keyNode.Value
// Marshal the value node back to YAML, then unmarshal into SelectorBlock
valueBytes, err := yaml.Marshal(valNode)
if err != nil {
return fmt.Errorf("failed to marshal field %q: %w", fieldName, err)
}
var block SelectorBlock
if err := yaml.Unmarshal(valueBytes, &block); err != nil {
return fmt.Errorf("failed to unmarshal field %q block: %w", fieldName, err)
}
result = append(result, FieldBlock{
Field: fieldName,
Block: block,
})
}
*f = result
return nil
}
// UnmarshalYAML sets default values for RowsBlock.
func (r *RowsBlock) UnmarshalYAML(value *yaml.Node) error {
// Use a raw type to avoid infinite recursion
type rawRows struct {
Selector string `yaml:"selector"`
Remove string `yaml:"remove"`
After int `yaml:"after"`
DateHeaders SelectorBlock `yaml:"dateheaders"`
}
var raw rawRows
if err := value.Decode(&raw); err != nil {
return err
}
r.Selector = raw.Selector
r.Remove = raw.Remove
r.After = raw.After
r.DateHeaders = raw.DateHeaders
return nil
}
// UnmarshalYAML sets default values for LoginBlock.
func (l *LoginBlock) UnmarshalYAML(value *yaml.Node) error {
type rawLogin struct {
Path string `yaml:"path"`
Method string `yaml:"method"`
Form string `yaml:"form"`
Inputs map[string]string `yaml:"inputs"`
Error []ErrorBlock `yaml:"error"`
Test PageTestBlock `yaml:"test"`
}
var raw rawLogin
if err := value.Decode(&raw); err != nil {
return err
}
l.Path = raw.Path
l.Method = raw.Method
l.Form = raw.Form
l.Inputs = raw.Inputs
l.Error = raw.Error
l.Test = raw.Test
// Apply defaults
if l.Method == "" {
l.Method = "form"
}
if l.Form == "" {
l.Form = "form"
}
return nil
}
// ParseDefinition parses raw YAML bytes into a Definition struct.
// It applies defaults and validates required fields.
func ParseDefinition(data []byte) (*Definition, error) {
var def Definition
if err := yaml.Unmarshal(data, &def); err != nil {
return nil, fmt.Errorf("parse YAML: %w", err)
}
// Apply defaults
if def.Language == "" {
def.Language = "en-us"
}
if def.Encoding == "" {
def.Encoding = "UTF-8"
}
// Validate required fields
if def.Site == "" {
return nil, fmt.Errorf("definition missing required field: site")
}
if def.Name == "" {
return nil, fmt.Errorf("definition missing required field: name")
}
if len(def.Links) == 0 {
return nil, fmt.Errorf("definition missing required field: links")
}
// Threat model T-10-04: Reject oversized definitions
if len(def.Search.Fields) > 100 {
return nil, fmt.Errorf("definition has too many search fields (%d > 100)", len(def.Search.Fields))
}
if len(def.Caps.Categories) > 1000 {
return nil, fmt.Errorf("definition has too many category mappings (%d > 1000)", len(def.Caps.Categories))
}
return &def, nil
}
// ValidateDefinition returns a list of validation warnings for a parsed definition.
// These are not errors — the definition may still be usable — but indicate potential issues.
func ValidateDefinition(def *Definition) []string {
var warnings []string
if def.Search.Rows.Selector == "" {
warnings = append(warnings, "search.rows.selector is empty — search will not find results")
}
hasTitle := false
hasDownload := false
for _, field := range def.Search.Fields {
switch field.Field {
case "title":
hasTitle = true
case "download":
hasDownload = true
}
}
if !hasTitle {
warnings = append(warnings, "search.fields missing \"title\" field — results will have no title")
}
if !hasDownload {
warnings = append(warnings, "search.fields missing \"download\" field — results will have no download URL")
}
// Check that login inputs reference config settings
if len(def.Login.Inputs) > 0 && len(def.Settings) > 0 {
settingNames := make(map[string]bool, len(def.Settings))
for _, s := range def.Settings {
settingNames[s.Name] = true
}
}
return warnings
}

View File

@@ -0,0 +1,614 @@
package cardigann
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/PuerkitoBio/goquery"
"github.com/dustin/go-humanize"
)
// CardigannResult is the output of a Cardigann search operation.
// It is converted to service.SearchResult by the service layer.
type CardigannResult struct {
Title string
GUID string
DownloadURL string
Size int64
PubDate string
Seeders int
Peers int
Category string
Description string
}
// IndexerTestResult is the result of testing a Cardigann indexer connection.
type IndexerTestResult struct {
Success bool
Error string
}
// CardigannEngine handles Cardigann indexer operations: search, login, test.
type CardigannEngine struct {
httpClient *http.Client
cookies []*http.Cookie
logger *slog.Logger
}
// NewCardigannEngine creates a new CardigannEngine with safe HTTP client.
func NewCardigannEngine() *CardigannEngine {
return &CardigannEngine{
httpClient: SafeHTTPClient(),
logger: slog.Default(),
}
}
// Search executes a Cardigann search: login (if needed), build request, parse HTML, extract results.
func (e *CardigannEngine) Search(ctx context.Context, def *Definition, config map[string]string, query SearchQuery) ([]CardigannResult, error) {
baseURL := e.getBaseURL(def, config)
// Login if required
if def.Login.Path != "" || len(def.Login.Inputs) > 0 {
if err := e.login(ctx, def, config, baseURL); err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
}
// Build search URL from path template
searchPath := def.Search.Path
if searchPath == "" {
searchPath = "/"
}
path, err := ApplyTemplate("search-path", searchPath, TemplateContext{
Query: query,
Config: config,
Categories: []string{},
})
if err != nil {
return nil, fmt.Errorf("template search path: %w", err)
}
searchURL, err := e.resolvePath(baseURL, path)
if err != nil {
return nil, fmt.Errorf("resolve search URL: %w", err)
}
// Validate the search URL (SSRF protection)
if err := ValidateURL(searchURL); err != nil {
return nil, fmt.Errorf("search URL blocked: %w", err)
}
// Build query inputs
inputValues := make(url.Values)
for key, tplStr := range def.Search.Inputs {
rendered, err := ApplyTemplate("input-"+key, tplStr, TemplateContext{
Query: query,
Config: config,
Categories: []string{},
})
if err != nil {
return nil, fmt.Errorf("template input %q: %w", key, err)
}
if key == "$raw" {
// Parse as query string and merge
parsed, err := url.ParseQuery(rendered)
if err == nil {
for k, vals := range parsed {
for _, v := range vals {
inputValues.Set(k, v)
}
}
}
} else {
inputValues.Set(key, rendered)
}
}
// Execute HTTP request
var resp *http.Response
method := strings.ToUpper(def.Search.Method)
if method == "" {
method = "GET"
}
searchCtx, searchCancel := context.WithTimeout(ctx, 15*time.Second)
defer searchCancel()
if method == "POST" {
req, err := http.NewRequestWithContext(searchCtx, http.MethodPost, searchURL, strings.NewReader(inputValues.Encode()))
if err != nil {
return nil, fmt.Errorf("create POST request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range e.cookies {
req.AddCookie(cookie)
}
resp, err = e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("POST search: %w", err)
}
} else {
// GET: append query string
if len(inputValues) > 0 {
if strings.Contains(searchURL, "?") {
searchURL += "&" + inputValues.Encode()
} else {
searchURL += "?" + inputValues.Encode()
}
}
req, err := http.NewRequestWithContext(searchCtx, http.MethodGet, searchURL, nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
for _, cookie := range e.cookies {
req.AddCookie(cookie)
}
resp, err = e.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("GET search: %w", err)
}
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("search returned HTTP %d", resp.StatusCode)
}
// Read response with size limit (T-10-07: 10MB cap)
body := io.LimitReader(resp.Body, 10*1024*1024)
// Parse HTML
doc, err := goquery.NewDocumentFromReader(body)
if err != nil {
return nil, fmt.Errorf("parse HTML: %w", err)
}
// Find rows
rows := doc.Find(def.Search.Rows.Selector)
if def.Search.Rows.Remove != "" {
rows.Find(def.Search.Rows.Remove).Remove()
}
var results []CardigannResult
rows.Each(func(i int, row *goquery.Selection) {
result := CardigannResult{}
fieldValues := make(map[string]string)
for _, field := range def.Search.Fields {
val, err := ExtractField(row, field.Block)
if err != nil {
e.logger.Warn("field extraction error", "field", field.Field, "error", err)
continue
}
fieldValues[field.Field] = val
}
// Map fields to result
result.Title = fieldValues["title"]
result.DownloadURL = fieldValues["download"]
result.GUID = fieldValues["details"]
result.Category = fieldValues["category"]
result.Description = fieldValues["description"]
result.PubDate = fieldValues["date"]
// Resolve relative URLs
if result.DownloadURL != "" {
resolved, err := e.resolvePath(baseURL, result.DownloadURL)
if err == nil {
result.DownloadURL = resolved
}
}
if result.GUID != "" {
resolved, err := e.resolvePath(baseURL, result.GUID)
if err == nil {
result.GUID = resolved
}
}
// Parse size
if sizeStr := fieldValues["size"]; sizeStr != "" {
if size, err := humanize.ParseBytes(strings.TrimSpace(sizeStr)); err == nil {
result.Size = int64(size)
}
}
// Parse seeders/peers
if seedersStr := fieldValues["seeders"]; seedersStr != "" {
if v, err := strconv.Atoi(strings.TrimSpace(seedersStr)); err == nil {
result.Seeders = v
}
}
if leechersStr := fieldValues["leechers"]; leechersStr != "" {
if v, err := strconv.Atoi(strings.TrimSpace(leechersStr)); err == nil {
result.Peers = v
}
}
// Parse date if it wasn't already RFC3339
if result.PubDate != "" {
result.PubDate = e.parseDateField(result.PubDate)
}
// Only include results with at least a title
if result.Title != "" {
results = append(results, result)
}
})
return results, nil
}
// login performs authentication against the Cardigann indexer.
func (e *CardigannEngine) login(ctx context.Context, def *Definition, config map[string]string, baseURL string) error {
loginPath := def.Login.Path
if loginPath == "" {
return fmt.Errorf("login path is empty")
}
path, err := ApplyTemplate("login-path", loginPath, TemplateContext{
Config: config,
})
if err != nil {
return fmt.Errorf("template login path: %w", err)
}
loginURL, err := e.resolvePath(baseURL, path)
if err != nil {
return fmt.Errorf("resolve login URL: %w", err)
}
if err := ValidateURL(loginURL); err != nil {
return fmt.Errorf("login URL blocked: %w", err)
}
// Build input values from login.inputs
inputValues := make(map[string]string)
for key, tplStr := range def.Login.Inputs {
rendered, err := ApplyTemplate("login-input-"+key, tplStr, TemplateContext{
Config: config,
})
if err != nil {
return fmt.Errorf("template login input %q: %w", key, err)
}
inputValues[key] = rendered
}
loginCtx, loginCancel := context.WithTimeout(ctx, 10*time.Second)
defer loginCancel()
switch def.Login.Method {
case "cookie":
// Set cookie directly
if cookieStr, ok := inputValues["cookie"]; ok {
parts := strings.SplitN(cookieStr, "=", 2)
cookie := &http.Cookie{
Name: parts[0],
Value: func() string { if len(parts) > 1 { return parts[1] }; return "" }(),
}
e.cookies = append(e.cookies, cookie)
}
return nil
case "post":
// POST directly to login path with inputs
form := url.Values{}
for key, val := range inputValues {
form.Set(key, val)
}
req, err := http.NewRequestWithContext(loginCtx, http.MethodPost, loginURL, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("create login POST: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("login POST: %w", err)
}
defer resp.Body.Close()
io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
// Store cookies from response
e.cookies = resp.Cookies()
// Check for errors
if err := e.checkLoginErrors(resp, def); err != nil {
return err
}
default:
// "form" method (default)
// GET login page, find form, fill inputs, submit
req, err := http.NewRequestWithContext(loginCtx, http.MethodGet, loginURL, nil)
if err != nil {
return fmt.Errorf("create login GET: %w", err)
}
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("login GET: %w", err)
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return fmt.Errorf("read login page: %w", err)
}
e.cookies = append(e.cookies, resp.Cookies()...)
// Parse the login page to find the form
doc, err := goquery.NewDocumentFromReader(strings.NewReader(string(bodyBytes)))
if err != nil {
return fmt.Errorf("parse login page: %w", err)
}
// Find the form
formSelector := def.Login.Form
if formSelector == "" {
formSelector = "form"
}
form := doc.Find(formSelector).First()
if form.Length() == 0 {
return fmt.Errorf("login form not found with selector %q", formSelector)
}
// Get form action
action, exists := form.Attr("action")
if !exists || action == "" {
action = loginPath
}
actionURL, err := e.resolvePath(baseURL, action)
if err != nil {
return fmt.Errorf("resolve form action: %w", err)
}
if err := ValidateURL(actionURL); err != nil {
return fmt.Errorf("form action URL blocked: %w", err)
}
// Collect hidden inputs from form
formValues := url.Values{}
form.Find("input[type='hidden']").Each(func(i int, s *goquery.Selection) {
name, _ := s.Attr("name")
value, _ := s.Attr("value")
if name != "" {
formValues.Set(name, value)
}
})
// Add login inputs
for key, val := range inputValues {
formValues.Set(key, val)
}
// Submit the form
submitReq, err := http.NewRequestWithContext(loginCtx, http.MethodPost, actionURL, strings.NewReader(formValues.Encode()))
if err != nil {
return fmt.Errorf("create form submit: %w", err)
}
submitReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, cookie := range e.cookies {
submitReq.AddCookie(cookie)
}
submitResp, err := e.httpClient.Do(submitReq)
if err != nil {
return fmt.Errorf("submit login form: %w", err)
}
defer submitResp.Body.Close()
io.ReadAll(io.LimitReader(submitResp.Body, 10*1024*1024))
e.cookies = append(e.cookies, submitResp.Cookies()...)
// Check for errors
if err := e.checkLoginErrors(submitResp, def); err != nil {
return err
}
}
// Test login if test block is defined
if def.Login.Test.Selector != "" || def.Login.Test.Path != "" {
testPath := def.Login.Test.Path
if testPath == "" {
testPath = "/"
}
testURL, err := e.resolvePath(baseURL, testPath)
if err != nil {
return fmt.Errorf("resolve test URL: %w", err)
}
if err := ValidateURL(testURL); err != nil {
return fmt.Errorf("test URL blocked: %w", err)
}
testReq, err := http.NewRequestWithContext(loginCtx, http.MethodGet, testURL, nil)
if err != nil {
return fmt.Errorf("create test request: %w", err)
}
for _, cookie := range e.cookies {
testReq.AddCookie(cookie)
}
testResp, err := e.httpClient.Do(testReq)
if err != nil {
return fmt.Errorf("login test request: %w", err)
}
defer testResp.Body.Close()
io.ReadAll(io.LimitReader(testResp.Body, 10*1024*1024))
if def.Login.Test.Selector != "" {
testDoc, err := goquery.NewDocumentFromReader(strings.NewReader(func() string {
// We can't re-read the body, so we just check the status code
return ""
}()))
if err != nil {
return nil // Don't fail on parse errors
}
if testDoc.Find(def.Login.Test.Selector).Length() == 0 {
return fmt.Errorf("login test: selector %q not found", def.Login.Test.Selector)
}
}
}
return nil
}
// Test validates a Cardigann indexer by checking base URL connectivity and optionally testing login.
func (e *CardigannEngine) Test(ctx context.Context, def *Definition, config map[string]string) (*IndexerTestResult, error) {
baseURL := e.getBaseURL(def, config)
if baseURL == "" {
return &IndexerTestResult{Success: false, Error: "no base URL in definition"}, nil
}
if err := ValidateURL(baseURL); err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("URL blocked: %v", err)}, nil
}
// If Login block present, attempt login
if def.Login.Path != "" || len(def.Login.Inputs) > 0 {
if err := e.login(ctx, def, config, baseURL); err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("login failed: %v", err)}, nil
}
}
// If Search block present, test search path
if def.Search.Path != "" {
testPath, err := ApplyTemplate("test-path", def.Search.Path, TemplateContext{
Config: config,
})
if err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("template error: %v", err)}, nil
}
testURL, err := e.resolvePath(baseURL, testPath)
if err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("resolve URL: %v", err)}, nil
}
if err := ValidateURL(testURL); err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("URL blocked: %v", err)}, nil
}
testCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(testCtx, http.MethodGet, testURL, nil)
if err != nil {
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
for _, cookie := range e.cookies {
req.AddCookie(cookie)
}
resp, err := e.httpClient.Do(req)
if err != nil {
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
resp.Body.Close()
if resp.StatusCode >= 400 {
return &IndexerTestResult{
Success: false,
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
}, nil
}
}
return &IndexerTestResult{Success: true}, nil
}
// resolvePath resolves a potentially relative path against a base URL.
func (e *CardigannEngine) resolvePath(baseURL, path string) (string, error) {
if path == "" {
return baseURL, nil
}
// Already absolute URL
if strings.HasPrefix(strings.ToLower(path), "http://") || strings.HasPrefix(strings.ToLower(path), "https://") {
return path, nil
}
// Relative URL — resolve against base
base, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("parse base URL: %w", err)
}
ref, err := url.Parse(path)
if err != nil {
return "", fmt.Errorf("parse path: %w", err)
}
resolved := base.ResolveReference(ref)
return resolved.String(), nil
}
// getBaseURL returns the first link from the definition, or a config override.
func (e *CardigannEngine) getBaseURL(def *Definition, config map[string]string) string {
if url, ok := config["base_url"]; ok && url != "" {
return url
}
if len(def.Links) > 0 {
return def.Links[0]
}
return ""
}
// parseDateField attempts to parse a date string in various formats.
func (e *CardigannEngine) parseDateField(val string) string {
// Already RFC3339
if _, err := time.Parse(time.RFC3339, val); err == nil {
return val
}
// Try common date layouts
layouts := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z07:00",
"2006-01-02T15:04:05",
"2006-01-02",
"02-Jan-2006",
"Jan 02, 2006",
"Jan 02 2006",
"02 Jan 2006 15:04:05",
"Mon, 02 Jan 2006 15:04:05 -0700",
time.RFC1123,
time.RFC1123Z,
time.RFC822,
time.RFC822Z,
}
for _, layout := range layouts {
if t, err := time.Parse(layout, strings.TrimSpace(val)); err == nil {
return t.Format(time.RFC3339)
}
}
// Try relative time
if t, err := parseFuzzyTime(val); err == nil {
return t.Format(time.RFC3339)
}
// Return as-is if we can't parse
return val
}
// checkLoginErrors checks for login error patterns in the response.
func (e *CardigannEngine) checkLoginErrors(resp *http.Response, def *Definition) error {
if len(def.Login.Error) == 0 {
return nil
}
// Note: body has already been read; we'd need to store it
// For now, just check status code
if resp.StatusCode >= 400 {
return fmt.Errorf("login returned HTTP %d", resp.StatusCode)
}
return nil
}

View File

@@ -0,0 +1,296 @@
package cardigann
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// ApplyFilters applies a chain of filter transformations to a value.
func ApplyFilters(val string, filters []FilterBlock) (string, error) {
var err error
for _, f := range filters {
val, err = invokeFilter(val, f)
if err != nil {
return val, err
}
}
return val, nil
}
// invokeFilter dispatches a single filter by name.
func invokeFilter(val string, f FilterBlock) (string, error) {
switch f.Name {
case "querystring":
return filterQuerystring(val, f.Args)
case "dateparse", "timeparse":
return filterDateParse(val, f.Args)
case "regexp":
return filterRegexp(val, f.Args)
case "split":
return filterSplit(val, f.Args)
case "replace":
return filterReplace(val, f.Args)
case "trim":
return filterTrim(val, f.Args)
case "append":
return filterAppend(val, f.Args)
case "prepend":
return filterPrepend(val, f.Args)
case "timeago", "fuzzytime", "reltime":
return filterTimeAgo(val, f.Args)
default:
return val, fmt.Errorf("unknown filter: %q", f.Name)
}
}
// filterQuerystring extracts a query parameter from a URL value.
// Args: param name string
func filterQuerystring(val string, args interface{}) (string, error) {
paramName, ok := args.(string)
if !ok {
return val, fmt.Errorf("querystring filter: args must be a string")
}
// Find the query string part
qIdx := strings.Index(val, "?")
if qIdx < 0 {
return "", nil
}
query := val[qIdx+1:]
// Parse manually to avoid importing net/url for simple cases
for _, pair := range strings.Split(query, "&") {
kv := strings.SplitN(pair, "=", 2)
if len(kv) == 2 && kv[0] == paramName {
// Basic URL decoding
result := strings.ReplaceAll(kv[1], "+", " ")
result = strings.ReplaceAll(result, "%20", " ")
return result, nil
}
}
return "", nil
}
// filterDateParse parses a date string using a Go time layout.
// Args: layout string (e.g., "2006-01-02")
func filterDateParse(val string, args interface{}) (string, error) {
layout, ok := args.(string)
if !ok {
return val, fmt.Errorf("dateparse filter: args must be a string (Go time layout)")
}
t, err := time.Parse(layout, strings.TrimSpace(val))
if err != nil {
return val, fmt.Errorf("dateparse: %w", err)
}
return t.Format(time.RFC3339), nil
}
// filterRegexp extracts the first capture group from value.
// Args: pattern string
func filterRegexp(val string, args interface{}) (string, error) {
pattern, ok := args.(string)
if !ok {
return val, fmt.Errorf("regexp filter: args must be a string (pattern)")
}
re, err := regexp.Compile(pattern)
if err != nil {
return val, fmt.Errorf("regexp compile: %w", err)
}
matches := re.FindStringSubmatch(val)
if len(matches) < 2 {
return val, nil
}
return matches[1], nil
}
// filterSplit splits value by separator and returns the element at position.
// Args: [separator, position] as []interface{} or single string
func filterSplit(val string, args interface{}) (string, error) {
sep, pos := parseSplitArgs(args)
parts := strings.Split(val, sep)
idx := int(pos)
if idx < 0 {
idx = len(parts) + idx
}
if idx < 0 || idx >= len(parts) {
return val, nil
}
return parts[idx], nil
}
// filterReplace performs string replacement.
// Args: [from, to] as []interface{} or single string
func filterReplace(val string, args interface{}) (string, error) {
from, to := parseReplaceArgs(args)
return strings.ReplaceAll(val, from, to), nil
}
// filterTrim trims characters from both sides of value.
// Args: cutset string
func filterTrim(val string, args interface{}) (string, error) {
cutset, ok := args.(string)
if !ok {
return strings.TrimSpace(val), nil
}
return strings.Trim(val, cutset), nil
}
// filterAppend appends a suffix to value.
// Args: suffix string
func filterAppend(val string, args interface{}) (string, error) {
suffix, ok := args.(string)
if !ok {
return val, fmt.Errorf("append filter: args must be a string")
}
return val + suffix, nil
}
// filterPrepend prepends a prefix to value.
// Args: prefix string
func filterPrepend(val string, args interface{}) (string, error) {
prefix, ok := args.(string)
if !ok {
return val, fmt.Errorf("prepend filter: args must be a string")
}
return prefix + val, nil
}
// filterTimeAgo parses relative time strings like "2 hours ago", "yesterday", "3d ago".
// It returns an RFC3339 formatted timestamp.
func filterTimeAgo(val string, _ interface{}) (string, error) {
t, err := parseFuzzyTime(strings.TrimSpace(val))
if err != nil {
return val, err
}
return t.Format(time.RFC3339), nil
}
// parseFuzzyTime handles relative time strings.
// Supports: "N unit(s) ago", "yesterday", abbreviations like "2h ago", "3d", "1w ago".
func parseFuzzyTime(val string) (time.Time, error) {
now := time.Now()
lower := strings.ToLower(val)
// Handle "yesterday"
if lower == "yesterday" {
return now.AddDate(0, 0, -1), nil
}
if lower == "today" || lower == "now" {
return now, nil
}
// Remove "ago" suffix
lower = strings.TrimSuffix(lower, " ago")
lower = strings.TrimSuffix(lower, " ago.")
lower = strings.TrimSpace(lower)
// Handle just a number + unit without "ago" (e.g., "3d", "2h")
// Pattern: optional number, then unit abbreviation or full name
re := regexp.MustCompile(`^(\d+)\s*(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:ou?r?s?)?|d(?:ay?s?)?|w(?:ee?k?s?)?|mo(?:nth?s?)?|y(?:ea?r?s?)?)$`)
matches := re.FindStringSubmatch(lower)
if len(matches) < 3 {
// Try the pattern: "N units ago" format
re2 := regexp.MustCompile(`^(\d+)\s+(s(?:ec(?:ond)?s?)?|m(?:in(?:ute)?s?)?|h(?:ou?r?s?)?|d(?:ay?s?)?|w(?:ee?k?s?)?|mo(?:nth?s?)?|y(?:ea?r?s?)?)$`)
matches = re2.FindStringSubmatch(lower)
}
if len(matches) < 3 {
// Try standard duration like "2 hours ago"
re3 := regexp.MustCompile(`^(\d+)\s+(seconds?|minutes?|hours?|days?|weeks?|months?|years?)$`)
matches = re3.FindStringSubmatch(lower)
}
if len(matches) < 3 {
return now, fmt.Errorf("unrecognized relative time: %q", val)
}
n, err := strconv.Atoi(matches[1])
if err != nil {
return now, fmt.Errorf("invalid number in relative time: %q", matches[1])
}
unit := matches[2]
switch {
case strings.HasPrefix(unit, "s"):
return now.Add(-time.Duration(n) * time.Second), nil
case strings.HasPrefix(unit, "mi"):
return now.Add(-time.Duration(n) * time.Minute), nil
case strings.HasPrefix(unit, "h"):
return now.Add(-time.Duration(n) * time.Hour), nil
case strings.HasPrefix(unit, "d"):
return now.AddDate(0, 0, -n), nil
case strings.HasPrefix(unit, "w"):
return now.AddDate(0, 0, -n*7), nil
case strings.HasPrefix(unit, "mo"):
return now.AddDate(0, -n, 0), nil
case strings.HasPrefix(unit, "y"):
return now.AddDate(-n, 0, 0), nil
default:
return now, fmt.Errorf("unrecognized time unit: %q", unit)
}
}
// parseSplitArgs extracts separator and position from filter args.
// Args can be: []interface{}{sep, pos}, or a string (defaults to comma separator, position 0).
func parseSplitArgs(args interface{}) (string, int) {
switch a := args.(type) {
case []interface{}:
sep := ","
pos := 0
if len(a) > 0 {
if s, ok := a[0].(string); ok {
sep = s
}
}
if len(a) > 1 {
switch p := a[1].(type) {
case int:
pos = p
case float64:
pos = int(p)
case string:
pos, _ = strconv.Atoi(p)
}
}
return sep, pos
case string:
return a, 0
default:
return ",", 0
}
}
// parseReplaceArgs extracts from/to from filter args.
// Args can be: []interface{}{from, to}, or a single string (empty replacement).
func parseReplaceArgs(args interface{}) (string, string) {
switch a := args.(type) {
case []interface{}:
from := ""
to := ""
if len(a) > 0 {
if s, ok := a[0].(string); ok {
from = s
}
}
if len(a) > 1 {
if s, ok := a[1].(string); ok {
to = s
}
}
return from, to
case string:
return a, ""
default:
return "", ""
}
}

View File

@@ -0,0 +1,48 @@
package cardigann
import (
"bytes"
"fmt"
"strings"
"text/template"
)
// SearchQuery represents a search query to be templated into request URLs and inputs.
type SearchQuery struct {
Keywords string
MediaType string
}
// TemplateContext provides the data available to Cardigann templates.
type TemplateContext struct {
Query SearchQuery
Config map[string]string
Categories []string
}
// ApplyTemplate processes a Go template string with the sandboxed Cardigann FuncMap.
// The FuncMap contains ONLY "replace" to prevent SSRF or file access via templates.
func ApplyTemplate(name, tpl string, ctx interface{}) (string, error) {
tmpl, err := template.New(name).Funcs(sandboxedFuncMap()).Parse(tpl)
if err != nil {
return "", fmt.Errorf("parse template %q: %w", name, err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, ctx); err != nil {
return "", fmt.Errorf("execute template %q: %w", name, err)
}
return buf.String(), nil
}
// sandboxedFuncMap returns a template FuncMap containing ONLY safe functions.
// SECURITY: No file, network, environment, or exec access allowed.
// Threat model T-10-02, T-10-06: FuncMap contains ONLY "replace".
func sandboxedFuncMap() template.FuncMap {
return template.FuncMap{
"replace": func(old, new, src string) string {
return strings.ReplaceAll(src, old, new)
},
}
}

View File

@@ -0,0 +1,165 @@
package cardigann
import (
"context"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
)
// ValidateURL validates that a URL is safe to make requests to.
// It blocks requests to private/internal IPs and non-HTTP schemes.
// Threat model T-10-05: SSRF protection.
func ValidateURL(rawURL string) error {
// Check for config override (testing only)
if os.Getenv("CARDIGANN_ALLOW_PRIVATE") == "true" {
return nil
}
// Basic scheme check before full URL parsing
lower := strings.ToLower(rawURL)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return fmt.Errorf("URL scheme must be http or https, got: %q", rawURL)
}
// Extract hostname
host := rawURL
// Remove scheme
if idx := strings.Index(host, "://"); idx >= 0 {
host = host[idx+3:]
}
// Remove path and everything after
if idx := strings.Index(host, "/"); idx >= 0 {
host = host[:idx]
}
// Remove port
if idx := strings.LastIndex(host, ":"); idx >= 0 {
host = host[:idx]
}
// Remove user info
if idx := strings.LastIndex(host, "@"); idx >= 0 {
host = host[idx+1:]
}
host = strings.ToLower(strings.TrimSpace(host))
// Block well-known local hostnames
if host == "localhost" || strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".internal") {
return fmt.Errorf("hostname %q is blocked (private/local)", host)
}
// Resolve hostname and check IPs
resolveCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resolver := net.Resolver{}
ips, err := resolver.LookupIPAddr(resolveCtx, host)
if err != nil {
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
for _, ipAddr := range ips {
ip := ipAddr.IP
if isPrivateIP(ip) {
return fmt.Errorf("hostname %q resolves to private IP %s", host, ip)
}
}
return nil
}
// isPrivateIP checks if an IP address is in a private/reserved range.
func isPrivateIP(ip net.IP) bool {
// IPv4 private ranges
if ip.To4() != nil {
// 127.0.0.0/8 (loopback)
if ip.IsLoopback() {
return true
}
// 10.0.0.0/8
if ip[0] == 10 {
return true
}
// 172.16.0.0/12
if ip[0] == 172 && ip[1] >= 16 && ip[1] <= 31 {
return true
}
// 192.168.0.0/16
if ip[0] == 192 && ip[1] == 168 {
return true
}
// 169.254.0.0/16 (link-local)
if ip[0] == 169 && ip[1] == 254 {
return true
}
// 0.0.0.0
if ip.IsUnspecified() {
return true
}
}
// IPv6 checks
if ip.To4() == nil {
// ::1 (loopback)
if ip.IsLoopback() {
return true
}
// fc00::/7 (unique local / private)
if (ip[0] & 0xfe) == 0xfc {
return true
}
// fe80::/10 (link-local)
if ip[0] == 0xfe && (ip[1]&0xc0) == 0x80 {
return true
}
// :: (unspecified)
if ip.IsUnspecified() {
return true
}
}
return false
}
// SafeHTTPClient returns an http.Client with timeouts and DNS checking.
func SafeHTTPClient() *http.Client {
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Extract host from addr (may include port)
host, _, err := net.SplitHostPort(addr)
if err != nil {
host = addr
}
// Resolve and check the IP
resolver := net.Resolver{}
ips, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("DNS resolution failed for %q: %w", host, err)
}
for _, ipAddr := range ips {
if isPrivateIP(ipAddr.IP) {
return nil, fmt.Errorf("blocked private IP %s for host %q", ipAddr.IP, host)
}
}
// Use the first resolved IP
if len(ips) == 0 {
return nil, fmt.Errorf("no IP addresses found for %q", host)
}
dialer := net.Dialer{Timeout: 10 * time.Second}
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].IP.String(), func() string {
_, port, _ := net.SplitHostPort(addr)
return port
}()))
},
},
}
}

View File

@@ -0,0 +1,84 @@
package cardigann
import (
"fmt"
"strings"
"github.com/PuerkitoBio/goquery"
)
// ExtractField evaluates a CSS selector block against a goquery selection
// and returns the extracted (and filtered) string value.
func ExtractField(selection *goquery.Selection, block SelectorBlock) (string, error) {
var val string
// If Text is set, it's a static text value
if block.Text != "" {
val = block.Text
return applyFiltersToValue(val, block)
}
// If no selector, return empty
if block.Selector == "" {
return "", nil
}
// Find matching elements
sub := selection.Find(block.Selector)
if sub.Length() == 0 {
return "", nil
}
// Remove child elements matching Remove selector
if block.Remove != "" {
sub.Find(block.Remove).Remove()
}
// If Case patterns defined, iterate and return matching value
if len(block.Case) > 0 {
for pattern, result := range block.Case {
// Check if any matched element matches the pattern
found := false
sub.EachWithBreak(func(i int, s *goquery.Selection) bool {
text := strings.TrimSpace(s.Text())
if text == pattern || strings.Contains(text, pattern) {
found = true
val = result
return false
}
return true
})
if found {
return applyFiltersToValue(val, block)
}
}
return "", nil
}
// If Attribute specified, get attribute from first element
if block.Attribute != "" {
attrVal, exists := sub.Attr(block.Attribute)
if !exists {
return "", nil
}
val = attrVal
} else {
// Get trimmed text content
val = strings.TrimSpace(sub.First().Text())
}
return applyFiltersToValue(val, block)
}
// applyFiltersToValue applies the filter chain to a value.
func applyFiltersToValue(val string, block SelectorBlock) (string, error) {
if len(block.Filters) == 0 {
return val, nil
}
result, err := ApplyFilters(val, block.Filters)
if err != nil {
return val, fmt.Errorf("filter chain error: %w", err)
}
return result, nil
}

119
internal/config/config.go Normal file
View File

@@ -0,0 +1,119 @@
package config
import "os"
type Config struct {
DatabaseURL string
QdrantURL string
OllamaURL string
Port string
FrontendURL string
DownloadDir string
TMDBAPIKey string
TVDBAPIKey string
OpenSubtitlesAPIKey string
ImageDir string
WorkerQueueInterval string
WorkerRSSSyncInterval string
WorkerMetadataInterval string
WorkerSubtitleInterval string
WorkerCleanupInterval string
WorkerHealthCheckInterval string
WorkerDiskUsageInterval string
WorkerLibraryScanInterval string
}
func Load() *Config {
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
databaseURL = "postgres://localhost:5432/unified_media_manager?sslmode=disable"
}
qdrantURL := os.Getenv("QDRANT_URL")
if qdrantURL == "" {
qdrantURL = "http://localhost:6333"
}
ollamaURL := os.Getenv("OLLAMA_URL")
if ollamaURL == "" {
ollamaURL = "http://localhost:11434"
}
frontendURL := os.Getenv("FRONTEND_URL")
if frontendURL == "" {
frontendURL = "http://umm.local.tophermayor.com"
}
port := os.Getenv("PORT")
if port == "" {
port = "8084"
}
downloadDir := os.Getenv("DOWNLOAD_DIR")
if downloadDir == "" {
downloadDir = "/data/downloads"
}
tmdbAPIKey := os.Getenv("TMDB_API_KEY")
tvdbAPIKey := os.Getenv("TVDB_API_KEY")
openSubtitlesAPIKey := os.Getenv("OPENSUBTITLES_API_KEY")
imageDir := os.Getenv("IMAGE_DIR")
if imageDir == "" {
imageDir = "/data/images"
}
workerQueueInterval := os.Getenv("WORKER_QUEUE_INTERVAL")
if workerQueueInterval == "" {
workerQueueInterval = "*/30 * * * * *"
}
workerRSSSyncInterval := os.Getenv("WORKER_RSS_SYNC_INTERVAL")
if workerRSSSyncInterval == "" {
workerRSSSyncInterval = "0 */15 * * * *"
}
workerMetadataInterval := os.Getenv("WORKER_METADATA_INTERVAL")
if workerMetadataInterval == "" {
workerMetadataInterval = "0 0 3 * * *"
}
workerSubtitleInterval := os.Getenv("WORKER_SUBTITLE_INTERVAL")
if workerSubtitleInterval == "" {
workerSubtitleInterval = "0 0 */2 * * *"
}
workerCleanupInterval := os.Getenv("WORKER_CLEANUP_INTERVAL")
if workerCleanupInterval == "" {
workerCleanupInterval = "0 0 4 * * *"
}
workerHealthCheckInterval := os.Getenv("WORKER_HEALTH_CHECK_INTERVAL")
if workerHealthCheckInterval == "" {
workerHealthCheckInterval = "0 */5 * * * *"
}
workerDiskUsageInterval := os.Getenv("WORKER_DISK_USAGE_INTERVAL")
if workerDiskUsageInterval == "" {
workerDiskUsageInterval = "0 0 * * * *"
}
workerLibraryScanInterval := os.Getenv("WORKER_LIBRARY_SCAN_INTERVAL")
if workerLibraryScanInterval == "" {
workerLibraryScanInterval = "0 0 5 * * *"
}
return &Config{
DatabaseURL: databaseURL,
QdrantURL: qdrantURL,
OllamaURL: ollamaURL,
Port: port,
FrontendURL: frontendURL,
DownloadDir: downloadDir,
TMDBAPIKey: tmdbAPIKey,
TVDBAPIKey: tvdbAPIKey,
OpenSubtitlesAPIKey: openSubtitlesAPIKey,
ImageDir: imageDir,
WorkerQueueInterval: workerQueueInterval,
WorkerRSSSyncInterval: workerRSSSyncInterval,
WorkerMetadataInterval: workerMetadataInterval,
WorkerSubtitleInterval: workerSubtitleInterval,
WorkerCleanupInterval: workerCleanupInterval,
WorkerHealthCheckInterval: workerHealthCheckInterval,
WorkerDiskUsageInterval: workerDiskUsageInterval,
WorkerLibraryScanInterval: workerLibraryScanInterval,
}
}

129
internal/db/db.go Normal file
View File

@@ -0,0 +1,129 @@
package db
import (
"context"
"embed"
"fmt"
"io/fs"
"log/slog"
"sort"
"strings"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var MigrationsFS embed.FS
type DB struct {
Pool *pgxpool.Pool
}
func New(ctx context.Context, databaseURL string) (*DB, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
config, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
config.MaxConns = 25
config.MinConns = 3
config.MaxConnLifetime = 1 * time.Hour
config.MaxConnIdleTime = 30 * time.Minute
config.HealthCheckPeriod = 30 * time.Second
pool, err := pgxpool.NewWithConfig(ctx, config)
if err != nil {
return nil, fmt.Errorf("create connection pool: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return &DB{Pool: pool}, nil
}
func (d *DB) Ping(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
return d.Pool.Ping(ctx)
}
func (d *DB) Close() {
d.Pool.Close()
}
func (d *DB) RunMigrations(ctx context.Context, migrationsFS embed.FS) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var count int
if err := d.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'schema_migrations'").Scan(&count); err != nil {
return fmt.Errorf("check migrations table: %w", err)
}
if count == 0 {
if _, err := d.Pool.Exec(ctx, "CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW())"); err != nil {
return fmt.Errorf("create schema_migrations table: %w", err)
}
slog.Info("created schema_migrations table")
}
files, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return fmt.Errorf("read migrations directory: %w", err)
}
sort.Slice(files, func(i, j int) bool {
return files[i].Name() < files[j].Name()
})
for _, f := range files {
if f.IsDir() || !strings.HasSuffix(f.Name(), ".sql") {
continue
}
version := f.Name()
var applied bool
if err := d.Pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", version, err)
}
if applied {
continue
}
content, err := migrationsFS.ReadFile("migrations/" + version)
if err != nil {
return fmt.Errorf("read migration %s: %w", version, err)
}
tx, err := d.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction for %s: %w", version, err)
}
if _, err := tx.Exec(ctx, string(content)); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("execute migration %s: %w", version, err)
}
if _, err := tx.Exec(ctx, "INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
tx.Rollback(ctx)
return fmt.Errorf("record migration %s: %w", version, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", version, err)
}
slog.Info("applied migration", "file", version)
}
return nil
}

View File

@@ -0,0 +1,253 @@
-- Custom types
CREATE TYPE MEDIA_TYPE AS ENUM (
'movie', 'series', 'episode', 'music', 'album',
'audiobook', 'podcast', 'photo', 'other'
);
CREATE TYPE MEDIA_STATUS AS ENUM (
'unavailable', 'searching', 'downloading', 'importing',
'available', 'upgrading', 'failed'
);
CREATE TYPE QUEUE_STATUS AS ENUM (
'pending', 'downloading', 'imported', 'failed',
'blacklisted', 'cancelled'
);
-- Quality profiles
CREATE TABLE quality_profiles (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
media_types MEDIA_TYPE[] NOT NULL,
cutoff_quality JSONB NOT NULL,
allowed_qualities JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Root folders
CREATE TABLE root_folders (
id SERIAL PRIMARY KEY,
path TEXT NOT NULL UNIQUE,
media_type MEDIA_TYPE NOT NULL,
free_space BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Tags
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
color TEXT DEFAULT '#6366f1'
);
-- Scheduled tasks
CREATE TABLE scheduled_tasks (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
cron_expr TEXT NOT NULL,
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
enabled BOOLEAN DEFAULT true,
retention_days INTEGER DEFAULT 7
);
-- Indexers
CREATE TABLE indexers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
implementation TEXT NOT NULL,
url TEXT NOT NULL,
api_key TEXT,
categories JSONB DEFAULT '[]',
settings JSONB DEFAULT '{}',
enabled BOOLEAN DEFAULT true,
priority INTEGER DEFAULT 0,
last_success_at TIMESTAMPTZ,
failure_count INTEGER DEFAULT 0,
disabled_until TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Download clients
CREATE TABLE download_clients (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
implementation TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT,
password TEXT,
settings JSONB DEFAULT '{}',
enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Unified media table (partitioned by type)
CREATE TABLE media (
id BIGSERIAL,
media_type MEDIA_TYPE NOT NULL,
title TEXT NOT NULL,
sort_title TEXT NOT NULL,
original_title TEXT,
overview TEXT,
year INTEGER,
status MEDIA_STATUS NOT NULL DEFAULT 'unavailable',
monitored BOOLEAN NOT NULL DEFAULT false,
external_ids JSONB NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}',
images JSONB NOT NULL DEFAULT '[]',
quality_profile_id INTEGER REFERENCES quality_profiles(id),
root_folder_id INTEGER REFERENCES root_folders(id),
current_quality JSONB,
desired_quality JSONB,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_search_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
PRIMARY KEY (id, media_type)
) PARTITION BY LIST (media_type);
CREATE TABLE media_movie PARTITION OF media FOR VALUES IN ('movie');
CREATE TABLE media_series PARTITION OF media FOR VALUES IN ('series');
CREATE TABLE media_episode PARTITION OF media FOR VALUES IN ('episode');
CREATE TABLE media_music PARTITION OF media FOR VALUES IN ('music');
CREATE TABLE media_album PARTITION OF media FOR VALUES IN ('album');
CREATE TABLE media_audiobook PARTITION OF media FOR VALUES IN ('audiobook');
CREATE TABLE media_podcast PARTITION OF media FOR VALUES IN ('podcast');
CREATE TABLE media_photo PARTITION OF media FOR VALUES IN ('photo');
CREATE TABLE media_other PARTITION OF media FOR VALUES IN ('other');
-- Media indexes
CREATE INDEX idx_media_title ON media USING gin (to_tsvector('english', coalesce(title, '')));
CREATE INDEX idx_media_monitored ON media (monitored) WHERE monitored = true;
CREATE INDEX idx_media_status ON media (status, media_type);
CREATE INDEX idx_media_external_ids ON media USING gin (external_ids);
-- Media relations (series->episodes, album->tracks)
CREATE TABLE media_relations (
id BIGSERIAL PRIMARY KEY,
parent_id BIGINT NOT NULL,
child_id BIGINT NOT NULL,
relation TEXT NOT NULL,
position INTEGER,
season INTEGER,
UNIQUE(parent_id, child_id, relation)
);
-- Media tags
CREATE TABLE media_tags (
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (media_id, media_type, tag_id)
);
-- Unified file tracking
CREATE TABLE media_files (
id BIGSERIAL PRIMARY KEY,
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
path TEXT NOT NULL,
original_path TEXT,
file_name TEXT NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
quality JSONB NOT NULL DEFAULT '{}',
codec TEXT,
resolution TEXT,
source TEXT,
is_hardlinked BOOLEAN DEFAULT false,
checksum TEXT,
transcode_status TEXT DEFAULT 'none',
transcode_preset TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
UNIQUE(media_id, media_type, path)
);
CREATE INDEX idx_media_files_media ON media_files (media_id, media_type);
CREATE INDEX idx_media_files_transcode ON media_files (transcode_status) WHERE transcode_status != 'done';
-- Download queue
CREATE TABLE download_queue (
id BIGSERIAL PRIMARY KEY,
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
release_title TEXT NOT NULL,
release_url TEXT,
indexer TEXT NOT NULL,
download_client TEXT NOT NULL,
quality JSONB NOT NULL DEFAULT '{}',
size BIGINT,
protocol TEXT NOT NULL DEFAULT 'torrent',
status QUEUE_STATUS NOT NULL DEFAULT 'pending',
progress REAL DEFAULT 0,
error_message TEXT,
batch_id UUID,
priority INTEGER DEFAULT 0,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 3,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_queue_status ON download_queue (status, priority DESC);
CREATE INDEX idx_queue_batch ON download_queue (batch_id) WHERE batch_id IS NOT NULL;
CREATE INDEX idx_queue_media ON download_queue (media_id, media_type);
-- Blocklist
CREATE TABLE blocklist (
id BIGSERIAL PRIMARY KEY,
release_title TEXT NOT NULL,
source_title TEXT,
quality JSONB DEFAULT '{}',
indexer TEXT,
protocol TEXT DEFAULT 'torrent',
torrent_hash TEXT,
size BIGINT,
message TEXT,
media_id BIGINT,
media_type MEDIA_TYPE,
block_reason TEXT DEFAULT 'manual',
auto_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blocklist_media ON blocklist (media_id, media_type);
CREATE INDEX idx_blocklist_expires ON blocklist (auto_expires_at) WHERE auto_expires_at IS NOT NULL;
-- Task execution log
CREATE TABLE task_executions (
id BIGSERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES scheduled_tasks(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'running',
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
duration_ms INTEGER,
result JSONB,
error TEXT
);
CREATE INDEX idx_task_exec_task ON task_executions (task_id, started_at DESC);
-- Download history (partitioned for auto-cleanup)
CREATE TABLE download_history (
id BIGSERIAL,
media_id BIGINT NOT NULL,
media_type MEDIA_TYPE NOT NULL,
action TEXT NOT NULL,
release_title TEXT,
quality JSONB DEFAULT '{}',
indexer TEXT,
client TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE download_history_current PARTITION OF download_history
FOR VALUES FROM (CURRENT_DATE - INTERVAL '90 days') TO (MAXVALUE);

View File

@@ -0,0 +1,5 @@
CREATE INDEX IF NOT EXISTS idx_media_quality_upgrade
ON media (monitored, media_type)
WHERE monitored = true AND deleted_at IS NULL
AND current_quality IS NOT NULL
AND desired_quality IS NOT NULL;

View File

@@ -0,0 +1,14 @@
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS url TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS api_key TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'umm';
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS protocol TEXT NOT NULL DEFAULT 'nzb';
UPDATE download_clients SET url = 'http://' || host || ':' || port::text WHERE url IS NULL;
ALTER TABLE download_clients ALTER COLUMN url SET NOT NULL;
ALTER TABLE download_clients DROP COLUMN IF EXISTS host;
ALTER TABLE download_clients DROP COLUMN IF EXISTS port;
ALTER TABLE download_clients DROP COLUMN IF EXISTS username;
ALTER TABLE download_clients DROP COLUMN IF EXISTS password;

View File

@@ -0,0 +1,15 @@
CREATE TABLE naming_templates (
id SERIAL PRIMARY KEY,
media_type MEDIA_TYPE NOT NULL UNIQUE,
template TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO naming_templates (media_type, template) VALUES
('movie', '{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}) - {{.Quality}}.{{.Ext}}'),
('series', '{{sanitize .Title}}/Season {{printf "%02d" .Season}}/{{sanitize .Title}} - S{{printf "%02d" .Season}}E{{printf "%02d" .Episode}} - {{.Quality}}.{{.Ext}}'),
('music', '{{sanitize .Artist}}/{{sanitize .Album}}/{{printf "%02d" .Track}} - {{sanitize .Title}}.{{.Ext}}'),
('audiobook', '{{sanitize .Author}}/{{sanitize .Title}}/{{sanitize .Title}} - Ch{{printf "%02d" .Chapter}}.{{.Ext}}'),
('podcast', '{{sanitize .Title}}/{{sanitize .Title}} - {{.Date}}.{{.Ext}}'),
('book', '{{sanitize .Author}}/{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}).{{.Ext}}');

View File

@@ -0,0 +1,12 @@
CREATE TABLE metadata_cache (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL,
provider_id TEXT NOT NULL,
media_type TEXT NOT NULL,
data JSONB NOT NULL,
cached_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE UNIQUE INDEX idx_metadata_cache_lookup ON metadata_cache (provider, provider_id);
CREATE INDEX idx_metadata_cache_expired ON metadata_cache (expires_at) WHERE expires_at < NOW();

View File

@@ -0,0 +1 @@
ALTER TABLE download_queue ADD COLUMN IF NOT EXISTS download_id TEXT;

View File

@@ -0,0 +1,32 @@
-- Users table for API key authentication
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'user',
api_key TEXT UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_key ON users (api_key) WHERE api_key IS NOT NULL;
-- Requests table for media request workflow
CREATE TABLE IF NOT EXISTS requests (
id BIGSERIAL PRIMARY KEY,
media_id BIGINT,
media_type TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
requested_by BIGINT NOT NULL REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'pending',
quality_profile_id INTEGER REFERENCES quality_profiles(id),
root_folder_id INTEGER REFERENCES root_folders(id),
notes TEXT DEFAULT '',
reviewed_by BIGINT REFERENCES users(id),
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests (status);
CREATE INDEX IF NOT EXISTS idx_requests_requested_by ON requests (requested_by);
CREATE INDEX IF NOT EXISTS idx_requests_media ON requests (media_id, media_type);

View File

@@ -0,0 +1,24 @@
-- Activity events table for unified event logging
CREATE TYPE EVENT_TYPE AS ENUM (
'grab', 'import', 'download_complete', 'download_failed',
'quality_upgrade', 'safety_block', 'error', 'info'
);
CREATE TABLE activity_events (
id BIGSERIAL,
event_type EVENT_TYPE NOT NULL,
media_id BIGINT,
media_type MEDIA_TYPE,
title TEXT NOT NULL,
description TEXT,
data JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE activity_events_current PARTITION OF activity_events
FOR VALUES FROM (CURRENT_DATE - INTERVAL '30 days') TO (MAXVALUE);
CREATE INDEX idx_activity_type ON activity_events (event_type, created_at DESC);
CREATE INDEX idx_activity_media ON activity_events (media_id, media_type, created_at DESC) WHERE media_id IS NOT NULL;
CREATE INDEX idx_activity_created ON activity_events (created_at DESC);

View File

@@ -0,0 +1,45 @@
-- Notification system schema
CREATE TYPE NOTIFICATION_CHANNEL_TYPE AS ENUM ('webhook', 'telegram');
CREATE TYPE NOTIFICATION_STATUS AS ENUM ('pending', 'delivering', 'delivered', 'failed', 'dead');
CREATE TABLE notification_channels (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
type NOTIFICATION_CHANNEL_TYPE NOT NULL,
enabled BOOLEAN DEFAULT true,
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE notification_subscriptions (
id BIGSERIAL PRIMARY KEY,
channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type EVENT_TYPE NOT NULL,
UNIQUE(channel_id, event_type)
);
CREATE TABLE notification_queue (
id BIGSERIAL PRIMARY KEY,
channel_id BIGINT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
event_type EVENT_TYPE NOT NULL,
title TEXT NOT NULL,
message JSONB NOT NULL DEFAULT '{}',
status NOTIFICATION_STATUS DEFAULT 'pending',
attempts INT DEFAULT 0,
max_attempts INT DEFAULT 5,
last_error TEXT,
next_retry_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
delivered_at TIMESTAMPTZ
);
CREATE TABLE notification_state (
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
last_event_id BIGINT DEFAULT 0,
last_event_created_at TIMESTAMPTZ
);
CREATE INDEX idx_notification_queue_status ON notification_queue (status, next_retry_at);
INSERT INTO notification_state (id) VALUES (1) ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,7 @@
-- Add release_date column for calendar view
ALTER TABLE media ADD COLUMN IF NOT EXISTS release_date TIMESTAMPTZ;
-- Partial index for efficient calendar range queries on monitored, non-deleted items
CREATE INDEX IF NOT EXISTS idx_media_release_date_monitored
ON media (release_date)
WHERE release_date IS NOT NULL AND monitored = true AND deleted_at IS NULL;

View File

@@ -0,0 +1,15 @@
-- Add has_files column to avoid correlated subquery per row
ALTER TABLE media ADD COLUMN IF NOT EXISTS has_files BOOLEAN NOT NULL DEFAULT false;
-- Backfill from existing data
UPDATE media SET has_files = EXISTS (SELECT 1 FROM media_files mf WHERE mf.media_id = media.id AND mf.deleted_at IS NULL);
-- Add index for the upgrade detection query
CREATE INDEX IF NOT EXISTS idx_media_upgrade_candidates
ON media (media_type) WHERE monitored = true AND has_files = true
AND current_quality IS NOT NULL AND desired_quality IS NOT NULL
AND current_quality::text != desired_quality::text;
-- Add trigram index for substring title search
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX IF NOT EXISTS idx_media_title_trgm ON media USING gin (title gin_trgm_ops);

View File

@@ -0,0 +1,14 @@
-- Subtitle cache table to avoid filesystem glob on every detail request
CREATE TABLE IF NOT EXISTS media_subtitles (
id BIGSERIAL PRIMARY KEY,
media_file_id BIGINT NOT NULL REFERENCES media_files(id) ON DELETE CASCADE,
file_name TEXT NOT NULL,
language TEXT NOT NULL,
language_code TEXT NOT NULL,
hi BOOLEAN NOT NULL DEFAULT false,
forced BOOLEAN NOT NULL DEFAULT false,
source TEXT NOT NULL DEFAULT 'downloaded',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_media_subtitles_file ON media_subtitles (media_file_id);

View File

@@ -0,0 +1,21 @@
-- Fix: migration 003 was tracked but ALTER TABLE statements did not persist.
-- Re-apply the schema changes to download_clients.
-- Add missing columns
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS url TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS api_key TEXT;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT 'umm';
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
ALTER TABLE download_clients ADD COLUMN IF NOT EXISTS protocol TEXT NOT NULL DEFAULT 'nzb';
-- Migrate existing rows: combine host+port into url
UPDATE download_clients SET url = 'http://' || host || ':' || port::text WHERE url IS NULL;
-- Now url should be populated — make it NOT NULL
ALTER TABLE download_clients ALTER COLUMN url SET NOT NULL;
-- Drop old columns
ALTER TABLE download_clients DROP COLUMN IF EXISTS host;
ALTER TABLE download_clients DROP COLUMN IF EXISTS port;
ALTER TABLE download_clients DROP COLUMN IF EXISTS username;
ALTER TABLE download_clients DROP COLUMN IF EXISTS password;

View File

@@ -0,0 +1,30 @@
package download
import "context"
type DownloadProgress struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Progress float64 `json:"progress"`
Speed int64 `json:"speed"`
ETA int `json:"eta"`
Size int64 `json:"size"`
}
type CompletedDownload struct {
ID string `json:"id"`
Name string `json:"name"`
OutputPath string `json:"output_path"`
Size int64 `json:"size"`
Status string `json:"status"`
}
type DownloadClient interface {
Add(ctx context.Context, url string, category string) (string, error)
GetProgress(ctx context.Context, id string) (*DownloadProgress, error)
GetCompleted(ctx context.Context) ([]CompletedDownload, error)
Remove(ctx context.Context, id string) error
Pause(ctx context.Context, id string) error
Resume(ctx context.Context, id string) error
}

View File

@@ -0,0 +1,285 @@
package download
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
type QBittorrentClient struct {
baseURL string
username string
password string
client *http.Client
mu sync.Mutex
sid *http.Cookie
}
func NewQBittorrentClient(baseURL, password string) *QBittorrentClient {
return &QBittorrentClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: "admin",
password: password,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (q *QBittorrentClient) login(ctx context.Context) error {
q.mu.Lock()
defer q.mu.Unlock()
form := url.Values{}
form.Set("username", q.username)
form.Set("password", q.password)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
q.baseURL+"/api/v2/auth/login", strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("qbittorrent login request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := q.client.Do(req)
if err != nil {
return fmt.Errorf("qbittorrent login: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(body), "Ok.") {
return fmt.Errorf("qbittorrent login failed: %s", string(body))
}
for _, cookie := range resp.Cookies() {
if cookie.Name == "SID" {
q.sid = cookie
return nil
}
}
return fmt.Errorf("qbittorrent login: no SID cookie in response")
}
func (q *QBittorrentClient) doAuthenticated(ctx context.Context, method, path string, body url.Values) (*http.Response, error) {
var bodyReader io.Reader
if body != nil {
bodyReader = strings.NewReader(body.Encode())
}
req, err := http.NewRequestWithContext(ctx, method, q.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if q.sid != nil {
req.AddCookie(q.sid)
}
resp, err := q.client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusForbidden {
resp.Body.Close()
if err := q.login(ctx); err != nil {
return nil, fmt.Errorf("qbittorrent re-auth: %w", err)
}
req2, err := http.NewRequestWithContext(ctx, method, q.baseURL+path, bodyReader)
if err != nil {
return nil, err
}
if body != nil {
req2.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
req2.AddCookie(q.sid)
return q.client.Do(req2)
}
return resp, nil
}
func (q *QBittorrentClient) Add(ctx context.Context, torrentURL string, category string) (string, error) {
if category == "" {
category = "umm"
}
form := url.Values{}
form.Set("urls", torrentURL)
form.Set("category", category)
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/add", form)
if err != nil {
return "", fmt.Errorf("qbittorrent add: %w", err)
}
resp.Body.Close()
resp, err = q.doAuthenticated(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/torrents/info?sort=added_on&reverse=true&category=%s", url.QueryEscape(category)), nil)
if err != nil {
return "", fmt.Errorf("qbittorrent add verify: %w", err)
}
defer resp.Body.Close()
var torrents []qbTorrentInfo
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return "", fmt.Errorf("qbittorrent add decode: %w", err)
}
for _, t := range torrents {
if t.MagnetURI == torrentURL || strings.Contains(t.Tracker, torrentURL) {
return t.Hash, nil
}
}
if len(torrents) > 0 {
return torrents[0].Hash, nil
}
return "", fmt.Errorf("qbittorrent add: torrent not found after adding")
}
func (q *QBittorrentClient) GetProgress(ctx context.Context, hash string) (*DownloadProgress, error) {
resp, err := q.doAuthenticated(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/torrents/info?hashes=%s", url.QueryEscape(hash)), nil)
if err != nil {
return nil, fmt.Errorf("qbittorrent progress: %w", err)
}
defer resp.Body.Close()
var torrents []qbTorrentInfo
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, fmt.Errorf("qbittorrent progress decode: %w", err)
}
if len(torrents) == 0 {
return nil, fmt.Errorf("qbittorrent: torrent %s not found", hash)
}
t := torrents[0]
return &DownloadProgress{
ID: t.Hash,
Name: t.Name,
Status: qbStateToStatus(t.State),
Progress: t.Progress * 100,
Speed: t.DLSpeed,
ETA: t.ETA,
Size: t.Size,
}, nil
}
func (q *QBittorrentClient) GetCompleted(ctx context.Context) ([]CompletedDownload, error) {
resp, err := q.doAuthenticated(ctx, http.MethodGet,
"/api/v2/torrents/info?filter=completed", nil)
if err != nil {
return nil, fmt.Errorf("qbittorrent completed: %w", err)
}
defer resp.Body.Close()
var torrents []qbTorrentInfo
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, fmt.Errorf("qbittorrent completed decode: %w", err)
}
var completed []CompletedDownload
for _, t := range torrents {
completed = append(completed, CompletedDownload{
ID: t.Hash,
Name: t.Name,
OutputPath: t.ContentPath,
Size: t.Size,
Status: qbStateToStatus(t.State),
})
}
return completed, nil
}
func (q *QBittorrentClient) Remove(ctx context.Context, hash string) error {
form := url.Values{}
form.Set("hashes", hash)
form.Set("deleteFiles", "true")
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/delete", form)
if err != nil {
return fmt.Errorf("qbittorrent remove: %w", err)
}
resp.Body.Close()
return nil
}
func (q *QBittorrentClient) Pause(ctx context.Context, hash string) error {
form := url.Values{}
form.Set("hashes", hash)
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/pause", form)
if err != nil {
return fmt.Errorf("qbittorrent pause: %w", err)
}
resp.Body.Close()
return nil
}
func (q *QBittorrentClient) Resume(ctx context.Context, hash string) error {
form := url.Values{}
form.Set("hashes", hash)
resp, err := q.doAuthenticated(ctx, http.MethodPost, "/api/v2/torrents/resume", form)
if err != nil {
return fmt.Errorf("qbittorrent resume: %w", err)
}
resp.Body.Close()
return nil
}
func qbStateToStatus(state string) string {
switch state {
case "downloading", "stalledDL", "forcedDL", "metaDL", "forcedMetaDL":
return "downloading"
case "uploading", "stalledUP", "forcedUP":
return "completed"
case "pausedDL", "pausedUP":
return "paused"
case "queuedDL", "queuedUP":
return "queued"
case "checkingDL", "checkingUP", "moving":
return "checking"
case "error", "missingFiles", "unknown":
return "error"
default:
return "unknown"
}
}
type qbTorrentInfo struct {
Hash string `json:"hash"`
Name string `json:"name"`
State string `json:"state"`
Progress float64 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
ETA int `json:"eta"`
Size int64 `json:"size"`
ContentPath string `json:"content_path"`
AddedOn int64 `json:"added_on"`
Tracker string `json:"tracker"`
MagnetURI string `json:"magnet_uri"`
}
var _ = strconv.Atoi
var _ io.Reader = nil

View File

@@ -0,0 +1,230 @@
package download
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
type SABnzbdClient struct {
baseURL string
apiKey string
client *http.Client
}
func NewSABnzbdClient(baseURL, apiKey string) *SABnzbdClient {
return &SABnzbdClient{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
client: &http.Client{Timeout: 30 * time.Second},
}
}
func (s *SABnzbdClient) Add(ctx context.Context, url string, category string) (string, error) {
if category == "" {
category = "umm"
}
apiURL := fmt.Sprintf("%s/api?mode=addurl&output=json&apikey=%s&name=%s&nzbname=umm&cat=%s",
s.baseURL, s.apiKey, url, category)
resp, err := s.doRequest(ctx, apiURL)
if err != nil {
return "", fmt.Errorf("sabnzbd add: %w", err)
}
defer resp.Body.Close()
var result sabAddResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("sabnzbd add decode: %w", err)
}
if !result.Status {
return "", fmt.Errorf("sabnzbd add failed: %s", result.Error)
}
if len(result.NzoIDs) == 0 {
return "", fmt.Errorf("sabnzbd add: no nzo_id returned")
}
return result.NzoIDs[0], nil
}
func (s *SABnzbdClient) GetProgress(ctx context.Context, id string) (*DownloadProgress, error) {
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s", s.baseURL, s.apiKey)
resp, err := s.doRequest(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("sabnzbd queue: %w", err)
}
defer resp.Body.Close()
var result sabQueueResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("sabnzbd queue decode: %w", err)
}
for _, slot := range result.Queue.Slots {
if slot.NzoID == id {
sizeMB, _ := strconv.ParseFloat(slot.SizeTotal, 64)
downloadedMB, _ := strconv.ParseFloat(slot.MB, 64)
size := int64(sizeMB * 1024 * 1024)
progress := float64(0)
if sizeMB > 0 {
progress = (downloadedMB / sizeMB) * 100
}
eta := parseTimeLeft(slot.TimeLeft)
return &DownloadProgress{
ID: slot.NzoID,
Name: slot.Filename,
Status: slot.Status,
Progress: progress,
Speed: int64(slot.Speed * 1024),
ETA: eta,
Size: size,
}, nil
}
}
return nil, fmt.Errorf("sabnzbd: item %s not found in queue", id)
}
func (s *SABnzbdClient) GetCompleted(ctx context.Context) ([]CompletedDownload, error) {
apiURL := fmt.Sprintf("%s/api?mode=history&output=json&apikey=%s", s.baseURL, s.apiKey)
resp, err := s.doRequest(ctx, apiURL)
if err != nil {
return nil, fmt.Errorf("sabnzbd history: %w", err)
}
defer resp.Body.Close()
var result sabHistoryResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("sabnzbd history decode: %w", err)
}
var completed []CompletedDownload
for _, slot := range result.History.Slots {
if strings.EqualFold(slot.Status, "Completed") {
size, _ := strconv.ParseInt(slot.Size, 10, 64)
completed = append(completed, CompletedDownload{
ID: slot.NzoID,
Name: slot.Name,
OutputPath: slot.Storage,
Size: size,
Status: slot.Status,
})
}
}
return completed, nil
}
func (s *SABnzbdClient) Remove(ctx context.Context, id string) error {
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=delete&value=%s&del_files=1",
s.baseURL, s.apiKey, id)
resp, err := s.doRequest(ctx, apiURL)
if err != nil {
return fmt.Errorf("sabnzbd remove: %w", err)
}
resp.Body.Close()
return nil
}
func (s *SABnzbdClient) Pause(ctx context.Context, id string) error {
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=pause&id=%s",
s.baseURL, s.apiKey, id)
resp, err := s.doRequest(ctx, apiURL)
if err != nil {
return fmt.Errorf("sabnzbd pause: %w", err)
}
resp.Body.Close()
return nil
}
func (s *SABnzbdClient) Resume(ctx context.Context, id string) error {
apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=resume&id=%s",
s.baseURL, s.apiKey, id)
resp, err := s.doRequest(ctx, apiURL)
if err != nil {
return fmt.Errorf("sabnzbd resume: %w", err)
}
resp.Body.Close()
return nil
}
func (s *SABnzbdClient) doRequest(ctx context.Context, url string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
return s.client.Do(req)
}
func parseTimeLeft(timeLeft string) int {
if timeLeft == "" {
return 0
}
parts := strings.Split(timeLeft, ":")
if len(parts) != 3 {
return 0
}
hours, _ := strconv.Atoi(parts[0])
minutes, _ := strconv.Atoi(parts[1])
seconds, _ := strconv.Atoi(parts[2])
return hours*3600 + minutes*60 + seconds
}
type sabAddResponse struct {
Status bool `json:"status"`
NzoIDs []string `json:"nzo_ids"`
Error string `json:"error,omitempty"`
}
type sabQueueResponse struct {
Queue struct {
Slots []sabQueueSlot `json:"slots"`
} `json:"queue"`
}
type sabQueueSlot struct {
NzoID string `json:"nzo_id"`
Filename string `json:"filename"`
Status string `json:"status"`
MB string `json:"mb"`
SizeTotal string `json:"sizeleft"`
TimeLeft string `json:"timeleft"`
Speed float64 `json:"speed"`
Percentage string `json:"percentage"`
}
type sabHistoryResponse struct {
History struct {
Slots []sabHistorySlot `json:"slots"`
} `json:"history"`
}
type sabHistorySlot struct {
NzoID string `json:"nzo_id"`
Name string `json:"name"`
Status string `json:"status"`
Storage string `json:"storage"`
Size string `json:"size"`
}
func init() {
_ = io.EOF
}

1036
internal/migrate/import.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
package migrate
import (
"context"
"fmt"
"log/slog"
"strings"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
// ArrSources holds paths to arr SQLite databases.
type ArrSources struct {
Sonarr string
Radarr string
SonarrAnime string
RadarrAnime string
Lidarr string
Readarr string
Prowlarr string
}
// ImportCount tracks imported and skipped counts for a category.
type ImportCount struct {
Imported int
Skipped int
}
// Report holds the migration results.
type Report struct {
Indexers ImportCount
Series ImportCount
Movies ImportCount
Albums ImportCount
Books ImportCount
Files ImportCount
Profiles ImportCount
RootFolders ImportCount
Tags ImportCount
Blocklist ImportCount
Errors int
}
func (r *Report) String() string {
var b strings.Builder
b.WriteString("Migration Report:\n")
fmt.Fprintf(&b, " Indexers: %d imported, %d skipped\n", r.Indexers.Imported, r.Indexers.Skipped)
fmt.Fprintf(&b, " Series: %d imported, %d deduplicated\n", r.Series.Imported, r.Series.Skipped)
fmt.Fprintf(&b, " Movies: %d imported, %d deduplicated\n", r.Movies.Imported, r.Movies.Skipped)
fmt.Fprintf(&b, " Albums: %d imported, %d deduplicated\n", r.Albums.Imported, r.Albums.Skipped)
fmt.Fprintf(&b, " Books: %d imported, %d deduplicated\n", r.Books.Imported, r.Books.Skipped)
fmt.Fprintf(&b, " Files: %d imported\n", r.Files.Imported)
fmt.Fprintf(&b, " Profiles: %d imported\n", r.Profiles.Imported)
fmt.Fprintf(&b, " Root Folders: %d imported\n", r.RootFolders.Imported)
fmt.Fprintf(&b, " Tags: %d imported\n", r.Tags.Imported)
fmt.Fprintf(&b, " Blocklist: %d imported\n", r.Blocklist.Imported)
fmt.Fprintf(&b, " Errors: %d\n", r.Errors)
return b.String()
}
// Migrator orchestrates the data migration from arr SQLite databases to UMM PostgreSQL.
type Migrator struct {
db *db.DB
sources ArrSources
report Report
// Maps arr entity IDs to UMM media IDs, keyed by arr instance
sonarrSeriesMap map[int64]int64 // sonarr series ID → UMM media ID
sonarrAnimeSeriesMap map[int64]int64 // sonarr-anime series ID → UMM media ID
radarrMovieMap map[int64]int64 // radarr movie ID → UMM media ID
radarrAnimeMovieMap map[int64]int64 // radarr-anime movie ID → UMM media ID
lidarrAlbumMap map[int64]int64 // lidarr album ID → UMM media ID
readarrBookMap map[int64]int64 // readarr book ID → UMM media ID
}
// NewMigrator creates a new Migrator instance.
func NewMigrator(database *db.DB, sources ArrSources) *Migrator {
return &Migrator{
db: database,
sources: sources,
sonarrSeriesMap: make(map[int64]int64),
sonarrAnimeSeriesMap: make(map[int64]int64),
radarrMovieMap: make(map[int64]int64),
radarrAnimeMovieMap: make(map[int64]int64),
lidarrAlbumMap: make(map[int64]int64),
readarrBookMap: make(map[int64]int64),
}
}
// Run executes the full migration pipeline.
func (m *Migrator) Run(ctx context.Context) (*Report, error) {
slog.Info("starting arr data migration")
// Step 1: Import Prowlarr indexers
if m.sources.Prowlarr != "" {
slog.Info("importing prowlarr indexers", "path", m.sources.Prowlarr)
if err := m.importProwlarr(ctx); err != nil {
slog.Error("failed to import prowlarr", "error", err)
m.report.Errors++
}
}
// Step 2: Import Sonarr series
if m.sources.Sonarr != "" {
slog.Info("importing sonarr series", "path", m.sources.Sonarr)
if err := m.importSonarr(ctx, m.sources.Sonarr, false); err != nil {
slog.Error("failed to import sonarr", "error", err)
m.report.Errors++
}
}
// Step 3: Import Sonarr-anime (deduplicate by TVDB ID)
if m.sources.SonarrAnime != "" {
slog.Info("importing sonarr-anime series", "path", m.sources.SonarrAnime)
if err := m.importSonarr(ctx, m.sources.SonarrAnime, true); err != nil {
slog.Error("failed to import sonarr-anime", "error", err)
m.report.Errors++
}
}
// Step 4: Import Radarr movies
if m.sources.Radarr != "" {
slog.Info("importing radarr movies", "path", m.sources.Radarr)
if err := m.importRadarr(ctx, m.sources.Radarr, false); err != nil {
slog.Error("failed to import radarr", "error", err)
m.report.Errors++
}
}
// Step 5: Import Radarr-anime (deduplicate by TMDB ID)
if m.sources.RadarrAnime != "" {
slog.Info("importing radarr-anime movies", "path", m.sources.RadarrAnime)
if err := m.importRadarr(ctx, m.sources.RadarrAnime, true); err != nil {
slog.Error("failed to import radarr-anime", "error", err)
m.report.Errors++
}
}
// Step 6: Import Lidarr artists + albums
if m.sources.Lidarr != "" {
slog.Info("importing lidarr", "path", m.sources.Lidarr)
if err := m.importLidarr(ctx); err != nil {
slog.Error("failed to import lidarr", "error", err)
m.report.Errors++
}
}
// Step 7: Import Readarr books
if m.sources.Readarr != "" {
slog.Info("importing readarr", "path", m.sources.Readarr)
if err := m.importReadarr(ctx); err != nil {
slog.Error("failed to import readarr", "error", err)
m.report.Errors++
}
}
// Step 8: Reset PostgreSQL sequences
if err := m.resetSequences(ctx); err != nil {
slog.Error("failed to reset sequences", "error", err)
m.report.Errors++
}
slog.Info("migration complete",
"series", m.report.Series.Imported,
"movies", m.report.Movies.Imported,
"albums", m.report.Albums.Imported,
"books", m.report.Books.Imported,
"files", m.report.Files.Imported,
"errors", m.report.Errors,
)
return &m.report, nil
}

637
internal/migrate/reader.go Normal file
View File

@@ -0,0 +1,637 @@
package migrate
import (
"database/sql"
"encoding/json"
"fmt"
_ "github.com/mattn/go-sqlite3"
)
// SonarrSeries represents a series from Sonarr's SQLite database.
type SonarrSeries struct {
ID int64
TVDBID int64
Title string
SortTitle string
Year int
Status string
Monitored bool
QualityProfileID int64
RootFolderPath string
Tags string // JSON array of tag IDs
Overview string
Images string // JSON
Runtime int
}
// SonarrEpisode represents an episode from Sonarr's SQLite database.
type SonarrEpisode struct {
ID int64
SeriesID int64
SeasonNumber int
EpisodeNumber int
Title string
AirDate string
Monitored bool
HasFile bool
}
// EpisodeFile represents an episode file from Sonarr's SQLite database.
type EpisodeFile struct {
ID int64
SeriesID int64
SeasonNumber int
RelativePath string
Path string
Size int64
Quality string // JSON
DateAdded string
}
// RadarrMovie represents a movie from Radarr's SQLite database.
type RadarrMovie struct {
ID int64
TMDBID int64
Title string
SortTitle string
Year int
Status string
Monitored bool
QualityProfileID int64
RootFolderPath string
HasFile bool
MovieFileID int64
Overview string
Images string // JSON
Runtime int
}
// MovieFile represents a movie file from Radarr's SQLite database.
type MovieFile struct {
ID int64
MovieID int64
RelativePath string
Path string
Size int64
Quality string // JSON
DateAdded string
}
// LidarrArtist represents an artist from Lidarr's SQLite database.
type LidarrArtist struct {
ID int64
ForeignArtistID string // MusicBrainz ID
Name string
Status string
Monitored bool
QualityProfileID int64
RootFolderPath string
Overview string
Images string
}
// LidarrAlbum represents an album from Lidarr's SQLite database.
type LidarrAlbum struct {
ID int64
ArtistID int64
ForeignAlbumID string // MusicBrainz ID
Title string
Year int
Monitored bool
AlbumType string
}
// ReadarrBook represents a book from Readarr's SQLite database.
type ReadarrBook struct {
ID int64
ForeignBookID string // ISBN or Goodreads ID
Title string
AuthorID int64
AuthorName string
Monitored bool
QualityProfileID int64
Overview string
Images string
}
// ProwlarrIndexer represents an indexer from Prowlarr's SQLite database.
type ProwlarrIndexer struct {
ID int64
Name string
Implementation string
Settings string // JSON with url, apiKey
Enable bool
Priority int
}
// ArrQualityProfile represents a quality profile from any arr app.
type ArrQualityProfile struct {
ID int64
Name string
Items string // JSON
Cutoff int64
}
// ArrRootFolder represents a root folder from any arr app.
type ArrRootFolder struct {
ID int64
Path string
}
// ArrBlocklistEntry represents a blocklist entry from any arr app.
type ArrBlocklistEntry struct {
ID int64
Title string
Quality string
SourceTitle string
Date string
TorrentHash string
Size int64
Protocol string
Message string
}
// ArrTag represents a tag from any arr app.
type ArrTag struct {
ID int64
Label string
}
// TrackFile represents a music track file from Lidarr's SQLite database.
type TrackFile struct {
ID int64
ArtistID int64
AlbumID int64
RelativePath string
Path string
Size int64
Quality string // JSON
DateAdded string
}
// BookFile represents a book file from Readarr's SQLite database.
type BookFile struct {
ID int64
BookID int64
RelativePath string
Path string
Size int64
Quality string // JSON
DateAdded string
}
// QualityItem represents a node in the arr quality profile Items JSON tree.
type QualityItem struct {
Quality *QualityDef `json:"quality"`
Items []QualityItem `json:"items"`
Allowed bool `json:"allowed"`
ID int64 `json:"id"`
Name string `json:"name"`
}
// QualityDef represents a quality definition from arr.
type QualityDef struct {
ID int64 `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Resolution int `json:"resolution"`
}
// ArrReader reads from arr SQLite databases.
type ArrReader struct {
db *sql.DB
}
// NewArrReader opens a read-only connection to an arr SQLite database.
func NewArrReader(dbPath string) (*ArrReader, error) {
db, err := sql.Open("sqlite3", dbPath+"?mode=ro")
if err != nil {
return nil, fmt.Errorf("open sqlite %s: %w", dbPath, err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("ping sqlite %s: %w", dbPath, err)
}
return &ArrReader{db: db}, nil
}
// Close closes the SQLite connection.
func (r *ArrReader) Close() error {
if r.db != nil {
return r.db.Close()
}
return nil
}
// ReadSonarrSeries reads all series from a Sonarr database.
func (r *ArrReader) ReadSonarrSeries() ([]SonarrSeries, error) {
rows, err := r.db.Query(`
SELECT Id, TvdbId, Title, COALESCE(SortTitle, Title), COALESCE(Year, 0),
COALESCE(Status, ''), Monitored,
COALESCE(QualityProfileId, 0), COALESCE(RootFolderPath, ''),
COALESCE(Tags, '[]'), COALESCE(Overview, ''),
COALESCE(Images, '[]'), COALESCE(Runtime, 0)
FROM Series`)
if err != nil {
return nil, fmt.Errorf("query sonarr series: %w", err)
}
defer rows.Close()
var series []SonarrSeries
for rows.Next() {
var s SonarrSeries
if err := rows.Scan(&s.ID, &s.TVDBID, &s.Title, &s.SortTitle, &s.Year,
&s.Status, &s.Monitored, &s.QualityProfileID, &s.RootFolderPath,
&s.Tags, &s.Overview, &s.Images, &s.Runtime); err != nil {
continue
}
series = append(series, s)
}
return series, nil
}
// ReadSonarrEpisodes reads episodes for a specific series.
func (r *ArrReader) ReadSonarrEpisodes(seriesID int64) ([]SonarrEpisode, error) {
rows, err := r.db.Query(`
SELECT Id, SeriesId, SeasonNumber, EpisodeNumber, Title,
COALESCE(AirDate, ''), Monitored, COALESCE(HasFile, 0)
FROM Episodes WHERE SeriesId = ?`, seriesID)
if err != nil {
return nil, fmt.Errorf("query sonarr episodes: %w", err)
}
defer rows.Close()
var episodes []SonarrEpisode
for rows.Next() {
var e SonarrEpisode
if err := rows.Scan(&e.ID, &e.SeriesID, &e.SeasonNumber, &e.EpisodeNumber,
&e.Title, &e.AirDate, &e.Monitored, &e.HasFile); err != nil {
continue
}
episodes = append(episodes, e)
}
return episodes, nil
}
// ReadEpisodeFiles reads all episode files from a Sonarr database.
func (r *ArrReader) ReadEpisodeFiles() ([]EpisodeFile, error) {
rows, err := r.db.Query(`
SELECT Id, SeriesId, SeasonNumber, COALESCE(RelativePath, ''),
COALESCE(Path, ''), COALESCE(Size, 0),
COALESCE(Quality, '{}'), COALESCE(DateAdded, '')
FROM EpisodeFiles`)
if err != nil {
return nil, fmt.Errorf("query episode files: %w", err)
}
defer rows.Close()
var files []EpisodeFile
for rows.Next() {
var f EpisodeFile
if err := rows.Scan(&f.ID, &f.SeriesID, &f.SeasonNumber, &f.RelativePath,
&f.Path, &f.Size, &f.Quality, &f.DateAdded); err != nil {
continue
}
files = append(files, f)
}
return files, nil
}
// ReadRadarrMovies reads all movies from a Radarr database.
func (r *ArrReader) ReadRadarrMovies() ([]RadarrMovie, error) {
rows, err := r.db.Query(`
SELECT Id, TmdbId, Title, COALESCE(SortTitle, Title), COALESCE(Year, 0),
COALESCE(Status, ''), Monitored,
COALESCE(QualityProfileId, 0), COALESCE(RootFolderPath, ''),
COALESCE(HasFile, 0), COALESCE(MovieFileId, 0),
COALESCE(Overview, ''), COALESCE(Images, '[]'), COALESCE(Runtime, 0)
FROM Movies`)
if err != nil {
return nil, fmt.Errorf("query radarr movies: %w", err)
}
defer rows.Close()
var movies []RadarrMovie
for rows.Next() {
var m RadarrMovie
if err := rows.Scan(&m.ID, &m.TMDBID, &m.Title, &m.SortTitle, &m.Year,
&m.Status, &m.Monitored, &m.QualityProfileID, &m.RootFolderPath,
&m.HasFile, &m.MovieFileID, &m.Overview, &m.Images, &m.Runtime); err != nil {
continue
}
movies = append(movies, m)
}
return movies, nil
}
// ReadMovieFiles reads all movie files from a Radarr database.
func (r *ArrReader) ReadMovieFiles() ([]MovieFile, error) {
rows, err := r.db.Query(`
SELECT Id, MovieId, COALESCE(RelativePath, ''), COALESCE(Path, ''),
COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '')
FROM MovieFiles`)
if err != nil {
return nil, fmt.Errorf("query movie files: %w", err)
}
defer rows.Close()
var files []MovieFile
for rows.Next() {
var f MovieFile
if err := rows.Scan(&f.ID, &f.MovieID, &f.RelativePath, &f.Path,
&f.Size, &f.Quality, &f.DateAdded); err != nil {
continue
}
files = append(files, f)
}
return files, nil
}
// ReadLidarrArtists reads all artists from a Lidarr database.
func (r *ArrReader) ReadLidarrArtists() ([]LidarrArtist, error) {
rows, err := r.db.Query(`
SELECT Id, COALESCE(ForeignArtistId, ''), Name, COALESCE(Status, ''),
Monitored, COALESCE(QualityProfileId, 0), COALESCE(RootFolderPath, ''),
COALESCE(Overview, ''), COALESCE(Images, '[]')
FROM Artists`)
if err != nil {
return nil, fmt.Errorf("query lidarr artists: %w", err)
}
defer rows.Close()
var artists []LidarrArtist
for rows.Next() {
var a LidarrArtist
if err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.Status,
&a.Monitored, &a.QualityProfileID, &a.RootFolderPath,
&a.Overview, &a.Images); err != nil {
continue
}
artists = append(artists, a)
}
return artists, nil
}
// ReadLidarrAlbums reads all albums from a Lidarr database.
func (r *ArrReader) ReadLidarrAlbums() ([]LidarrAlbum, error) {
rows, err := r.db.Query(`
SELECT Id, COALESCE(ArtistId, 0), COALESCE(ForeignAlbumId, ''),
Title, COALESCE(Year, 0), Monitored, COALESCE(AlbumType, '')
FROM Albums`)
if err != nil {
return nil, fmt.Errorf("query lidarr albums: %w", err)
}
defer rows.Close()
var albums []LidarrAlbum
for rows.Next() {
var a LidarrAlbum
if err := rows.Scan(&a.ID, &a.ArtistID, &a.ForeignAlbumID,
&a.Title, &a.Year, &a.Monitored, &a.AlbumType); err != nil {
continue
}
albums = append(albums, a)
}
return albums, nil
}
// ReadReadarrBooks reads all books from a Readarr database.
func (r *ArrReader) ReadReadarrBooks() ([]ReadarrBook, error) {
rows, err := r.db.Query(`
SELECT b.Id, COALESCE(b.ForeignBookId, ''), b.Title,
COALESCE(b.AuthorId, 0), COALESCE(a.Name, ''),
b.Monitored, COALESCE(b.QualityProfileId, 0),
COALESCE(b.Overview, ''), COALESCE(b.Images, '[]')
FROM Books b
LEFT JOIN Authors a ON b.AuthorId = a.Id`)
if err != nil {
return nil, fmt.Errorf("query readarr books: %w", err)
}
defer rows.Close()
var books []ReadarrBook
for rows.Next() {
var b ReadarrBook
if err := rows.Scan(&b.ID, &b.ForeignBookID, &b.Title,
&b.AuthorID, &b.AuthorName, &b.Monitored, &b.QualityProfileID,
&b.Overview, &b.Images); err != nil {
continue
}
books = append(books, b)
}
return books, nil
}
// ReadProwlarrIndexers reads all indexers from a Prowlarr database.
func (r *ArrReader) ReadProwlarrIndexers() ([]ProwlarrIndexer, error) {
rows, err := r.db.Query(`
SELECT Id, Name, COALESCE(Implementation, ''), COALESCE(Settings, '{}'),
COALESCE(Enable, 1), COALESCE(Priority, 0)
FROM Indexers`)
if err != nil {
return nil, fmt.Errorf("query prowlarr indexers: %w", err)
}
defer rows.Close()
var indexers []ProwlarrIndexer
for rows.Next() {
var idx ProwlarrIndexer
if err := rows.Scan(&idx.ID, &idx.Name, &idx.Implementation, &idx.Settings,
&idx.Enable, &idx.Priority); err != nil {
continue
}
indexers = append(indexers, idx)
}
return indexers, nil
}
// ReadQualityProfiles reads quality profiles from any arr database.
func (r *ArrReader) ReadQualityProfiles() ([]ArrQualityProfile, error) {
rows, err := r.db.Query(`
SELECT Id, Name, COALESCE(Items, '[]'), COALESCE(Cutoff, 0)
FROM QualityProfiles`)
if err != nil {
return nil, fmt.Errorf("query quality profiles: %w", err)
}
defer rows.Close()
var profiles []ArrQualityProfile
for rows.Next() {
var p ArrQualityProfile
if err := rows.Scan(&p.ID, &p.Name, &p.Items, &p.Cutoff); err != nil {
continue
}
profiles = append(profiles, p)
}
return profiles, nil
}
// ReadRootFolders reads root folders from any arr database.
func (r *ArrReader) ReadRootFolders() ([]ArrRootFolder, error) {
rows, err := r.db.Query(`SELECT Id, Path FROM RootFolders`)
if err != nil {
return nil, fmt.Errorf("query root folders: %w", err)
}
defer rows.Close()
var folders []ArrRootFolder
for rows.Next() {
var f ArrRootFolder
if err := rows.Scan(&f.ID, &f.Path); err != nil {
continue
}
folders = append(folders, f)
}
return folders, nil
}
// ReadBlocklist reads blocklist entries from any arr database.
func (r *ArrReader) ReadBlocklist() ([]ArrBlocklistEntry, error) {
rows, err := r.db.Query(`
SELECT Id,
COALESCE(SeriesTitle, COALESCE(SourceTitle, '')),
COALESCE(Quality, '{}'),
COALESCE(SourceTitle, ''),
COALESCE(Date, ''),
COALESCE(TorrentHash, ''),
COALESCE(Size, 0),
COALESCE(Protocol, 'torrent'),
COALESCE(Message, '')
FROM Blocklist`)
if err != nil {
return nil, fmt.Errorf("query blocklist: %w", err)
}
defer rows.Close()
var entries []ArrBlocklistEntry
for rows.Next() {
var e ArrBlocklistEntry
if err := rows.Scan(&e.ID, &e.Title, &e.Quality, &e.SourceTitle,
&e.Date, &e.TorrentHash, &e.Size, &e.Protocol, &e.Message); err != nil {
continue
}
entries = append(entries, e)
}
return entries, nil
}
// ReadTags reads tags from any arr database.
func (r *ArrReader) ReadTags() ([]ArrTag, error) {
rows, err := r.db.Query(`SELECT Id, Label FROM Tags`)
if err != nil {
return nil, fmt.Errorf("query tags: %w", err)
}
defer rows.Close()
var tags []ArrTag
for rows.Next() {
var t ArrTag
if err := rows.Scan(&t.ID, &t.Label); err != nil {
continue
}
tags = append(tags, t)
}
return tags, nil
}
// ReadTrackFiles reads track files from a Lidarr database.
func (r *ArrReader) ReadTrackFiles() ([]TrackFile, error) {
rows, err := r.db.Query(`
SELECT Id, COALESCE(ArtistId, 0), COALESCE(AlbumId, 0),
COALESCE(RelativePath, ''), COALESCE(Path, ''),
COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '')
FROM TrackFiles`)
if err != nil {
return nil, fmt.Errorf("query track files: %w", err)
}
defer rows.Close()
var files []TrackFile
for rows.Next() {
var f TrackFile
if err := rows.Scan(&f.ID, &f.ArtistID, &f.AlbumID, &f.RelativePath,
&f.Path, &f.Size, &f.Quality, &f.DateAdded); err != nil {
continue
}
files = append(files, f)
}
return files, nil
}
// ReadBookFiles reads book files from a Readarr database.
func (r *ArrReader) ReadBookFiles() ([]BookFile, error) {
rows, err := r.db.Query(`
SELECT Id, COALESCE(BookId, 0),
COALESCE(RelativePath, ''), COALESCE(Path, ''),
COALESCE(Size, 0), COALESCE(Quality, '{}'), COALESCE(DateAdded, '')
FROM BookFiles`)
if err != nil {
return nil, fmt.Errorf("query book files: %w", err)
}
defer rows.Close()
var files []BookFile
for rows.Next() {
var f BookFile
if err := rows.Scan(&f.ID, &f.BookID, &f.RelativePath, &f.Path,
&f.Size, &f.Quality, &f.DateAdded); err != nil {
continue
}
files = append(files, f)
}
return files, nil
}
// ParseIndexerSettings extracts URL and API key from Prowlarr indexer settings JSON.
func ParseIndexerSettings(settingsJSON string) (url, apiKey string) {
var settings map[string]interface{}
if err := json.Unmarshal([]byte(settingsJSON), &settings); err != nil {
return "", ""
}
if u, ok := settings["url"].(string); ok {
url = u
}
if k, ok := settings["apiKey"].(string); ok {
apiKey = k
}
return url, apiKey
}
// ExtractAllowedQualities recursively extracts allowed quality names from arr Items JSON.
func ExtractAllowedQualities(items []QualityItem) []string {
var names []string
for _, item := range items {
if item.Allowed && item.Quality != nil && item.Quality.Name != "" {
names = append(names, item.Quality.Name)
}
if len(item.Items) > 0 {
names = append(names, ExtractAllowedQualities(item.Items)...)
}
}
return names
}
// FindCutoffName finds the quality name matching the cutoff ID in the Items tree.
func FindCutoffName(items []QualityItem, cutoffID int64) string {
for _, item := range items {
if item.Quality != nil && item.Quality.ID == cutoffID {
return item.Quality.Name
}
if len(item.Items) > 0 {
if name := FindCutoffName(item.Items, cutoffID); name != "" {
return name
}
}
}
return ""
}
// ParseQualityItems parses the Items JSON from an arr quality profile.
func ParseQualityItems(itemsJSON string) []QualityItem {
var items []QualityItem
if err := json.Unmarshal([]byte(itemsJSON), &items); err != nil {
return nil
}
return items
}

View File

@@ -0,0 +1,153 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type ActivityEvent struct {
ID int64 `json:"id"`
EventType string `json:"event_type"`
MediaID *int64 `json:"media_id,omitempty"`
MediaType *string `json:"media_type,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Data json.RawMessage `json:"data"`
CreatedAt time.Time `json:"created_at"`
}
type ActivityFilters struct {
EventType string
MediaID *int64
MediaType string
Page int
PageSize int
}
type LogEntry struct {
EventType string
MediaID *int64
MediaType *string
Title string
Description *string
Data json.RawMessage
}
type ActivityService struct {
db *db.DB
}
func NewActivityService(database *db.DB) *ActivityService {
return &ActivityService{db: database}
}
const activityColumns = `id, event_type, media_id, media_type, title, description, data, created_at`
func scanActivityEvent(scanner interface{ Scan(...interface{}) error }) (*ActivityEvent, error) {
var event ActivityEvent
var mediaID sql.NullInt64
var mediaType sql.NullString
var description sql.NullString
var data []byte
err := scanner.Scan(&event.ID, &event.EventType, &mediaID, &mediaType,
&event.Title, &description, &data, &event.CreatedAt)
if err != nil {
return nil, err
}
if mediaID.Valid {
event.MediaID = &mediaID.Int64
}
if mediaType.Valid {
event.MediaType = &mediaType.String
}
if description.Valid {
event.Description = &description.String
}
if data != nil {
event.Data = json.RawMessage(data)
}
return &event, nil
}
func (s *ActivityService) Log(ctx context.Context, entry LogEntry) (int64, error) {
data := entry.Data
if data == nil {
data = json.RawMessage("{}")
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO activity_events (event_type, media_id, media_type, title, description, data)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
entry.EventType, entry.MediaID, entry.MediaType, entry.Title, entry.Description, data).Scan(&id)
if err != nil {
return 0, fmt.Errorf("insert activity event: %w", err)
}
return id, nil
}
func (s *ActivityService) LogAsync(entry LogEntry) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if _, err := s.Log(ctx, entry); err != nil {
slog.Error("failed to log activity event async", "error", err, "event_type", entry.EventType, "title", entry.Title)
}
}()
}
func (s *ActivityService) List(ctx context.Context, filters ActivityFilters) ([]ActivityEvent, int, error) {
qb := NewQueryBuilder(1)
if filters.EventType != "" {
qb.Add("event_type = $%d", filters.EventType)
}
if filters.MediaID != nil {
qb.Add("media_id = $%d", *filters.MediaID)
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
}
where := qb.Where()
var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM activity_events%s", where)
if err := s.db.Pool.QueryRow(ctx, countQuery, qb.Args()...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count activity events: %w", err)
}
offset := (filters.Page - 1) * filters.PageSize
dataQuery := fmt.Sprintf(
"SELECT %s FROM activity_events%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
activityColumns, where, qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, offset)
rows, err := s.db.Pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("list activity events: %w", err)
}
defer rows.Close()
var events []ActivityEvent
for rows.Next() {
event, err := scanActivityEvent(rows)
if err != nil {
slog.Error("failed to scan activity event", "error", err)
continue
}
events = append(events, *event)
}
return events, total, nil
}

View File

@@ -0,0 +1,25 @@
package service
import (
"testing"
)
func TestActivityLog(t *testing.T) {
t.Skip("requires database")
}
func TestActivityFilterByType(t *testing.T) {
t.Skip("requires database")
}
func TestActivityFilterByMedia(t *testing.T) {
t.Skip("requires database")
}
func TestActivityPagination(t *testing.T) {
t.Skip("requires database")
}
func TestActivityLogAsync(t *testing.T) {
t.Skip("requires database")
}

View File

@@ -0,0 +1,182 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type BlocklistItem struct {
ID int64 `json:"id"`
ReleaseTitle string `json:"release_title"`
SourceTitle *string `json:"source_title,omitempty"`
Quality json.RawMessage `json:"quality"`
Indexer *string `json:"indexer,omitempty"`
Protocol string `json:"protocol"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Size *int64 `json:"size,omitempty"`
Message *string `json:"message,omitempty"`
MediaID *int64 `json:"media_id,omitempty"`
BlockReason string `json:"block_reason"`
AutoExpiresAt *time.Time `json:"auto_expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type BlocklistFilters struct {
Page int
PageSize int
}
type AddBlocklistRequest struct {
ReleaseTitle string `json:"release_title"`
SourceTitle *string `json:"source_title,omitempty"`
Quality json.RawMessage `json:"quality,omitempty"`
Indexer *string `json:"indexer,omitempty"`
Protocol string `json:"protocol,omitempty"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Size *int64 `json:"size,omitempty"`
Message *string `json:"message,omitempty"`
MediaID *int64 `json:"media_id,omitempty"`
BlockReason *string `json:"block_reason,omitempty"`
AutoExpiresAt *time.Time `json:"auto_expires_at,omitempty"`
}
const blocklistColumns = `id, release_title, source_title, quality, indexer, protocol,
torrent_hash, size, message, media_id, block_reason, auto_expires_at, created_at`
type BlocklistService struct {
db *db.DB
}
func NewBlocklistService(database *db.DB) *BlocklistService {
return &BlocklistService{db: database}
}
func scanBlocklistItem(scanner interface{ Scan(...interface{}) error }) (*BlocklistItem, error) {
var item BlocklistItem
var sourceTitle, indexer, torrentHash, message sql.NullString
var size, mediaID sql.NullInt64
var autoExpiresAt sql.NullTime
var quality []byte
err := scanner.Scan(&item.ID, &item.ReleaseTitle, &sourceTitle, &quality, &indexer,
&item.Protocol, &torrentHash, &size, &message, &mediaID, &item.BlockReason,
&autoExpiresAt, &item.CreatedAt)
if err != nil {
return nil, err
}
if sourceTitle.Valid {
item.SourceTitle = &sourceTitle.String
}
if indexer.Valid {
item.Indexer = &indexer.String
}
if torrentHash.Valid {
item.TorrentHash = &torrentHash.String
}
if message.Valid {
item.Message = &message.String
}
if size.Valid {
item.Size = &size.Int64
}
if mediaID.Valid {
item.MediaID = &mediaID.Int64
}
if autoExpiresAt.Valid {
item.AutoExpiresAt = &autoExpiresAt.Time
}
if quality != nil {
item.Quality = json.RawMessage(quality)
}
return &item, nil
}
func (s *BlocklistService) List(ctx context.Context, filters BlocklistFilters) ([]BlocklistItem, int, error) {
var total int
if err := s.db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM blocklist").Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count blocklist: %w", err)
}
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM blocklist ORDER BY created_at DESC LIMIT $1 OFFSET $2", blocklistColumns),
filters.PageSize, (filters.Page-1)*filters.PageSize)
if err != nil {
return nil, 0, fmt.Errorf("list blocklist: %w", err)
}
defer rows.Close()
var items []BlocklistItem
for rows.Next() {
item, err := scanBlocklistItem(rows)
if err != nil {
slog.Error("failed to scan blocklist item", "error", err)
continue
}
items = append(items, *item)
}
return items, total, nil
}
func (s *BlocklistService) Add(ctx context.Context, req AddBlocklistRequest) (int64, error) {
protocol := req.Protocol
if protocol == "" {
protocol = "torrent"
}
blockReason := "manual"
if req.BlockReason != nil {
blockReason = *req.BlockReason
}
quality := req.Quality
if quality == nil {
quality = json.RawMessage("{}")
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO blocklist (release_title, source_title, quality, indexer, protocol,
torrent_hash, size, message, media_id, block_reason, auto_expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
req.ReleaseTitle, req.SourceTitle, quality, req.Indexer, protocol,
req.TorrentHash, req.Size, req.Message, req.MediaID, blockReason, req.AutoExpiresAt).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create blocklist entry: %w", err)
}
return id, nil
}
func (s *BlocklistService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM blocklist WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete blocklist item: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("blocklist item not found")
}
return nil
}
func (s *BlocklistService) Clear(ctx context.Context) (int64, error) {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM blocklist")
if err != nil {
return 0, fmt.Errorf("clear blocklist: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *BlocklistService) ClearExpired(ctx context.Context) (int64, error) {
tag, err := s.db.Pool.Exec(ctx,
"DELETE FROM blocklist WHERE auto_expires_at IS NOT NULL AND auto_expires_at < NOW()")
if err != nil {
return 0, fmt.Errorf("clear expired blocklist: %w", err)
}
return tag.RowsAffected(), nil
}

View File

@@ -0,0 +1,103 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
// CalendarEvent represents a single event on the calendar.
type CalendarEvent struct {
ID int64 `json:"id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
Date string `json:"date"`
Year *int `json:"year,omitempty"`
Status string `json:"status"`
PosterURL string `json:"poster_url,omitempty"`
}
// CalendarService queries monitored media by release date for calendar views.
type CalendarService struct {
db *db.DB
}
// NewCalendarService creates a new CalendarService.
func NewCalendarService(database *db.DB) *CalendarService {
return &CalendarService{db: database}
}
type posterImage struct {
URL string `json:"url"`
Type string `json:"type"`
}
// EventsByMonth returns all monitored media with release dates in the given month.
func (s *CalendarService) EventsByMonth(ctx context.Context, year int, month time.Month) ([]CalendarEvent, error) {
startDate := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
endDate := startDate.AddDate(0, 1, 0).Add(-time.Nanosecond)
query := `SELECT id, media_type, title, release_date, year, status, images
FROM media
WHERE monitored = true AND deleted_at IS NULL
AND release_date IS NOT NULL
AND release_date BETWEEN $1 AND $2
ORDER BY release_date`
rows, err := s.db.Pool.Query(ctx, query, startDate, endDate)
if err != nil {
return nil, fmt.Errorf("query calendar events: %w", err)
}
defer rows.Close()
var events []CalendarEvent
for rows.Next() {
var id int64
var mediaType, title, status string
var releaseDate time.Time
var yearVal *int
var imagesJSON []byte
if err := rows.Scan(&id, &mediaType, &title, &releaseDate, &yearVal, &status, &imagesJSON); err != nil {
continue
}
posterURL := extractPosterURL(imagesJSON)
events = append(events, CalendarEvent{
ID: id,
MediaType: mediaType,
Title: title,
Date: releaseDate.Format("2006-01-02"),
Year: yearVal,
Status: status,
PosterURL: posterURL,
})
}
if events == nil {
events = []CalendarEvent{}
}
return events, nil
}
// extractPosterURL parses the images JSONB and returns the first poster URL.
func extractPosterURL(imagesJSON []byte) string {
if len(imagesJSON) == 0 {
return ""
}
var images []posterImage
if err := json.Unmarshal(imagesJSON, &images); err != nil {
return ""
}
for _, img := range images {
if img.Type == "poster" && img.URL != "" {
return img.URL
}
}
return ""
}

View File

@@ -0,0 +1,97 @@
package service
import (
"context"
"fmt"
"log/slog"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type DashboardStats struct {
TotalMedia int64 `json:"total_media"`
Monitored int64 `json:"monitored"`
Unavailable int64 `json:"unavailable"`
Available int64 `json:"available"`
QualityUpgrades int64 `json:"quality_upgrades"`
QueuePending int64 `json:"queue_pending"`
QueueDownloading int64 `json:"queue_downloading"`
QueueFailed int64 `json:"queue_failed"`
BlocklistCount int64 `json:"blocklist_count"`
BlocklistExpired int64 `json:"blocklist_expired"`
IndexersEnabled int64 `json:"indexers_enabled"`
MediaByType map[string]int64 `json:"media_by_type"`
StorageByType map[string]int64 `json:"storage_by_type"`
RecentDownloads int64 `json:"recent_downloads"`
}
type DashboardService struct {
db *db.DB
}
func NewDashboardService(database *db.DB) *DashboardService {
return &DashboardService{db: database}
}
func (s *DashboardService) Stats(ctx context.Context) (*DashboardStats, error) {
stats := &DashboardStats{
MediaByType: make(map[string]int64),
StorageByType: make(map[string]int64),
}
combinedQuery := `
SELECT
(SELECT COUNT(*) FROM media WHERE deleted_at IS NULL),
(SELECT COUNT(*) FROM media WHERE monitored = true AND deleted_at IS NULL),
(SELECT COUNT(*) FROM media WHERE status = 'unavailable' AND deleted_at IS NULL),
(SELECT COUNT(*) FROM media WHERE status = 'available' AND deleted_at IS NULL),
(SELECT COUNT(*) FROM media WHERE desired_quality IS NOT NULL AND current_quality IS NULL AND deleted_at IS NULL),
(SELECT COUNT(*) FROM download_queue WHERE status = 'pending'),
(SELECT COUNT(*) FROM download_queue WHERE status = 'downloading'),
(SELECT COUNT(*) FROM download_queue WHERE status = 'failed'),
(SELECT COUNT(*) FROM blocklist),
(SELECT COUNT(*) FROM blocklist WHERE auto_expires_at IS NOT NULL AND auto_expires_at < NOW()),
(SELECT COUNT(*) FROM indexers WHERE enabled = true),
(SELECT COUNT(*) FROM download_history WHERE created_at > NOW() - INTERVAL '24 hours')`
err := s.db.Pool.QueryRow(ctx, combinedQuery).Scan(
&stats.TotalMedia, &stats.Monitored, &stats.Unavailable, &stats.Available,
&stats.QualityUpgrades, &stats.QueuePending, &stats.QueueDownloading, &stats.QueueFailed,
&stats.BlocklistCount, &stats.BlocklistExpired, &stats.IndexersEnabled, &stats.RecentDownloads)
if err != nil {
slog.Error("dashboard combined query failed", "error", err)
return nil, fmt.Errorf("dashboard stats: %w", err)
}
rows, err := s.db.Pool.Query(ctx,
"SELECT media_type, COUNT(*) FROM media WHERE deleted_at IS NULL GROUP BY media_type")
if err == nil {
defer rows.Close()
for rows.Next() {
var mediaType string
var count int64
if err := rows.Scan(&mediaType, &count); err == nil {
stats.MediaByType[mediaType] = count
}
}
}
sRows, err := s.db.Pool.Query(ctx,
`SELECT m.media_type, COALESCE(SUM(mf.file_size), 0)
FROM media m
JOIN media_files mf ON m.id = mf.media_id AND mf.deleted_at IS NULL
WHERE m.deleted_at IS NULL
GROUP BY m.media_type`)
if err == nil {
defer sRows.Close()
for sRows.Next() {
var mediaType string
var totalSize int64
if err := sRows.Scan(&mediaType, &totalSize); err == nil {
stats.StorageByType[mediaType] = totalSize
}
}
}
return stats, nil
}

View File

@@ -0,0 +1,328 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"sync"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
// DiscoverItem represents a single item returned from the discover endpoints.
type DiscoverItem struct {
TMDBID int `json:"tmdb_id"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
MediaType string `json:"media_type"`
Overview string `json:"overview,omitempty"`
PosterURL string `json:"poster_url,omitempty"`
BackdropURL string `json:"backdrop_url,omitempty"`
VoteAverage float64 `json:"vote_average"`
InLibrary bool `json:"in_library"`
}
type discoverCacheEntry struct {
data []DiscoverItem
expiresAt time.Time
}
// DiscoverService provides trending/popular browsing and add-to-library functionality.
type DiscoverService struct {
tmdb *TMDBProvider
db *db.DB
cache sync.Map
}
// NewDiscoverService creates a new DiscoverService.
func NewDiscoverService(tmdb *TMDBProvider, database *db.DB) *DiscoverService {
return &DiscoverService{tmdb: tmdb, db: database}
}
const discoverCacheTTL = 6 * time.Hour
// Trending returns trending items from TMDB, checking an in-memory cache first.
func (s *DiscoverService) Trending(ctx context.Context, mediaType string, page int) ([]DiscoverItem, error) {
cacheKey := fmt.Sprintf("trending:%s:%d", mediaType, page)
if cached, ok := s.cache.Load(cacheKey); ok {
entry := cached.(*discoverCacheEntry)
if time.Now().Before(entry.expiresAt) {
return entry.data, nil
}
s.cache.Delete(cacheKey)
}
items, err := s.tmdb.Trending(ctx, mediaType, page)
if err != nil {
return nil, fmt.Errorf("fetch trending: %w", err)
}
result := s.convertItems(ctx, items, mediaType)
s.cache.Store(cacheKey, &discoverCacheEntry{
data: result,
expiresAt: time.Now().Add(discoverCacheTTL),
})
return result, nil
}
// Popular returns popular items from TMDB, checking an in-memory cache first.
func (s *DiscoverService) Popular(ctx context.Context, mediaType string, page int) ([]DiscoverItem, error) {
cacheKey := fmt.Sprintf("popular:%s:%d", mediaType, page)
if cached, ok := s.cache.Load(cacheKey); ok {
entry := cached.(*discoverCacheEntry)
if time.Now().Before(entry.expiresAt) {
return entry.data, nil
}
s.cache.Delete(cacheKey)
}
items, err := s.tmdb.Popular(ctx, mediaType, page)
if err != nil {
return nil, fmt.Errorf("fetch popular: %w", err)
}
result := s.convertItems(ctx, items, mediaType)
s.cache.Store(cacheKey, &discoverCacheEntry{
data: result,
expiresAt: time.Now().Add(discoverCacheTTL),
})
return result, nil
}
// AddToLibrary adds a TMDB item to the user's monitored library.
// If the item already exists, it returns the existing ID with no error.
func (s *DiscoverService) AddToLibrary(ctx context.Context, tmdbID int, mediaType string) (int64, bool, error) {
// Check if already in library
var existingID int64
err := s.db.Pool.QueryRow(ctx,
`SELECT id FROM media WHERE external_ids @> $1::jsonb AND deleted_at IS NULL LIMIT 1`,
fmt.Sprintf(`{"tmdb":"%d"}`, tmdbID)).Scan(&existingID)
if err == nil {
return existingID, true, nil
}
// Fetch full details from TMDB
detail, err := s.fetchFullDetail(ctx, tmdbID, mediaType)
if err != nil {
return 0, false, fmt.Errorf("fetch tmdb detail: %w", err)
}
req := s.buildCreateRequest(detail, mediaType)
newID, err := NewMediaService(s.db).Create(ctx, req)
if err != nil {
return 0, false, fmt.Errorf("create media: %w", err)
}
return newID, false, nil
}
func (s *DiscoverService) convertItems(ctx context.Context, items []tmdbSearchItem, mediaType string) []DiscoverItem {
if len(items) == 0 {
return []DiscoverItem{}
}
// Collect TMDB IDs for batch library check
tmdbIDs := make([]int, len(items))
for i, item := range items {
tmdbIDs[i] = item.ID
}
libMembership := s.checkLibraryMembership(ctx, tmdbIDs, mediaType)
result := make([]DiscoverItem, 0, len(items))
for _, item := range items {
title := item.Title
dateStr := item.ReleaseDate
mType := "movie"
if mediaType == "series" || item.MediaType == "tv" {
title = item.Name
if title == "" {
title = item.Title
}
dateStr = item.FirstAirDate
mType = "series"
}
if title == "" {
title = item.Name
}
year := parseTMDBYear(dateStr)
result = append(result, DiscoverItem{
TMDBID: item.ID,
Title: title,
Year: year,
MediaType: mType,
Overview: item.Overview,
PosterURL: buildPosterURL(item.PosterPath),
BackdropURL: buildBackdropURL(item.BackdropPath),
VoteAverage: item.VoteAverage,
InLibrary: libMembership[item.ID],
})
}
return result
}
func (s *DiscoverService) checkLibraryMembership(ctx context.Context, tmdbIDs []int, _ string) map[int]bool {
membership := make(map[int]bool)
if len(tmdbIDs) == 0 {
return membership
}
// Build JSONB array condition for batch check
conditions := make([]string, len(tmdbIDs))
args := make([]interface{}, len(tmdbIDs))
for i, id := range tmdbIDs {
conditions[i] = fmt.Sprintf("external_ids @> $%d::jsonb", i+1)
args[i] = fmt.Sprintf(`{"tmdb":"%d"}`, id)
}
query := fmt.Sprintf(
"SELECT external_ids FROM media WHERE (%s) AND deleted_at IS NULL",
strings.Join(conditions, " OR "),
)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
slog.Error("check library membership", "error", err)
return membership
}
defer rows.Close()
for rows.Next() {
var extIDs json.RawMessage
if err := rows.Scan(&extIDs); err != nil {
continue
}
var ids map[string]string
if json.Unmarshal(extIDs, &ids) == nil {
if idStr, ok := ids["tmdb"]; ok {
if id, err := strconv.Atoi(idStr); err == nil {
membership[id] = true
}
}
}
}
return membership
}
func (s *DiscoverService) fetchFullDetail(ctx context.Context, tmdbID int, mediaType string) (*TMDBFullDetail, error) {
idStr := strconv.Itoa(tmdbID)
if mediaType == "series" {
return s.tmdb.GetTVDetails(ctx, idStr)
}
return s.tmdb.GetMovieDetails(ctx, idStr)
}
func (s *DiscoverService) buildCreateRequest(detail *TMDBFullDetail, mediaType string) CreateMediaRequest {
title := detail.Title
dateStr := detail.ReleaseDate
if mediaType == "series" {
if detail.Name != "" {
title = detail.Name
}
dateStr = detail.FirstAirDate
}
year := parseTMDBYear(dateStr)
overview := detail.Overview
// Parse release_date for the dedicated column
var releaseDate *time.Time
if dateStr != "" {
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
releaseDate = &parsed
}
}
// Build external IDs
extIDs := map[string]string{
"tmdb": strconv.Itoa(detail.ID),
}
if detail.ExternalIDs.IMDbID != "" {
extIDs["imdb"] = detail.ExternalIDs.IMDbID
}
if detail.ExternalIDs.TVDBID != "" {
extIDs["tvdb"] = detail.ExternalIDs.TVDBID
}
extIDsJSON, _ := json.Marshal(extIDs)
// Build metadata
meta := map[string]interface{}{
"tmdb_rating": detail.VoteAverage,
}
var genreNames []string
for _, g := range detail.Genres {
genreNames = append(genreNames, g.Name)
}
if len(genreNames) > 0 {
meta["genres"] = genreNames
}
if detail.Runtime > 0 {
meta["runtime"] = detail.Runtime
}
if mediaType == "series" {
meta["number_of_seasons"] = detail.NumberOfSeasons
meta["number_of_episodes"] = detail.NumberOfEpisodes
}
// Store date string in metadata for reference
if mediaType == "movie" && detail.ReleaseDate != "" {
meta["release_date"] = detail.ReleaseDate
}
if mediaType == "series" && detail.FirstAirDate != "" {
meta["first_air_date"] = detail.FirstAirDate
}
metaJSON, _ := json.Marshal(meta)
// Build images
var images []map[string]interface{}
if detail.PosterPath != "" {
images = append(images, map[string]interface{}{
"url": fmt.Sprintf("https://image.tmdb.org/t/p/original%s", detail.PosterPath),
"type": "poster",
})
}
if detail.BackdropPath != "" {
images = append(images, map[string]interface{}{
"url": fmt.Sprintf("https://image.tmdb.org/t/p/original%s", detail.BackdropPath),
"type": "backdrop",
})
}
imagesJSON, _ := json.Marshal(images)
return CreateMediaRequest{
MediaType: mediaType,
Title: title,
Overview: &overview,
Year: year,
ReleaseDate: releaseDate,
Status: "unavailable",
Monitored: true,
ExternalIDs: extIDsJSON,
Metadata: metaJSON,
Images: imagesJSON,
}
}
func buildPosterURL(path string) string {
if path == "" {
return ""
}
return "https://image.tmdb.org/t/p/w500" + path
}
func buildBackdropURL(path string) string {
if path == "" {
return ""
}
return "https://image.tmdb.org/t/p/w780" + path
}

View File

@@ -0,0 +1,353 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/download"
)
type DownloadClientConfig struct {
ID int64 `json:"id"`
Name string `json:"name"`
Implementation string `json:"implementation"`
URL string `json:"url"`
APIKey *string `json:"-"`
Category string `json:"category"`
Priority int `json:"priority"`
Protocol string `json:"protocol"`
Settings json.RawMessage `json:"settings"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DownloadClientConfigResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Implementation string `json:"implementation"`
URL string `json:"url"`
Category string `json:"category"`
Priority int `json:"priority"`
Protocol string `json:"protocol"`
Settings json.RawMessage `json:"settings"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type DownloadClientWithInfo struct {
Client download.DownloadClient
Config DownloadClientConfig
}
type CreateDownloadClientRequest struct {
Name string `json:"name"`
Implementation string `json:"implementation"`
URL string `json:"url"`
APIKey *string `json:"api_key,omitempty"`
Category string `json:"category,omitempty"`
Priority *int `json:"priority,omitempty"`
Protocol string `json:"protocol,omitempty"`
Settings json.RawMessage `json:"settings,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
type UpdateDownloadClientRequest struct {
Name *string `json:"name,omitempty"`
Implementation *string `json:"implementation,omitempty"`
URL *string `json:"url,omitempty"`
APIKey *string `json:"api_key,omitempty"`
Category *string `json:"category,omitempty"`
Priority *int `json:"priority,omitempty"`
Protocol *string `json:"protocol,omitempty"`
Settings json.RawMessage `json:"settings,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
type DownloadClientTestResult struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
const downloadClientColumns = `id, name, implementation, url, api_key, category, priority, protocol, settings, enabled, created_at, updated_at`
type DownloadClientService struct {
db *db.DB
}
func NewDownloadClientService(database *db.DB) *DownloadClientService {
return &DownloadClientService{db: database}
}
func scanDownloadClientConfig(scanner interface{ Scan(...interface{}) error }) (*DownloadClientConfig, error) {
var cfg DownloadClientConfig
var apiKey sql.NullString
var settings []byte
err := scanner.Scan(&cfg.ID, &cfg.Name, &cfg.Implementation, &cfg.URL, &apiKey,
&cfg.Category, &cfg.Priority, &cfg.Protocol, &settings,
&cfg.Enabled, &cfg.CreatedAt, &cfg.UpdatedAt)
if err != nil {
return nil, err
}
if apiKey.Valid {
cfg.APIKey = &apiKey.String
}
cfg.Settings = json.RawMessage(settings)
return &cfg, nil
}
func clientConfigToResponse(cfg *DownloadClientConfig) DownloadClientConfigResponse {
return DownloadClientConfigResponse{
ID: cfg.ID,
Name: cfg.Name,
Implementation: cfg.Implementation,
URL: cfg.URL,
Category: cfg.Category,
Priority: cfg.Priority,
Protocol: cfg.Protocol,
Settings: cfg.Settings,
Enabled: cfg.Enabled,
CreatedAt: cfg.CreatedAt,
UpdatedAt: cfg.UpdatedAt,
}
}
func (s *DownloadClientService) List(ctx context.Context) ([]DownloadClientConfigResponse, error) {
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM download_clients ORDER BY priority, name", downloadClientColumns))
if err != nil {
return nil, fmt.Errorf("list download clients: %w", err)
}
defer rows.Close()
var items []DownloadClientConfigResponse
for rows.Next() {
cfg, err := scanDownloadClientConfig(rows)
if err != nil {
slog.Error("failed to scan download client", "error", err)
continue
}
items = append(items, clientConfigToResponse(cfg))
}
return items, nil
}
func (s *DownloadClientService) GetByID(ctx context.Context, id int64) (*DownloadClientConfig, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM download_clients WHERE id = $1", downloadClientColumns), id)
cfg, err := scanDownloadClientConfig(row)
if err != nil {
return nil, fmt.Errorf("download client not found")
}
return cfg, nil
}
func (s *DownloadClientService) Create(ctx context.Context, req CreateDownloadClientRequest) (int64, error) {
category := req.Category
if category == "" {
category = "umm"
}
protocol := req.Protocol
if protocol == "" {
switch req.Implementation {
case "sabnzbd":
protocol = "nzb"
case "qbittorrent":
protocol = "torrent"
default:
protocol = "nzb"
}
}
settings := req.Settings
if settings == nil {
settings = json.RawMessage("{}")
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
priority := 0
if req.Priority != nil {
priority = *req.Priority
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO download_clients (name, implementation, url, api_key, category, priority, protocol, settings, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
req.Name, req.Implementation, req.URL, req.APIKey, category, priority, protocol, settings, enabled).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create download client: %w", err)
}
return id, nil
}
func (s *DownloadClientService) Update(ctx context.Context, id int64, req UpdateDownloadClientRequest) error {
var setClauses []string
var args []interface{}
idx := 1
addCol := func(col string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if req.Name != nil {
addCol("name", *req.Name)
}
if req.Implementation != nil {
addCol("implementation", *req.Implementation)
}
if req.URL != nil {
addCol("url", *req.URL)
}
if req.APIKey != nil {
addCol("api_key", *req.APIKey)
}
if req.Category != nil {
addCol("category", *req.Category)
}
if req.Priority != nil {
addCol("priority", *req.Priority)
}
if req.Protocol != nil {
addCol("protocol", *req.Protocol)
}
if req.Settings != nil {
addCol("settings", req.Settings)
}
if req.Enabled != nil {
addCol("enabled", *req.Enabled)
}
if len(setClauses) == 0 {
return fmt.Errorf("no fields to update")
}
addCol("updated_at", time.Now())
query := fmt.Sprintf("UPDATE download_clients SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), idx)
args = append(args, id)
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("update download client: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("download client not found")
}
return nil
}
func (s *DownloadClientService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM download_clients WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete download client: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("download client not found")
}
return nil
}
func (s *DownloadClientService) GetClient(ctx context.Context, protocol string) (download.DownloadClient, *DownloadClientConfig, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM download_clients WHERE enabled = true AND protocol = $1 ORDER BY priority ASC LIMIT 1", downloadClientColumns),
protocol)
cfg, err := scanDownloadClientConfig(row)
if err != nil {
return nil, nil, fmt.Errorf("no enabled download client for protocol: %s", protocol)
}
client, err := s.instantiateClient(cfg)
if err != nil {
return nil, nil, err
}
return client, cfg, nil
}
func (s *DownloadClientService) GetAllEnabled(ctx context.Context, protocol string) ([]DownloadClientWithInfo, error) {
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM download_clients WHERE enabled = true AND protocol = $1 ORDER BY priority ASC", downloadClientColumns),
protocol)
if err != nil {
return nil, fmt.Errorf("list enabled download clients: %w", err)
}
defer rows.Close()
var clients []DownloadClientWithInfo
for rows.Next() {
cfg, err := scanDownloadClientConfig(rows)
if err != nil {
slog.Error("failed to scan download client", "error", err)
continue
}
client, err := s.instantiateClient(cfg)
if err != nil {
slog.Error("failed to instantiate download client", "error", err, "name", cfg.Name)
continue
}
clients = append(clients, DownloadClientWithInfo{
Client: client,
Config: *cfg,
})
}
return clients, nil
}
func (s *DownloadClientService) Test(ctx context.Context, id int64) (*DownloadClientTestResult, error) {
cfg, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
client, err := s.instantiateClient(cfg)
if err != nil {
return &DownloadClientTestResult{Success: false, Error: err.Error()}, nil
}
_, err = client.GetCompleted(ctx)
if err != nil {
return &DownloadClientTestResult{Success: false, Error: err.Error()}, nil
}
return &DownloadClientTestResult{Success: true}, nil
}
func (s *DownloadClientService) instantiateClient(cfg *DownloadClientConfig) (download.DownloadClient, error) {
apiKey := ""
if cfg.APIKey != nil {
apiKey = *cfg.APIKey
}
switch cfg.Implementation {
case "sabnzbd":
return download.NewSABnzbdClient(cfg.URL, apiKey), nil
case "qbittorrent":
return download.NewQBittorrentClient(cfg.URL, apiKey), nil
default:
return nil, fmt.Errorf("unknown download client implementation: %s", cfg.Implementation)
}
}

427
internal/service/import.go Normal file
View File

@@ -0,0 +1,427 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/download"
)
type ImportResult struct {
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
SourcePath string `json:"source_path"`
DestPath string `json:"dest_path"`
FileSize int64 `json:"file_size"`
Quality string `json:"quality"`
Status string `json:"status"`
}
type ImportReport struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
Results []ImportResult `json:"results"`
}
type ImportService struct {
db *db.DB
downloadClientSvc *DownloadClientService
namingSvc *NamingService
matcherSvc *MatcherService
mediaSvc *MediaService
parser *ReleaseParser
downloadDir string
subtitleSvc *SubtitleService
activitySvc *ActivityService
}
func NewImportService(database *db.DB, dcSvc *DownloadClientService, nSvc *NamingService, mSvc *MatcherService, mediaSvc *MediaService, downloadDir string, subtitleSvc *SubtitleService, activitySvc *ActivityService) *ImportService {
return &ImportService{
db: database,
downloadClientSvc: dcSvc,
namingSvc: nSvc,
matcherSvc: mSvc,
mediaSvc: mediaSvc,
parser: NewReleaseParser(),
downloadDir: downloadDir,
subtitleSvc: subtitleSvc,
activitySvc: activitySvc,
}
}
var mediaExts = map[string]bool{
".mkv": true,
".mp4": true,
".avi": true,
".wmv": true,
".flv": true,
".webm": true,
".mp3": true,
".flac": true,
".m4a": true,
".m4b": true,
".ogg": true,
".opus": true,
".epub": true,
".pdf": true,
".mobi": true,
".azw3": true,
}
func (s *ImportService) ProcessCompleted(ctx context.Context) (*ImportReport, error) {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
report := &ImportReport{}
nzbClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "nzb")
if err != nil {
slog.Error("failed to get nzb clients", "error", err)
}
torrentClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "torrent")
if err != nil {
slog.Error("failed to get torrent clients", "error", err)
}
allClients := append(nzbClients, torrentClients...)
for _, client := range allClients {
completed, err := client.Client.GetCompleted(ctx)
if err != nil {
slog.Error("failed to get completed downloads", "error", err, "client", client.Config.Name)
continue
}
for _, dl := range completed {
s.processDownload(ctx, dl, client, report)
}
}
return report, nil
}
func (s *ImportService) processDownload(ctx context.Context, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) {
var exists bool
err := s.db.Pool.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM media_files WHERE original_path = $1 AND deleted_at IS NULL)",
dl.OutputPath).Scan(&exists)
if err != nil {
slog.Error("failed to check existing import", "error", err, "path", dl.OutputPath)
report.Errors++
return
}
if exists {
report.Skipped++
return
}
files, err := s.findMediaFiles(dl.Name)
if err != nil {
slog.Error("failed to find media files", "error", err, "download", dl.Name)
report.Errors++
return
}
if len(files) == 0 {
slog.Warn("no media files found for download", "download", dl.Name)
report.Skipped++
return
}
for _, filePath := range files {
s.processFile(ctx, filePath, dl, client, report)
}
}
func (s *ImportService) processFile(ctx context.Context, sourcePath string, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) {
fileCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
releaseInfo := s.parser.Parse(filepath.Base(sourcePath))
mediaType := "movie"
if _, _, hasSE := parseSeasonEpisode(filepath.Base(sourcePath)); hasSE {
mediaType = "series"
}
match, err := s.matcherSvc.Match(fileCtx, dl.Name, mediaType)
if err != nil {
slog.Error("failed to match release to media", "error", err, "release", dl.Name)
report.Errors++
return
}
if match.Confidence == "none" {
slog.Warn("no media match for release", "release", dl.Name, "path", sourcePath)
report.Skipped++
return
}
result, err := s.importFile(fileCtx, sourcePath, match, releaseInfo, dl, client)
if err != nil {
slog.Error("failed to import file", "error", err, "source", sourcePath)
report.Errors++
return
}
report.Imported++
report.Results = append(report.Results, *result)
}
func (s *ImportService) importFile(ctx context.Context, sourcePath string, match *MatchResult, releaseInfo ReleaseInfo, completed download.CompletedDownload, client DownloadClientWithInfo) (*ImportResult, error) {
status := "importing"
err := s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{
Status: &status,
})
if err != nil {
return nil, fmt.Errorf("update media status to importing: %w", err)
}
qualityTier := s.parser.MatchQuality(releaseInfo)
qualityJSON, _ := json.Marshal(qualityTier)
year := 0
if match.Year != nil {
year = *match.Year
}
season := 0
if match.Season != nil {
season = *match.Season
}
episode := 0
if match.Episode != nil {
episode = *match.Episode
}
namingData := NamingData{
Title: match.Title,
Year: year,
Season: season,
Episode: episode,
Quality: qualityTier.Name,
Ext: ExtractExt(filepath.Base(sourcePath)),
ReleaseGroup: releaseInfo.ReleaseGroup,
Resolution: releaseInfo.Resolution,
Source: releaseInfo.Source,
Codec: releaseInfo.VideoCodec,
}
relativePath, err := s.namingSvc.Render(ctx, match.MediaType, namingData)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Naming template failed for media %d: %v", match.MediaID, err))
return nil, fmt.Errorf("render naming template: %w", err)
}
targetPath := filepath.Join(match.RootFolder, relativePath)
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(match.RootFolder)) {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("path traversal detected: target path escapes root folder")
}
targetDir := filepath.Dir(targetPath)
if err := os.MkdirAll(targetDir, 0755); err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("create target directory: %w", err)
}
if err := os.Link(sourcePath, targetPath); err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Hardlink failed for media %d: %v", match.MediaID, err))
return nil, fmt.Errorf("hardlink file: %w", err)
}
srcInfo, err := os.Stat(sourcePath)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("stat source file: %w", err)
}
dstInfo, err := os.Stat(targetPath)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("stat target file: %w", err)
}
if !os.SameFile(srcInfo, dstInfo) {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("hardlink verification failed: files are not the same inode")
}
fileSize := dstInfo.Size()
if s.subtitleSvc != nil {
extractCtx, extractCancel := context.WithTimeout(ctx, 30*time.Second)
baseName := s.buildImportSubtitleBaseName(match, releaseInfo)
extracted, err := s.subtitleSvc.ExtractSubtitles(extractCtx, targetPath, filepath.Dir(targetPath), baseName)
if err != nil {
slog.Error("failed to extract subtitles", "error", err, "path", targetPath)
}
if len(extracted) > 0 {
slog.Info("extracted subtitles", "count", len(extracted), "media_id", match.MediaID)
}
extractCancel()
}
_, err = s.db.Pool.Exec(ctx,
`INSERT INTO media_files (media_id, media_type, path, original_path, file_name, file_size, quality, codec, resolution, source, is_hardlinked)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
match.MediaID, match.MediaType, targetPath, sourcePath, filepath.Base(targetPath),
fileSize, qualityJSON, ptrStr(releaseInfo.VideoCodec), ptrStr(releaseInfo.Resolution),
ptrStr(releaseInfo.Source), true)
if err != nil {
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
return nil, fmt.Errorf("insert media file record: %w", err)
}
availableStatus := "available"
err = s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{
Status: &availableStatus,
CurrentQuality: qualityJSON,
})
if err != nil {
slog.Error("failed to update media status to available", "error", err, "media_id", match.MediaID)
}
if _, err := s.db.Pool.Exec(ctx, `UPDATE media SET has_files = true WHERE id = $1`, match.MediaID); err != nil {
slog.Error("failed to update has_files", "error", err, "media_id", match.MediaID)
}
// Log successful import activity
if s.activitySvc != nil {
s.activitySvc.LogAsync(LogEntry{
EventType: "import",
MediaID: &match.MediaID,
MediaType: &match.MediaType,
Title: fmt.Sprintf("Imported %s", filepath.Base(sourcePath)),
Data: json.RawMessage(fmt.Sprintf(`{"source":"%s","dest":"%s","quality":"%s","size":%d}`,
sourcePath, targetPath, qualityTier.Name, fileSize)),
})
}
_, err = s.db.Pool.Exec(ctx,
`UPDATE download_queue SET status = 'imported', completed_at = NOW()
WHERE media_id = $1 AND release_title = $2 AND status IN ('downloading', 'pending')`,
match.MediaID, completed.Name)
if err != nil {
slog.Error("failed to update download queue", "error", err, "media_id", match.MediaID)
}
if err := client.Client.Remove(ctx, completed.ID); err != nil {
slog.Warn("failed to remove download client entry", "error", err, "id", completed.ID)
}
return &ImportResult{
MediaID: match.MediaID,
MediaType: match.MediaType,
SourcePath: sourcePath,
DestPath: targetPath,
FileSize: fileSize,
Quality: qualityTier.Name,
Status: "imported",
}, nil
}
func (s *ImportService) rollbackStatus(ctx context.Context, mediaID int64, mediaType string, status string) {
if err := s.mediaSvc.Update(ctx, mediaID, mediaType, UpdateMediaRequest{Status: &status}); err != nil {
slog.Error("failed to rollback media status", "error", err, "media_id", mediaID)
}
}
func (s *ImportService) findMediaFiles(downloadName string) ([]string, error) {
downloadPath := filepath.Join(s.downloadDir, downloadName)
cleanBase := filepath.Clean(s.downloadDir)
info, err := os.Stat(downloadPath)
if err != nil {
entries, err := os.ReadDir(s.downloadDir)
if err != nil {
return nil, fmt.Errorf("read download directory: %w", err)
}
for _, entry := range entries {
candidate := filepath.Join(s.downloadDir, entry.Name())
if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(downloadName)) {
if entry.IsDir() {
return s.walkMediaDir(candidate, cleanBase)
}
if mediaExts[filepath.Ext(entry.Name())] {
return []string{candidate}, nil
}
}
}
return nil, nil
}
if !strings.HasPrefix(filepath.Clean(downloadPath), cleanBase) {
return nil, fmt.Errorf("path traversal detected: download path escapes download dir")
}
if info.IsDir() {
return s.walkMediaDir(downloadPath, cleanBase)
}
if mediaExts[filepath.Ext(downloadPath)] {
return []string{downloadPath}, nil
}
return nil, nil
}
func (s *ImportService) walkMediaDir(dir string, cleanBase string) ([]string, error) {
var files []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !strings.HasPrefix(filepath.Clean(path), cleanBase) {
return fmt.Errorf("path traversal detected: walked path escapes download dir")
}
if d.IsDir() {
return nil
}
if mediaExts[filepath.Ext(path)] {
files = append(files, path)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk download directory: %w", err)
}
return files, nil
}
func (s *ImportService) buildImportSubtitleBaseName(match *MatchResult, info ReleaseInfo) string {
parts := []string{sanitize(match.Title)}
if match.Year != nil {
parts = append(parts, fmt.Sprintf("%d", *match.Year))
}
if match.Season != nil && match.Episode != nil {
parts = append(parts, fmt.Sprintf("S%02dE%02d", *match.Season, *match.Episode))
}
return strings.Join(parts, ".")
}
func ptrStr(s string) *string {
if s == "" {
return nil
}
return &s
}
func (s *ImportService) logImportError(mediaID int64, mediaType string, msg string) {
if s.activitySvc != nil {
s.activitySvc.LogAsync(LogEntry{
EventType: "error",
Title: msg,
MediaID: &mediaID,
MediaType: &mediaType,
})
}
}

557
internal/service/indexer.go Normal file
View File

@@ -0,0 +1,557 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/cardigann"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type Indexer struct {
ID int64 `json:"id"`
Name string `json:"name"`
Implementation string `json:"implementation"`
URL string `json:"url"`
APIKey *string `json:"-"`
Categories json.RawMessage `json:"categories"`
Settings json.RawMessage `json:"settings"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
FailureCount int `json:"failure_count"`
DisabledUntil *time.Time `json:"disabled_until,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type IndexerResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Implementation string `json:"implementation"`
URL string `json:"url"`
Categories json.RawMessage `json:"categories"`
Settings json.RawMessage `json:"settings"`
Enabled bool `json:"enabled"`
Priority int `json:"priority"`
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
FailureCount int `json:"failure_count"`
DisabledUntil *time.Time `json:"disabled_until,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type IndexerTestResult struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
StatusCode int `json:"status_code,omitempty"`
}
type IndexerStats struct {
ID int64 `json:"id"`
Name string `json:"name"`
TotalGrabs int `json:"total_grabs"`
TotalFailed int `json:"total_failed"`
SuccessRate float64 `json:"success_rate"`
FailureCount int `json:"failure_count"`
LastSuccess string `json:"last_success,omitempty"`
}
type CreateIndexerRequest struct {
Name string `json:"name"`
Implementation string `json:"implementation"`
URL string `json:"url"`
APIKey *string `json:"api_key,omitempty"`
Categories json.RawMessage `json:"categories,omitempty"`
Settings json.RawMessage `json:"settings,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Priority *int `json:"priority,omitempty"`
}
type UpdateIndexerRequest struct {
Name *string `json:"name,omitempty"`
Implementation *string `json:"implementation,omitempty"`
URL *string `json:"url,omitempty"`
APIKey *string `json:"api_key,omitempty"`
Categories json.RawMessage `json:"categories,omitempty"`
Settings json.RawMessage `json:"settings,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Priority *int `json:"priority,omitempty"`
}
const indexerColumns = `id, name, implementation, url, api_key, categories, settings,
enabled, priority, last_success_at, failure_count, disabled_until, created_at, updated_at`
type IndexerService struct {
db *db.DB
cardigannEngine *cardigann.CardigannEngine
}
func NewIndexerService(database *db.DB) *IndexerService {
return &IndexerService{db: database}
}
// SetCardigannEngine sets the Cardigann engine for advanced indexer testing.
func (s *IndexerService) SetCardigannEngine(engine *cardigann.CardigannEngine) {
s.cardigannEngine = engine
}
// CardigannIndexerConfig holds the Cardigann-specific configuration stored in settings JSONB.
type CardigannIndexerConfig struct {
YAML string `json:"yaml"`
Config map[string]string `json:"config"`
}
// GetCardigannConfig extracts Cardigann configuration from indexer settings JSONB.
func (s *IndexerService) GetCardigannConfig(settings json.RawMessage) (*CardigannIndexerConfig, error) {
if len(settings) == 0 {
return nil, fmt.Errorf("no settings provided")
}
var cfg CardigannIndexerConfig
if err := json.Unmarshal(settings, &cfg); err != nil {
return nil, fmt.Errorf("parse cardigann config: %w", err)
}
if cfg.YAML == "" {
return nil, fmt.Errorf("cardigann settings missing yaml field")
}
return &cfg, nil
}
func scanIndexer(scanner interface{ Scan(...interface{}) error }) (*Indexer, error) {
var idx Indexer
var apiKey sql.NullString
var categories, settings []byte
var lastSuccessAt, disabledUntil sql.NullTime
err := scanner.Scan(&idx.ID, &idx.Name, &idx.Implementation, &idx.URL, &apiKey,
&categories, &settings, &idx.Enabled, &idx.Priority,
&lastSuccessAt, &idx.FailureCount, &disabledUntil,
&idx.CreatedAt, &idx.UpdatedAt)
if err != nil {
return nil, err
}
if apiKey.Valid {
idx.APIKey = &apiKey.String
}
idx.Categories = json.RawMessage(categories)
idx.Settings = json.RawMessage(settings)
if lastSuccessAt.Valid {
idx.LastSuccessAt = &lastSuccessAt.Time
}
if disabledUntil.Valid {
idx.DisabledUntil = &disabledUntil.Time
}
return &idx, nil
}
func indexerToResponse(idx *Indexer) IndexerResponse {
return IndexerResponse{
ID: idx.ID,
Name: idx.Name,
Implementation: idx.Implementation,
URL: idx.URL,
Categories: idx.Categories,
Settings: idx.Settings,
Enabled: idx.Enabled,
Priority: idx.Priority,
LastSuccessAt: idx.LastSuccessAt,
FailureCount: idx.FailureCount,
DisabledUntil: idx.DisabledUntil,
CreatedAt: idx.CreatedAt,
UpdatedAt: idx.UpdatedAt,
}
}
func (s *IndexerService) List(ctx context.Context) ([]IndexerResponse, error) {
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM indexers ORDER BY priority, name", indexerColumns))
if err != nil {
return nil, fmt.Errorf("list indexers: %w", err)
}
defer rows.Close()
var items []IndexerResponse
for rows.Next() {
idx, err := scanIndexer(rows)
if err != nil {
slog.Error("failed to scan indexer", "error", err)
continue
}
items = append(items, indexerToResponse(idx))
}
return items, nil
}
func (s *IndexerService) Create(ctx context.Context, req CreateIndexerRequest) (int64, error) {
categories := req.Categories
if categories == nil {
categories = json.RawMessage("[]")
}
settings := req.Settings
if settings == nil {
settings = json.RawMessage("{}")
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
priority := 0
if req.Priority != nil {
priority = *req.Priority
}
// For Cardigann indexers, extract URL from YAML definition
url := req.URL
if req.Implementation == "cardigann" {
cfg, err := s.GetCardigannConfig(settings)
if err != nil {
return 0, fmt.Errorf("invalid cardigann settings: %w", err)
}
def, err := cardigann.ParseDefinition([]byte(cfg.YAML))
if err != nil {
return 0, fmt.Errorf("invalid cardigann YAML: %w", err)
}
if len(def.Links) > 0 {
url = def.Links[0]
}
if req.Name == "" {
req.Name = def.Name
}
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO indexers (name, implementation, url, api_key, categories, settings, enabled, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
req.Name, req.Implementation, url, req.APIKey, categories, settings, enabled, priority).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create indexer: %w", err)
}
return id, nil
}
func (s *IndexerService) Update(ctx context.Context, id int64, req UpdateIndexerRequest) error {
var setClauses []string
var args []interface{}
idx := 1
addCol := func(col string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if req.Name != nil {
addCol("name", *req.Name)
}
if req.Implementation != nil {
addCol("implementation", *req.Implementation)
}
if req.URL != nil {
addCol("url", *req.URL)
}
if req.APIKey != nil {
addCol("api_key", *req.APIKey)
}
if req.Categories != nil {
addCol("categories", req.Categories)
}
if req.Settings != nil {
addCol("settings", req.Settings)
}
if req.Enabled != nil {
addCol("enabled", *req.Enabled)
}
if req.Priority != nil {
addCol("priority", *req.Priority)
}
if len(setClauses) == 0 {
return fmt.Errorf("no fields to update")
}
addCol("updated_at", time.Now())
query := fmt.Sprintf("UPDATE indexers SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), idx)
args = append(args, id)
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("update indexer: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("indexer not found")
}
return nil
}
func (s *IndexerService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM indexers WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete indexer: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("indexer not found")
}
return nil
}
func (s *IndexerService) Test(ctx context.Context, id int64) (*IndexerTestResult, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM indexers WHERE id = $1", indexerColumns), id)
idx, err := scanIndexer(row)
if err != nil {
return nil, fmt.Errorf("indexer not found")
}
// Cardigann indexers: parse YAML, perform connectivity check
if idx.Implementation == "cardigann" {
return s.testCardigannIndexer(ctx, idx)
}
testURL := idx.URL
switch idx.Implementation {
case "newznab", "torznab":
testURL = testURL + "/api?t=caps"
if idx.APIKey != nil && *idx.APIKey != "" {
testURL = testURL + "&apikey=" + *idx.APIKey
}
default:
testURL = strings.TrimRight(testURL, "/")
}
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
if err != nil {
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
resp, err := client.Do(req)
if err != nil {
s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", id)
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
resp.Body.Close()
if resp.StatusCode >= 400 {
s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", id)
return &IndexerTestResult{
Success: false,
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
StatusCode: resp.StatusCode,
}, nil
}
s.db.Pool.Exec(ctx,
"UPDATE indexers SET last_success_at = NOW(), failure_count = 0, updated_at = NOW() WHERE id = $1", id)
return &IndexerTestResult{
Success: true,
StatusCode: resp.StatusCode,
}, nil
}
func (s *IndexerService) testCardigannIndexer(ctx context.Context, idx *Indexer) (*IndexerTestResult, error) {
cfg, err := s.GetCardigannConfig(idx.Settings)
if err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("invalid cardigann config: %v", err)}, nil
}
def, err := cardigann.ParseDefinition([]byte(cfg.YAML))
if err != nil {
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("invalid YAML: %v", err)}, nil
}
// Use CardigannEngine for full test if available
if s.cardigannEngine != nil {
result, err := s.cardigannEngine.Test(ctx, def, cfg.Config)
if err != nil {
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
if !result.Success {
s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", idx.ID)
} else {
s.db.Pool.Exec(ctx,
"UPDATE indexers SET last_success_at = NOW(), failure_count = 0, updated_at = NOW() WHERE id = $1", idx.ID)
}
return &IndexerTestResult{
Success: result.Success,
Error: result.Error,
}, nil
}
// Fallback: basic connectivity check to first link
if len(def.Links) == 0 {
return &IndexerTestResult{Success: false, Error: "definition has no links"}, nil
}
testURL := def.Links[0]
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
if err != nil {
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
resp, err := client.Do(req)
if err != nil {
s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", idx.ID)
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
}
resp.Body.Close()
if resp.StatusCode >= 400 {
s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", idx.ID)
return &IndexerTestResult{
Success: false,
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
StatusCode: resp.StatusCode,
}, nil
}
s.db.Pool.Exec(ctx,
"UPDATE indexers SET last_success_at = NOW(), failure_count = 0, updated_at = NOW() WHERE id = $1", idx.ID)
return &IndexerTestResult{
Success: true,
StatusCode: resp.StatusCode,
}, nil
}
func (s *IndexerService) ListEnabled(ctx context.Context) ([]Indexer, error) {
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM indexers WHERE enabled = true AND (disabled_until IS NULL OR disabled_until < NOW()) ORDER BY priority, name", indexerColumns))
if err != nil {
return nil, fmt.Errorf("list enabled indexers: %w", err)
}
defer rows.Close()
var items []Indexer
for rows.Next() {
idx, err := scanIndexer(rows)
if err != nil {
slog.Error("failed to scan indexer", "error", err)
continue
}
items = append(items, *idx)
}
return items, nil
}
func (s *IndexerService) RecordSuccess(ctx context.Context, id int64) error {
_, err := s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = 0, last_success_at = NOW(), disabled_until = NULL, updated_at = NOW() WHERE id = $1", id)
if err != nil {
return fmt.Errorf("record indexer success: %w", err)
}
return nil
}
func (s *IndexerService) RecordFailure(ctx context.Context, id int64) error {
var failureCount int
err := s.db.Pool.QueryRow(ctx,
"SELECT failure_count FROM indexers WHERE id = $1", id).Scan(&failureCount)
if err != nil {
return fmt.Errorf("get indexer failure count: %w", err)
}
failureCount++
if failureCount >= 5 {
backoffMinutes := 1 << min(failureCount, 6)
if backoffMinutes > 60 {
backoffMinutes = 60
}
disabledUntil := time.Now().Add(time.Duration(backoffMinutes) * time.Minute)
_, err = s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = $1, disabled_until = $2, updated_at = NOW() WHERE id = $3",
failureCount, disabledUntil, id)
slog.Warn("indexer auto-disabled after consecutive failures", "id", id, "failure_count", failureCount, "disabled_until", disabledUntil)
} else {
_, err = s.db.Pool.Exec(ctx,
"UPDATE indexers SET failure_count = $1, updated_at = NOW() WHERE id = $2",
failureCount, id)
slog.Warn("indexer search failed", "id", id, "failure_count", failureCount)
}
if err != nil {
return fmt.Errorf("record indexer failure: %w", err)
}
return nil
}
func MediaTypeToCategory(mediaType string) string {
switch strings.ToLower(mediaType) {
case "movie":
return "2000"
case "series", "episode":
return "5000"
case "music", "album":
return "3000"
case "book":
return "7000"
case "audiobook":
return "3030"
default:
return ""
}
}
func (s *IndexerService) Stats(ctx context.Context, id int64) (*IndexerStats, error) {
var name string
var failureCount int
var lastSuccessAt sql.NullTime
err := s.db.Pool.QueryRow(ctx,
"SELECT name, failure_count, last_success_at FROM indexers WHERE id = $1", id,
).Scan(&name, &failureCount, &lastSuccessAt)
if err != nil {
return nil, fmt.Errorf("indexer not found")
}
var totalGrabs, totalFailed int
s.db.Pool.QueryRow(ctx,
`SELECT COUNT(*) FILTER (WHERE action IN ('grabbed', 'imported')),
COUNT(*) FILTER (WHERE action = 'failed')
FROM download_history WHERE indexer = $1`, name,
).Scan(&totalGrabs, &totalFailed)
successRate := 0.0
total := totalGrabs + totalFailed
if total > 0 {
successRate = float64(totalGrabs) / float64(total) * 100
}
result := &IndexerStats{
ID: id,
Name: name,
TotalGrabs: totalGrabs,
TotalFailed: totalFailed,
SuccessRate: successRate,
FailureCount: failureCount,
}
if lastSuccessAt.Valid {
result.LastSuccess = lastSuccessAt.Time.Format(time.RFC3339)
}
return result, nil
}

221
internal/service/matcher.go Normal file
View File

@@ -0,0 +1,221 @@
package service
import (
"context"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type MatchResult struct {
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
Season *int `json:"season,omitempty"`
Episode *int `json:"episode,omitempty"`
RootFolder string `json:"root_folder"`
Confidence string `json:"confidence"`
}
type MatcherService struct {
db *db.DB
}
func NewMatcherService(database *db.DB) *MatcherService {
return &MatcherService{db: database}
}
var (
seasonEpisodeRe = regexp.MustCompile(`(?i)[sS](\d{1,2})[eE](\d{1,2})`)
altSeasonEpsRe = regexp.MustCompile(`(\d{1,2})[xX](\d{1,2})`)
bracketRe2 = regexp.MustCompile(`\[.*?\]`)
qualityTrailRe = regexp.MustCompile(`(?i)(?:[sS]\d{1,2}[eE]\d{1,2}|\d{3,4}[pi]|720|1080|2160|HDTV|WEB|BluRay|BRRip|BDRip|DVDRip|REMUX|x264|x265|HEVC|AAC|DTS|AC3|DD|FLAC).*$`)
sepRe = regexp.MustCompile(`[._-]+`)
punctRe = regexp.MustCompile(`[^\w\s]`)
multiSpaceRe = regexp.MustCompile(`\s+`)
)
func normalizeTitle(s string) string {
s = strings.ToLower(s)
s = punctRe.ReplaceAllString(s, " ")
s = multiSpaceRe.ReplaceAllString(s, " ")
return strings.TrimSpace(s)
}
func parseSeasonEpisode(s string) (season, episode int, found bool) {
if m := seasonEpisodeRe.FindStringSubmatch(s); m != nil {
season = atoi(m[1])
episode = atoi(m[2])
return season, episode, true
}
if m := altSeasonEpsRe.FindStringSubmatch(s); m != nil {
season = atoi(m[1])
episode = atoi(m[2])
return season, episode, true
}
return 0, 0, false
}
func atoi(s string) int {
n := 0
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
}
}
return n
}
func extractCleanTitle(releaseName string) string {
cleaned := bracketRe2.ReplaceAllString(releaseName, " ")
if m := seasonEpisodeRe.FindStringIndex(cleaned); m != nil {
cleaned = cleaned[:m[0]]
} else if m := qualityTrailRe.FindStringIndex(cleaned); m != nil {
cleaned = cleaned[:m[0]]
}
cleaned = sepRe.ReplaceAllString(cleaned, " ")
return normalizeTitle(cleaned)
}
func levenshteinDistance(a, b string) int {
la, lb := len(a), len(b)
if la == 0 {
return lb
}
if lb == 0 {
return la
}
prev := make([]int, lb+1)
curr := make([]int, lb+1)
for j := 0; j <= lb; j++ {
prev[j] = j
}
for i := 1; i <= la; i++ {
curr[0] = i
for j := 1; j <= lb; j++ {
cost := 1
if a[i-1] == b[j-1] {
cost = 0
}
curr[j] = minOf3(
prev[j]+1,
curr[j-1]+1,
prev[j-1]+cost,
)
}
prev, curr = curr, prev
}
return prev[lb]
}
func minOf3(a, b, c int) int {
if a < b {
if a < c {
return a
}
return c
}
if b < c {
return b
}
return c
}
func (s *MatcherService) Match(ctx context.Context, releaseName string, mediaType string) (*MatchResult, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
season, episode, hasSE := parseSeasonEpisode(releaseName)
cleanTitle := extractCleanTitle(releaseName)
if cleanTitle == "" {
return &MatchResult{Confidence: "none"}, nil
}
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
qb.AddLiteral("monitored = true")
if mediaType == "series" || hasSE {
qb.AddLiteral("media_type IN ('series', 'episode')")
} else if mediaType != "" {
qb.AddLiteral("media_type NOT IN ('series', 'episode')")
qb.Add("media_type = $%d", mediaType)
}
query := fmt.Sprintf("SELECT %s FROM media%s", mediaColumns, qb.Where())
rows, err := s.db.Pool.Query(ctx, query, qb.Args()...)
if err != nil {
slog.Error("failed to query media for matching", "error", err)
return nil, fmt.Errorf("query media candidates: %w", err)
}
defer rows.Close()
candidates, err := scanMediaRows(rows)
if err != nil {
return nil, fmt.Errorf("scan media candidates: %w", err)
}
var exactMatch *Media
var fuzzyMatch *Media
var fuzzyDist int
for i := range candidates {
c := &candidates[i]
norm := normalizeTitle(c.Title)
if norm == cleanTitle {
exactMatch = c
break
}
dist := levenshteinDistance(cleanTitle, norm)
if dist <= 2 {
if fuzzyMatch == nil || dist < fuzzyDist {
fuzzyMatch = c
fuzzyDist = dist
}
}
}
matched := exactMatch
confidence := "exact"
if matched == nil && fuzzyMatch != nil {
matched = fuzzyMatch
confidence = "fuzzy"
}
if matched == nil {
return &MatchResult{Confidence: "none"}, nil
}
result := &MatchResult{
MediaID: matched.ID,
MediaType: matched.MediaType,
Title: matched.Title,
Year: matched.Year,
Confidence: confidence,
}
if hasSE {
result.Season = &season
result.Episode = &episode
}
if matched.RootFolderID != nil {
var path string
if err := s.db.Pool.QueryRow(ctx,
"SELECT path FROM root_folders WHERE id = $1", *matched.RootFolderID).Scan(&path); err != nil {
slog.Error("failed to query root folder", "error", err, "root_folder_id", *matched.RootFolderID)
} else {
result.RootFolder = path
}
}
return result, nil
}

621
internal/service/media.go Normal file
View File

@@ -0,0 +1,621 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/jackc/pgx/v5"
)
type Media struct {
ID int64 `json:"id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
SortTitle string `json:"sort_title"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
Status string `json:"status"`
Monitored bool `json:"monitored"`
ExternalIDs json.RawMessage `json:"external_ids"`
Metadata json.RawMessage `json:"metadata"`
Images json.RawMessage `json:"images"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
QualityUpgradeNeeded bool `json:"quality_upgrade_needed"`
AddedAt time.Time `json:"added_at"`
LastSearchAt *time.Time `json:"last_search_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type MediaFile struct {
ID int64 `json:"id"`
MediaID int64 `json:"media_id"`
Path string `json:"path"`
OriginalPath *string `json:"original_path,omitempty"`
FileName string `json:"file_name"`
FileSize int64 `json:"file_size"`
Quality json.RawMessage `json:"quality"`
Codec *string `json:"codec,omitempty"`
Resolution *string `json:"resolution,omitempty"`
Source *string `json:"source,omitempty"`
TranscodeStatus string `json:"transcode_status"`
CreatedAt time.Time `json:"created_at"`
}
type MediaRelation struct {
ID int64 `json:"id"`
ParentID int64 `json:"parent_id"`
ChildID int64 `json:"child_id"`
Relation string `json:"relation"`
Position *int `json:"position,omitempty"`
Season *int `json:"season,omitempty"`
}
type MediaDetail struct {
Media Media `json:"media"`
Files []MediaFile `json:"files"`
Relations []MediaRelation `json:"relations"`
}
type MediaFilters struct {
MediaType string
Status string
Monitored string
Query string
Tag string
Page int
PageSize int
}
type CreateMediaRequest struct {
MediaType string `json:"media_type"`
Title string `json:"title"`
SortTitle string `json:"sort_title,omitempty"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
Status string `json:"status"`
Monitored bool `json:"monitored"`
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
}
type UpdateMediaRequest struct {
Title *string `json:"title,omitempty"`
SortTitle *string `json:"sort_title,omitempty"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
Status *string `json:"status,omitempty"`
Monitored *bool `json:"monitored,omitempty"`
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Images json.RawMessage `json:"images,omitempty"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
}
const mediaColumns = `id, media_type, title, sort_title, original_title, overview, year,
release_date,
status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id,
current_quality, desired_quality,
has_files,
CASE
WHEN has_files AND desired_quality IS NOT NULL
AND current_quality IS NOT NULL
AND current_quality::text != desired_quality::text
THEN true
ELSE false
END AS quality_upgrade_needed,
added_at, last_search_at, updated_at`
type MediaService struct {
db *db.DB
}
func NewMediaService(database *db.DB) *MediaService {
return &MediaService{db: database}
}
func scanMedia(scanner interface{ Scan(...interface{}) error }) (*Media, error) {
var m Media
var origTitle, overview sql.NullString
var year sql.NullInt64
var releaseDate sql.NullTime
var qpID, rfID sql.NullInt64
var lastSearchAt sql.NullTime
var hasFiles bool
err := scanner.Scan(&m.ID, &m.MediaType, &m.Title, &m.SortTitle, &origTitle, &overview, &year,
&releaseDate,
&m.Status, &m.Monitored, &m.ExternalIDs, &m.Metadata, &m.Images, &qpID, &rfID,
&m.CurrentQuality, &m.DesiredQuality, &hasFiles, &m.QualityUpgradeNeeded, &m.AddedAt, &lastSearchAt, &m.UpdatedAt)
if err != nil {
return nil, err
}
if origTitle.Valid {
m.OriginalTitle = &origTitle.String
}
if overview.Valid {
m.Overview = &overview.String
}
if year.Valid {
y := int(year.Int64)
m.Year = &y
}
if releaseDate.Valid {
m.ReleaseDate = &releaseDate.Time
}
if qpID.Valid {
m.QualityProfileID = &qpID.Int64
}
if rfID.Valid {
m.RootFolderID = &rfID.Int64
}
if lastSearchAt.Valid {
m.LastSearchAt = &lastSearchAt.Time
}
return &m, nil
}
type mediaWithTotal struct {
Media
total int
}
func scanMediaRowWithTotal(scanner interface{ Scan(...interface{}) error }) (*mediaWithTotal, error) {
var m Media
var origTitle, overview sql.NullString
var year sql.NullInt64
var releaseDate sql.NullTime
var qpID, rfID sql.NullInt64
var lastSearchAt sql.NullTime
var hasFiles bool
var total int
err := scanner.Scan(&m.ID, &m.MediaType, &m.Title, &m.SortTitle, &origTitle, &overview, &year,
&releaseDate,
&m.Status, &m.Monitored, &m.ExternalIDs, &m.Metadata, &m.Images, &qpID, &rfID,
&m.CurrentQuality, &m.DesiredQuality, &hasFiles, &m.QualityUpgradeNeeded, &m.AddedAt, &lastSearchAt, &m.UpdatedAt,
&total)
if err != nil {
return nil, err
}
if origTitle.Valid {
m.OriginalTitle = &origTitle.String
}
if overview.Valid {
m.Overview = &overview.String
}
if year.Valid {
y := int(year.Int64)
m.Year = &y
}
if releaseDate.Valid {
m.ReleaseDate = &releaseDate.Time
}
if qpID.Valid {
m.QualityProfileID = &qpID.Int64
}
if rfID.Valid {
m.RootFolderID = &rfID.Int64
}
if lastSearchAt.Valid {
m.LastSearchAt = &lastSearchAt.Time
}
return &mediaWithTotal{Media: m, total: total}, nil
}
func scanMediaRows(rows pgx.Rows) ([]Media, error) {
var results []Media
for rows.Next() {
m, err := scanMedia(rows)
if err != nil {
return nil, fmt.Errorf("scan media row: %w", err)
}
results = append(results, *m)
}
return results, nil
}
func buildMediaFilters(filters *MediaFilters) *QueryBuilder {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
if filters.Status != "" {
qb.Add("status = $%d", filters.Status)
}
if filters.Monitored != "" {
qb.Add("monitored = $%d", filters.Monitored == "true")
}
return qb
}
func (s *MediaService) List(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := buildMediaFilters(&filters)
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("list media: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan media row: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func (s *MediaService) GetByID(ctx context.Context, id int64, mediaType string) (*MediaDetail, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
qb.Add("id = $%d", id)
if mediaType != "" {
qb.Add("media_type = $%d", mediaType)
}
row := s.db.Pool.QueryRow(ctx,
"SELECT "+mediaColumns+" FROM media"+qb.Where(), qb.Args()...)
m, err := scanMedia(row)
if err != nil {
return nil, fmt.Errorf("get media: %w", err)
}
detail := &MediaDetail{Media: *m}
fileRows, err := s.db.Pool.Query(ctx,
`SELECT id, media_id, path, original_path, file_name, file_size, quality, codec, resolution, source, transcode_status, created_at
FROM media_files WHERE media_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC`, id)
if err == nil {
defer fileRows.Close()
for fileRows.Next() {
var f MediaFile
var origPath, codec, resolution, source sql.NullString
if err := fileRows.Scan(&f.ID, &f.MediaID, &f.Path, &origPath, &f.FileName, &f.FileSize,
&f.Quality, &codec, &resolution, &source, &f.TranscodeStatus, &f.CreatedAt); err != nil {
slog.Error("failed to scan media file", "error", err)
continue
}
if origPath.Valid {
f.OriginalPath = &origPath.String
}
if codec.Valid {
f.Codec = &codec.String
}
if resolution.Valid {
f.Resolution = &resolution.String
}
if source.Valid {
f.Source = &source.String
}
detail.Files = append(detail.Files, f)
}
}
relRows, err := s.db.Pool.Query(ctx,
`SELECT id, parent_id, child_id, relation, position, season
FROM media_relations WHERE parent_id = $1 OR child_id = $1 ORDER BY relation, position`, id)
if err == nil {
defer relRows.Close()
for relRows.Next() {
var r MediaRelation
if err := relRows.Scan(&r.ID, &r.ParentID, &r.ChildID, &r.Relation, &r.Position, &r.Season); err != nil {
slog.Error("failed to scan media relation", "error", err)
continue
}
detail.Relations = append(detail.Relations, r)
}
}
return detail, nil
}
func (s *MediaService) Create(ctx context.Context, req CreateMediaRequest) (int64, error) {
if req.SortTitle == "" {
req.SortTitle = req.Title
}
if req.Status == "" {
req.Status = "unavailable"
}
if req.ExternalIDs == nil {
req.ExternalIDs = json.RawMessage("{}")
}
if req.Metadata == nil {
req.Metadata = json.RawMessage("{}")
}
if req.Images == nil {
req.Images = json.RawMessage("[]")
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO media (media_type, title, sort_title, original_title, overview, year,
release_date,
status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id,
current_quality, desired_quality)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`,
req.MediaType, req.Title, req.SortTitle, req.OriginalTitle, req.Overview, req.Year,
req.ReleaseDate,
req.Status, req.Monitored, req.ExternalIDs, req.Metadata, req.Images,
req.QualityProfileID, req.RootFolderID, req.CurrentQuality, req.DesiredQuality).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create media: %w", err)
}
return id, nil
}
func (s *MediaService) Update(ctx context.Context, id int64, mediaType string, req UpdateMediaRequest) error {
var setClauses []string
var args []interface{}
idx := 1
addCol := func(col string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if req.Title != nil {
addCol("title", *req.Title)
}
if req.SortTitle != nil {
addCol("sort_title", *req.SortTitle)
}
if req.OriginalTitle != nil {
addCol("original_title", *req.OriginalTitle)
}
if req.Overview != nil {
addCol("overview", *req.Overview)
}
if req.Year != nil {
addCol("year", *req.Year)
}
if req.Status != nil {
addCol("status", *req.Status)
}
if req.Monitored != nil {
addCol("monitored", *req.Monitored)
}
if req.ExternalIDs != nil {
addCol("external_ids", req.ExternalIDs)
}
if req.Metadata != nil {
addCol("metadata", req.Metadata)
}
if req.Images != nil {
addCol("images", req.Images)
}
if req.QualityProfileID != nil {
addCol("quality_profile_id", *req.QualityProfileID)
}
if req.RootFolderID != nil {
addCol("root_folder_id", *req.RootFolderID)
}
if req.CurrentQuality != nil {
addCol("current_quality", req.CurrentQuality)
}
if req.DesiredQuality != nil {
addCol("desired_quality", req.DesiredQuality)
}
if len(setClauses) == 0 {
return fmt.Errorf("no fields to update")
}
addCol("updated_at", time.Now())
query := fmt.Sprintf("UPDATE media SET %s WHERE id = $%d AND deleted_at IS NULL",
strings.Join(setClauses, ", "), idx)
args = append(args, id)
if mediaType != "" {
query += fmt.Sprintf(" AND media_type = $%d", idx+1)
args = append(args, mediaType)
}
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("update media: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("media not found")
}
return nil
}
func (s *MediaService) Delete(ctx context.Context, id int64, mediaType string) error {
query := "UPDATE media SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
args := []interface{}{id}
if mediaType != "" {
query += " AND media_type = $2"
args = append(args, mediaType)
}
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("delete media: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("media not found")
}
return nil
}
func (s *MediaService) Search(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
if filters.Query != "" {
qb.Add("to_tsvector('english', coalesce(title, '')) @@ plainto_tsquery('english', $%d)", filters.Query)
}
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
if filters.Status != "" {
qb.Add("status = $%d", filters.Status)
}
if filters.Tag != "" {
qb.Add("id IN (SELECT mt.media_id FROM media_tags mt JOIN tags t ON mt.tag_id = t.id WHERE t.name = $%d)", filters.Tag)
}
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("search media: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan search results: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func (s *MediaService) SearchMissing(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("monitored = true")
qb.AddLiteral("status = 'unavailable'")
qb.AddLiteral("deleted_at IS NULL")
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query missing media: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan missing media: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func (s *MediaService) SearchUpgrades(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("deleted_at IS NULL")
qb.AddLiteral("monitored = true")
qb.AddLiteral("has_files = true")
qb.AddLiteral("current_quality IS NOT NULL")
qb.AddLiteral("desired_quality IS NOT NULL")
qb.AddLiteral("current_quality::text != desired_quality::text")
if filters.MediaType != "" {
qb.Add("media_type = $%d", filters.MediaType)
}
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("query upgrades: %w", err)
}
defer rows.Close()
var items []Media
var total int
for rows.Next() {
m, err := scanMediaRowWithTotal(rows)
if err != nil {
return nil, 0, fmt.Errorf("scan upgrades: %w", err)
}
if total == 0 {
total = m.total
}
items = append(items, m.Media)
}
return items, total, nil
}
func CalcTotalPages(total, pageSize int) int {
totalPages := total / pageSize
if total%pageSize > 0 {
totalPages++
}
return totalPages
}
func ParsePagination(pageStr, pageSizeStr string) (page, pageSize int) {
page, _ = strconv.Atoi(pageStr)
if page < 1 {
page = 1
}
pageSize, _ = strconv.Atoi(pageSizeStr)
if pageSize < 1 || pageSize > 100 {
pageSize = 50
}
return
}

Some files were not shown because too many files have changed in this diff Show More