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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

View File

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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