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"})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1036
internal/migrate/import.go Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,337 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/jackc/pgx/v5"
)
// FullMediaDetail is the comprehensive detail response for the MediaDetail page.
type FullMediaDetail struct {
Media MediaDetail `json:"media"`
QualityProfile *QualityProfileInfo `json:"quality_profile,omitempty"`
FilesWithSubs []FileWithSubtitles `json:"files_with_subtitles"`
Episodes []EpisodeInfo `json:"episodes,omitempty"`
History []MediaHistoryItem `json:"history"`
}
// QualityProfileInfo contains the quality profile data for the detail response.
type QualityProfileInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
CutoffQuality json.RawMessage `json:"cutoff_quality"`
AllowedQualities json.RawMessage `json:"allowed_qualities"`
}
// FileWithSubtitles extends MediaFile with associated subtitle information.
type FileWithSubtitles struct {
MediaFile
Subtitles []SubtitleInfo `json:"subtitles,omitempty"`
}
// SubtitleInfo represents a subtitle file associated with a media file.
type SubtitleInfo struct {
FileName string `json:"file_name"`
Language string `json:"language"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
Source string `json:"source"`
}
// EpisodeInfo represents a single episode within a series.
type EpisodeInfo struct {
MediaID int64 `json:"media_id"`
Title string `json:"title"`
Season int `json:"season"`
Episode int `json:"episode"`
Status string `json:"status"`
Monitored bool `json:"monitored"`
AirDate *string `json:"air_date,omitempty"`
HasFile bool `json:"has_file"`
Quality json.RawMessage `json:"quality,omitempty"`
}
// MediaHistoryItem represents an activity event in the media detail history.
type MediaHistoryItem struct {
ID int64 `json:"id"`
EventType string `json:"event_type"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
Data json.RawMessage `json:"data"`
CreatedAt time.Time `json:"created_at"`
}
// MediaDetailService aggregates data for the media detail page using pgx.Batch.
type MediaDetailService struct {
db *db.DB
mediaSvc *MediaService
activitySvc *ActivityService
}
// NewMediaDetailService creates a new MediaDetailService.
func NewMediaDetailService(database *db.DB, mediaSvc *MediaService, activitySvc *ActivityService) *MediaDetailService {
return &MediaDetailService{db: database, mediaSvc: mediaSvc, activitySvc: activitySvc}
}
// GetFullDetail returns the complete media detail using pgx.Batch for parallel queries.
func (s *MediaDetailService) GetFullDetail(ctx context.Context, id int64, mediaType string) (*FullMediaDetail, error) {
// Step 1: Get base media detail via existing service
baseDetail, err := s.mediaSvc.GetByID(ctx, id, mediaType)
if err != nil {
return nil, fmt.Errorf("get media detail: %w", err)
}
result := &FullMediaDetail{
Media: *baseDetail,
}
// Step 2: Build a pgx.Batch with queries
batch := &pgx.Batch{}
hasQualityProfile := baseDetail.Media.QualityProfileID != nil
hasEpisodes := mediaType == "series"
// Query: Quality profile
if hasQualityProfile {
batch.Queue(
"SELECT id, name, cutoff_quality, allowed_qualities FROM quality_profiles WHERE id = $1",
*baseDetail.Media.QualityProfileID,
)
}
// Query: Activity history
batch.Queue(
"SELECT id, event_type, title, description, data, created_at FROM activity_events WHERE media_id = $1 ORDER BY created_at DESC LIMIT 100",
id,
)
// Query: Episode children (series only)
if hasEpisodes {
batch.Queue(
`SELECT m.id, m.title, mr.season, mr.position, m.status, m.monitored,
EXISTS(SELECT 1 FROM media_files mf WHERE mf.media_id = m.id AND mf.deleted_at IS NULL) as has_file,
mf.quality
FROM media m
JOIN media_relations mr ON mr.child_id = m.id
LEFT JOIN LATERAL (SELECT quality FROM media_files WHERE media_id = m.id AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1) mf ON true
WHERE mr.parent_id = $1 AND mr.relation = 'episode'
ORDER BY mr.season, mr.position`,
id,
)
}
// Step 3: Send batch
batchResults := s.db.Pool.SendBatch(ctx, batch)
defer batchResults.Close()
// Step 4: Read results in the same order they were queued
// Read quality profile
if hasQualityProfile {
row := batchResults.QueryRow()
var qp QualityProfileInfo
if err := row.Scan(&qp.ID, &qp.Name, &qp.CutoffQuality, &qp.AllowedQualities); err == nil {
result.QualityProfile = &qp
}
}
// Read activity history
historyRows, err := batchResults.Query()
if err != nil {
slog.Error("read activity history from batch", "error", err)
} else {
defer historyRows.Close()
for historyRows.Next() {
var item MediaHistoryItem
var description sql.NullString
var data []byte
if err := historyRows.Scan(&item.ID, &item.EventType, &item.Title, &description, &data, &item.CreatedAt); err != nil {
slog.Error("scan history item", "error", err)
continue
}
if description.Valid {
item.Description = &description.String
}
if data != nil {
item.Data = json.RawMessage(data)
}
result.History = append(result.History, item)
}
}
// Read episodes (series only)
if hasEpisodes {
epRows, err := batchResults.Query()
if err != nil {
slog.Error("read episodes from batch", "error", err)
} else {
defer epRows.Close()
for epRows.Next() {
var ep EpisodeInfo
var season, position sql.NullInt64
var airDate sql.NullString
var quality []byte
if err := epRows.Scan(&ep.MediaID, &ep.Title, &season, &position, &ep.Status, &ep.Monitored, &ep.HasFile, &quality); err != nil {
slog.Error("scan episode", "error", err)
continue
}
if season.Valid {
ep.Season = int(season.Int64)
}
if position.Valid {
ep.Episode = int(position.Int64)
}
if airDate.Valid {
ep.AirDate = &airDate.String
}
if quality != nil {
ep.Quality = json.RawMessage(quality)
}
result.Episodes = append(result.Episodes, ep)
}
}
}
// Step 5: Build subtitle info for each media file (from DB cache)
result.FilesWithSubs = s.buildFilesWithSubtitlesFromDB(ctx, baseDetail.Files)
return result, nil
}
func (s *MediaDetailService) buildFilesWithSubtitlesFromDB(ctx context.Context, files []MediaFile) []FileWithSubtitles {
if len(files) == 0 {
return []FileWithSubtitles{}
}
fileIDs := make([]interface{}, len(files))
for i, f := range files {
fileIDs[i] = f.ID
}
subMap := make(map[int64][]SubtitleInfo)
if len(fileIDs) > 0 {
rows, err := s.db.Pool.Query(ctx,
`SELECT media_file_id, file_name, language, language_code, hi, forced, source
FROM media_subtitles WHERE media_file_id = ANY($1)`, fileIDs)
if err == nil {
defer rows.Close()
for rows.Next() {
var fileID int64
var sub SubtitleInfo
if err := rows.Scan(&fileID, &sub.FileName, &sub.Language, &sub.LanguageCode, &sub.HI, &sub.Forced, &sub.Source); err == nil {
subMap[fileID] = append(subMap[fileID], sub)
}
}
}
}
result := make([]FileWithSubtitles, len(files))
for i, f := range files {
subs := subMap[f.ID]
if subs == nil {
subs = scanSubtitleFiles(f)
}
result[i] = FileWithSubtitles{
MediaFile: f,
Subtitles: subs,
}
}
return result
}
// buildFilesWithSubtitles creates FileWithSubtitles entries with scanned subtitle files.
func buildFilesWithSubtitles(files []MediaFile) []FileWithSubtitles {
if len(files) == 0 {
return []FileWithSubtitles{}
}
result := make([]FileWithSubtitles, len(files))
for i, f := range files {
subs := scanSubtitleFiles(f)
result[i] = FileWithSubtitles{
MediaFile: f,
Subtitles: subs,
}
}
return result
}
// scanSubtitleFiles looks for .srt sidecar files next to the media file.
func scanSubtitleFiles(f MediaFile) []SubtitleInfo {
if f.Path == "" {
return nil
}
dir := filepath.Dir(f.Path)
base := strings.TrimSuffix(f.FileName, filepath.Ext(f.FileName))
pattern := filepath.Join(filepath.Clean(dir), base+"*.srt")
matches, err := filepath.Glob(pattern)
if err != nil {
return nil
}
var subs []SubtitleInfo
for _, match := range matches {
filename := filepath.Base(match)
langCode, hi, forced := parseSubtitleFilename(filename, base)
if langCode == "" {
continue
}
source := "downloaded"
if hi || forced {
source = "extracted"
}
subs = append(subs, SubtitleInfo{
FileName: filename,
Language: langCode,
LanguageCode: langCode,
HI: hi,
Forced: forced,
Source: source,
})
}
return subs
}
// parseSubtitleFilename extracts language code and flags from a subtitle filename.
// Pattern: basename.lang[.sdh|.forced].srt
func parseSubtitleFilename(filename, baseName string) (langCode string, hi bool, forced bool) {
remainder := filename
if strings.HasPrefix(filename, baseName+".") {
remainder = filename[len(baseName)+1:]
}
remainder = strings.TrimSuffix(remainder, ".srt")
parts := strings.Split(remainder, ".")
if len(parts) == 0 {
return "", false, false
}
langCode = parts[0]
if langCode == "" {
return "", false, false
}
for _, part := range parts[1:] {
switch strings.ToLower(part) {
case "sdh", "hi":
hi = true
case "forced":
forced = true
}
}
return langCode, hi, forced
}

View File

