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

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
}