Sync from /srv/compose/unified-media-manager
This commit is contained in:
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"})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user