@@ -0,0 +1,335 @@
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type MetadataSearchResult struct {
ProviderID string `json:"provider_id"`
Title string `json:"title"`
Year *int `json:"year,omitempty"`
MediaType string `json:"media_type"`
Overview string `json:"overview,omitempty"`
OriginalTitle string `json:"original_title,omitempty"`
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
}
type MetadataDetails struct {
ProviderID string `json:"provider_id"`
Title string `json:"title"`
OriginalTitle *string `json:"original_title,omitempty"`
Overview *string `json:"overview,omitempty"`
Year *int `json:"year,omitempty"`
Ratings json.RawMessage `json:"ratings,omitempty"`
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Images []ImageResult `json:"images,omitempty"`
}
type ImageResult struct {
URL string `json:"url"`
Type string `json:"type"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type SearchOptions struct {
Year *int
MediaType string
Page int
}
type MetadataProvider interface {
Name() string
Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error)
GetDetails(ctx context.Context, id string) (*MetadataDetails, error)
GetImages(ctx context.Context, id string) ([]ImageResult, error)
}
type MetadataService struct {
db *db.DB
mediaSvc *MediaService
providers map[string]MetadataProvider
imageDir string
httpClient *http.Client
}
func NewMetadataService(database *db.DB, mediaSvc *MediaService, imageDir string) *MetadataService {
return &MetadataService{
db: database,
mediaSvc: mediaSvc,
providers: make(map[string]MetadataProvider),
imageDir: imageDir,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (s *MetadataService) RegisterProvider(provider MetadataProvider) {
s.providers[provider.Name()] = provider
}
func (s *MetadataService) GetCached(ctx context.Context, provider, providerID string) (*MetadataDetails, error) {
var data []byte
err := s.db.Pool.QueryRow(ctx,
"SELECT data FROM metadata_cache WHERE provider = $1 AND provider_id = $2 AND expires_at > NOW()",
provider, providerID).Scan(&data)
if err != nil {
return nil, nil
}
var details MetadataDetails
if err := json.Unmarshal(data, &details); err != nil {
return nil, nil
}
return &details, nil
}
func (s *MetadataService) SetCached(ctx context.Context, provider, providerID, mediaType string, data *MetadataDetails, ttl time.Duration) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal cache data: %w", err)
}
_, err = s.db.Pool.Exec(ctx,
`INSERT INTO metadata_cache (provider, provider_id, media_type, data, expires_at)
VALUES ($1, $2, $3, $4, NOW() + $5::interval)
ON CONFLICT (provider, provider_id) DO UPDATE SET data = $4, media_type = $3, cached_at = NOW(), expires_at = NOW() + $5::interval`,
provider, providerID, mediaType, jsonData, fmt.Sprintf("%d seconds", int(ttl.Seconds())))
if err != nil {
return fmt.Errorf("upsert metadata cache: %w", err)
}
return nil
}
func (s *MetadataService) DownloadImage(ctx context.Context, imageURL, mediaType, filename string) error {
dir := filepath.Join(s.imageDir, mediaType)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create image directory: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil)
if err != nil {
return fmt.Errorf("create image request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download image failed: status %d", resp.StatusCode)
}
contentType := resp.Header.Get("Content-Type")
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("invalid content type: %s", contentType)
}
destPath := filepath.Join(dir, filename)
f, err := os.Create(destPath)
if err != nil {
return fmt.Errorf("create image file: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, resp.Body); err != nil {
return fmt.Errorf("write image file: %w", err)
}
return nil
}
func (s *MetadataService) RefreshMetadata(ctx context.Context, mediaID int64, mediaType string) error {
detail, err := s.mediaSvc.GetByID(ctx, mediaID, mediaType)
if err != nil {
return fmt.Errorf("get media: %w", err)
}
media := detail.Media
var existingIDs map[string]interface{}
if err := json.Unmarshal(media.ExternalIDs, &existingIDs); err != nil {
existingIDs = make(map[string]interface{})
}
var allMetadata map[string]interface{}
if err := json.Unmarshal(media.Metadata, &allMetadata); err != nil {
allMetadata = make(map[string]interface{})
}
var existingImages []map[string]interface{}
if err := json.Unmarshal(media.Images, &existingImages); err != nil {
existingImages = []map[string]interface{}{}
}
var updatedOverview *string
var updatedOriginalTitle *string
var updatedYear *int
for name, provider := range s.providers {
providerID := ""
if idVal, ok := existingIDs[name]; ok {
providerID = fmt.Sprintf("%v", idVal)
}
if providerID == "" {
results, err := provider.Search(ctx, media.Title, SearchOptions{
Year: media.Year,
MediaType: mediaType,
})
if err != nil {
slog.Error("provider search failed", "provider", name, "error", err)
continue
}
if len(results) == 0 {
continue
}
providerID = results[0].ProviderID }
cached, _ := s.GetCached(ctx, name, providerID)
var metaDetails *MetadataDetails
if cached != nil {
metaDetails = cached
} else {
details, err := provider.GetDetails(ctx, providerID)
if err != nil {
slog.Error("provider get details failed", "provider", name, "error", err)
continue
}
metaDetails = details
if err := s.SetCached(ctx, name, providerID, mediaType, details, 7*24*time.Hour); err != nil {
slog.Error("cache metadata failed", "provider", name, "error", err)
}
}
existingIDs[name] = providerID
if metaDetails.ExternalIDs != nil {
var providerIDs map[string]interface{}
if err := json.Unmarshal(metaDetails.ExternalIDs, &providerIDs); err == nil {
for k, v := range providerIDs {
existingIDs[k] = v
}
}
}
if metaDetails.Overview != nil && *metaDetails.Overview != "" {
updatedOverview = metaDetails.Overview
}
if metaDetails.OriginalTitle != nil && *metaDetails.OriginalTitle != "" {
updatedOriginalTitle = metaDetails.OriginalTitle
}
if metaDetails.Year != nil {
updatedYear = metaDetails.Year
}
if metaDetails.Metadata != nil {
var providerMeta map[string]interface{}
if err := json.Unmarshal(metaDetails.Metadata, &providerMeta); err == nil {
allMetadata[name] = providerMeta
}
}
if metaDetails.Ratings != nil {
var ratings map[string]interface{}
if err := json.Unmarshal(metaDetails.Ratings, &ratings); err == nil {
allMetadata[name+"_ratings"] = ratings
}
}
images, err := provider.GetImages(ctx, providerID)
if err != nil {
slog.Error("provider get images failed", "provider", name, "error", err)
continue
}
for _, img := range images {
ext := filepath.Ext(img.URL)
if ext == "" {
ext = ".jpg"
}
filename := fmt.Sprintf("%s_%s_%d%s", name, img.Type, mediaID, ext)
imgCtx, imgCancel := context.WithTimeout(ctx, 15*time.Second)
if err := s.DownloadImage(imgCtx, img.URL, mediaType, filename); err != nil {
slog.Error("download image failed", "provider", name, "error", err)
imgCancel()
continue
}
imgCancel()
localPath := fmt.Sprintf("/api/images/%s/%s", mediaType, filename)
existingImages = append(existingImages, map[string]interface{}{
"url": localPath,
"type": img.Type,
"width": img.Width,
"height": img.Height,
"source": name,
})
}
}
externalIDsJSON, _ := json.Marshal(existingIDs)
metadataJSON, _ := json.Marshal(allMetadata)
imagesJSON, _ := json.Marshal(existingImages)
updateReq := UpdateMediaRequest{
ExternalIDs: externalIDsJSON,
Metadata: metadataJSON,
Images: imagesJSON,
}
if updatedOverview != nil {
updateReq.Overview = updatedOverview
}
if updatedOriginalTitle != nil {
updateReq.OriginalTitle = updatedOriginalTitle
}
if updatedYear != nil {
updateReq.Year = updatedYear
}
if err := s.mediaSvc.Update(ctx, mediaID, mediaType, updateReq); err != nil {
return fmt.Errorf("update media metadata: %w", err)
}
return nil
}
func (s *MetadataService) RefreshAllMetadata(ctx context.Context) error {
rows, err := s.db.Pool.Query(ctx,
"SELECT id, media_type FROM media WHERE monitored = true AND deleted_at IS NULL")
if err != nil {
return fmt.Errorf("query monitored media: %w", err)
}
defer rows.Close()
for rows.Next() {
var id int64
var mediaType string
if err := rows.Scan(&id, &mediaType); err != nil {
slog.Error("scan media row", "error", err)
continue
}
itemCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
if err := s.RefreshMetadata(itemCtx, id, mediaType); err != nil {
slog.Error("refresh metadata failed", "media_id", id, "error", err)
}
cancel()
}
return nil
}

View File

@@ -0,0 +1,320 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type MusicBrainzProvider struct {
baseURL string
userAgent string
httpClient *http.Client
lastRequest time.Time
}
func NewMusicBrainzProvider() *MusicBrainzProvider {
return &MusicBrainzProvider{
baseURL: "https://musicbrainz.org/ws/2",
userAgent: "UnifiedMediaManager/1.0 (https://github.com/TopherMayor/unified-media-manager)",
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type mbArtistSearchResponse struct {
Count int `json:"count"`
Artists []mbArtist `json:"artists"`
}
type mbReleaseGroupSearchResponse struct {
Count int `json:"count"`
ReleaseGroups []mbReleaseGroup `json:"release-groups"`
}
type mbArtist struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sort-name"`
Disambiguation string `json:"disambiguation"`
LifeSpan mbLifeSpan `json:"life-span"`
}
type mbLifeSpan struct {
Begin string `json:"begin"`
End string `json:"end"`
}
type mbReleaseGroup struct {
ID string `json:"id"`
Title string `json:"title"`
FirstReleaseDate string `json:"first-release-date"`
Disambiguation string `json:"disambiguation"`
ArtistCredit []mbArtistCredit `json:"artist-credit"`
PrimaryType string `json:"primary-type"`
}
type mbArtistCredit struct {
Artist mbArtistRef `json:"artist"`
}
type mbArtistRef struct {
ID string `json:"id"`
Name string `json:"name"`
}
type mbArtistDetail struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sort-name"`
Disambiguation string `json:"disambiguation"`
LifeSpan mbLifeSpan `json:"life-span"`
}
type mbReleaseGroupDetail struct {
ID string `json:"id"`
Title string `json:"title"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []mbArtistCredit `json:"artist-credit"`
}
type coverArtResponse struct {
Images []coverArtImage `json:"images"`
}
type coverArtImage struct {
Image string `json:"image"`
Front bool `json:"front"`
}
func (p *MusicBrainzProvider) rateLimit() {
elapsed := time.Since(p.lastRequest)
if elapsed < time.Second {
time.Sleep(time.Second - elapsed)
}
p.lastRequest = time.Now()
}
func (p *MusicBrainzProvider) fetch(ctx context.Context, url string, result interface{}) error {
p.rateLimit()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", p.userAgent)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch musicbrainz: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("musicbrainz api returned status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("decode musicbrainz response: %w", err)
}
return nil
}
func (p *MusicBrainzProvider) Name() string {
return "musicbrainz"
}
func (p *MusicBrainzProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) {
if opts.MediaType == "album" {
return p.searchReleaseGroups(ctx, query)
}
return p.searchArtists(ctx, query)
}
func (p *MusicBrainzProvider) searchArtists(ctx context.Context, query string) ([]MetadataSearchResult, error) {
url := fmt.Sprintf("%s/artist?query=%s&fmt=json&limit=20", p.baseURL, query)
var resp mbArtistSearchResponse
if err := p.fetch(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("search musicbrainz artists: %w", err)
}
var results []MetadataSearchResult
for _, a := range resp.Artists {
var year *int
if len(a.LifeSpan.Begin) >= 4 {
y := 0
for _, c := range a.LifeSpan.Begin[:4] {
if c >= '0' && c <= '9' {
y = y*10 + int(c-'0')
}
}
year = &y
}
extIDs, _ := json.Marshal(map[string]string{"musicbrainz": a.ID})
results = append(results, MetadataSearchResult{
ProviderID: a.ID,
Title: a.Name,
Year: year,
MediaType: "music",
OriginalTitle: a.Disambiguation,
ExternalIDs: extIDs,
})
}
return results, nil
}
func (p *MusicBrainzProvider) searchReleaseGroups(ctx context.Context, query string) ([]MetadataSearchResult, error) {
url := fmt.Sprintf("%s/release-group?query=%s&fmt=json&limit=20", p.baseURL, query)
var resp mbReleaseGroupSearchResponse
if err := p.fetch(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("search musicbrainz release groups: %w", err)
}
var results []MetadataSearchResult
for _, rg := range resp.ReleaseGroups {
var year *int
if len(rg.FirstReleaseDate) >= 4 {
y := 0
for _, c := range rg.FirstReleaseDate[:4] {
if c >= '0' && c <= '9' {
y = y*10 + int(c-'0')
}
}
year = &y
}
extIDs, _ := json.Marshal(map[string]string{"musicbrainz": rg.ID})
artistName := ""
if len(rg.ArtistCredit) > 0 {
artistName = rg.ArtistCredit[0].Artist.Name
}
results = append(results, MetadataSearchResult{
ProviderID: rg.ID,
Title: rg.Title,
Year: year,
MediaType: "album",
OriginalTitle: artistName,
ExternalIDs: extIDs,
})
}
return results, nil
}
func (p *MusicBrainzProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) {
artistURL := fmt.Sprintf("%s/artist/%s?fmt=json&inc=url-rels", p.baseURL, id)
var artistDetail mbArtistDetail
err := p.fetch(ctx, artistURL, &artistDetail)
if err == nil {
extIDs, _ := json.Marshal(map[string]string{"musicbrainz": id})
metadata, _ := json.Marshal(map[string]interface{}{
"sort_name": artistDetail.SortName,
"disambiguation": artistDetail.Disambiguation,
"life_span_begin": artistDetail.LifeSpan.Begin,
"life_span_end": artistDetail.LifeSpan.End,
})
name := artistDetail.Name
return &MetadataDetails{
ProviderID: id,
Title: name,
ExternalIDs: extIDs,
Metadata: metadata,
}, nil
}
rgURL := fmt.Sprintf("%s/release-group/%s?fmt=json&inc=artist-credits", p.baseURL, id)
var rgDetail mbReleaseGroupDetail
if err := p.fetch(ctx, rgURL, &rgDetail); err != nil {
return nil, fmt.Errorf("get musicbrainz details: %w", err)
}
extIDs, _ := json.Marshal(map[string]string{"musicbrainz": id})
artistName := ""
if len(rgDetail.ArtistCredit) > 0 {
artistName = rgDetail.ArtistCredit[0].Artist.Name
}
metadata, _ := json.Marshal(map[string]interface{}{
"first_release_date": rgDetail.FirstReleaseDate,
"artist": artistName,
})
var year *int
if len(rgDetail.FirstReleaseDate) >= 4 {
y := 0
for _, c := range rgDetail.FirstReleaseDate[:4] {
if c >= '0' && c <= '9' {
y = y*10 + int(c-'0')
}
}
year = &y
}
title := rgDetail.Title
return &MetadataDetails{
ProviderID: id,
Title: title,
Year: year,
ExternalIDs: extIDs,
Metadata: metadata,
}, nil
}
func (p *MusicBrainzProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) {
coverURL := fmt.Sprintf("https://coverartarchive.org/release-group/%s", id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, coverURL, nil)
if err != nil {
return nil, nil
}
req.Header.Set("User-Agent", p.userAgent)
p.rateLimit()
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil
}
var coverResp coverArtResponse
if err := json.NewDecoder(resp.Body).Decode(&coverResp); err != nil {
return nil, nil
}
for _, img := range coverResp.Images {
if img.Front {
return []ImageResult{{
URL: img.Image,
Type: "cover",
}}, nil
}
}
if len(coverResp.Images) > 0 {
return []ImageResult{{
URL: coverResp.Images[0].Image,
Type: "cover",
}}, nil
}
return nil, nil
}

118
internal/service/naming.go Normal file
View File

@@ -0,0 +1,118 @@
package service
import (
"bytes"
"context"
"fmt"
"log/slog"
"path/filepath"
"strings"
"text/template"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type NamingData struct {
Title string
SortTitle string
Year int
Season int
Episode int
Quality string
Ext string
ReleaseGroup string
Resolution string
Source string
Codec string
Artist string
Album string
Track int
Author string
Chapter int
Date string
OriginalName string
}
type NamingService struct {
db *db.DB
}
func NewNamingService(database *db.DB) *NamingService {
return &NamingService{db: database}
}
var DefaultTemplates = map[string]string{
"movie": "{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}) - {{.Quality}}.{{.Ext}}",
"series": "{{sanitize .Title}}/Season {{printf \"%02d\" .Season}}/{{sanitize .Title}} - S{{printf \"%02d\" .Season}}E{{printf \"%02d\" .Episode}} - {{.Quality}}.{{.Ext}}",
"music": "{{sanitize .Artist}}/{{sanitize .Album}}/{{printf \"%02d\" .Track}} - {{sanitize .Title}}.{{.Ext}}",
"audiobook": "{{sanitize .Author}}/{{sanitize .Title}}/{{sanitize .Title}} - Ch{{printf \"%02d\" .Chapter}}.{{.Ext}}",
"podcast": "{{sanitize .Title}}/{{sanitize .Title}} - {{.Date}}.{{.Ext}}",
"book": "{{sanitize .Author}}/{{sanitize .Title}} ({{.Year}})/{{sanitize .Title}} ({{.Year}}).{{.Ext}}",
}
func sanitize(s string) string {
s = strings.Map(func(r rune) rune {
switch r {
case '\\', '/', ':', '*', '?', '"', '<', '>', '|':
return -1
}
return r
}, s)
for strings.Contains(s, " ") {
s = strings.ReplaceAll(s, " ", " ")
}
return strings.TrimSpace(s)
}
func namingFuncMap() template.FuncMap {
return template.FuncMap{
"sanitize": sanitize,
"lower": strings.ToLower,
}
}
func (s *NamingService) GetTemplate(ctx context.Context, mediaType string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
var tmpl string
err := s.db.Pool.QueryRow(ctx,
"SELECT template FROM naming_templates WHERE media_type = $1", mediaType).Scan(&tmpl)
if err != nil {
if fallback, ok := DefaultTemplates[mediaType]; ok {
return fallback, nil
}
return "", fmt.Errorf("get naming template: %w", err)
}
return tmpl, nil
}
func (s *NamingService) Render(ctx context.Context, mediaType string, data NamingData) (string, error) {
tmplStr, err := s.GetTemplate(ctx, mediaType)
if err != nil {
return "", fmt.Errorf("get template for render: %w", err)
}
tmpl, err := template.New("naming").Funcs(namingFuncMap()).Parse(tmplStr)
if err != nil {
slog.Error("failed to parse naming template", "error", err, "media_type", mediaType)
return "", fmt.Errorf("parse naming template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
slog.Error("failed to execute naming template", "error", err, "media_type", mediaType)
return "", fmt.Errorf("execute naming template: %w", err)
}
return buf.String(), nil
}
func ExtractExt(filename string) string {
ext := filepath.Ext(filename)
if ext == "" {
return ""
}
return strings.TrimPrefix(ext, ".")
}

View File

@@ -0,0 +1,673 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"net/http"
"net/url"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/jackc/pgx/v5"
)
type NotificationChannel struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
Config json.RawMessage `json:"config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// EventTypes populated by JOIN (not a DB column)
EventTypes []string `json:"event_types,omitempty"`
}
type QueueEntry struct {
ID int64 `json:"id"`
ChannelID int64 `json:"channel_id"`
EventType string `json:"event_type"`
Title string `json:"title"`
Message json.RawMessage `json:"message"`
Status string `json:"status"`
Attempts int `json:"attempts"`
MaxAttempts int `json:"max_attempts"`
LastError *string `json:"last_error,omitempty"`
NextRetryAt *time.Time `json:"next_retry_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
DeliveredAt *time.Time `json:"delivered_at,omitempty"`
}
type NotificationService struct {
db *db.DB
http *http.Client
telegramBaseURL string // override for testing
done chan struct{}
}
func NewNotificationService(database *db.DB) *NotificationService {
return &NotificationService{
db: database,
http: &http.Client{Timeout: 10 * time.Second},
telegramBaseURL: "https://api.telegram.org",
done: make(chan struct{}),
}
}
// ValidateChannelConfig checks config has required fields for the channel type.
func (s *NotificationService) ValidateChannelConfig(channelType string, config json.RawMessage) error {
var m map[string]interface{}
if err := json.Unmarshal(config, &m); err != nil {
return fmt.Errorf("invalid config JSON: %w", err)
}
switch channelType {
case "webhook":
urlStr, _ := m["url"].(string)
if urlStr == "" {
return fmt.Errorf("webhook config requires 'url' field")
}
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("webhook url is invalid: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("webhook url must use http or https scheme")
}
case "telegram":
botToken, _ := m["bot_token"].(string)
chatID, _ := m["chat_id"].(string)
if botToken == "" {
return fmt.Errorf("telegram config requires 'bot_token' field")
}
if chatID == "" {
return fmt.Errorf("telegram config requires 'chat_id' field")
}
default:
return fmt.Errorf("unknown channel type: %s", channelType)
}
return nil
}
// ListChannels returns all channels with masked configs and their event subscriptions.
func (s *NotificationService) ListChannels(ctx context.Context) ([]NotificationChannel, error) {
rows, err := s.db.Pool.Query(ctx,
`SELECT c.id, c.name, c.type, c.enabled, c.config, c.created_at, c.updated_at,
COALESCE(json_agg(s.event_type) FILTER (WHERE s.event_type IS NOT NULL), '[]') AS event_types
FROM notification_channels c
LEFT JOIN notification_subscriptions s ON c.id = s.channel_id
GROUP BY c.id, c.name, c.type, c.enabled, c.config, c.created_at, c.updated_at
ORDER BY c.name`)
if err != nil {
return nil, fmt.Errorf("list notification channels: %w", err)
}
defer rows.Close()
var channels []NotificationChannel
for rows.Next() {
var ch NotificationChannel
var eventTypesJSON []byte
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Type, &ch.Enabled, &ch.Config,
&ch.CreatedAt, &ch.UpdatedAt, &eventTypesJSON); err != nil {
slog.Error("failed to scan notification channel", "error", err)
continue
}
ch.Config = maskConfig(ch.Type, ch.Config)
var types []string
if err := json.Unmarshal(eventTypesJSON, &types); err == nil {
ch.EventTypes = types
}
channels = append(channels, ch)
}
return channels, nil
}
// CreateChannel creates a new notification channel and returns its ID.
func (s *NotificationService) CreateChannel(ctx context.Context, name, channelType string, config json.RawMessage) (int64, error) {
if err := s.ValidateChannelConfig(channelType, config); err != nil {
return 0, err
}
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO notification_channels (name, type, config) VALUES ($1, $2, $3) RETURNING id`,
name, channelType, config).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create notification channel: %w", err)
}
slog.Info("created notification channel", "name", name, "type", channelType)
return id, nil
}
// UpdateChannel updates a notification channel's fields.
func (s *NotificationService) UpdateChannel(ctx context.Context, id int64, name *string, enabled *bool, config json.RawMessage) error {
qb := NewQueryBuilder(1)
setClauses := []string{}
if name != nil {
setClauses = append(setClauses, fmt.Sprintf("name = $%d", qb.Idx()))
qb.Add("", *name)
}
if enabled != nil {
setClauses = append(setClauses, fmt.Sprintf("enabled = $%d", qb.Idx()))
qb.Add("", *enabled)
}
if config != nil {
if err := s.ValidateChannelConfig("", config); err != nil {
// Skip type-specific validation on update since we don't know the type here
// The channel type doesn't change, just validate JSON is valid
}
setClauses = append(setClauses, fmt.Sprintf("config = $%d", qb.Idx()))
qb.Add("", config)
}
if len(setClauses) == 0 {
return nil
}
setClauses = append(setClauses, "updated_at = NOW()")
query := fmt.Sprintf("UPDATE notification_channels SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), qb.Idx())
qb.Add("", id)
tag, err := s.db.Pool.Exec(ctx, query, qb.Args()...)
if err != nil {
return fmt.Errorf("update notification channel: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("notification channel not found: %d", id)
}
return nil
}
// DeleteChannel removes a notification channel and its subscriptions.
func (s *NotificationService) DeleteChannel(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, `DELETE FROM notification_channels WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("delete notification channel: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("notification channel not found: %d", id)
}
slog.Info("deleted notification channel", "id", id)
return nil
}
// GetChannelWithConfig returns a channel with full unmasked config for delivery.
func (s *NotificationService) GetChannelWithConfig(ctx context.Context, id int64) (*NotificationChannel, error) {
var ch NotificationChannel
err := s.db.Pool.QueryRow(ctx,
`SELECT id, name, type, enabled, config, created_at, updated_at FROM notification_channels WHERE id = $1`, id).
Scan(&ch.ID, &ch.Name, &ch.Type, &ch.Enabled, &ch.Config, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
return nil, fmt.Errorf("get notification channel: %w", err)
}
return &ch, nil
}
// ListSubscriptions returns event types subscribed by a channel.
func (s *NotificationService) ListSubscriptions(ctx context.Context, channelID int64) ([]string, error) {
rows, err := s.db.Pool.Query(ctx,
`SELECT event_type FROM notification_subscriptions WHERE channel_id = $1`, channelID)
if err != nil {
return nil, fmt.Errorf("list subscriptions: %w", err)
}
defer rows.Close()
var types []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err != nil {
continue
}
types = append(types, t)
}
return types, nil
}
// UpdateSubscriptions replaces all subscriptions for a channel.
func (s *NotificationService) UpdateSubscriptions(ctx context.Context, channelID int64, eventTypes []string) error {
tx, err := s.db.Pool.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `DELETE FROM notification_subscriptions WHERE channel_id = $1`, channelID); err != nil {
return fmt.Errorf("delete subscriptions: %w", err)
}
for _, et := range eventTypes {
if _, err := tx.Exec(ctx,
`INSERT INTO notification_subscriptions (channel_id, event_type) VALUES ($1, $2)`,
channelID, et); err != nil {
return fmt.Errorf("insert subscription: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit subscriptions: %w", err)
}
return nil
}
// GetSubscribersForEvent returns enabled channels subscribed to an event type.
func (s *NotificationService) GetSubscribersForEvent(ctx context.Context, eventType string) ([]NotificationChannel, error) {
rows, err := s.db.Pool.Query(ctx,
`SELECT DISTINCT c.id, c.name, c.type, c.enabled, c.config, c.created_at, c.updated_at
FROM notification_channels c
JOIN notification_subscriptions s ON c.id = s.channel_id
WHERE s.event_type = $1 AND c.enabled = true`, eventType)
if err != nil {
return nil, fmt.Errorf("get subscribers for event: %w", err)
}
defer rows.Close()
var channels []NotificationChannel
for rows.Next() {
var ch NotificationChannel
if err := rows.Scan(&ch.ID, &ch.Name, &ch.Type, &ch.Enabled, &ch.Config,
&ch.CreatedAt, &ch.UpdatedAt); err != nil {
continue
}
channels = append(channels, ch)
}
return channels, nil
}
// DeliverWebhook sends an HTTP POST with JSON payload.
func (s *NotificationService) DeliverWebhook(ctx context.Context, webhookURL string, payload map[string]interface{}) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal webhook payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create webhook request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.http.Do(req)
if err != nil {
return fmt.Errorf("webhook delivery failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}
// DeliverTelegram sends a message via the Telegram Bot API.
func (s *NotificationService) DeliverTelegram(ctx context.Context, botToken, chatID, text string) error {
apiURL := fmt.Sprintf("%s/bot%s/sendMessage", s.telegramBaseURL, botToken)
payload := map[string]interface{}{
"chat_id": chatID,
"text": text,
"parse_mode": "HTML",
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal telegram payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create telegram request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.http.Do(req)
if err != nil {
return fmt.Errorf("telegram delivery failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("telegram returned status %d", resp.StatusCode)
}
return nil
}
// calculateBackoff returns exponential backoff: 30s * 2^attempt, capped at 480s.
func calculateBackoff(attempt int) time.Duration {
d := 30 * time.Second * time.Duration(math.Pow(2, float64(attempt)))
if d > 480*time.Second {
return 480 * time.Second
}
return d
}
// maskConfig masks sensitive fields in channel config for API responses.
func maskConfig(channelType string, raw json.RawMessage) json.RawMessage {
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
return raw
}
switch channelType {
case "telegram":
if _, ok := m["bot_token"]; ok {
m["bot_token"] = "***"
}
}
masked, err := json.Marshal(m)
if err != nil {
return raw
}
return masked
}
// ListQueue returns paginated notification queue entries.
func (s *NotificationService) ListQueue(ctx context.Context, status string, page, pageSize int) ([]QueueEntry, int, error) {
qb := NewQueryBuilder(1)
if status != "" {
qb.Add("status = $%d", status)
}
where := qb.Where()
var total int
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM notification_queue%s", where)
if err := s.db.Pool.QueryRow(ctx, countQuery, qb.Args()...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count notification queue: %w", err)
}
offset := (page - 1) * pageSize
dataQuery := fmt.Sprintf(
`SELECT id, channel_id, event_type, title, message, status, attempts, max_attempts,
last_error, next_retry_at, created_at, delivered_at
FROM notification_queue%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`,
where, qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), pageSize, offset)
rows, err := s.db.Pool.Query(ctx, dataQuery, args...)
if err != nil {
return nil, 0, fmt.Errorf("list notification queue: %w", err)
}
defer rows.Close()
var entries []QueueEntry
for rows.Next() {
var e QueueEntry
var lastError, nextRetry, deliveredAt interface{} // nullable
if err := rows.Scan(&e.ID, &e.ChannelID, &e.EventType, &e.Title, &e.Message,
&e.Status, &e.Attempts, &e.MaxAttempts,
&lastError, &nextRetry, &e.CreatedAt, &deliveredAt); err != nil {
continue
}
if le, ok := lastError.(*string); ok && le != nil {
e.LastError = le
} else if le, ok := lastError.(string); ok && le != "" {
e.LastError = &le
}
if nr, ok := nextRetry.(*time.Time); ok && nr != nil {
e.NextRetryAt = nr
}
if da, ok := deliveredAt.(*time.Time); ok && da != nil {
e.DeliveredAt = da
}
entries = append(entries, e)
}
return entries, total, nil
}
// StartDispatcher launches the notification dispatcher goroutines.
func (s *NotificationService) StartDispatcher(ctx context.Context) {
go s.pollActivityEvents(ctx)
go s.processQueue(ctx)
}
// StopDispatcher signals both dispatcher goroutines to stop.
func (s *NotificationService) StopDispatcher() {
close(s.done)
}
func (s *NotificationService) pollActivityEvents(ctx context.Context) {
slog.Info("notification event poller started")
defer slog.Info("notification event poller stopped")
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ctx.Done():
return
case <-ticker.C:
s.pollOnce(ctx)
}
}
}
func (s *NotificationService) pollOnce(ctx context.Context) {
pollCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
// Get cursor
var lastID int64
var lastCreatedAt *time.Time
err := s.db.Pool.QueryRow(pollCtx,
`SELECT last_event_id, last_event_created_at FROM notification_state WHERE id = 1`).
Scan(&lastID, &lastCreatedAt)
if err != nil {
slog.Error("failed to read notification state", "error", err)
return
}
type eventRow struct {
ID int64
EventType string
Title string
CreatedAt time.Time
}
var events []eventRow
var rows pgx.Rows
if lastCreatedAt != nil {
rows, err = s.db.Pool.Query(pollCtx,
`SELECT id, event_type, title, created_at FROM activity_events
WHERE (created_at, id) > ($1, $2) ORDER BY created_at, id LIMIT 100`,
*lastCreatedAt, lastID)
} else {
rows, err = s.db.Pool.Query(pollCtx,
`SELECT id, event_type, title, created_at FROM activity_events
WHERE id > $1 ORDER BY created_at, id LIMIT 100`,
lastID)
}
if err != nil {
slog.Error("failed to poll activity events", "error", err)
return
}
defer rows.Close()
for rows.Next() {
var e eventRow
if err := rows.Scan(&e.ID, &e.EventType, &e.Title, &e.CreatedAt); err != nil {
continue
}
events = append(events, e)
}
if len(events) == 0 {
return
}
// For each event, find subscribers and create queue entries
var maxID int64
var maxCreatedAt time.Time
for _, e := range events {
subscribers, subErr := s.GetSubscribersForEvent(pollCtx, e.EventType)
if subErr != nil {
slog.Error("failed to get subscribers", "error", subErr, "event_type", e.EventType)
continue
}
message, _ := json.Marshal(map[string]interface{}{
"event_type": e.EventType,
"title": e.Title,
})
for _, ch := range subscribers {
_, qErr := s.db.Pool.Exec(pollCtx,
`INSERT INTO notification_queue (channel_id, event_type, title, message)
VALUES ($1, $2, $3, $4)`, ch.ID, e.EventType, e.Title, message)
if qErr != nil {
slog.Error("failed to enqueue notification", "error", qErr, "channel", ch.Name)
}
}
if e.ID > maxID {
maxID = e.ID
maxCreatedAt = e.CreatedAt
}
}
// Update cursor
_, err = s.db.Pool.Exec(pollCtx,
`UPDATE notification_state SET last_event_id = $1, last_event_created_at = $2 WHERE id = 1`,
maxID, maxCreatedAt)
if err != nil {
slog.Error("failed to update notification state", "error", err)
}
}
func (s *NotificationService) processQueue(ctx context.Context) {
slog.Info("notification queue processor started")
defer slog.Info("notification queue processor stopped")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ctx.Done():
return
case <-ticker.C:
s.processQueueBatch(ctx)
}
}
}
func (s *NotificationService) processQueueBatch(ctx context.Context) {
batchCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
rows, err := s.db.Pool.Query(batchCtx,
`SELECT q.id, q.channel_id, q.event_type, q.title, q.message, q.status, q.attempts,
c.name AS channel_name, c.type AS channel_type, c.config AS channel_config
FROM notification_queue q
JOIN notification_channels c ON q.channel_id = c.id
WHERE q.status IN ('pending', 'failed')
AND (q.next_retry_at IS NULL OR q.next_retry_at <= NOW())
AND q.attempts < q.max_attempts
LIMIT 50`)
if err != nil {
slog.Error("failed to query notification queue", "error", err)
return
}
defer rows.Close()
type queueItem struct {
ID int64
ChannelID int64
EventType string
Title string
Message json.RawMessage
Status string
Attempts int
ChannelName string
ChannelType string
ChannelConfig json.RawMessage
}
var items []queueItem
for rows.Next() {
var q queueItem
if err := rows.Scan(&q.ID, &q.ChannelID, &q.EventType, &q.Title, &q.Message,
&q.Status, &q.Attempts, &q.ChannelName, &q.ChannelType, &q.ChannelConfig); err != nil {
continue
}
items = append(items, q)
}
for _, q := range items {
deliverCtx, deliverCancel := context.WithTimeout(batchCtx, 15*time.Second)
var deliverErr error
switch q.ChannelType {
case "webhook":
var configMap map[string]interface{}
json.Unmarshal(q.ChannelConfig, &configMap)
webhookURL, _ := configMap["url"].(string)
var payload map[string]interface{}
json.Unmarshal(q.Message, &payload)
deliverErr = s.DeliverWebhook(deliverCtx, webhookURL, payload)
case "telegram":
var configMap map[string]interface{}
json.Unmarshal(q.ChannelConfig, &configMap)
botToken, _ := configMap["bot_token"].(string)
chatID, _ := configMap["chat_id"].(string)
var msg map[string]interface{}
json.Unmarshal(q.Message, &msg)
title, _ := msg["title"].(string)
text := fmt.Sprintf("<b>%s</b>\n%s", q.EventType, title)
deliverErr = s.DeliverTelegram(deliverCtx, botToken, chatID, text)
}
deliverCancel()
newAttempts := q.Attempts + 1
if deliverErr == nil {
_, err := s.db.Pool.Exec(batchCtx,
`UPDATE notification_queue SET status = 'delivered', attempts = $1, delivered_at = NOW() WHERE id = $2`,
newAttempts, q.ID)
if err != nil {
slog.Error("failed to update queue entry", "error", err)
}
slog.Info("notification delivered", "channel", q.ChannelName, "event", q.EventType)
} else {
var nextRetry *time.Time
var newStatus string = "failed"
errMsg := deliverErr.Error()
if newAttempts >= 5 {
newStatus = "dead"
slog.Warn("notification dead-lettered", "channel", q.ChannelName, "attempts", newAttempts)
} else {
backoff := calculateBackoff(newAttempts)
t := time.Now().Add(backoff)
nextRetry = &t
}
_, err := s.db.Pool.Exec(batchCtx,
`UPDATE notification_queue SET status = $1, attempts = $2, last_error = $3, next_retry_at = $4 WHERE id = $5`,
newStatus, newAttempts, errMsg, nextRetry, q.ID)
if err != nil {
slog.Error("failed to update queue entry", "error", err)
}
slog.Error("notification delivery failed", "channel", q.ChannelName, "type", q.ChannelType, "attempts", newAttempts)
}
}
}

View File

@@ -0,0 +1,216 @@
package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNotificationChannelCRUD(t *testing.T) {
t.Skip("requires database")
}
func TestNotificationChannelTelegram(t *testing.T) {
t.Skip("requires database")
}
func TestNotificationUpdateSubscriptions(t *testing.T) {
t.Skip("requires database")
}
func TestDeliverWebhook_Success(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Errorf("expected POST, got %s", r.Method)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected application/json content type")
}
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["title"] != "Test Event" {
t.Errorf("expected title 'Test Event', got %v", body["title"])
}
w.WriteHeader(200)
}))
defer server.Close()
svc := NewNotificationService(nil) // nil DB is fine for delivery tests
err := svc.DeliverWebhook(context.Background(), server.URL, map[string]interface{}{
"title": "Test Event",
})
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
}
func TestDeliverWebhook_Non200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
defer server.Close()
svc := NewNotificationService(nil)
err := svc.DeliverWebhook(context.Background(), server.URL, map[string]interface{}{})
if err == nil {
t.Fatal("expected error on 500 response, got nil")
}
}
func TestDeliverWebhook_Timeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(15 * time.Second) // exceed client timeout
}))
defer server.Close()
svc := NewNotificationService(nil)
err := svc.DeliverWebhook(context.Background(), server.URL, map[string]interface{}{})
if err == nil {
t.Fatal("expected error on timeout, got nil")
}
}
func TestDeliverTelegram_Success(t *testing.T) {
var receivedPath string
var receivedBody map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
receivedPath = r.URL.Path
json.NewDecoder(r.Body).Decode(&receivedBody)
w.WriteHeader(200)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}))
defer server.Close()
svc := NewNotificationService(nil)
svc.telegramBaseURL = server.URL // override for testing
err := svc.DeliverTelegram(context.Background(), "123456:ABC-DEF", "987654321", "<b>Test Message</b>")
if err != nil {
t.Fatalf("expected nil error, got: %v", err)
}
expectedPath := "/bot123456:ABC-DEF/sendMessage"
if receivedPath != expectedPath {
t.Errorf("expected path %s, got %s", expectedPath, receivedPath)
}
if receivedBody["chat_id"] != "987654321" {
t.Errorf("expected chat_id 987654321, got %v", receivedBody["chat_id"])
}
if receivedBody["parse_mode"] != "HTML" {
t.Errorf("expected parse_mode HTML, got %v", receivedBody["parse_mode"])
}
}
func TestDeliverTelegram_Non200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "description": "Bad Request"})
}))
defer server.Close()
svc := NewNotificationService(nil)
svc.telegramBaseURL = server.URL
err := svc.DeliverTelegram(context.Background(), "token", "chat", "text")
if err == nil {
t.Fatal("expected error on 400 response, got nil")
}
}
func TestCalculateBackoff(t *testing.T) {
expected := []time.Duration{
30 * time.Second, // attempt 0: 30s
60 * time.Second, // attempt 1: 60s
120 * time.Second, // attempt 2: 120s
240 * time.Second, // attempt 3: 240s
480 * time.Second, // attempt 4: 480s (capped)
480 * time.Second, // attempt 5: still capped
}
for i, want := range expected {
got := calculateBackoff(i)
if got != want {
t.Errorf("calculateBackoff(%d) = %v, want %v", i, got, want)
}
}
}
func TestMaskTelegramConfig(t *testing.T) {
raw := json.RawMessage(`{"bot_token":"secret123","chat_id":"999"}`)
masked := maskConfig("telegram", raw)
var m map[string]interface{}
json.Unmarshal(masked, &m)
if m["bot_token"] != "***" {
t.Errorf("expected bot_token masked as ***, got %v", m["bot_token"])
}
if m["chat_id"] != "999" {
t.Errorf("expected chat_id preserved as 999, got %v", m["chat_id"])
}
}
func TestMaskWebhookConfig(t *testing.T) {
raw := json.RawMessage(`{"url":"https://example.com/hook"}`)
masked := maskConfig("webhook", raw)
var m map[string]interface{}
json.Unmarshal(masked, &m)
if m["url"] != "https://example.com/hook" {
t.Errorf("expected url preserved, got %v", m["url"])
}
}
func TestValidateChannelConfig_Webhook(t *testing.T) {
svc := NewNotificationService(nil)
tests := []struct {
name string
config string
wantErr bool
}{
{"valid", `{"url":"https://example.com/hook"}`, false},
{"http scheme", `{"url":"http://example.com/hook"}`, false},
{"missing url", `{"url":""}`, true},
{"no url field", `{}`, true},
{"invalid scheme", `{"url":"ftp://example.com"}`, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := svc.ValidateChannelConfig("webhook", json.RawMessage(tt.config))
if (err != nil) != tt.wantErr {
t.Errorf("ValidateChannelConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidateChannelConfig_Telegram(t *testing.T) {
svc := NewNotificationService(nil)
tests := []struct {
name string
config string
wantErr bool
}{
{"valid", `{"bot_token":"123:ABC","chat_id":"999"}`, false},
{"missing bot_token", `{"bot_token":"","chat_id":"999"}`, true},
{"missing chat_id", `{"bot_token":"123:ABC","chat_id":""}`, true},
{"both missing", `{}`, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := svc.ValidateChannelConfig("telegram", json.RawMessage(tt.config))
if (err != nil) != tt.wantErr {
t.Errorf("ValidateChannelConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -0,0 +1,199 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
type OpenLibraryProvider struct {
baseURL string
httpClient *http.Client
}
func NewOpenLibraryProvider() *OpenLibraryProvider {
return &OpenLibraryProvider{
baseURL: "https://openlibrary.org",
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type olSearchResponse struct {
NumFound int `json:"numFound"`
Docs []olDoc `json:"docs"`
}
type olDoc struct {
Key string `json:"key"`
Title string `json:"title"`
FirstPublishYear int `json:"first_publish_year"`
AuthorName []string `json:"author_name"`
ISBN []string `json:"isbn"`
Publisher []string `json:"publisher"`
CoverID int `json:"cover_i"`
Subject []string `json:"subject"`
}
type olWorkDetail struct {
Title string `json:"title"`
Description interface{} `json:"description"`
Covers []int `json:"covers"`
Authors []olAuthorRef `json:"authors"`
}
type olAuthorRef struct {
Author struct {
Key string `json:"key"`
} `json:"author"`
}
type olAuthorDetail struct {
Name string `json:"name"`
Bio interface{} `json:"bio"`
BirthDate string `json:"birth_date"`
DeathDate string `json:"death_date"`
Photos []int `json:"photos"`
}
func (p *OpenLibraryProvider) Name() string {
return "openlibrary"
}
func (p *OpenLibraryProvider) fetch(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch open library: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("open library api returned status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("decode open library response: %w", err)
}
return nil
}
func extractOLDescription(desc interface{}) string {
switch v := desc.(type) {
case string:
return v
case map[string]interface{}:
if val, ok := v["value"].(string); ok {
return val
}
}
return ""
}
func (p *OpenLibraryProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) {
url := fmt.Sprintf("%s/search.json?q=%s&limit=20", p.baseURL, query)
if opts.Year != nil {
url += fmt.Sprintf("&first_publish_year=%d", *opts.Year)
}
var resp olSearchResponse
if err := p.fetch(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("search open library: %w", err)
}
var results []MetadataSearchResult
for _, doc := range resp.Docs {
var year *int
if doc.FirstPublishYear != 0 {
y := doc.FirstPublishYear
year = &y
}
extIDMap := map[string]string{"openlibrary": doc.Key}
if len(doc.ISBN) > 0 {
extIDMap["isbn"] = doc.ISBN[0]
}
extIDs, _ := json.Marshal(extIDMap)
authorName := ""
if len(doc.AuthorName) > 0 {
authorName = doc.AuthorName[0]
}
results = append(results, MetadataSearchResult{
ProviderID: doc.Key,
Title: doc.Title,
Year: year,
MediaType: "book",
OriginalTitle: authorName,
ExternalIDs: extIDs,
})
}
return results, nil
}
func (p *OpenLibraryProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) {
workURL := fmt.Sprintf("%s%s.json", p.baseURL, id)
var work olWorkDetail
if err := p.fetch(ctx, workURL, &work); err != nil {
return nil, fmt.Errorf("get open library work: %w", err)
}
extIDs, _ := json.Marshal(map[string]string{"openlibrary": id})
description := extractOLDescription(work.Description)
authorNames := []string{}
for _, aRef := range work.Authors {
authorURL := fmt.Sprintf("%s%s.json", p.baseURL, aRef.Author.Key)
var author olAuthorDetail
if err := p.fetch(ctx, authorURL, &author); err == nil {
authorNames = append(authorNames, author.Name)
}
}
metadata, _ := json.Marshal(map[string]interface{}{
"authors": authorNames,
"covers": work.Covers,
})
overview := description
return &MetadataDetails{
ProviderID: id,
Title: work.Title,
Overview: &overview,
ExternalIDs: extIDs,
Metadata: metadata,
}, nil
}
func (p *OpenLibraryProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) {
workURL := fmt.Sprintf("%s%s.json", p.baseURL, id)
var work olWorkDetail
if err := p.fetch(ctx, workURL, &work); err != nil {
return nil, nil
}
if len(work.Covers) == 0 {
return nil, nil
}
coverID := work.Covers[0]
return []ImageResult{{
URL: fmt.Sprintf("https://covers.openlibrary.org/b/id/%d-L.jpg", coverID),
Type: "cover",
}}, nil
}

282
internal/service/quality.go Normal file
View File

@@ -0,0 +1,282 @@
package service
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type QualityTier struct {
Name string `json:"name"`
Rank int `json:"rank"`
Resolution string `json:"resolution"`
Source string `json:"source"`
Codec string `json:"codec"`
MinLinesize int `json:"min_linesize"`
}
var QualityTiers = []QualityTier{
{Name: "SDTV", Rank: 1, Resolution: "", Source: "television"},
{Name: "SDDVD", Rank: 2, Resolution: "480p", Source: "dvd"},
{Name: "WEBDL-480p", Rank: 3, Resolution: "480p", Source: "web"},
{Name: "HDTV-720p", Rank: 4, Resolution: "720p", Source: "television"},
{Name: "WEBDL-720p", Rank: 5, Resolution: "720p", Source: "web"},
{Name: "Bluray-720p", Rank: 6, Resolution: "720p", Source: "bluray"},
{Name: "HDTV-1080p", Rank: 7, Resolution: "1080p", Source: "television"},
{Name: "WEBDL-1080p", Rank: 8, Resolution: "1080p", Source: "web"},
{Name: "Bluray-1080p", Rank: 9, Resolution: "1080p", Source: "bluray"},
{Name: "Remux-1080p", Rank: 10, Resolution: "1080p", Source: "remux"},
{Name: "HDTV-2160p", Rank: 11, Resolution: "2160p", Source: "television"},
{Name: "WEBDL-2160p", Rank: 12, Resolution: "2160p", Source: "web"},
{Name: "Bluray-2160p", Rank: 13, Resolution: "2160p", Source: "bluray"},
{Name: "Remux-2160p", Rank: 14, Resolution: "2160p", Source: "remux"},
}
var sourceMatchMap = map[string][]string{
"television": {"HDTV", "PDTV", "SDTV"},
"web": {"WEB-DL", "WEBDL", "WEBRip", "WEB"},
"bluray": {"BluRay", "BDRip", "BRRip"},
"remux": {"REMUX", "Remux"},
"dvd": {"DVDRip", "DVD"},
}
func SourceMatch(tierSource, releaseSource string) bool {
matches, ok := sourceMatchMap[tierSource]
if !ok {
return strings.EqualFold(tierSource, releaseSource)
}
for _, m := range matches {
if strings.EqualFold(m, releaseSource) {
return true
}
}
return false
}
func GetTierByName(name string) *QualityTier {
for i := range QualityTiers {
if QualityTiers[i].Name == name {
return &QualityTiers[i]
}
}
return nil
}
func GetTiers() []QualityTier {
result := make([]QualityTier, len(QualityTiers))
copy(result, QualityTiers)
return result
}
func GetTiersByMediaType(mediaType string) []QualityTier {
return GetTiers()
}
type QualityProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
MediaTypes []string `json:"media_types"`
CutoffQuality string `json:"cutoff_quality"`
AllowedQualities []string `json:"allowed_qualities"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type QualityService struct {
db *db.DB
}
func NewQualityService(database *db.DB) *QualityService {
return &QualityService{db: database}
}
const qualityProfileColumns = `id, name, media_types, cutoff_quality, allowed_qualities, created_at, updated_at`
func scanQualityProfile(scanner interface{ Scan(...interface{}) error }) (*QualityProfile, error) {
var p QualityProfile
var mediaTypes []string
var cutoffQuality []byte
var allowedQualities []byte
var createdAt, updatedAt time.Time
err := scanner.Scan(&p.ID, &p.Name, &mediaTypes, &cutoffQuality, &allowedQualities, &createdAt, &updatedAt)
if err != nil {
return nil, err
}
p.MediaTypes = mediaTypes
if err := json.Unmarshal(cutoffQuality, &p.CutoffQuality); err != nil {
p.CutoffQuality = ""
}
if err := json.Unmarshal(allowedQualities, &p.AllowedQualities); err != nil {
p.AllowedQualities = []string{}
}
p.CreatedAt = createdAt.Format(time.RFC3339)
p.UpdatedAt = updatedAt.Format(time.RFC3339)
return &p, nil
}
func (s *QualityService) List(ctx context.Context) ([]QualityProfile, error) {
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM quality_profiles ORDER BY name", qualityProfileColumns))
if err != nil {
return nil, fmt.Errorf("list quality profiles: %w", err)
}
defer rows.Close()
var items []QualityProfile
for rows.Next() {
p, err := scanQualityProfile(rows)
if err != nil {
continue
}
items = append(items, *p)
}
return items, nil
}
func (s *QualityService) Create(ctx context.Context, name string, mediaTypes []string, cutoffQuality string, allowedQualities []string) (int64, error) {
if GetTierByName(cutoffQuality) == nil {
return 0, fmt.Errorf("invalid cutoff quality tier: %s", cutoffQuality)
}
for _, q := range allowedQualities {
if GetTierByName(q) == nil {
return 0, fmt.Errorf("invalid allowed quality tier: %s", q)
}
}
cutoffJSON, _ := json.Marshal(cutoffQuality)
allowedJSON, _ := json.Marshal(allowedQualities)
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO quality_profiles (name, media_types, cutoff_quality, allowed_qualities)
VALUES ($1, $2::media_type[], $3, $4) RETURNING id`,
name, mediaTypes, cutoffJSON, allowedJSON).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create quality profile: %w", err)
}
return id, nil
}
type UpdateQualityProfileRequest struct {
Name *string `json:"name,omitempty"`
MediaTypes []string `json:"media_types,omitempty"`
CutoffQuality *string `json:"cutoff_quality,omitempty"`
AllowedQualities []string `json:"allowed_qualities,omitempty"`
}
func (s *QualityService) Update(ctx context.Context, id int64, req UpdateQualityProfileRequest) error {
var setClauses []string
var args []interface{}
idx := 1
addCol := func(col string, val interface{}) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
args = append(args, val)
idx++
}
if req.Name != nil {
addCol("name", *req.Name)
}
if req.MediaTypes != nil {
setClauses = append(setClauses, fmt.Sprintf("media_types = $%d::media_type[]", idx))
args = append(args, req.MediaTypes)
idx++
}
if req.CutoffQuality != nil {
if GetTierByName(*req.CutoffQuality) == nil {
return fmt.Errorf("invalid cutoff quality tier: %s", *req.CutoffQuality)
}
cutoffJSON, _ := json.Marshal(*req.CutoffQuality)
addCol("cutoff_quality", cutoffJSON)
}
if req.AllowedQualities != nil {
for _, q := range req.AllowedQualities {
if GetTierByName(q) == nil {
return fmt.Errorf("invalid allowed quality tier: %s", q)
}
}
allowedJSON, _ := json.Marshal(req.AllowedQualities)
addCol("allowed_qualities", allowedJSON)
}
if len(setClauses) == 0 {
return fmt.Errorf("no fields to update")
}
addCol("updated_at", time.Now())
query := fmt.Sprintf("UPDATE quality_profiles SET %s WHERE id = $%d",
strings.Join(setClauses, ", "), idx)
args = append(args, id)
tag, err := s.db.Pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("update quality profile: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("quality profile not found")
}
return nil
}
func (s *QualityService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM quality_profiles WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete quality profile: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("quality profile not found")
}
return nil
}
func (s *QualityService) GetByID(ctx context.Context, id int64) (*QualityProfile, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM quality_profiles WHERE id = $1", qualityProfileColumns), id)
p, err := scanQualityProfile(row)
if err != nil {
return nil, fmt.Errorf("quality profile not found")
}
return p, nil
}
func (s *QualityService) NeedsUpgrade(currentQuality string, cutoffQuality string) bool {
current := GetTierByName(currentQuality)
cutoff := GetTierByName(cutoffQuality)
if current == nil || cutoff == nil {
return false
}
return current.Rank < cutoff.Rank
}
func (s *QualityService) IsCutoffMet(currentQuality string, cutoffQuality string) bool {
current := GetTierByName(currentQuality)
cutoff := GetTierByName(cutoffQuality)
if current == nil || cutoff == nil {
return false
}
return current.Rank >= cutoff.Rank
}
func (s *QualityService) GetAllowedTierNames(allowedQualitiesJSON json.RawMessage) []string {
var names []string
if err := json.Unmarshal(allowedQualitiesJSON, &names); err != nil {
return []string{}
}
return names
}

41
internal/service/query.go Normal file
View File

@@ -0,0 +1,41 @@
package service
import (
"fmt"
"strings"
)
type QueryBuilder struct {
conditions []string
args []interface{}
idx int
}
func NewQueryBuilder(startIdx int) *QueryBuilder {
return &QueryBuilder{idx: startIdx}
}
func (qb *QueryBuilder) Add(condition string, arg interface{}) {
qb.conditions = append(qb.conditions, fmt.Sprintf(condition, qb.idx))
qb.args = append(qb.args, arg)
qb.idx++
}
func (qb *QueryBuilder) AddLiteral(condition string) {
qb.conditions = append(qb.conditions, condition)
}
func (qb *QueryBuilder) Where() string {
if len(qb.conditions) == 0 {
return ""
}
return " WHERE " + strings.Join(qb.conditions, " AND ")
}
func (qb *QueryBuilder) Args() []interface{} {
return qb.args
}
func (qb *QueryBuilder) Idx() int {
return qb.idx
}

226
internal/service/queue.go Normal file
View File

@@ -0,0 +1,226 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log/slog"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type QueueItem struct {
ID int64 `json:"id"`
MediaID int64 `json:"media_id"`
ReleaseTitle string `json:"release_title"`
ReleaseURL *string `json:"release_url,omitempty"`
Indexer string `json:"indexer"`
DownloadClient string `json:"download_client"`
Quality json.RawMessage `json:"quality"`
Size *int64 `json:"size,omitempty"`
Protocol string `json:"protocol"`
Status string `json:"status"`
Progress float64 `json:"progress"`
ErrorMessage *string `json:"error_message,omitempty"`
BatchID *string `json:"batch_id,omitempty"`
Priority int `json:"priority"`
RetryCount int `json:"retry_count"`
MaxRetries int `json:"max_retries"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type QueueFilters struct {
Status string
Page int
PageSize int
}
type QueueBatchDeleteRequest struct {
Status *string `json:"status,omitempty"`
BatchID *string `json:"batch_id,omitempty"`
IDs []int64 `json:"ids,omitempty"`
}
const queueColumns = `id, media_id, release_title, release_url, indexer, download_client,
quality, size, protocol, status, progress, error_message, batch_id, priority,
retry_count, max_retries, created_at, started_at, completed_at, updated_at`
type QueueService struct {
db *db.DB
}
func NewQueueService(database *db.DB) *QueueService {
return &QueueService{db: database}
}
func scanQueueItem(scanner interface{ Scan(...interface{}) error }) (*QueueItem, error) {
var item QueueItem
var releaseURL, errorMsg, batchID sql.NullString
var size sql.NullInt64
var startedAt, completedAt sql.NullTime
err := scanner.Scan(&item.ID, &item.MediaID, &item.ReleaseTitle, &releaseURL, &item.Indexer,
&item.DownloadClient, &item.Quality, &size, &item.Protocol, &item.Status,
&item.Progress, &errorMsg, &batchID, &item.Priority, &item.RetryCount,
&item.MaxRetries, &item.CreatedAt, &startedAt, &completedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
if releaseURL.Valid {
item.ReleaseURL = &releaseURL.String
}
if errorMsg.Valid {
item.ErrorMessage = &errorMsg.String
}
if batchID.Valid {
item.BatchID = &batchID.String
}
if size.Valid {
item.Size = &size.Int64
}
if startedAt.Valid {
item.StartedAt = &startedAt.Time
}
if completedAt.Valid {
item.CompletedAt = &completedAt.Time
}
return &item, nil
}
func (s *QueueService) List(ctx context.Context, filters QueueFilters) ([]QueueItem, int, error) {
qb := NewQueryBuilder(1)
if filters.Status != "" {
qb.Add("status = $%d", filters.Status)
}
var total int
if err := s.db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM download_queue"+qb.Where(), qb.Args()...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count queue: %w", err)
}
query := fmt.Sprintf("SELECT %s FROM download_queue%s ORDER BY priority DESC, created_at ASC LIMIT $%d OFFSET $%d",
queueColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
rows, err := s.db.Pool.Query(ctx, query, args...)
if err != nil {
return nil, 0, fmt.Errorf("list queue: %w", err)
}
defer rows.Close()
var items []QueueItem
for rows.Next() {
item, err := scanQueueItem(rows)
if err != nil {
slog.Error("failed to scan queue item", "error", err)
continue
}
items = append(items, *item)
}
return items, total, nil
}
func (s *QueueService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx,
"UPDATE download_queue SET status = 'cancelled', updated_at = NOW() WHERE id = $1", id)
if err != nil {
return fmt.Errorf("cancel queue item: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("queue item not found")
}
return nil
}
func (s *QueueService) BatchDelete(ctx context.Context, req QueueBatchDeleteRequest) (int64, error) {
qb := NewQueryBuilder(1)
if req.Status != nil {
qb.Add("status = $%d", *req.Status)
}
if req.BatchID != nil {
qb.Add("batch_id = $%d", *req.BatchID)
}
if len(req.IDs) > 0 {
qb.Add("id = ANY($%d)", req.IDs)
}
if len(qb.conditions) == 0 {
return 0, fmt.Errorf("must provide status, batch_id, or ids")
}
query := fmt.Sprintf("UPDATE download_queue SET status = 'cancelled', updated_at = NOW() WHERE %s",
strings.Join(qb.conditions, " AND "))
tag, err := s.db.Pool.Exec(ctx, query, qb.Args()...)
if err != nil {
return 0, fmt.Errorf("batch cancel queue: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *QueueService) Clear(ctx context.Context) (int64, error) {
tag, err := s.db.Pool.Exec(ctx,
"DELETE FROM download_queue WHERE status IN ('imported', 'failed', 'cancelled')")
if err != nil {
return 0, fmt.Errorf("clear queue: %w", err)
}
return tag.RowsAffected(), nil
}
func (s *QueueService) Retry(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx,
`UPDATE download_queue SET status = 'pending', progress = 0, error_message = NULL,
retry_count = retry_count + 1, updated_at = NOW() WHERE id = $1 AND status = 'failed'`, id)
if err != nil {
return fmt.Errorf("retry queue item: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("queue item not found or not failed")
}
return nil
}
func (s *QueueService) RetryFailed(ctx context.Context) (int64, error) {
tag, err := s.db.Pool.Exec(ctx,
`UPDATE download_queue SET status = 'pending', progress = 0, error_message = NULL,
retry_count = retry_count + 1, updated_at = NOW()
WHERE status = 'failed' AND retry_count < max_retries`)
if err != nil {
return 0, fmt.Errorf("retry all failed: %w", err)
}
return tag.RowsAffected(), nil
}
type CreateQueueEntryRequest struct {
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
ReleaseTitle string `json:"release_title"`
Indexer string `json:"indexer"`
DownloadClient string `json:"download_client"`
Quality json.RawMessage `json:"quality"`
Protocol string `json:"protocol"`
DownloadID string `json:"download_id"`
}
func (s *QueueService) CreateQueueEntry(ctx context.Context, req CreateQueueEntryRequest) (int64, error) {
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO download_queue (media_id, media_type, release_title, indexer, download_client, quality, protocol, status, progress, download_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'downloading', 0, $8) RETURNING id`,
req.MediaID, req.MediaType, req.ReleaseTitle, req.Indexer, req.DownloadClient,
req.Quality, req.Protocol, req.DownloadID).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create queue entry: %w", err)
}
return id, nil
}

195
internal/service/release.go Normal file
View File

@@ -0,0 +1,195 @@
package service
import (
"regexp"
"strings"
)
type ReleaseInfo struct {
Title string `json:"title"`
Resolution string `json:"resolution"`
Source string `json:"source"`
VideoCodec string `json:"video_codec"`
AudioFormat string `json:"audio_format"`
ReleaseGroup string `json:"release_group"`
ParseWarning bool `json:"parse_warning"`
}
type ReleaseParser struct{}
func NewReleaseParser() *ReleaseParser {
return &ReleaseParser{}
}
var (
bracketRe = regexp.MustCompile(`\[.*?\]`)
releaseRe = regexp.MustCompile(`(?i)` +
`(?:.*?[-. ])?` +
`(?:(?P<resolution>480[p|i]|576[p|i]|720[p|i]|1080[p|i]|2160[p|i]|4K)[-. ])?` +
`(?:(?P<source>HDTV|PDTV|SDTV|WEB-DL|WEBDL|WEBRip|WEB\s|BluRay|BDRip|BRRip|REMUX|Remux|DVDRip|DVD|CAM|TS|HDCAM)[-. ])?` +
`(?:(?P<codec>x264|h264|X264|H264|x265|h265|X265|HEVC|XviD|MPEG2|VC1|AV1)[-. ])?` +
`(?:(?P<audio>DTS-HD\.MA|DTS-HD|DTS\.HD|DTS-X|DTS\.X|ATMOS|Atmos|TrueHD|DDP5\.1|DD\+?5\.1|DolbyDigitalPlus|AAC[. ]?2\.0|AAC[. ]?5\.1|AAC|AC3|DD|FLAC|MP3)[-. ])?` +
`.*?(?:-(?P<group>[A-Za-z0-9]+))?$`,
)
resolutionCleanRe = regexp.MustCompile(`(?i)(480)[pi]|(576)[pi]|(720)[pi]|(1080)[pi]|(2160)[pi]|4K`)
knownSuffixes = []string{"Esubs", "TGx", "ettv", "eztv", "x0r", "FGT", "ION10", "NTb"}
)
func normalizeResolution(raw string) string {
if raw == "" {
return ""
}
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, "pPiI")
if strings.EqualFold(raw, "4K") {
return "2160p"
}
return raw + "p"
}
func normalizeSource(raw string) string {
if raw == "" {
return ""
}
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, " -.")
upper := strings.ToUpper(raw)
switch {
case upper == "HDTV", upper == "PDTV", upper == "SDTV":
return upper
case upper == "WEB-DL", upper == "WEBDL", strings.HasPrefix(upper, "WEB"):
return "WEB-DL"
case strings.HasPrefix(upper, "BLURAY"), upper == "BDRIP", upper == "BRRIP":
return "BluRay"
case upper == "REMUX":
return "REMUX"
case strings.HasPrefix(upper, "DVD"):
return "DVD"
case upper == "CAM", upper == "TS", upper == "HDCAM":
return upper
}
return raw
}
func normalizeCodec(raw string) string {
if raw == "" {
return ""
}
raw = strings.TrimSpace(raw)
switch {
case strings.EqualFold(raw, "x264"), strings.EqualFold(raw, "h264"):
return "x264"
case strings.EqualFold(raw, "x265"), strings.EqualFold(raw, "h265"), strings.EqualFold(raw, "HEVC"):
return "x265"
case strings.EqualFold(raw, "XviD"):
return "XviD"
case strings.EqualFold(raw, "MPEG2"):
return "MPEG2"
case strings.EqualFold(raw, "VC1"):
return "VC1"
case strings.EqualFold(raw, "AV1"):
return "AV1"
}
return raw
}
func normalizeAudio(raw string) string {
if raw == "" {
return ""
}
raw = strings.TrimSpace(raw)
upper := strings.ToUpper(raw)
switch {
case strings.HasPrefix(upper, "DTS-HD"):
return "DTS-HD"
case strings.HasPrefix(upper, "DTS-X") || strings.HasPrefix(upper, "DTS.X"):
return "DTS-X"
case upper == "ATMOS":
return "ATMOS"
case upper == "TRUEHD":
return "TrueHD"
case strings.HasPrefix(upper, "DDP") || strings.HasPrefix(upper, "DD+") || strings.HasPrefix(upper, "DOLBYDIGITALPLUS"):
return "DDP5.1"
case strings.HasPrefix(upper, "DD") && (strings.Contains(upper, "5.1") || strings.Contains(upper, "51")):
return "DDP5.1"
case upper == "AAC":
return "AAC"
case strings.HasPrefix(upper, "AAC"):
return "AAC"
case upper == "AC3":
return "AC3"
case upper == "DD":
return "DD"
case upper == "FLAC":
return "FLAC"
case upper == "MP3":
return "MP3"
}
return raw
}
func cleanGroup(raw string) string {
if raw == "" {
return ""
}
for _, suffix := range knownSuffixes {
if strings.EqualFold(raw, suffix) {
return ""
}
}
return raw
}
func (p *ReleaseParser) Parse(title string) ReleaseInfo {
info := ReleaseInfo{Title: title}
cleaned := bracketRe.ReplaceAllString(title, " ")
cleaned = strings.TrimSpace(cleaned)
match := releaseRe.FindStringSubmatch(cleaned)
if match == nil {
info.ParseWarning = true
return info
}
for i, name := range releaseRe.SubexpNames() {
if i == 0 || name == "" {
continue
}
value := match[i]
switch name {
case "resolution":
info.Resolution = normalizeResolution(value)
case "source":
info.Source = normalizeSource(value)
case "codec":
info.VideoCodec = normalizeCodec(value)
case "audio":
info.AudioFormat = normalizeAudio(value)
case "group":
info.ReleaseGroup = cleanGroup(value)
}
}
if info.Resolution == "" {
info.ParseWarning = true
}
return info
}
func (p *ReleaseParser) MatchQuality(info ReleaseInfo) *QualityTier {
for i := len(QualityTiers) - 1; i >= 0; i-- {
tier := &QualityTiers[i]
if tier.Resolution != "" && tier.Resolution != info.Resolution {
continue
}
if info.Source == "" || SourceMatch(tier.Source, info.Source) {
return tier
}
}
return &QualityTiers[0]
}

266
internal/service/request.go Normal file
View File

@@ -0,0 +1,266 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type Request struct {
ID int64 `json:"id"`
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
RequestedBy int64 `json:"requested_by"`
Status string `json:"status"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
Notes string `json:"notes,omitempty"`
ReviewedBy *int64 `json:"reviewed_by,omitempty"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type CreateRequest struct {
MediaID int64 `json:"media_id"`
MediaType string `json:"media_type"`
Title string `json:"title"`
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
RootFolderID *int64 `json:"root_folder_id,omitempty"`
Notes string `json:"notes,omitempty"`
}
type RequestFilters struct {
Status string `json:"status,omitempty"`
RequestedBy *int64 `json:"requested_by,omitempty"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
type RequestStats struct {
Total int `json:"total"`
Pending int `json:"pending"`
Approved int `json:"approved"`
Rejected int `json:"rejected"`
Fulfilled int `json:"fulfilled"`
Withdrawn int `json:"withdrawn"`
}
const requestColumns = `id, media_id, media_type, title, requested_by, status, quality_profile_id, root_folder_id, notes, reviewed_by, reviewed_at, created_at, updated_at`
type RequestService struct {
db *db.DB
searchSvc *SearchService
}
func NewRequestService(database *db.DB, searchSvc *SearchService) *RequestService {
return &RequestService{db: database, searchSvc: searchSvc}
}
func scanRequest(scanner interface{ Scan(...interface{}) error }) (*Request, error) {
var r Request
err := scanner.Scan(&r.ID, &r.MediaID, &r.MediaType, &r.Title, &r.RequestedBy, &r.Status,
&r.QualityProfileID, &r.RootFolderID, &r.Notes,
&r.ReviewedBy, &r.ReviewedAt, &r.CreatedAt, &r.UpdatedAt)
if err != nil {
return nil, err
}
return &r, nil
}
func (s *RequestService) Create(ctx context.Context, req CreateRequest, userRole string, userID int64) (int64, error) {
status := "pending"
if userRole == "admin" || userRole == "power_user" {
status = "approved"
}
now := time.Now()
var id int64
err := s.db.Pool.QueryRow(ctx,
`INSERT INTO requests (media_id, media_type, title, requested_by, status, quality_profile_id, root_folder_id, notes, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id`,
req.MediaID, req.MediaType, req.Title, userID, status,
req.QualityProfileID, req.RootFolderID, req.Notes, now, now).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create request: %w", err)
}
// Auto-approve: trigger search immediately in background
if status == "approved" && s.searchSvc != nil {
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.triggerSearch(bgCtx, req)
}()
}
return id, nil
}
func (s *RequestService) List(ctx context.Context, filters RequestFilters) ([]Request, int, error) {
qb := NewQueryBuilder(1)
qb.AddLiteral("1=1")
if filters.Status != "" {
qb.Add("AND status = $%d", filters.Status)
}
if filters.RequestedBy != nil {
qb.Add("AND requested_by = $%d", *filters.RequestedBy)
}
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM requests WHERE %s", qb.conditions[0])
countArgs := qb.Args()
if len(qb.conditions) > 1 {
countQuery = fmt.Sprintf("SELECT COUNT(*) FROM requests %s", qb.Where())
countArgs = qb.Args()
}
var total int
if err := s.db.Pool.QueryRow(ctx, countQuery, countArgs...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count requests: %w", err)
}
offset := (filters.Page - 1) * filters.PageSize
dataQuery := fmt.Sprintf("SELECT %s FROM requests %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
requestColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
dataArgs := append(qb.Args(), filters.PageSize, offset)
rows, err := s.db.Pool.Query(ctx, dataQuery, dataArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list requests: %w", err)
}
defer rows.Close()
var items []Request
for rows.Next() {
r, err := scanRequest(rows)
if err != nil {
slog.Error("failed to scan request", "error", err)
continue
}
items = append(items, *r)
}
return items, total, nil
}
func (s *RequestService) GetByID(ctx context.Context, id int64) (*Request, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM requests WHERE id = $1", requestColumns), id)
r, err := scanRequest(row)
if err != nil {
return nil, fmt.Errorf("request not found")
}
return r, nil
}
func (s *RequestService) Approve(ctx context.Context, id int64, reviewerID int64, notes string) error {
now := time.Now()
tag, err := s.db.Pool.Exec(ctx,
`UPDATE requests SET status = 'approved', reviewed_by = $1, reviewed_at = $2, notes = COALESCE(NULLIF($3, ''), notes), updated_at = $4
WHERE id = $5 AND status = 'pending'`, reviewerID, now, notes, now, id)
if err != nil {
return fmt.Errorf("approve request: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("request not found or not pending")
}
// Trigger search in background
if s.searchSvc != nil {
req, err := s.GetByID(ctx, id)
if err == nil {
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
s.triggerSearch(bgCtx, CreateRequest{
MediaID: req.MediaID,
MediaType: req.MediaType,
Title: req.Title,
})
}()
}
}
return nil
}
func (s *RequestService) Reject(ctx context.Context, id int64, reviewerID int64, notes string) error {
now := time.Now()
tag, err := s.db.Pool.Exec(ctx,
`UPDATE requests SET status = 'rejected', reviewed_by = $1, reviewed_at = $2, notes = COALESCE(NULLIF($3, ''), notes), updated_at = $4
WHERE id = $5 AND status = 'pending'`, reviewerID, now, notes, now, id)
if err != nil {
return fmt.Errorf("reject request: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("request not found or not pending")
}
return nil
}
func (s *RequestService) Withdraw(ctx context.Context, id int64, userID int64) error {
now := time.Now()
tag, err := s.db.Pool.Exec(ctx,
`UPDATE requests SET status = 'withdrawn', updated_at = $1
WHERE id = $2 AND requested_by = $3 AND status IN ('pending', 'approved')`, now, id, userID)
if err != nil {
return fmt.Errorf("withdraw request: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("request not found, not owned by user, or not withdrawable")
}
return nil
}
func (s *RequestService) MarkFulfilled(ctx context.Context, mediaID int64) error {
now := time.Now()
tag, err := s.db.Pool.Exec(ctx,
`UPDATE requests SET status = 'fulfilled', updated_at = $1
WHERE media_id = $2 AND status = 'approved'`, now, mediaID)
if err != nil {
return fmt.Errorf("mark fulfilled: %w", err)
}
if tag.RowsAffected() > 0 {
slog.Info("request marked fulfilled", "media_id", mediaID)
}
return nil
}
func (s *RequestService) Stats(ctx context.Context) (*RequestStats, error) {
var stats RequestStats
err := s.db.Pool.QueryRow(ctx,
`SELECT
COUNT(*) FILTER (WHERE 1=1) AS total,
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
COUNT(*) FILTER (WHERE status = 'approved') AS approved,
COUNT(*) FILTER (WHERE status = 'rejected') AS rejected,
COUNT(*) FILTER (WHERE status = 'fulfilled') AS fulfilled,
COUNT(*) FILTER (WHERE status = 'withdrawn') AS withdrawn
FROM requests`).Scan(&stats.Total, &stats.Pending, &stats.Approved, &stats.Rejected, &stats.Fulfilled, &stats.Withdrawn)
if err != nil {
return nil, fmt.Errorf("request stats: %w", err)
}
return &stats, nil
}
func (s *RequestService) triggerSearch(ctx context.Context, req CreateRequest) {
if req.Title == "" {
return
}
_, err := s.searchSvc.Search(ctx, SearchRequest{
Query: req.Title,
MediaType: req.MediaType,
})
if err != nil {
slog.Error("auto-search for request failed", "title", req.Title, "error", err)
} else {
slog.Info("auto-search triggered for request", "title", req.Title)
}
}

View File

@@ -0,0 +1,78 @@
package service
import (
"context"
"fmt"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type RootFolder struct {
ID int64 `json:"id"`
Path string `json:"path"`
MediaType string `json:"media_type"`
FreeSpace *int64 `json:"free_space,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type CreateRootFolderRequest struct {
Path string `json:"path"`
MediaType string `json:"media_type"`
}
type RootFolderService struct {
db *db.DB
}
func NewRootFolderService(database *db.DB) *RootFolderService {
return &RootFolderService{db: database}
}
func (s *RootFolderService) List(ctx context.Context) ([]RootFolder, error) {
rows, err := s.db.Pool.Query(ctx,
"SELECT id, path, media_type, free_space, created_at FROM root_folders ORDER BY path")
if err != nil {
return nil, fmt.Errorf("list root folders: %w", err)
}
defer rows.Close()
var folders []RootFolder
for rows.Next() {
var f RootFolder
if err := rows.Scan(&f.ID, &f.Path, &f.MediaType, &f.FreeSpace, &f.CreatedAt); err != nil {
return nil, fmt.Errorf("scan root folder: %w", err)
}
folders = append(folders, f)
}
return folders, nil
}
func (s *RootFolderService) Create(ctx context.Context, req CreateRootFolderRequest) (int64, error) {
if req.Path == "" {
return 0, fmt.Errorf("path is required")
}
if req.MediaType == "" {
return 0, fmt.Errorf("media_type is required")
}
var id int64
err := s.db.Pool.QueryRow(ctx,
"INSERT INTO root_folders (path, media_type, created_at) VALUES ($1, $2, NOW()) RETURNING id",
req.Path, req.MediaType).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create root folder: %w", err)
}
return id, nil
}
func (s *RootFolderService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM root_folders WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete root folder: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("root folder not found")
}
return nil
}

View File

@@ -0,0 +1,56 @@
package service
import (
"net/url"
"path/filepath"
"strings"
)
var dangerousExtensions = map[string]bool{
".exe": true, ".bat": true, ".cmd": true, ".scr": true,
".js": true, ".vbs": true, ".com": true, ".ps1": true,
".sh": true, ".wsf": true, ".wsh": true, ".msi": true,
".dll": true, ".lnk": true, ".inf": true, ".reg": true,
".vbe": true, ".jse": true, ".cpl": true, ".hta": true,
}
type SafetyBlockResult struct {
Blocked bool `json:"blocked"`
Reason string `json:"reason"`
MatchedExtension string `json:"matched_extension"`
}
type SafetyService struct{}
func NewSafetyService() *SafetyService {
return &SafetyService{}
}
func (s *SafetyService) Check(title string, downloadURL string) *SafetyBlockResult {
// Check extension from release title
ext := strings.ToLower(filepath.Ext(title))
if dangerousExtensions[ext] {
return &SafetyBlockResult{
Blocked: true,
Reason: "Release contains dangerous file extension: " + ext,
MatchedExtension: ext,
}
}
// Check extension from download URL
if downloadURL != "" {
u, err := url.Parse(downloadURL)
if err == nil {
urlExt := strings.ToLower(filepath.Ext(u.Path))
if dangerousExtensions[urlExt] {
return &SafetyBlockResult{
Blocked: true,
Reason: "Download URL contains dangerous file extension: " + urlExt,
MatchedExtension: urlExt,
}
}
}
}
return nil
}

View File

@@ -0,0 +1,92 @@
package service
import "testing"
func TestSafetyCheck_Safe(t *testing.T) {
svc := NewSafetyService()
tests := []struct {
name string
title string
url string
}{
{"mkv file", "Movie.2024.1080p.BluRay.mkv", ""},
{"mp4 file", "Show.S01E01.720p.WEB.mp4", "http://example.com/Show.S01E01.720p.WEB.mp4"},
{"no extension", "Some-Release-Group", ""},
{"nzb file", "Movie.2024.1080p.nzb", "http://indexer.example.com/api?t=get&id=123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.Check(tt.title, tt.url)
if result != nil {
t.Errorf("expected nil (safe), got blocked: %s", result.Reason)
}
})
}
}
func TestSafetyCheck_DangerousTitle(t *testing.T) {
svc := NewSafetyService()
tests := []struct {
name string
title string
wantExt string
}{
{"exe in title", "Malware.2024.exe", ".exe"},
{"bat in title", "Suspicious.Release.bat", ".bat"},
{"scr in title", "Screensaver.scr", ".scr"},
{"cmd in title", "Malware.Release.cmd", ".cmd"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := svc.Check(tt.title, "")
if result == nil {
t.Fatal("expected block, got nil")
}
if !result.Blocked {
t.Error("expected Blocked=true")
}
if result.MatchedExtension != tt.wantExt {
t.Errorf("expected extension %s, got %s", tt.wantExt, result.MatchedExtension)
}
})
}
}
func TestSafetyCheck_DangerousURL(t *testing.T) {
svc := NewSafetyService()
result := svc.Check("normal-release", "http://example.com/file.bat")
if result == nil {
t.Fatal("expected block from URL extension, got nil")
}
if !result.Blocked {
t.Error("expected Blocked=true")
}
if result.MatchedExtension != ".bat" {
t.Errorf("expected .bat, got %s", result.MatchedExtension)
}
}
func TestSafetyCheck_BothSafe(t *testing.T) {
svc := NewSafetyService()
result := svc.Check("Movie.2024.1080p.mkv", "http://example.com/Movie.2024.1080p.mkv")
if result != nil {
t.Errorf("expected nil (both safe), got blocked: %s", result.Reason)
}
}
func TestSafetyCheck_ExtensionInMiddle(t *testing.T) {
svc := NewSafetyService()
// filepath.Ext returns the part after the LAST dot.
// "Movie.EXE-group.1080p.mkv" has .mkv as extension — should NOT be blocked
result := svc.Check("Movie.EXE-group.1080p.mkv", "")
if result != nil {
t.Errorf("expected nil (safe .mkv extension), got blocked: %s", result.Reason)
}
}

426
internal/service/search.go Normal file
View File

@@ -0,0 +1,426 @@
package service
import (
"context"
"encoding/xml"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/TopherMayor/unified-media-manager/internal/cardigann"
"golang.org/x/sync/errgroup"
)
type rssFeed struct {
XMLName xml.Name `xml:"rss"`
Channel rssChannel `xml:"channel"`
}
type rssChannel struct {
Items []rssItem `xml:"item"`
}
type rssItem struct {
Title string `xml:"title"`
GUID string `xml:"guid"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Description string `xml:"description"`
Enclosure *rssEnclosure `xml:"enclosure"`
Attrs []rssAttr `xml:"attr"`
Size string `xml:"size"`
}
type rssEnclosure struct {
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
Type string `xml:"type,attr"`
}
type rssAttr struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
type SearchResult struct {
Title string `json:"title"`
GUID string `json:"guid"`
Link string `json:"link"`
Size int64 `json:"size"`
PubDate string `json:"pub_date"`
IndexerName string `json:"indexer_name"`
IndexerPriority int `json:"indexer_priority"`
Quality ReleaseInfo `json:"quality"`
QualityTier *QualityTier `json:"quality_tier"`
Seeders int `json:"seeders"`
Peers int `json:"peers"`
Category string `json:"category"`
DownloadURL string `json:"download_url"`
SourceIndexers []string `json:"source_indexers"`
}
type SearchRequest struct {
Query string `json:"query"`
MediaType string `json:"media_type"`
IndexerIDs []int64 `json:"indexer_ids,omitempty"`
}
type SearchService struct {
indexerSvc *IndexerService
parser *ReleaseParser
cardigannEngine *cardigann.CardigannEngine
httpClient *http.Client
}
func NewSearchService(indexerSvc *IndexerService, parser *ReleaseParser, cardigannEngine *cardigann.CardigannEngine) *SearchService {
transport := &http.Transport{
MaxIdleConns: 20,
MaxIdleConnsPerHost: 5,
IdleConnTimeout: 90 * time.Second,
DisableKeepAlives: false,
}
return &SearchService{
indexerSvc: indexerSvc,
parser: parser,
cardigannEngine: cardigannEngine,
httpClient: &http.Client{Timeout: 30 * time.Second, Transport: transport},
}
}
func (s *SearchService) Search(ctx context.Context, req SearchRequest) ([]SearchResult, error) {
indexers, err := s.indexerSvc.ListEnabled(ctx)
if err != nil {
return nil, fmt.Errorf("get enabled indexers: %w", err)
}
if len(indexers) == 0 {
return nil, fmt.Errorf("no enabled indexers available")
}
if len(req.IndexerIDs) > 0 {
idSet := make(map[int64]bool, len(req.IndexerIDs))
for _, id := range req.IndexerIDs {
idSet[id] = true
}
var filtered []Indexer
for _, idx := range indexers {
if idSet[idx.ID] {
filtered = append(filtered, idx)
}
}
indexers = filtered
}
if len(indexers) == 0 {
return nil, fmt.Errorf("no matching indexers found")
}
category := MediaTypeToCategory(req.MediaType)
searchCtx, searchCancel := context.WithTimeout(ctx, 30*time.Second)
defer searchCancel()
var mu sync.Mutex
var allResults []SearchResult
g, gCtx := errgroup.WithContext(searchCtx)
g.SetLimit(10)
for _, idx := range indexers {
idx := idx
g.Go(func() error {
results, err := s.searchIndexer(gCtx, idx, req.Query, category)
if err != nil {
return nil
}
mu.Lock()
allResults = append(allResults, results...)
mu.Unlock()
return nil
})
}
_ = g.Wait()
merged := s.mergeAndDedup(allResults)
sort.Slice(merged, func(i, j int) bool {
ti := 0
tj := 0
if merged[i].QualityTier != nil {
ti = merged[i].QualityTier.Rank
}
if merged[j].QualityTier != nil {
tj = merged[j].QualityTier.Rank
}
if ti != tj {
return ti > tj
}
return merged[i].Size < merged[j].Size
})
return merged, nil
}
func (s *SearchService) searchIndexer(ctx context.Context, idx Indexer, query string, category string) ([]SearchResult, error) {
// Dispatch to Cardigann engine for cardigann implementation
if idx.Implementation == "cardigann" {
return s.searchCardigannIndexer(ctx, idx, query)
}
searchURL := fmt.Sprintf("%s/api?t=search&q=%s", idx.URL, url.QueryEscape(query))
apiKey := ""
if idx.APIKey != nil {
apiKey = *idx.APIKey
}
if apiKey != "" {
searchURL += "&apikey=" + apiKey
}
if category != "" {
searchURL += "&cat=" + category
}
if idx.Implementation == "torznab" {
searchURL += "&extended=1"
}
searchURL += "&offset=0&limit=100"
reqCtx, reqCancel := context.WithTimeout(ctx, 15*time.Second)
defer reqCancel()
httpReq, err := http.NewRequestWithContext(reqCtx, http.MethodGet, searchURL, nil)
if err != nil {
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
client := s.httpClient
resp, err := client.Do(httpReq)
if err != nil {
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
var feed rssFeed
if err := xml.Unmarshal(body, &feed); err != nil {
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
slog.Error("failed to parse indexer XML response", "indexer", idx.Name, "error", err)
return nil, nil
}
go s.indexerSvc.RecordSuccess(context.Background(), idx.ID)
var results []SearchResult
for _, item := range feed.Channel.Items {
result := s.parseItem(item, idx)
if result.DownloadURL != "" {
results = append(results, result)
}
}
return results, nil
}
func (s *SearchService) parseItem(item rssItem, idx Indexer) SearchResult {
result := SearchResult{
Title: item.Title,
GUID: item.GUID,
Link: item.Link,
PubDate: item.PubDate,
IndexerName: idx.Name,
IndexerPriority: idx.Priority,
SourceIndexers: []string{idx.Name},
}
if item.Enclosure != nil && item.Enclosure.URL != "" {
result.DownloadURL = item.Enclosure.URL
if item.Enclosure.Length != "" {
if size, err := strconv.ParseInt(item.Enclosure.Length, 10, 64); err == nil {
result.Size = size
}
}
} else if item.Link != "" {
result.DownloadURL = item.Link
}
if result.Size == 0 && item.Size != "" {
if size, err := strconv.ParseInt(item.Size, 10, 64); err == nil {
result.Size = size
}
}
for _, attr := range item.Attrs {
switch attr.Name {
case "size":
if result.Size == 0 {
if size, err := strconv.ParseInt(attr.Value, 10, 64); err == nil {
result.Size = size
}
}
case "seeders":
if v, err := strconv.Atoi(attr.Value); err == nil {
result.Seeders = v
}
case "peers":
if v, err := strconv.Atoi(attr.Value); err == nil {
result.Peers = v
}
case "category":
result.Category = attr.Value
}
}
quality := s.parser.Parse(item.Title)
result.Quality = quality
result.QualityTier = s.parser.MatchQuality(quality)
return result
}
func (s *SearchService) searchCardigannIndexer(ctx context.Context, idx Indexer, query string) ([]SearchResult, error) {
cfg, err := s.indexerSvc.GetCardigannConfig(idx.Settings)
if err != nil {
slog.Error("failed to get cardigann config", "indexer", idx.Name, "error", err)
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
def, err := cardigann.ParseDefinition([]byte(cfg.YAML))
if err != nil {
slog.Error("failed to parse cardigann YAML", "indexer", idx.Name, "error", err)
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
results, err := s.cardigannEngine.Search(ctx, def, cfg.Config, cardigann.SearchQuery{
Keywords: query,
})
if err != nil {
slog.Error("cardigann search failed", "indexer", idx.Name, "error", err)
go s.indexerSvc.RecordFailure(context.Background(), idx.ID)
return nil, nil
}
go s.indexerSvc.RecordSuccess(context.Background(), idx.ID)
var searchResults []SearchResult
for _, cr := range results {
result := SearchResult{
Title: cr.Title,
GUID: cr.GUID,
DownloadURL: cr.DownloadURL,
Size: cr.Size,
PubDate: cr.PubDate,
IndexerName: idx.Name,
IndexerPriority: idx.Priority,
SourceIndexers: []string{idx.Name},
Seeders: cr.Seeders,
Peers: cr.Peers,
Category: cr.Category,
}
result.Quality = s.parser.Parse(cr.Title)
result.QualityTier = s.parser.MatchQuality(result.Quality)
searchResults = append(searchResults, result)
}
return searchResults, nil
}
func (s *SearchService) mergeAndDedup(results []SearchResult) []SearchResult {
seen := make(map[string]int)
var merged []SearchResult
for _, r := range results {
guid := strings.ToLower(r.GUID)
if guid == "" {
guid = strings.ToLower(r.DownloadURL)
}
if guid == "" {
merged = append(merged, r)
continue
}
if existingIdx, ok := seen[guid]; ok {
existing := &merged[existingIdx]
if r.IndexerPriority < existing.IndexerPriority {
existing.SourceIndexers = append(existing.SourceIndexers, r.IndexerName)
} else {
newSources := make([]string, 0, len(existing.SourceIndexers)+1)
newSources = append(newSources, r.IndexerName)
newSources = append(newSources, existing.SourceIndexers...)
r.SourceIndexers = newSources
merged[existingIdx] = r
}
} else {
seen[guid] = len(merged)
merged = append(merged, r)
}
}
return merged
}
type GrabRequest struct {
DownloadURL string `json:"download_url"`
Title string `json:"title"`
MediaType string `json:"media_type"`
Quality ReleaseInfo `json:"quality"`
IndexerName string `json:"indexer_name"`
MediaID int64 `json:"media_id"`
}
type GrabResult struct {
QueueID int64 `json:"queue_id"`
DownloadID string `json:"download_id"`
ClientName string `json:"client_name"`
Protocol string `json:"protocol"`
}
func (s *SearchService) Grab(ctx context.Context, req GrabRequest, downloadClientSvc *DownloadClientService) (*GrabResult, error) {
protocol := "torrent"
if strings.HasPrefix(req.DownloadURL, "magnet:?") {
protocol = "torrent"
} else if strings.HasSuffix(strings.ToLower(req.DownloadURL), ".nzb") {
protocol = "nzb"
}
client, cfg, err := downloadClientSvc.GetClient(ctx, protocol)
if err != nil {
return nil, fmt.Errorf("get download client: %w", err)
}
downloadID, err := client.Add(ctx, req.DownloadURL, "umm")
if err != nil {
return nil, fmt.Errorf("add download: %w", err)
}
return &GrabResult{
DownloadID: downloadID,
ClientName: cfg.Name,
Protocol: cfg.Protocol,
}, nil
}

View File

@@ -0,0 +1,378 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type SubtitleSearchResult struct {
ID string `json:"id"`
FileName string `json:"file_name"`
Language string `json:"language"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
DownloadCount int `json:"download_count"`
ReleaseName string `json:"release_name"`
Provider string `json:"provider"`
}
type SubtitleFile struct {
Path string `json:"path"`
Language string `json:"language"`
LanguageCode string `json:"language_code"`
HI bool `json:"hi"`
Forced bool `json:"forced"`
Source string `json:"source"`
}
type SubtitleSearchOptions struct {
LanguageCodes []string
HI bool
Forced bool
}
type SubtitleService struct {
db *db.DB
apiKey string
baseURL string
httpClient *http.Client
ffmpegPath string
ffprobePath string
}
func NewSubtitleService(database *db.DB, apiKey string) *SubtitleService {
return &SubtitleService{
db: database,
apiKey: apiKey,
baseURL: "https://api.opensubtitles.com/api/v1",
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
ffmpegPath: "ffmpeg",
ffprobePath: "ffprobe",
}
}
type osSearchResponse struct {
TotalPages int `json:"total_pages"`
Data []osSubtitle `json:"data"`
}
type osSubtitle struct {
ID string `json:"id"`
FileName string `json:"file_name"`
Language string `json:"language"`
MovieFileNameMatch string `json:"movie_file_name_match"`
DownloadCount int `json:"download_count"`
HearingImpaired bool `json:"hearing_impaired"`
ForeignPartsOnly bool `json:"foreign_parts_only"`
ReleaseName string `json:"release_name"`
MovieHash string `json:"movie_hash"`
}
type osDownloadResponse struct {
Link string `json:"link"`
FileName string `json:"file_name"`
}
type osLoginResponse struct {
Token string `json:"token"`
}
type ffprobeStream struct {
Streams []ffprobeStreamInfo `json:"streams"`
}
type ffprobeStreamInfo struct {
Index int `json:"index"`
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Disposition ffprobeDisposition `json:"disposition"`
Tags ffprobeTags `json:"tags"`
}
type ffprobeDisposition struct {
Forced int `json:"forced"`
}
type ffprobeTags struct {
Language string `json:"language"`
Title string `json:"title"`
}
func (s *SubtitleService) osLogin(ctx context.Context) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/login", nil)
if err != nil {
return "", fmt.Errorf("create login request: %w", err)
}
req.Header.Set("Api-Key", s.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("opensubtitles login: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("opensubtitles login failed: status %d", resp.StatusCode)
}
var loginResp osLoginResponse
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
return "", fmt.Errorf("decode login response: %w", err)
}
return loginResp.Token, nil
}
func (s *SubtitleService) Search(ctx context.Context, query string, opts SubtitleSearchOptions) ([]SubtitleSearchResult, error) {
token, err := s.osLogin(ctx)
if err != nil {
return nil, fmt.Errorf("opensubtitles login: %w", err)
}
langs := strings.Join(opts.LanguageCodes, ",")
url := fmt.Sprintf("%s/subtitles?query=%s&languages=%s", s.baseURL, query, langs)
if opts.HI {
url += "&hearing_impaired=true"
}
if opts.Forced {
url += "&foreign_parts_only=true"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create search request: %w", err)
}
req.Header.Set("Api-Key", s.apiKey)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("search opensubtitles: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("opensubtitles search failed: status %d", resp.StatusCode)
}
var searchResp osSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("decode search response: %w", err)
}
var results []SubtitleSearchResult
for _, sub := range searchResp.Data {
langCode := ""
if parts := strings.Split(sub.Language, "-"); len(parts) > 0 {
langCode = strings.ToLower(parts[0])
}
results = append(results, SubtitleSearchResult{
ID: sub.ID,
FileName: sub.FileName,
Language: sub.Language,
LanguageCode: langCode,
HI: sub.HearingImpaired,
Forced: sub.ForeignPartsOnly,
DownloadCount: sub.DownloadCount,
ReleaseName: sub.ReleaseName,
Provider: "opensubtitles",
})
}
return results, nil
}
func (s *SubtitleService) Download(ctx context.Context, subtitleID string, targetDir string, baseName string, langCode string, hi bool, forced bool) (*SubtitleFile, error) {
token, err := s.osLogin(ctx)
if err != nil {
return nil, fmt.Errorf("opensubtitles login: %w", err)
}
body, _ := json.Marshal(map[string]string{"file_id": subtitleID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/download", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create download request: %w", err)
}
req.Header.Set("Api-Key", s.apiKey)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request subtitle download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subtitle download request failed: status %d", resp.StatusCode)
}
var downloadResp osDownloadResponse
if err := json.NewDecoder(resp.Body).Decode(&downloadResp); err != nil {
return nil, fmt.Errorf("decode download response: %w", err)
}
fileReq, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadResp.Link, nil)
if err != nil {
return nil, fmt.Errorf("create file download request: %w", err)
}
fileResp, err := s.httpClient.Do(fileReq)
if err != nil {
return nil, fmt.Errorf("download subtitle file: %w", err)
}
defer fileResp.Body.Close()
if fileResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subtitle file download failed: status %d", fileResp.StatusCode)
}
filename := baseName + "." + langCode + ".srt"
if hi {
filename = baseName + "." + langCode + ".sdh.srt"
}
if forced {
filename = baseName + "." + langCode + ".forced.srt"
}
if err := os.MkdirAll(targetDir, 0755); err != nil {
return nil, fmt.Errorf("create target directory: %w", err)
}
destPath := filepath.Join(targetDir, filename)
f, err := os.Create(destPath)
if err != nil {
return nil, fmt.Errorf("create subtitle file: %w", err)
}
defer f.Close()
if _, err := io.Copy(f, fileResp.Body); err != nil {
return nil, fmt.Errorf("write subtitle file: %w", err)
}
return &SubtitleFile{
Path: destPath,
Language: langCode,
LanguageCode: langCode,
HI: hi,
Forced: forced,
Source: "downloaded",
}, nil
}
func (s *SubtitleService) ExtractSubtitles(ctx context.Context, mediaFilePath string, targetDir string, baseName string) ([]SubtitleFile, error) {
probeCtx, probeCancel := context.WithTimeout(ctx, 30*time.Second)
defer probeCancel()
cmd := exec.CommandContext(probeCtx, s.ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_streams",
"-select_streams", "s",
mediaFilePath,
)
var stdout bytes.Buffer
cmd.Stdout = &stdout
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("ffprobe subtitle streams: %w", err)
}
var probeResult ffprobeStream
if err := json.Unmarshal(stdout.Bytes(), &probeResult); err != nil {
return nil, fmt.Errorf("parse ffprobe output: %w", err)
}
if len(probeResult.Streams) == 0 {
return nil, nil
}
var results []SubtitleFile
for i, stream := range probeResult.Streams {
langCode := strings.ToLower(stream.Tags.Language)
if langCode == "" {
langCode = "und"
}
hi := false
if strings.Contains(strings.ToLower(stream.Tags.Title), "sdh") {
hi = true
}
forced := stream.Disposition.Forced == 1
filename := baseName + "." + langCode + ".srt"
if hi {
filename = baseName + "." + langCode + ".sdh.srt"
}
if forced {
filename = baseName + "." + langCode + ".forced.srt"
}
outputPath := filepath.Join(targetDir, filename)
extractCtx, extractCancel := context.WithTimeout(ctx, 30*time.Second)
extractCmd := exec.CommandContext(extractCtx, s.ffmpegPath,
"-i", mediaFilePath,
"-map", fmt.Sprintf("0:s:%d", i),
"-f", "srt",
outputPath,
)
if err := extractCmd.Run(); err != nil {
slog.Error("failed to extract subtitle stream", "error", err, "stream_index", i)
extractCancel()
continue
}
extractCancel()
results = append(results, SubtitleFile{
Path: outputPath,
Language: langCode,
LanguageCode: langCode,
HI: hi,
Forced: forced,
Source: "extracted",
})
}
return results, nil
}
func BuildSubtitleBaseName(title string, year *int, season, episode int) string {
parts := []string{sanitizeSubtitleName(title)}
if year != nil {
parts = append(parts, fmt.Sprintf("%d", *year))
}
if season > 0 && episode > 0 {
parts = append(parts, fmt.Sprintf("S%02dE%02d", season, episode))
}
return strings.Join(parts, ".")
}
var nonAlphaNumRe = regexp.MustCompile(`[^a-zA-Z0-9]+`)
func sanitizeSubtitleName(s string) string {
s = nonAlphaNumRe.ReplaceAllString(s, ".")
for strings.Contains(s, "..") {
s = strings.ReplaceAll(s, "..", ".")
}
return strings.Trim(s, ".")
}

76
internal/service/tag.go Normal file
View File

@@ -0,0 +1,76 @@
package service
import (
"context"
"fmt"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type Tag struct {
ID int64 `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
}
type CreateTagRequest struct {
Name string `json:"name"`
Color string `json:"color,omitempty"`
}
type TagService struct {
db *db.DB
}
func NewTagService(database *db.DB) *TagService {
return &TagService{db: database}
}
func (s *TagService) List(ctx context.Context) ([]Tag, error) {
rows, err := s.db.Pool.Query(ctx,
"SELECT id, name, COALESCE(color, '#6366f1') FROM tags ORDER BY name")
if err != nil {
return nil, fmt.Errorf("list tags: %w", err)
}
defer rows.Close()
var tags []Tag
for rows.Next() {
var t Tag
if err := rows.Scan(&t.ID, &t.Name, &t.Color); err != nil {
return nil, fmt.Errorf("scan tag: %w", err)
}
tags = append(tags, t)
}
return tags, nil
}
func (s *TagService) Create(ctx context.Context, req CreateTagRequest) (int64, error) {
if req.Name == "" {
return 0, fmt.Errorf("name is required")
}
color := req.Color
if color == "" {
color = "#6366f1"
}
var id int64
err := s.db.Pool.QueryRow(ctx,
"INSERT INTO tags (name, color) VALUES ($1, $2) RETURNING id",
req.Name, color).Scan(&id)
if err != nil {
return 0, fmt.Errorf("create tag: %w", err)
}
return id, nil
}
func (s *TagService) Delete(ctx context.Context, id int64) error {
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM tags WHERE id = $1", id)
if err != nil {
return fmt.Errorf("delete tag: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("tag not found")
}
return nil
}

403
internal/service/tmdb.go Normal file
View File

@@ -0,0 +1,403 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strconv"
"time"
)
type TMDBProvider struct {
apiKey string
baseURL string
httpClient *http.Client
}
func NewTMDBProvider(apiKey string) *TMDBProvider {
return &TMDBProvider{
apiKey: apiKey,
baseURL: "https://api.themoviedb.org/3",
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
type tmdbSearchResponse struct {
Page int `json:"page"`
TotalResults int `json:"total_results"`
Results []tmdbSearchItem `json:"results"`
}
type tmdbSearchItem struct {
ID int `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
OriginalTitle string `json:"original_title"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
VoteAverage float64 `json:"vote_average"`
MediaType string `json:"media_type"`
}
type tmdbMovieDetail struct {
ID int `json:"id"`
Title string `json:"title"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
ReleaseDate string `json:"release_date"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
VoteAverage float64 `json:"vote_average"`
Runtime int `json:"runtime"`
Genres []tmdbGenre `json:"genres"`
ExternalIDs tmdbExternalIDs `json:"external_ids"`
}
type tmdbTVDetail struct {
ID int `json:"id"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
FirstAirDate string `json:"first_air_date"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
VoteAverage float64 `json:"vote_average"`
NumberOfSeasons int `json:"number_of_seasons"`
NumberOfEpisodes int `json:"number_of_episodes"`
Genres []tmdbGenre `json:"genres"`
ExternalIDs tmdbExternalIDs `json:"external_ids"`
}
type tmdbGenre struct {
ID int `json:"id"`
Name string `json:"name"`
}
type tmdbExternalIDs struct {
IMDbID string `json:"imdb_id"`
TVDBID string `json:"tvdb_id"`
}
type TMDBFullDetail struct {
ID int `json:"id"`
Title string `json:"title"`
Name string `json:"name"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
BackdropPath string `json:"backdrop_path"`
ReleaseDate string `json:"release_date"`
FirstAirDate string `json:"first_air_date"`
VoteAverage float64 `json:"vote_average"`
Genres []tmdbGenre `json:"genres"`
ExternalIDs tmdbExtIDs `json:"external_ids"`
NumberOfSeasons int `json:"number_of_seasons"`
NumberOfEpisodes int `json:"number_of_episodes"`
Runtime int `json:"runtime"`
Status string `json:"status"`
}
type tmdbExtIDs struct {
IMDbID string `json:"imdb_id"`
TVDBID string `json:"tvdb_id"`
}
type tmdbImage struct {
FilePath string `json:"file_path"`
Width int `json:"width"`
Height int `json:"height"`
AspectRatio float64 `json:"aspect_ratio"`
VoteAverage float64 `json:"vote_average"`
}
type tmdbImagesResponse struct {
Backdrops []tmdbImage `json:"backdrops"`
Posters []tmdbImage `json:"posters"`
}
func (p *TMDBProvider) Name() string {
return "tmdb"
}
func (p *TMDBProvider) fetchTMDB(ctx context.Context, url string, result interface{}) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch tmdb: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tmdb api returned status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("decode tmdb response: %w", err)
}
return nil
}
func parseTMDBYear(dateStr string) *int {
if dateStr == "" {
return nil
}
if len(dateStr) < 4 {
return nil
}
year, err := strconv.Atoi(dateStr[:4])
if err != nil {
return nil
}
return &year
}
func (p *TMDBProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) {
url := fmt.Sprintf("%s/search/multi?api_key=%s&query=%s", p.baseURL, p.apiKey, query)
if opts.Page > 0 {
url += fmt.Sprintf("&page=%d", opts.Page)
}
if opts.Year != nil {
url += fmt.Sprintf("&year=%d", *opts.Year)
}
var resp tmdbSearchResponse
if err := p.fetchTMDB(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("search tmdb: %w", err)
}
var results []MetadataSearchResult
for _, item := range resp.Results {
if item.MediaType != "movie" && item.MediaType != "tv" {
continue
}
title := item.Title
origTitle := item.OriginalTitle
dateStr := item.ReleaseDate
mediaType := "movie"
if item.MediaType == "tv" {
title = item.Name
origTitle = item.OriginalName
dateStr = item.FirstAirDate
mediaType = "series"
}
year := parseTMDBYear(dateStr)
externalIDs, _ := json.Marshal(map[string]string{
"tmdb": strconv.Itoa(item.ID),
})
overview := item.Overview
results = append(results, MetadataSearchResult{
ProviderID: strconv.Itoa(item.ID),
Title: title,
Year: year,
MediaType: mediaType,
Overview: overview,
OriginalTitle: origTitle,
ExternalIDs: externalIDs,
})
}
return results, nil
}
func (p *TMDBProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) {
movieURL := fmt.Sprintf("%s/movie/%s?api_key=%s&append_to_response=external_ids", p.baseURL, id, p.apiKey)
var movieDetail tmdbMovieDetail
err := p.fetchTMDB(ctx, movieURL, &movieDetail)
if err == nil {
ratings, _ := json.Marshal(map[string]float64{
"tmdb": movieDetail.VoteAverage,
})
extIDs := map[string]string{
"tmdb": strconv.Itoa(movieDetail.ID),
}
if movieDetail.ExternalIDs.IMDbID != "" {
extIDs["imdb"] = movieDetail.ExternalIDs.IMDbID
}
if movieDetail.ExternalIDs.TVDBID != "" {
extIDs["tvdb"] = movieDetail.ExternalIDs.TVDBID
}
extIDsJSON, _ := json.Marshal(extIDs)
var genreNames []string
for _, g := range movieDetail.Genres {
genreNames = append(genreNames, g.Name)
}
metadata, _ := json.Marshal(map[string]interface{}{
"runtime": movieDetail.Runtime,
"genres": genreNames,
"release_date": movieDetail.ReleaseDate,
})
overview := movieDetail.Overview
origTitle := movieDetail.OriginalTitle
return &MetadataDetails{
ProviderID: id,
Title: movieDetail.Title,
OriginalTitle: &origTitle,
Overview: &overview,
Year: parseTMDBYear(movieDetail.ReleaseDate),
Ratings: ratings,
ExternalIDs: extIDsJSON,
Metadata: metadata,
}, nil
}
tvURL := fmt.Sprintf("%s/tv/%s?api_key=%s&append_to_response=external_ids", p.baseURL, id, p.apiKey)
var tvDetail tmdbTVDetail
if err := p.fetchTMDB(ctx, tvURL, &tvDetail); err != nil {
slog.Error("tmdb get details failed for both movie and tv", "id", id, "error", err)
return nil, fmt.Errorf("get tmdb details: %w", err)
}
ratings, _ := json.Marshal(map[string]float64{
"tmdb": tvDetail.VoteAverage,
})
extIDs := map[string]string{
"tmdb": strconv.Itoa(tvDetail.ID),
}
if tvDetail.ExternalIDs.IMDbID != "" {
extIDs["imdb"] = tvDetail.ExternalIDs.IMDbID
}
if tvDetail.ExternalIDs.TVDBID != "" {
extIDs["tvdb"] = tvDetail.ExternalIDs.TVDBID
}
extIDsJSON, _ := json.Marshal(extIDs)
var genreNames []string
for _, g := range tvDetail.Genres {
genreNames = append(genreNames, g.Name)
}
metadata, _ := json.Marshal(map[string]interface{}{
"number_of_seasons": tvDetail.NumberOfSeasons,
"number_of_episodes": tvDetail.NumberOfEpisodes,
"genres": genreNames,
"first_air_date": tvDetail.FirstAirDate,
})
overview := tvDetail.Overview
origTitle := tvDetail.OriginalName
return &MetadataDetails{
ProviderID: id,
Title: tvDetail.Name,
OriginalTitle: &origTitle,
Overview: &overview,
Year: parseTMDBYear(tvDetail.FirstAirDate),
Ratings: ratings,
ExternalIDs: extIDsJSON,
Metadata: metadata,
}, nil
}
func (p *TMDBProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) {
var images []ImageResult
movieURL := fmt.Sprintf("%s/movie/%s/images?api_key=%s", p.baseURL, id, p.apiKey)
var movieImages tmdbImagesResponse
err := p.fetchTMDB(ctx, movieURL, &movieImages)
if err != nil {
tvURL := fmt.Sprintf("%s/tv/%s/images?api_key=%s", p.baseURL, id, p.apiKey)
var tvImages tmdbImagesResponse
if err := p.fetchTMDB(ctx, tvURL, &tvImages); err != nil {
return nil, fmt.Errorf("get tmdb images: %w", err)
}
movieImages = tvImages
}
for _, img := range movieImages.Posters {
images = append(images, ImageResult{
URL: fmt.Sprintf("https://image.tmdb.org/t/p/original%s", img.FilePath),
Type: "poster",
Width: img.Width,
Height: img.Height,
})
}
for _, img := range movieImages.Backdrops {
images = append(images, ImageResult{
URL: fmt.Sprintf("https://image.tmdb.org/t/p/original%s", img.FilePath),
Type: "backdrop",
Width: img.Width,
Height: img.Height,
})
}
return images, nil
}
// Trending fetches trending items from TMDB for the given media type ("movie" or "tv").
func (p *TMDBProvider) Trending(ctx context.Context, mediaType string, page int) ([]tmdbSearchItem, error) {
tmdbType := mediaType
if tmdbType == "series" {
tmdbType = "tv"
}
url := fmt.Sprintf("%s/trending/%s/week?api_key=%s&page=%d", p.baseURL, tmdbType, p.apiKey, page)
var resp tmdbSearchResponse
if err := p.fetchTMDB(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("fetch trending: %w", err)
}
return resp.Results, nil
}
// Popular fetches popular items from TMDB for the given media type ("movie" or "tv").
func (p *TMDBProvider) Popular(ctx context.Context, mediaType string, page int) ([]tmdbSearchItem, error) {
tmdbType := mediaType
if tmdbType == "series" {
tmdbType = "tv"
}
url := fmt.Sprintf("%s/%s/popular?api_key=%s&page=%d", p.baseURL, tmdbType, p.apiKey, page)
var resp tmdbSearchResponse
if err := p.fetchTMDB(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("fetch popular: %w", err)
}
return resp.Results, nil
}
// GetMovieDetails fetches full movie details from TMDB including credits and external IDs.
func (p *TMDBProvider) GetMovieDetails(ctx context.Context, id string) (*TMDBFullDetail, error) {
url := fmt.Sprintf("%s/movie/%s?api_key=%s&append_to_response=external_ids,credits", p.baseURL, id, p.apiKey)
var detail TMDBFullDetail
if err := p.fetchTMDB(ctx, url, &detail); err != nil {
return nil, fmt.Errorf("fetch movie details: %w", err)
}
return &detail, nil
}
// GetTVDetails fetches full TV show details from TMDB including credits and external IDs.
func (p *TMDBProvider) GetTVDetails(ctx context.Context, id string) (*TMDBFullDetail, error) {
url := fmt.Sprintf("%s/tv/%s?api_key=%s&append_to_response=external_ids,credits", p.baseURL, id, p.apiKey)
var detail TMDBFullDetail
if err := p.fetchTMDB(ctx, url, &detail); err != nil {
return nil, fmt.Errorf("fetch tv details: %w", err)
}
return &detail, nil
}

308
internal/service/tvdb.go Normal file
View File

@@ -0,0 +1,308 @@
package service
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
type TVDBProvider struct {
apiKey string
baseURL string
httpClient *http.Client
bearerToken string
tokenExpiry time.Time
lastRequest time.Time
}
func NewTVDBProvider(apiKey string) *TVDBProvider {
return &TVDBProvider{
apiKey: apiKey,
baseURL: "https://api4.thetvdb.com/v4",
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
type tvdbLoginRequest struct {
APIKey string `json:"apikey"`
}
type tvdbLoginResponse struct {
Token string `json:"token"`
}
type tvdbSearchResponse struct {
Data []tvdbSearchItem `json:"data"`
}
type tvdbSearchItem struct {
ID string `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
FirstAired string `json:"first_aired"`
Year string `json:"year"`
ImageURL string `json:"image"`
Type string `json:"type"`
}
type tvdbSeriesDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
FirstAired string `json:"firstAired"`
Status tvdbStatus `json:"status"`
NumberOfSeasons int `json:"numberOfSeasons"`
NumberOfEpisodes int `json:"numberOfEpisodes"`
AverageRuntime int `json:"averageRuntime"`
Ratings []tvdbRating `json:"ratings"`
Image string `json:"image"`
}
type tvdbStatus struct {
Name string `json:"name"`
}
type tvdbRating struct {
Name string `json:"name"`
Source string `json:"source"`
Rating float64 `json:"rating"`
}
type tvdbEpisodesResponse struct {
Data []tvdbEpisode `json:"data"`
}
type tvdbEpisode struct {
ID string `json:"id"`
Name string `json:"name"`
Overview string `json:"overview"`
AiredSeasonNumber int `json:"airedSeasonNumber"`
AiredEpisodeNumber int `json:"airedEpisodeNumber"`
AbsoluteNumber int `json:"absoluteNumber"`
Runtime int `json:"runtime"`
AiredAt string `json:"aired"`
Thumbnail string `json:"thumbnail"`
}
type tvdbImagesResponse struct {
Data []tvdbImage `json:"data"`
}
type tvdbImage struct {
Image string `json:"image"`
ImageType string `json:"type"`
Resolution string `json:"resolution"`
}
func (p *TVDBProvider) rateLimit() {
elapsed := time.Since(p.lastRequest)
if elapsed < 250*time.Millisecond {
time.Sleep(250*time.Millisecond - elapsed)
}
p.lastRequest = time.Now()
}
func (p *TVDBProvider) authenticate(ctx context.Context) error {
if p.bearerToken != "" && time.Now().Before(p.tokenExpiry) {
return nil
}
p.rateLimit()
loginReq := tvdbLoginRequest{APIKey: p.apiKey}
body, err := json.Marshal(loginReq)
if err != nil {
return fmt.Errorf("marshal login request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.baseURL+"/login", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create login request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("tvdb login: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tvdb login failed: status %d", resp.StatusCode)
}
var loginResp tvdbLoginResponse
if err := json.NewDecoder(resp.Body).Decode(&loginResp); err != nil {
return fmt.Errorf("decode login response: %w", err)
}
p.bearerToken = loginResp.Token
p.tokenExpiry = time.Now().Add(24 * time.Hour)
return nil
}
func (p *TVDBProvider) fetchTVDB(ctx context.Context, url string, result interface{}) error {
if err := p.authenticate(ctx); err != nil {
return fmt.Errorf("tvdb auth: %w", err)
}
p.rateLimit()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+p.bearerToken)
resp, err := p.httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch tvdb: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("tvdb api returned status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("decode tvdb response: %w", err)
}
return nil
}
func (p *TVDBProvider) Name() string {
return "tvdb"
}
func (p *TVDBProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) {
url := fmt.Sprintf("%s/search?query=%s&type=series", p.baseURL, query)
var resp tvdbSearchResponse
if err := p.fetchTVDB(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("search tvdb: %w", err)
}
var results []MetadataSearchResult
for _, item := range resp.Data {
var year *int
if item.FirstAired != "" && len(item.FirstAired) >= 4 {
if y, err := strconv.Atoi(item.FirstAired[:4]); err == nil {
year = &y
}
}
results = append(results, MetadataSearchResult{
ProviderID: item.ID,
Title: item.Name,
Year: year,
MediaType: "series",
Overview: item.Overview,
})
}
return results, nil
}
func (p *TVDBProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) {
seriesURL := fmt.Sprintf("%s/series/%s", p.baseURL, id)
var detail tvdbSeriesDetail
if err := p.fetchTVDB(ctx, seriesURL, &detail); err != nil {
return nil, fmt.Errorf("get tvdb series: %w", err)
}
var year *int
if detail.FirstAired != "" && len(detail.FirstAired) >= 4 {
if y, err := strconv.Atoi(detail.FirstAired[:4]); err == nil {
year = &y
}
}
extIDs, _ := json.Marshal(map[string]string{"tvdb": id})
ratingsMap := make(map[string]float64)
for _, r := range detail.Ratings {
ratingsMap[r.Source] = r.Rating
}
ratings, _ := json.Marshal(ratingsMap)
episodesURL := fmt.Sprintf("%s/series/%s/episodes", p.baseURL, id)
var episodesResp tvdbEpisodesResponse
var episodeList []map[string]interface{}
if err := p.fetchTVDB(ctx, episodesURL, &episodesResp); err == nil {
for _, ep := range episodesResp.Data {
episodeList = append(episodeList, map[string]interface{}{
"season": ep.AiredSeasonNumber,
"episode": ep.AiredEpisodeNumber,
"absolute_number": ep.AbsoluteNumber,
"title": ep.Name,
"aired_date": ep.AiredAt,
"runtime": ep.Runtime,
})
}
}
metadata, _ := json.Marshal(map[string]interface{}{
"number_of_seasons": detail.NumberOfSeasons,
"number_of_episodes": detail.NumberOfEpisodes,
"average_runtime": detail.AverageRuntime,
"status": detail.Status.Name,
"episodes": episodeList,
})
overview := detail.Overview
return &MetadataDetails{
ProviderID: id,
Title: detail.Name,
Overview: &overview,
Year: year,
Ratings: ratings,
ExternalIDs: extIDs,
Metadata: metadata,
}, nil
}
func (p *TVDBProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) {
imagesURL := fmt.Sprintf("%s/series/%s/images", p.baseURL, id)
var resp tvdbImagesResponse
if err := p.fetchTVDB(ctx, imagesURL, &resp); err != nil {
return nil, fmt.Errorf("get tvdb images: %w", err)
}
var images []ImageResult
for _, img := range resp.Data {
imgURL := img.Image
if !strings.HasPrefix(imgURL, "http") {
imgURL = "https://artworks.thetvdb.com" + imgURL
}
imgType := img.ImageType
switch imgType {
case "poster":
imgType = "poster"
case "fanart":
imgType = "backdrop"
case "season":
imgType = "poster"
case "series":
imgType = "poster"
}
images = append(images, ImageResult{
URL: imgURL,
Type: imgType,
})
}
return images, nil
}

121
internal/service/user.go Normal file
View File

@@ -0,0 +1,121 @@
package service
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"` // admin, power_user, user
APIKey string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
type UserResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
}
const userColumns = `id, username, display_name, role, api_key, created_at`
type UserService struct {
db *db.DB
}
func NewUserService(database *db.DB) *UserService {
return &UserService{db: database}
}
func userToResponse(u *User) UserResponse {
return UserResponse{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Role: u.Role,
CreatedAt: u.CreatedAt,
}
}
func (s *UserService) SeedAdmin(ctx context.Context, apiKey string) error {
if apiKey == "" {
slog.Warn("ADMIN_API_KEY not set, skipping admin seed")
return nil
}
tag, err := s.db.Pool.Exec(ctx,
`INSERT INTO users (username, display_name, role, api_key)
VALUES ('admin', 'Administrator', 'admin', $1)
ON CONFLICT (username) DO NOTHING`, apiKey)
if err != nil {
return fmt.Errorf("seed admin: %w", err)
}
if tag.RowsAffected() > 0 {
slog.Info("seeded admin user")
}
return nil
}
func (s *UserService) GetUserByAPIKey(ctx context.Context, apiKey string) (*User, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM users WHERE api_key = $1", userColumns), apiKey)
var u User
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.APIKey, &u.CreatedAt)
if err != nil {
return nil, fmt.Errorf("user not found")
}
return &u, nil
}
func (s *UserService) List(ctx context.Context) ([]UserResponse, error) {
rows, err := s.db.Pool.Query(ctx,
fmt.Sprintf("SELECT %s FROM users ORDER BY id", userColumns))
if err != nil {
return nil, fmt.Errorf("list users: %w", err)
}
defer rows.Close()
var items []UserResponse
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.APIKey, &u.CreatedAt); err != nil {
slog.Error("failed to scan user", "error", err)
continue
}
items = append(items, userToResponse(&u))
}
return items, nil
}
func (s *UserService) GetByID(ctx context.Context, id int64) (*User, error) {
row := s.db.Pool.QueryRow(ctx,
fmt.Sprintf("SELECT %s FROM users WHERE id = $1", userColumns), id)
var u User
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &u.APIKey, &u.CreatedAt)
if err != nil {
return nil, fmt.Errorf("user not found")
}
return &u, nil
}
// GetUser returns the UserResponse (without API key) for display.
func (s *UserService) GetUser(ctx context.Context, id int64) (*UserResponse, error) {
u, err := s.GetByID(ctx, id)
if err != nil {
return nil, err
}
resp := userToResponse(u)
return &resp, nil
}

View File

@@ -0,0 +1,87 @@
package worker
import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type CleanupWorker struct {
database *db.DB
cfg *config.Config
}
func NewCleanupWorker(database *db.DB, cfg *config.Config) *CleanupWorker {
return &CleanupWorker{
database: database,
cfg: cfg,
}
}
func (w *CleanupWorker) Name() string {
return "cleanup"
}
func (w *CleanupWorker) CronExpr() string {
return w.cfg.WorkerCleanupInterval
}
func (w *CleanupWorker) Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
partitionsDropped := 0
rows, err := w.database.Pool.Query(ctx,
`SELECT inhrelid::regclass::text FROM pg_inherits
JOIN pg_class ON (inhrelid = oid)
WHERE inhparent = 'download_history'::regclass`)
if err != nil {
return fmt.Errorf("query download_history partitions: %w", err)
}
defer rows.Close()
cutoff := time.Now().AddDate(0, 0, -90)
for rows.Next() {
var partName string
if err := rows.Scan(&partName); err != nil {
slog.Error("failed to scan partition name", "error", err)
continue
}
partLower := strings.ToLower(partName)
if strings.HasPrefix(partLower, "download_history_") {
dateStr := strings.TrimPrefix(partLower, "download_history_")
partTime, err := time.Parse("2006_01_02", dateStr)
if err != nil {
continue
}
if partTime.Before(cutoff) {
_, err := w.database.Pool.Exec(ctx, fmt.Sprintf("DROP TABLE IF EXISTS %s", partName))
if err != nil {
slog.Error("failed to drop partition", "partition", partName, "error", err)
continue
}
partitionsDropped++
}
}
}
tag, err := w.database.Pool.Exec(ctx,
"DELETE FROM task_executions WHERE started_at < NOW() - INTERVAL '7 days'")
if err != nil {
slog.Error("failed to clean old task executions", "error", err)
}
executionsCleaned := tag.RowsAffected()
slog.Info("cleanup completed",
"partitions_dropped", partitionsDropped,
"executions_cleaned", executionsCleaned)
return nil
}

69
internal/worker/disk.go Normal file
View File

@@ -0,0 +1,69 @@
package worker
import (
"context"
"fmt"
"log/slog"
"syscall"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
)
type DiskUsageWorker struct {
database *db.DB
cfg *config.Config
}
func NewDiskUsageWorker(database *db.DB, cfg *config.Config) *DiskUsageWorker {
return &DiskUsageWorker{
database: database,
cfg: cfg,
}
}
func (w *DiskUsageWorker) Name() string {
return "disk_usage"
}
func (w *DiskUsageWorker) CronExpr() string {
return w.cfg.WorkerDiskUsageInterval
}
func (w *DiskUsageWorker) Run(ctx context.Context) error {
rows, err := w.database.Pool.Query(ctx, "SELECT id, path FROM root_folders")
if err != nil {
return fmt.Errorf("query root folders: %w", err)
}
defer rows.Close()
updated := 0
for rows.Next() {
var id int64
var path string
if err := rows.Scan(&id, &path); err != nil {
slog.Error("failed to scan root folder", "error", err)
continue
}
var stat syscall.Statfs_t
if err := syscall.Statfs(path, &stat); err != nil {
slog.Error("failed to stat filesystem", "path", path, "error", err)
continue
}
freeSpace := int64(stat.Bavail) * int64(stat.Bsize)
_, err := w.database.Pool.Exec(ctx,
"UPDATE root_folders SET free_space = $1 WHERE id = $2",
freeSpace, id)
if err != nil {
slog.Error("failed to update free space", "id", id, "error", err)
continue
}
updated++
}
slog.Info("disk usage updated", "folders_updated", updated)
return nil
}

69
internal/worker/health.go Normal file
View File

@@ -0,0 +1,69 @@
package worker
import (
"context"
"log/slog"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/service"
)
type HealthChecker struct {
indexerSvc *service.IndexerService
dcSvc *service.DownloadClientService
cfg *config.Config
}
func NewHealthChecker(indexerSvc *service.IndexerService, dcSvc *service.DownloadClientService, cfg *config.Config) *HealthChecker {
return &HealthChecker{
indexerSvc: indexerSvc,
dcSvc: dcSvc,
cfg: cfg,
}
}
func (w *HealthChecker) Name() string {
return "health_check"
}
func (w *HealthChecker) CronExpr() string {
return w.cfg.WorkerHealthCheckInterval
}
func (w *HealthChecker) Run(ctx context.Context) error {
healthy := 0
unhealthy := 0
indexers, err := w.indexerSvc.List(ctx)
if err != nil {
slog.Error("failed to list indexers", "error", err)
} else {
for _, idx := range indexers {
_, testErr := w.indexerSvc.Test(ctx, idx.ID)
if testErr != nil {
unhealthy++
slog.Warn("indexer health check failed", "id", idx.ID, "name", idx.Name, "error", testErr)
} else {
healthy++
}
}
}
clients, err := w.dcSvc.List(ctx)
if err != nil {
slog.Error("failed to list download clients", "error", err)
} else {
for _, dc := range clients {
_, testErr := w.dcSvc.Test(ctx, dc.ID)
if testErr != nil {
unhealthy++
slog.Warn("download client health check failed", "id", dc.ID, "name", dc.Name, "error", testErr)
} else {
healthy++
}
}
}
slog.Info("health check completed", "healthy", healthy, "unhealthy", unhealthy)
return nil
}

View File

@@ -0,0 +1,182 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
)
var scannerMediaExts = map[string]bool{
".mkv": true,
".mp4": true,
".avi": true,
".wmv": true,
".flv": true,
".webm": true,
".mp3": true,
".flac": true,
".m4a": true,
".m4b": true,
".ogg": true,
".opus": true,
".epub": true,
".pdf": true,
".mobi": true,
".azw3": true,
}
type LibraryScanner struct {
database *db.DB
matcherSvc *service.MatcherService
mediaSvc *service.MediaService
cfg *config.Config
}
func NewLibraryScanner(database *db.DB, matcherSvc *service.MatcherService, mediaSvc *service.MediaService, cfg *config.Config) *LibraryScanner {
return &LibraryScanner{
database: database,
matcherSvc: matcherSvc,
mediaSvc: mediaSvc,
cfg: cfg,
}
}
func (w *LibraryScanner) Name() string {
return "library_scanner"
}
func (w *LibraryScanner) CronExpr() string {
return w.cfg.WorkerLibraryScanInterval
}
func (w *LibraryScanner) Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
scanned := 0
tracked := 0
matched := 0
unmatched := 0
rows, err := w.database.Pool.Query(ctx, "SELECT id, path, media_type FROM root_folders")
if err != nil {
return fmt.Errorf("query root folders: %w", err)
}
defer rows.Close()
type rootFolder struct {
id int64
path string
mediaType string
}
var roots []rootFolder
for rows.Next() {
var r rootFolder
if err := rows.Scan(&r.id, &r.path, &r.mediaType); err != nil {
slog.Error("failed to scan root folder", "error", err)
continue
}
roots = append(roots, r)
}
for _, root := range roots {
cleanRoot := filepath.Clean(root.path)
err := filepath.WalkDir(root.path, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return nil
}
if d.IsDir() {
return nil
}
if !strings.HasPrefix(filepath.Clean(path), cleanRoot) {
return nil
}
ext := filepath.Ext(path)
if !scannerMediaExts[ext] {
return nil
}
scanned++
var exists bool
checkErr := w.database.Pool.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM media_files WHERE path = $1 AND deleted_at IS NULL)",
path).Scan(&exists)
if checkErr != nil {
slog.Error("failed to check tracked file", "path", path, "error", checkErr)
return nil
}
if exists {
tracked++
return nil
}
dirName := filepath.Base(filepath.Dir(path))
fileName := filepath.Base(path)
searchName := dirName
if searchName == filepath.Base(root.path) {
searchName = fileName
}
matchResult, matchErr := w.matcherSvc.Match(ctx, searchName, root.mediaType)
if matchErr != nil {
slog.Error("failed to match file", "path", path, "error", matchErr)
unmatched++
return nil
}
if matchResult.Confidence == "none" {
unmatched++
return nil
}
fileInfo, statErr := os.Stat(path)
if statErr != nil {
slog.Error("failed to stat file", "path", path, "error", statErr)
unmatched++
return nil
}
_, insertErr := w.database.Pool.Exec(ctx,
`INSERT INTO media_files (media_id, media_type, path, file_name, file_size, quality, is_hardlinked)
VALUES ($1, $2, $3, $4, $5, $6, false)`,
matchResult.MediaID, matchResult.MediaType, path, filepath.Base(path),
fileInfo.Size(), json.RawMessage("{}"))
if insertErr != nil {
slog.Error("failed to insert media file", "path", path, "error", insertErr)
unmatched++
return nil
}
_, updateErr := w.database.Pool.Exec(ctx,
"UPDATE media SET status = 'available' WHERE id = $1 AND status = 'unavailable'",
matchResult.MediaID)
if updateErr != nil {
slog.Error("failed to update media status", "media_id", matchResult.MediaID, "error", updateErr)
}
matched++
return nil
})
if err != nil {
slog.Error("library scan walk error", "root", root.path, "error", err)
}
}
slog.Info("library scan completed", "scanned", scanned, "tracked", tracked, "matched", matched, "unmatched", unmatched)
return nil
}

View File

@@ -0,0 +1,39 @@
package worker
import (
"context"
"log/slog"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/service"
)
type MetadataRefreshWorker struct {
metadataSvc *service.MetadataService
cfg *config.Config
}
func NewMetadataRefreshWorker(metadataSvc *service.MetadataService, cfg *config.Config) *MetadataRefreshWorker {
return &MetadataRefreshWorker{
metadataSvc: metadataSvc,
cfg: cfg,
}
}
func (w *MetadataRefreshWorker) Name() string {
return "metadata_refresh"
}
func (w *MetadataRefreshWorker) CronExpr() string {
return w.cfg.WorkerMetadataInterval
}
func (w *MetadataRefreshWorker) Run(ctx context.Context) error {
if err := w.metadataSvc.RefreshAllMetadata(ctx); err != nil {
slog.Error("metadata refresh failed", "error", err)
return err
}
slog.Info("metadata refresh completed")
return nil
}

131
internal/worker/queue.go Normal file
View File

@@ -0,0 +1,131 @@
package worker
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
)
type QueueProcessor struct {
database *db.DB
importSvc *service.ImportService
dcSvc *service.DownloadClientService
cfg *config.Config
activitySvc *service.ActivityService
}
func NewQueueProcessor(database *db.DB, importSvc *service.ImportService, dcSvc *service.DownloadClientService, cfg *config.Config, activitySvc *service.ActivityService) *QueueProcessor {
return &QueueProcessor{
database: database,
importSvc: importSvc,
dcSvc: dcSvc,
cfg: cfg,
activitySvc: activitySvc,
}
}
func (w *QueueProcessor) Name() string {
return "queue_processor"
}
func (w *QueueProcessor) CronExpr() string {
return w.cfg.WorkerQueueInterval
}
func (w *QueueProcessor) Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
progressUpdated := 0
errors := 0
nzbClients, err := w.dcSvc.GetAllEnabled(ctx, "nzb")
if err != nil {
slog.Error("failed to get nzb clients", "error", err)
}
torrentClients, err := w.dcSvc.GetAllEnabled(ctx, "torrent")
if err != nil {
slog.Error("failed to get torrent clients", "error", err)
}
allClients := append(nzbClients, torrentClients...)
for _, client := range allClients {
rows, err := w.database.Pool.Query(ctx,
`SELECT id, download_id FROM download_queue
WHERE status = 'downloading' AND download_client = $1 AND download_id IS NOT NULL AND download_id != ''`,
client.Config.Name)
if err != nil {
slog.Error("failed to query active downloads", "client", client.Config.Name, "error", err)
continue
}
type queueItem struct {
id int64
downloadID string
}
var items []queueItem
for rows.Next() {
var item queueItem
if err := rows.Scan(&item.id, &item.downloadID); err != nil {
slog.Error("failed to scan queue item", "error", err)
continue
}
items = append(items, item)
}
rows.Close()
for _, item := range items {
progress, err := client.Client.GetProgress(ctx, item.downloadID)
if err != nil {
slog.Error("failed to get download progress", "id", item.downloadID, "error", err)
errors++
continue
}
// Detect failed downloads
if progress.Status == "failed" || progress.Status == "error" {
if w.activitySvc != nil {
w.activitySvc.LogAsync(service.LogEntry{
EventType: "download_failed",
Title: fmt.Sprintf("Download failed: %s", item.downloadID),
Data: []byte(fmt.Sprintf(`{"queue_id":%d,"download_id":"%s"}`, item.id, item.downloadID)),
})
}
}
_, err = w.database.Pool.Exec(ctx,
"UPDATE download_queue SET progress = $1, updated_at = NOW() WHERE id = $2",
progress.Progress, item.id)
if err != nil {
slog.Error("failed to update progress", "id", item.id, "error", err)
errors++
continue
}
progressUpdated++
}
}
report, err := w.importSvc.ProcessCompleted(ctx)
if err != nil {
slog.Error("failed to process completed downloads", "error", err)
errors++
}
imported := 0
if report != nil {
imported = report.Imported
}
slog.Info("queue processor completed",
"progress_updated", progressUpdated,
"completed_imported", imported,
"errors", errors)
return nil
}

