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