197
internal/worker/rss_sync.go Normal file
View File

@@ -0,0 +1,197 @@
package worker
import (
"context"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
)
type RSSSyncWorker struct {
database *db.DB
mediaSvc *service.MediaService
searchSvc *service.SearchService
dcSvc *service.DownloadClientService
qualitySvc *service.QualityService
cfg *config.Config
}
func NewRSSSyncWorker(database *db.DB, mediaSvc *service.MediaService, searchSvc *service.SearchService, dcSvc *service.DownloadClientService, qualitySvc *service.QualityService, cfg *config.Config) *RSSSyncWorker {
return &RSSSyncWorker{
database: database,
mediaSvc: mediaSvc,
searchSvc: searchSvc,
dcSvc: dcSvc,
qualitySvc: qualitySvc,
cfg: cfg,
}
}
func (w *RSSSyncWorker) Name() string {
return "rss_sync"
}
func (w *RSSSyncWorker) CronExpr() string {
return w.cfg.WorkerRSSSyncInterval
}
func (w *RSSSyncWorker) Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
searched := 0
grabs := 0
errors := 0
seen := make(map[int64]bool)
missing, _, err := w.mediaSvc.SearchMissing(ctx, service.MediaFilters{PageSize: 100})
if err != nil {
slog.Error("failed to search missing media", "error", err)
errors++
}
for i := range missing {
seen[missing[i].ID] = true
}
upgrades, _, err := w.mediaSvc.SearchUpgrades(ctx, service.MediaFilters{PageSize: 100})
if err != nil {
slog.Error("failed to search upgrade media", "error", err)
errors++
}
for i := range upgrades {
seen[upgrades[i].ID] = true
}
var allItems []service.Media
for _, m := range missing {
allItems = append(allItems, m)
}
for _, m := range upgrades {
if !seen[m.ID] {
allItems = append(allItems, m)
seen[m.ID] = true
}
}
for _, item := range allItems {
var hasPending bool
err := w.database.Pool.QueryRow(ctx,
`SELECT EXISTS(SELECT 1 FROM download_queue WHERE media_id = $1 AND status IN ('pending', 'downloading'))`,
item.ID).Scan(&hasPending)
if err != nil {
slog.Error("failed to check pending queue", "media_id", item.ID, "error", err)
continue
}
if hasPending {
continue
}
_, err = w.database.Pool.Exec(ctx,
"UPDATE media SET last_search_at = NOW() WHERE id = $1", item.ID)
if err != nil {
slog.Error("failed to update last_search_at", "media_id", item.ID, "error", err)
}
results, err := w.searchSvc.Search(ctx, service.SearchRequest{
Query: item.Title,
MediaType: item.MediaType,
})
if err != nil {
slog.Error("failed to search indexers", "media_id", item.ID, "title", item.Title, "error", err)
errors++
searched++
continue
}
searched++
if len(results) == 0 {
continue
}
var filtered []service.SearchResult
for _, r := range results {
var blocked bool
err := w.database.Pool.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM blocklist WHERE release_title = $1)", r.Title).Scan(&blocked)
if err != nil {
slog.Error("failed to check blocklist", "error", err)
continue
}
if blocked {
continue
}
if item.QualityProfileID != nil {
profile, err := w.qualitySvc.GetByID(ctx, *item.QualityProfileID)
if err != nil {
slog.Error("failed to get quality profile", "profile_id", *item.QualityProfileID, "error", err)
filtered = append(filtered, r)
continue
}
if r.QualityTier != nil {
allowed := false
for _, name := range profile.AllowedQualities {
if name == r.QualityTier.Name {
allowed = true
break
}
}
if !allowed {
continue
}
}
}
filtered = append(filtered, r)
}
if len(filtered) == 0 {
continue
}
best := filtered[0]
for _, r := range filtered[1:] {
bestRank := 0
if best.QualityTier != nil {
bestRank = best.QualityTier.Rank
}
rank := 0
if r.QualityTier != nil {
rank = r.QualityTier.Rank
}
if rank > bestRank || (rank == bestRank && r.Size > best.Size) {
best = r
}
}
_, err = w.searchSvc.Grab(ctx, service.GrabRequest{
DownloadURL: best.DownloadURL,
Title: best.Title,
MediaType: item.MediaType,
Quality: best.Quality,
IndexerName: best.IndexerName,
MediaID: item.ID,
}, w.dcSvc)
if err != nil {
slog.Error("failed to auto-grab release", "media_id", item.ID, "release", best.Title, "error", err)
errors++
continue
}
status := "searching"
if err := w.mediaSvc.Update(ctx, item.ID, item.MediaType, service.UpdateMediaRequest{Status: &status}); err != nil {
slog.Error("failed to update media status", "media_id", item.ID, "error", err)
}
slog.Info("auto-grabbed release", "media", item.Title, "release", best.Title, "quality", best.QualityTier.Name)
grabs++
}
slog.Info("rss sync completed", "searched", searched, "grabs", grabs, "errors", errors)
return nil
}

View File

@@ -0,0 +1,236 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"time"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/robfig/cron/v3"
)
type Worker interface {
Name() string
CronExpr() string
Run(ctx context.Context) error
}
type Scheduler struct {
cron *cron.Cron
database *db.DB
workers map[string]Worker
ctx context.Context
cancel context.CancelFunc
}
func NewScheduler(database *db.DB) *Scheduler {
return &Scheduler{
cron: cron.New(cron.WithSeconds()),
database: database,
workers: make(map[string]Worker),
}
}
func (s *Scheduler) Register(w Worker) {
s.workers[w.Name()] = w
_, err := s.database.Pool.Exec(context.Background(),
`INSERT INTO scheduled_tasks (name, cron_expr, enabled)
VALUES ($1, $2, true)
ON CONFLICT (name) DO UPDATE SET cron_expr = EXCLUDED.cron_expr`,
w.Name(), w.CronExpr())
if err != nil {
slog.Error("failed to seed scheduled task", "worker", w.Name(), "error", err)
}
wrapper := s.runWithLogging(w)
_, err = s.cron.AddFunc(w.CronExpr(), wrapper)
if err != nil {
slog.Error("failed to schedule worker", "worker", w.Name(), "error", err)
}
}
func (s *Scheduler) runWithLogging(w Worker) func() {
return func() {
// Check if task is enabled before running
var enabled bool
err := s.database.Pool.QueryRow(context.Background(),
"SELECT enabled FROM scheduled_tasks WHERE name = $1", w.Name()).Scan(&enabled)
if err != nil {
slog.Error("failed to check task enabled status", "worker", w.Name(), "error", err)
return
}
if !enabled {
slog.Debug("skipping disabled task", "worker", w.Name())
return
}
var taskID int
err = s.database.Pool.QueryRow(context.Background(),
"SELECT id FROM scheduled_tasks WHERE name = $1", w.Name()).Scan(&taskID)
if err != nil {
slog.Error("failed to get task id", "worker", w.Name(), "error", err)
return
}
var execID int64
err = s.database.Pool.QueryRow(context.Background(),
"INSERT INTO task_executions (task_id, status, started_at) VALUES ($1, 'running', NOW()) RETURNING id",
taskID).Scan(&execID)
if err != nil {
slog.Error("failed to create execution record", "worker", w.Name(), "error", err)
return
}
start := time.Now()
runErr := w.Run(s.ctx)
duration := time.Since(start)
if runErr != nil {
slog.Error("worker execution failed", "worker", w.Name(), "error", runErr, "duration_ms", duration.Milliseconds())
_, _ = s.database.Pool.Exec(context.Background(),
"UPDATE task_executions SET status = 'failed', ended_at = NOW(), duration_ms = $1, error = $2 WHERE id = $3",
duration.Milliseconds(), runErr.Error(), execID)
} else {
slog.Info("worker execution completed", "worker", w.Name(), "duration_ms", duration.Milliseconds())
_, _ = s.database.Pool.Exec(context.Background(),
"UPDATE task_executions SET status = 'success', ended_at = NOW(), duration_ms = $1 WHERE id = $2",
duration.Milliseconds(), execID)
}
schedule, parseErr := cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow).Parse(w.CronExpr())
var nextRunAt time.Time
if parseErr == nil {
nextRunAt = schedule.Next(time.Now())
}
_, _ = s.database.Pool.Exec(context.Background(),
"UPDATE scheduled_tasks SET last_run_at = NOW(), next_run_at = $1 WHERE id = $2",
nextRunAt, taskID)
}
}
func (s *Scheduler) TriggerWorker(name string) error {
w, ok := s.workers[name]
if !ok {
return fmt.Errorf("worker not found: %s", name)
}
go s.runWithLogging(w)()
return nil
}
func (s *Scheduler) GetWorkers() []ScheduledTaskInfo {
rows, err := s.database.Pool.Query(context.Background(),
"SELECT id, name, cron_expr, enabled, last_run_at, next_run_at FROM scheduled_tasks ORDER BY name")
if err != nil {
slog.Error("failed to query scheduled tasks", "error", err)
return nil
}
defer rows.Close()
var tasks []ScheduledTaskInfo
for rows.Next() {
var t ScheduledTaskInfo
var lastRunAt, nextRunAt *time.Time
if err := rows.Scan(&t.ID, &t.Name, &t.CronExpr, &t.Enabled, &lastRunAt, &nextRunAt); err != nil {
slog.Error("failed to scan scheduled task", "error", err)
continue
}
if lastRunAt != nil {
t.LastRunAt = lastRunAt
}
if nextRunAt != nil {
t.NextRunAt = nextRunAt
}
tasks = append(tasks, t)
}
return tasks
}
func (s *Scheduler) GetHistory(ctx context.Context, name string, page, pageSize int) ([]TaskExecution, int, error) {
var total int
err := s.database.Pool.QueryRow(ctx,
`SELECT COUNT(*) FROM task_executions te JOIN scheduled_tasks st ON te.task_id = st.id WHERE st.name = $1`,
name).Scan(&total)
if err != nil {
return nil, 0, fmt.Errorf("count task executions: %w", err)
}
offset := (page - 1) * pageSize
rows, err := s.database.Pool.Query(ctx,
`SELECT te.id, te.status, te.started_at, te.ended_at, te.duration_ms, te.result, te.error
FROM task_executions te JOIN scheduled_tasks st ON te.task_id = st.id
WHERE st.name = $1 ORDER BY te.started_at DESC LIMIT $2 OFFSET $3`,
name, pageSize, offset)
if err != nil {
return nil, 0, fmt.Errorf("query task executions: %w", err)
}
defer rows.Close()
var executions []TaskExecution
for rows.Next() {
var e TaskExecution
var result []byte
var execError *string
if err := rows.Scan(&e.ID, &e.Status, &e.StartedAt, &e.EndedAt, &e.DurationMS, &result, &execError); err != nil {
slog.Error("failed to scan task execution", "error", err)
continue
}
if result != nil {
e.Result = json.RawMessage(result)
}
if execError != nil {
e.Error = *execError
}
executions = append(executions, e)
}
return executions, total, nil
}
func (s *Scheduler) SetEnabled(name string, enabled bool) error {
tag, err := s.database.Pool.Exec(context.Background(),
"UPDATE scheduled_tasks SET enabled = $1 WHERE name = $2", enabled, name)
if err != nil {
return fmt.Errorf("update scheduled task enabled: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("scheduled task not found: %s", name)
}
return nil
}
func (s *Scheduler) Start(ctx context.Context) {
s.ctx, s.cancel = context.WithCancel(ctx)
s.cron.Start()
slog.Info("worker scheduler started")
}
func (s *Scheduler) Stop() {
s.cron.Stop()
if s.cancel != nil {
s.cancel()
}
slog.Info("worker scheduler stopped")
}
type ScheduledTaskInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
CronExpr string `json:"cron_expr"`
Enabled bool `json:"enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
NextRunAt *time.Time `json:"next_run_at,omitempty"`
}
type TaskExecution struct {
ID int64 `json:"id"`
Status string `json:"status"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at,omitempty"`
DurationMS *int64 `json:"duration_ms,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}

View File

@@ -0,0 +1,112 @@
package worker
import (
"context"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/TopherMayor/unified-media-manager/internal/config"
"github.com/TopherMayor/unified-media-manager/internal/db"
"github.com/TopherMayor/unified-media-manager/internal/service"
)
type SubtitleSearchWorker struct {
database *db.DB
subtitleSvc *service.SubtitleService
cfg *config.Config
}
func NewSubtitleSearchWorker(database *db.DB, subtitleSvc *service.SubtitleService, cfg *config.Config) *SubtitleSearchWorker {
return &SubtitleSearchWorker{
database: database,
subtitleSvc: subtitleSvc,
cfg: cfg,
}
}
func (w *SubtitleSearchWorker) Name() string {
return "subtitle_search"
}
func (w *SubtitleSearchWorker) CronExpr() string {
return w.cfg.WorkerSubtitleInterval
}
func (w *SubtitleSearchWorker) Run(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
searched := 0
downloaded := 0
errors := 0
rows, err := w.database.Pool.Query(ctx,
`SELECT m.id, m.media_type, m.title, mf.path
FROM media m
JOIN media_files mf ON m.id = mf.media_id AND mf.deleted_at IS NULL
WHERE m.deleted_at IS NULL AND m.status = 'available'
ORDER BY m.id`)
if err != nil {
return err
}
defer rows.Close()
type mediaFile struct {
mediaID int64
mediaType string
title string
path string
}
var files []mediaFile
for rows.Next() {
var f mediaFile
if err := rows.Scan(&f.mediaID, &f.mediaType, &f.title, &f.path); err != nil {
slog.Error("failed to scan media file", "error", err)
continue
}
files = append(files, f)
}
for _, f := range files {
ext := filepath.Ext(f.path)
basePath := f.path[:len(f.path)-len(ext)]
srtPath := basePath + ".eng.srt"
sdhPath := basePath + ".eng.sdh.srt"
if _, err := os.Stat(srtPath); err == nil {
continue
}
if _, err := os.Stat(sdhPath); err == nil {
continue
}
results, err := w.subtitleSvc.Search(ctx, f.title, service.SubtitleSearchOptions{
LanguageCodes: []string{"eng"},
})
if err != nil {
slog.Error("failed to search subtitles", "media_id", f.mediaID, "error", err)
errors++
continue
}
searched++
if len(results) == 0 {
continue
}
baseName := filepath.Base(basePath)
_, err = w.subtitleSvc.Download(ctx, results[0].ID, filepath.Dir(f.path), baseName, results[0].LanguageCode, results[0].HI, results[0].Forced)
if err != nil {
slog.Error("failed to download subtitle", "media_id", f.mediaID, "error", err)
errors++
continue
}
downloaded++
}
slog.Info("subtitle search completed", "searched", searched, "downloaded", downloaded, "errors", errors)
return nil
}