Sync from /srv/compose/unified-media-manager
This commit is contained in:
153
internal/service/activity.go
Normal file
153
internal/service/activity.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type ActivityEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
EventType string `json:"event_type"`
|
||||
MediaID *int64 `json:"media_id,omitempty"`
|
||||
MediaType *string `json:"media_type,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type ActivityFilters struct {
|
||||
EventType string
|
||||
MediaID *int64
|
||||
MediaType string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
EventType string
|
||||
MediaID *int64
|
||||
MediaType *string
|
||||
Title string
|
||||
Description *string
|
||||
Data json.RawMessage
|
||||
}
|
||||
|
||||
type ActivityService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewActivityService(database *db.DB) *ActivityService {
|
||||
return &ActivityService{db: database}
|
||||
}
|
||||
|
||||
const activityColumns = `id, event_type, media_id, media_type, title, description, data, created_at`
|
||||
|
||||
func scanActivityEvent(scanner interface{ Scan(...interface{}) error }) (*ActivityEvent, error) {
|
||||
var event ActivityEvent
|
||||
var mediaID sql.NullInt64
|
||||
var mediaType sql.NullString
|
||||
var description sql.NullString
|
||||
var data []byte
|
||||
|
||||
err := scanner.Scan(&event.ID, &event.EventType, &mediaID, &mediaType,
|
||||
&event.Title, &description, &data, &event.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if mediaID.Valid {
|
||||
event.MediaID = &mediaID.Int64
|
||||
}
|
||||
if mediaType.Valid {
|
||||
event.MediaType = &mediaType.String
|
||||
}
|
||||
if description.Valid {
|
||||
event.Description = &description.String
|
||||
}
|
||||
if data != nil {
|
||||
event.Data = json.RawMessage(data)
|
||||
}
|
||||
return &event, nil
|
||||
}
|
||||
|
||||
func (s *ActivityService) Log(ctx context.Context, entry LogEntry) (int64, error) {
|
||||
data := entry.Data
|
||||
if data == nil {
|
||||
data = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
var id int64
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO activity_events (event_type, media_id, media_type, title, description, data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||
entry.EventType, entry.MediaID, entry.MediaType, entry.Title, entry.Description, data).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("insert activity event: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *ActivityService) LogAsync(entry LogEntry) {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := s.Log(ctx, entry); err != nil {
|
||||
slog.Error("failed to log activity event async", "error", err, "event_type", entry.EventType, "title", entry.Title)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *ActivityService) List(ctx context.Context, filters ActivityFilters) ([]ActivityEvent, int, error) {
|
||||
qb := NewQueryBuilder(1)
|
||||
|
||||
if filters.EventType != "" {
|
||||
qb.Add("event_type = $%d", filters.EventType)
|
||||
}
|
||||
if filters.MediaID != nil {
|
||||
qb.Add("media_id = $%d", *filters.MediaID)
|
||||
if filters.MediaType != "" {
|
||||
qb.Add("media_type = $%d", filters.MediaType)
|
||||
}
|
||||
}
|
||||
|
||||
where := qb.Where()
|
||||
|
||||
var total int
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM activity_events%s", where)
|
||||
if err := s.db.Pool.QueryRow(ctx, countQuery, qb.Args()...).Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count activity events: %w", err)
|
||||
}
|
||||
|
||||
offset := (filters.Page - 1) * filters.PageSize
|
||||
dataQuery := fmt.Sprintf(
|
||||
"SELECT %s FROM activity_events%s ORDER BY created_at DESC LIMIT $%d OFFSET $%d",
|
||||
activityColumns, where, qb.Idx(), qb.Idx()+1)
|
||||
args := append(qb.Args(), filters.PageSize, offset)
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, dataQuery, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list activity events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []ActivityEvent
|
||||
for rows.Next() {
|
||||
event, err := scanActivityEvent(rows)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan activity event", "error", err)
|
||||
continue
|
||||
}
|
||||
events = append(events, *event)
|
||||
}
|
||||
|
||||
return events, total, nil
|
||||
}
|
||||
25
internal/service/activity_test.go
Normal file
25
internal/service/activity_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActivityLog(t *testing.T) {
|
||||
t.Skip("requires database")
|
||||
}
|
||||
|
||||
func TestActivityFilterByType(t *testing.T) {
|
||||
t.Skip("requires database")
|
||||
}
|
||||
|
||||
func TestActivityFilterByMedia(t *testing.T) {
|
||||
t.Skip("requires database")
|
||||
}
|
||||
|
||||
func TestActivityPagination(t *testing.T) {
|
||||
t.Skip("requires database")
|
||||
}
|
||||
|
||||
func TestActivityLogAsync(t *testing.T) {
|
||||
t.Skip("requires database")
|
||||
}
|
||||
182
internal/service/blocklist.go
Normal file
182
internal/service/blocklist.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type BlocklistItem struct {
|
||||
ID int64 `json:"id"`
|
||||
ReleaseTitle string `json:"release_title"`
|
||||
SourceTitle *string `json:"source_title,omitempty"`
|
||||
Quality json.RawMessage `json:"quality"`
|
||||
Indexer *string `json:"indexer,omitempty"`
|
||||
Protocol string `json:"protocol"`
|
||||
TorrentHash *string `json:"torrent_hash,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
MediaID *int64 `json:"media_id,omitempty"`
|
||||
BlockReason string `json:"block_reason"`
|
||||
AutoExpiresAt *time.Time `json:"auto_expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type BlocklistFilters struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type AddBlocklistRequest struct {
|
||||
ReleaseTitle string `json:"release_title"`
|
||||
SourceTitle *string `json:"source_title,omitempty"`
|
||||
Quality json.RawMessage `json:"quality,omitempty"`
|
||||
Indexer *string `json:"indexer,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
TorrentHash *string `json:"torrent_hash,omitempty"`
|
||||
Size *int64 `json:"size,omitempty"`
|
||||
Message *string `json:"message,omitempty"`
|
||||
MediaID *int64 `json:"media_id,omitempty"`
|
||||
BlockReason *string `json:"block_reason,omitempty"`
|
||||
AutoExpiresAt *time.Time `json:"auto_expires_at,omitempty"`
|
||||
}
|
||||
|
||||
const blocklistColumns = `id, release_title, source_title, quality, indexer, protocol,
|
||||
torrent_hash, size, message, media_id, block_reason, auto_expires_at, created_at`
|
||||
|
||||
type BlocklistService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewBlocklistService(database *db.DB) *BlocklistService {
|
||||
return &BlocklistService{db: database}
|
||||
}
|
||||
|
||||
func scanBlocklistItem(scanner interface{ Scan(...interface{}) error }) (*BlocklistItem, error) {
|
||||
var item BlocklistItem
|
||||
var sourceTitle, indexer, torrentHash, message sql.NullString
|
||||
var size, mediaID sql.NullInt64
|
||||
var autoExpiresAt sql.NullTime
|
||||
var quality []byte
|
||||
|
||||
err := scanner.Scan(&item.ID, &item.ReleaseTitle, &sourceTitle, &quality, &indexer,
|
||||
&item.Protocol, &torrentHash, &size, &message, &mediaID, &item.BlockReason,
|
||||
&autoExpiresAt, &item.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if sourceTitle.Valid {
|
||||
item.SourceTitle = &sourceTitle.String
|
||||
}
|
||||
if indexer.Valid {
|
||||
item.Indexer = &indexer.String
|
||||
}
|
||||
if torrentHash.Valid {
|
||||
item.TorrentHash = &torrentHash.String
|
||||
}
|
||||
if message.Valid {
|
||||
item.Message = &message.String
|
||||
}
|
||||
if size.Valid {
|
||||
item.Size = &size.Int64
|
||||
}
|
||||
if mediaID.Valid {
|
||||
item.MediaID = &mediaID.Int64
|
||||
}
|
||||
if autoExpiresAt.Valid {
|
||||
item.AutoExpiresAt = &autoExpiresAt.Time
|
||||
}
|
||||
if quality != nil {
|
||||
item.Quality = json.RawMessage(quality)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (s *BlocklistService) List(ctx context.Context, filters BlocklistFilters) ([]BlocklistItem, int, error) {
|
||||
var total int
|
||||
if err := s.db.Pool.QueryRow(ctx, "SELECT COUNT(*) FROM blocklist").Scan(&total); err != nil {
|
||||
return nil, 0, fmt.Errorf("count blocklist: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM blocklist ORDER BY created_at DESC LIMIT $1 OFFSET $2", blocklistColumns),
|
||||
filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list blocklist: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []BlocklistItem
|
||||
for rows.Next() {
|
||||
item, err := scanBlocklistItem(rows)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan blocklist item", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *BlocklistService) Add(ctx context.Context, req AddBlocklistRequest) (int64, error) {
|
||||
protocol := req.Protocol
|
||||
if protocol == "" {
|
||||
protocol = "torrent"
|
||||
}
|
||||
blockReason := "manual"
|
||||
if req.BlockReason != nil {
|
||||
blockReason = *req.BlockReason
|
||||
}
|
||||
quality := req.Quality
|
||||
if quality == nil {
|
||||
quality = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
var id int64
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO blocklist (release_title, source_title, quality, indexer, protocol,
|
||||
torrent_hash, size, message, media_id, block_reason, auto_expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id`,
|
||||
req.ReleaseTitle, req.SourceTitle, quality, req.Indexer, protocol,
|
||||
req.TorrentHash, req.Size, req.Message, req.MediaID, blockReason, req.AutoExpiresAt).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create blocklist entry: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *BlocklistService) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM blocklist WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete blocklist item: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("blocklist item not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BlocklistService) Clear(ctx context.Context) (int64, error) {
|
||||
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM blocklist")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear blocklist: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
func (s *BlocklistService) ClearExpired(ctx context.Context) (int64, error) {
|
||||
tag, err := s.db.Pool.Exec(ctx,
|
||||
"DELETE FROM blocklist WHERE auto_expires_at IS NOT NULL AND auto_expires_at < NOW()")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("clear expired blocklist: %w", err)
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
103
internal/service/calendar.go
Normal file
103
internal/service/calendar.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
// CalendarEvent represents a single event on the calendar.
|
||||
type CalendarEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
MediaType string `json:"media_type"`
|
||||
Title string `json:"title"`
|
||||
Date string `json:"date"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
Status string `json:"status"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
}
|
||||
|
||||
// CalendarService queries monitored media by release date for calendar views.
|
||||
type CalendarService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
// NewCalendarService creates a new CalendarService.
|
||||
func NewCalendarService(database *db.DB) *CalendarService {
|
||||
return &CalendarService{db: database}
|
||||
}
|
||||
|
||||
type posterImage struct {
|
||||
URL string `json:"url"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// EventsByMonth returns all monitored media with release dates in the given month.
|
||||
func (s *CalendarService) EventsByMonth(ctx context.Context, year int, month time.Month) ([]CalendarEvent, error) {
|
||||
startDate := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
endDate := startDate.AddDate(0, 1, 0).Add(-time.Nanosecond)
|
||||
|
||||
query := `SELECT id, media_type, title, release_date, year, status, images
|
||||
FROM media
|
||||
WHERE monitored = true AND deleted_at IS NULL
|
||||
AND release_date IS NOT NULL
|
||||
AND release_date BETWEEN $1 AND $2
|
||||
ORDER BY release_date`
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query calendar events: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []CalendarEvent
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var mediaType, title, status string
|
||||
var releaseDate time.Time
|
||||
var yearVal *int
|
||||
var imagesJSON []byte
|
||||
|
||||
if err := rows.Scan(&id, &mediaType, &title, &releaseDate, &yearVal, &status, &imagesJSON); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
posterURL := extractPosterURL(imagesJSON)
|
||||
|
||||
events = append(events, CalendarEvent{
|
||||
ID: id,
|
||||
MediaType: mediaType,
|
||||
Title: title,
|
||||
Date: releaseDate.Format("2006-01-02"),
|
||||
Year: yearVal,
|
||||
Status: status,
|
||||
PosterURL: posterURL,
|
||||
})
|
||||
}
|
||||
|
||||
if events == nil {
|
||||
events = []CalendarEvent{}
|
||||
}
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// extractPosterURL parses the images JSONB and returns the first poster URL.
|
||||
func extractPosterURL(imagesJSON []byte) string {
|
||||
if len(imagesJSON) == 0 {
|
||||
return ""
|
||||
}
|
||||
var images []posterImage
|
||||
if err := json.Unmarshal(imagesJSON, &images); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, img := range images {
|
||||
if img.Type == "poster" && img.URL != "" {
|
||||
return img.URL
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
97
internal/service/dashboard.go
Normal file
97
internal/service/dashboard.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type DashboardStats struct {
|
||||
TotalMedia int64 `json:"total_media"`
|
||||
Monitored int64 `json:"monitored"`
|
||||
Unavailable int64 `json:"unavailable"`
|
||||
Available int64 `json:"available"`
|
||||
QualityUpgrades int64 `json:"quality_upgrades"`
|
||||
QueuePending int64 `json:"queue_pending"`
|
||||
QueueDownloading int64 `json:"queue_downloading"`
|
||||
QueueFailed int64 `json:"queue_failed"`
|
||||
BlocklistCount int64 `json:"blocklist_count"`
|
||||
BlocklistExpired int64 `json:"blocklist_expired"`
|
||||
IndexersEnabled int64 `json:"indexers_enabled"`
|
||||
MediaByType map[string]int64 `json:"media_by_type"`
|
||||
StorageByType map[string]int64 `json:"storage_by_type"`
|
||||
RecentDownloads int64 `json:"recent_downloads"`
|
||||
}
|
||||
|
||||
type DashboardService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewDashboardService(database *db.DB) *DashboardService {
|
||||
return &DashboardService{db: database}
|
||||
}
|
||||
|
||||
func (s *DashboardService) Stats(ctx context.Context) (*DashboardStats, error) {
|
||||
stats := &DashboardStats{
|
||||
MediaByType: make(map[string]int64),
|
||||
StorageByType: make(map[string]int64),
|
||||
}
|
||||
|
||||
combinedQuery := `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM media WHERE deleted_at IS NULL),
|
||||
(SELECT COUNT(*) FROM media WHERE monitored = true AND deleted_at IS NULL),
|
||||
(SELECT COUNT(*) FROM media WHERE status = 'unavailable' AND deleted_at IS NULL),
|
||||
(SELECT COUNT(*) FROM media WHERE status = 'available' AND deleted_at IS NULL),
|
||||
(SELECT COUNT(*) FROM media WHERE desired_quality IS NOT NULL AND current_quality IS NULL AND deleted_at IS NULL),
|
||||
(SELECT COUNT(*) FROM download_queue WHERE status = 'pending'),
|
||||
(SELECT COUNT(*) FROM download_queue WHERE status = 'downloading'),
|
||||
(SELECT COUNT(*) FROM download_queue WHERE status = 'failed'),
|
||||
(SELECT COUNT(*) FROM blocklist),
|
||||
(SELECT COUNT(*) FROM blocklist WHERE auto_expires_at IS NOT NULL AND auto_expires_at < NOW()),
|
||||
(SELECT COUNT(*) FROM indexers WHERE enabled = true),
|
||||
(SELECT COUNT(*) FROM download_history WHERE created_at > NOW() - INTERVAL '24 hours')`
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx, combinedQuery).Scan(
|
||||
&stats.TotalMedia, &stats.Monitored, &stats.Unavailable, &stats.Available,
|
||||
&stats.QualityUpgrades, &stats.QueuePending, &stats.QueueDownloading, &stats.QueueFailed,
|
||||
&stats.BlocklistCount, &stats.BlocklistExpired, &stats.IndexersEnabled, &stats.RecentDownloads)
|
||||
if err != nil {
|
||||
slog.Error("dashboard combined query failed", "error", err)
|
||||
return nil, fmt.Errorf("dashboard stats: %w", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx,
|
||||
"SELECT media_type, COUNT(*) FROM media WHERE deleted_at IS NULL GROUP BY media_type")
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var mediaType string
|
||||
var count int64
|
||||
if err := rows.Scan(&mediaType, &count); err == nil {
|
||||
stats.MediaByType[mediaType] = count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sRows, err := s.db.Pool.Query(ctx,
|
||||
`SELECT m.media_type, COALESCE(SUM(mf.file_size), 0)
|
||||
FROM media m
|
||||
JOIN media_files mf ON m.id = mf.media_id AND mf.deleted_at IS NULL
|
||||
WHERE m.deleted_at IS NULL
|
||||
GROUP BY m.media_type`)
|
||||
if err == nil {
|
||||
defer sRows.Close()
|
||||
for sRows.Next() {
|
||||
var mediaType string
|
||||
var totalSize int64
|
||||
if err := sRows.Scan(&mediaType, &totalSize); err == nil {
|
||||
stats.StorageByType[mediaType] = totalSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
328
internal/service/discover.go
Normal file
328
internal/service/discover.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
// DiscoverItem represents a single item returned from the discover endpoints.
|
||||
type DiscoverItem struct {
|
||||
TMDBID int `json:"tmdb_id"`
|
||||
Title string `json:"title"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
MediaType string `json:"media_type"`
|
||||
Overview string `json:"overview,omitempty"`
|
||||
PosterURL string `json:"poster_url,omitempty"`
|
||||
BackdropURL string `json:"backdrop_url,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
InLibrary bool `json:"in_library"`
|
||||
}
|
||||
|
||||
type discoverCacheEntry struct {
|
||||
data []DiscoverItem
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// DiscoverService provides trending/popular browsing and add-to-library functionality.
|
||||
type DiscoverService struct {
|
||||
tmdb *TMDBProvider
|
||||
db *db.DB
|
||||
cache sync.Map
|
||||
}
|
||||
|
||||
// NewDiscoverService creates a new DiscoverService.
|
||||
func NewDiscoverService(tmdb *TMDBProvider, database *db.DB) *DiscoverService {
|
||||
return &DiscoverService{tmdb: tmdb, db: database}
|
||||
}
|
||||
|
||||
const discoverCacheTTL = 6 * time.Hour
|
||||
|
||||
// Trending returns trending items from TMDB, checking an in-memory cache first.
|
||||
func (s *DiscoverService) Trending(ctx context.Context, mediaType string, page int) ([]DiscoverItem, error) {
|
||||
cacheKey := fmt.Sprintf("trending:%s:%d", mediaType, page)
|
||||
if cached, ok := s.cache.Load(cacheKey); ok {
|
||||
entry := cached.(*discoverCacheEntry)
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
return entry.data, nil
|
||||
}
|
||||
s.cache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
items, err := s.tmdb.Trending(ctx, mediaType, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch trending: %w", err)
|
||||
}
|
||||
|
||||
result := s.convertItems(ctx, items, mediaType)
|
||||
|
||||
s.cache.Store(cacheKey, &discoverCacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(discoverCacheTTL),
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Popular returns popular items from TMDB, checking an in-memory cache first.
|
||||
func (s *DiscoverService) Popular(ctx context.Context, mediaType string, page int) ([]DiscoverItem, error) {
|
||||
cacheKey := fmt.Sprintf("popular:%s:%d", mediaType, page)
|
||||
if cached, ok := s.cache.Load(cacheKey); ok {
|
||||
entry := cached.(*discoverCacheEntry)
|
||||
if time.Now().Before(entry.expiresAt) {
|
||||
return entry.data, nil
|
||||
}
|
||||
s.cache.Delete(cacheKey)
|
||||
}
|
||||
|
||||
items, err := s.tmdb.Popular(ctx, mediaType, page)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch popular: %w", err)
|
||||
}
|
||||
|
||||
result := s.convertItems(ctx, items, mediaType)
|
||||
|
||||
s.cache.Store(cacheKey, &discoverCacheEntry{
|
||||
data: result,
|
||||
expiresAt: time.Now().Add(discoverCacheTTL),
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddToLibrary adds a TMDB item to the user's monitored library.
|
||||
// If the item already exists, it returns the existing ID with no error.
|
||||
func (s *DiscoverService) AddToLibrary(ctx context.Context, tmdbID int, mediaType string) (int64, bool, error) {
|
||||
// Check if already in library
|
||||
var existingID int64
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`SELECT id FROM media WHERE external_ids @> $1::jsonb AND deleted_at IS NULL LIMIT 1`,
|
||||
fmt.Sprintf(`{"tmdb":"%d"}`, tmdbID)).Scan(&existingID)
|
||||
if err == nil {
|
||||
return existingID, true, nil
|
||||
}
|
||||
|
||||
// Fetch full details from TMDB
|
||||
detail, err := s.fetchFullDetail(ctx, tmdbID, mediaType)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("fetch tmdb detail: %w", err)
|
||||
}
|
||||
|
||||
req := s.buildCreateRequest(detail, mediaType)
|
||||
|
||||
newID, err := NewMediaService(s.db).Create(ctx, req)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("create media: %w", err)
|
||||
}
|
||||
|
||||
return newID, false, nil
|
||||
}
|
||||
|
||||
func (s *DiscoverService) convertItems(ctx context.Context, items []tmdbSearchItem, mediaType string) []DiscoverItem {
|
||||
if len(items) == 0 {
|
||||
return []DiscoverItem{}
|
||||
}
|
||||
|
||||
// Collect TMDB IDs for batch library check
|
||||
tmdbIDs := make([]int, len(items))
|
||||
for i, item := range items {
|
||||
tmdbIDs[i] = item.ID
|
||||
}
|
||||
|
||||
libMembership := s.checkLibraryMembership(ctx, tmdbIDs, mediaType)
|
||||
|
||||
result := make([]DiscoverItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
title := item.Title
|
||||
dateStr := item.ReleaseDate
|
||||
mType := "movie"
|
||||
|
||||
if mediaType == "series" || item.MediaType == "tv" {
|
||||
title = item.Name
|
||||
if title == "" {
|
||||
title = item.Title
|
||||
}
|
||||
dateStr = item.FirstAirDate
|
||||
mType = "series"
|
||||
}
|
||||
if title == "" {
|
||||
title = item.Name
|
||||
}
|
||||
|
||||
year := parseTMDBYear(dateStr)
|
||||
|
||||
result = append(result, DiscoverItem{
|
||||
TMDBID: item.ID,
|
||||
Title: title,
|
||||
Year: year,
|
||||
MediaType: mType,
|
||||
Overview: item.Overview,
|
||||
PosterURL: buildPosterURL(item.PosterPath),
|
||||
BackdropURL: buildBackdropURL(item.BackdropPath),
|
||||
VoteAverage: item.VoteAverage,
|
||||
InLibrary: libMembership[item.ID],
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *DiscoverService) checkLibraryMembership(ctx context.Context, tmdbIDs []int, _ string) map[int]bool {
|
||||
membership := make(map[int]bool)
|
||||
if len(tmdbIDs) == 0 {
|
||||
return membership
|
||||
}
|
||||
|
||||
// Build JSONB array condition for batch check
|
||||
conditions := make([]string, len(tmdbIDs))
|
||||
args := make([]interface{}, len(tmdbIDs))
|
||||
for i, id := range tmdbIDs {
|
||||
conditions[i] = fmt.Sprintf("external_ids @> $%d::jsonb", i+1)
|
||||
args[i] = fmt.Sprintf(`{"tmdb":"%d"}`, id)
|
||||
}
|
||||
query := fmt.Sprintf(
|
||||
"SELECT external_ids FROM media WHERE (%s) AND deleted_at IS NULL",
|
||||
strings.Join(conditions, " OR "),
|
||||
)
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
slog.Error("check library membership", "error", err)
|
||||
return membership
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var extIDs json.RawMessage
|
||||
if err := rows.Scan(&extIDs); err != nil {
|
||||
continue
|
||||
}
|
||||
var ids map[string]string
|
||||
if json.Unmarshal(extIDs, &ids) == nil {
|
||||
if idStr, ok := ids["tmdb"]; ok {
|
||||
if id, err := strconv.Atoi(idStr); err == nil {
|
||||
membership[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return membership
|
||||
}
|
||||
|
||||
func (s *DiscoverService) fetchFullDetail(ctx context.Context, tmdbID int, mediaType string) (*TMDBFullDetail, error) {
|
||||
idStr := strconv.Itoa(tmdbID)
|
||||
if mediaType == "series" {
|
||||
return s.tmdb.GetTVDetails(ctx, idStr)
|
||||
}
|
||||
return s.tmdb.GetMovieDetails(ctx, idStr)
|
||||
}
|
||||
|
||||
func (s *DiscoverService) buildCreateRequest(detail *TMDBFullDetail, mediaType string) CreateMediaRequest {
|
||||
title := detail.Title
|
||||
dateStr := detail.ReleaseDate
|
||||
if mediaType == "series" {
|
||||
if detail.Name != "" {
|
||||
title = detail.Name
|
||||
}
|
||||
dateStr = detail.FirstAirDate
|
||||
}
|
||||
|
||||
year := parseTMDBYear(dateStr)
|
||||
overview := detail.Overview
|
||||
|
||||
// Parse release_date for the dedicated column
|
||||
var releaseDate *time.Time
|
||||
if dateStr != "" {
|
||||
if parsed, err := time.Parse("2006-01-02", dateStr); err == nil {
|
||||
releaseDate = &parsed
|
||||
}
|
||||
}
|
||||
|
||||
// Build external IDs
|
||||
extIDs := map[string]string{
|
||||
"tmdb": strconv.Itoa(detail.ID),
|
||||
}
|
||||
if detail.ExternalIDs.IMDbID != "" {
|
||||
extIDs["imdb"] = detail.ExternalIDs.IMDbID
|
||||
}
|
||||
if detail.ExternalIDs.TVDBID != "" {
|
||||
extIDs["tvdb"] = detail.ExternalIDs.TVDBID
|
||||
}
|
||||
extIDsJSON, _ := json.Marshal(extIDs)
|
||||
|
||||
// Build metadata
|
||||
meta := map[string]interface{}{
|
||||
"tmdb_rating": detail.VoteAverage,
|
||||
}
|
||||
var genreNames []string
|
||||
for _, g := range detail.Genres {
|
||||
genreNames = append(genreNames, g.Name)
|
||||
}
|
||||
if len(genreNames) > 0 {
|
||||
meta["genres"] = genreNames
|
||||
}
|
||||
if detail.Runtime > 0 {
|
||||
meta["runtime"] = detail.Runtime
|
||||
}
|
||||
if mediaType == "series" {
|
||||
meta["number_of_seasons"] = detail.NumberOfSeasons
|
||||
meta["number_of_episodes"] = detail.NumberOfEpisodes
|
||||
}
|
||||
// Store date string in metadata for reference
|
||||
if mediaType == "movie" && detail.ReleaseDate != "" {
|
||||
meta["release_date"] = detail.ReleaseDate
|
||||
}
|
||||
if mediaType == "series" && detail.FirstAirDate != "" {
|
||||
meta["first_air_date"] = detail.FirstAirDate
|
||||
}
|
||||
metaJSON, _ := json.Marshal(meta)
|
||||
|
||||
// Build images
|
||||
var images []map[string]interface{}
|
||||
if detail.PosterPath != "" {
|
||||
images = append(images, map[string]interface{}{
|
||||
"url": fmt.Sprintf("https://image.tmdb.org/t/p/original%s", detail.PosterPath),
|
||||
"type": "poster",
|
||||
})
|
||||
}
|
||||
if detail.BackdropPath != "" {
|
||||
images = append(images, map[string]interface{}{
|
||||
"url": fmt.Sprintf("https://image.tmdb.org/t/p/original%s", detail.BackdropPath),
|
||||
"type": "backdrop",
|
||||
})
|
||||
}
|
||||
imagesJSON, _ := json.Marshal(images)
|
||||
|
||||
return CreateMediaRequest{
|
||||
MediaType: mediaType,
|
||||
Title: title,
|
||||
Overview: &overview,
|
||||
Year: year,
|
||||
ReleaseDate: releaseDate,
|
||||
Status: "unavailable",
|
||||
Monitored: true,
|
||||
ExternalIDs: extIDsJSON,
|
||||
Metadata: metaJSON,
|
||||
Images: imagesJSON,
|
||||
}
|
||||
}
|
||||
|
||||
func buildPosterURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://image.tmdb.org/t/p/w500" + path
|
||||
}
|
||||
|
||||
func buildBackdropURL(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return "https://image.tmdb.org/t/p/w780" + path
|
||||
}
|
||||
353
internal/service/download_client.go
Normal file
353
internal/service/download_client.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/download"
|
||||
)
|
||||
|
||||
type DownloadClientConfig struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Implementation string `json:"implementation"`
|
||||
URL string `json:"url"`
|
||||
APIKey *string `json:"-"`
|
||||
Category string `json:"category"`
|
||||
Priority int `json:"priority"`
|
||||
Protocol string `json:"protocol"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DownloadClientConfigResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Implementation string `json:"implementation"`
|
||||
URL string `json:"url"`
|
||||
Category string `json:"category"`
|
||||
Priority int `json:"priority"`
|
||||
Protocol string `json:"protocol"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DownloadClientWithInfo struct {
|
||||
Client download.DownloadClient
|
||||
Config DownloadClientConfig
|
||||
}
|
||||
|
||||
type CreateDownloadClientRequest struct {
|
||||
Name string `json:"name"`
|
||||
Implementation string `json:"implementation"`
|
||||
URL string `json:"url"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateDownloadClientRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Implementation *string `json:"implementation,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
Protocol *string `json:"protocol,omitempty"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadClientTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const downloadClientColumns = `id, name, implementation, url, api_key, category, priority, protocol, settings, enabled, created_at, updated_at`
|
||||
|
||||
type DownloadClientService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewDownloadClientService(database *db.DB) *DownloadClientService {
|
||||
return &DownloadClientService{db: database}
|
||||
}
|
||||
|
||||
func scanDownloadClientConfig(scanner interface{ Scan(...interface{}) error }) (*DownloadClientConfig, error) {
|
||||
var cfg DownloadClientConfig
|
||||
var apiKey sql.NullString
|
||||
var settings []byte
|
||||
|
||||
err := scanner.Scan(&cfg.ID, &cfg.Name, &cfg.Implementation, &cfg.URL, &apiKey,
|
||||
&cfg.Category, &cfg.Priority, &cfg.Protocol, &settings,
|
||||
&cfg.Enabled, &cfg.CreatedAt, &cfg.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if apiKey.Valid {
|
||||
cfg.APIKey = &apiKey.String
|
||||
}
|
||||
cfg.Settings = json.RawMessage(settings)
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func clientConfigToResponse(cfg *DownloadClientConfig) DownloadClientConfigResponse {
|
||||
return DownloadClientConfigResponse{
|
||||
ID: cfg.ID,
|
||||
Name: cfg.Name,
|
||||
Implementation: cfg.Implementation,
|
||||
URL: cfg.URL,
|
||||
Category: cfg.Category,
|
||||
Priority: cfg.Priority,
|
||||
Protocol: cfg.Protocol,
|
||||
Settings: cfg.Settings,
|
||||
Enabled: cfg.Enabled,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
UpdatedAt: cfg.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) List(ctx context.Context) ([]DownloadClientConfigResponse, error) {
|
||||
rows, err := s.db.Pool.Query(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM download_clients ORDER BY priority, name", downloadClientColumns))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list download clients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []DownloadClientConfigResponse
|
||||
for rows.Next() {
|
||||
cfg, err := scanDownloadClientConfig(rows)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan download client", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, clientConfigToResponse(cfg))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) GetByID(ctx context.Context, id int64) (*DownloadClientConfig, error) {
|
||||
row := s.db.Pool.QueryRow(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM download_clients WHERE id = $1", downloadClientColumns), id)
|
||||
|
||||
cfg, err := scanDownloadClientConfig(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download client not found")
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) Create(ctx context.Context, req CreateDownloadClientRequest) (int64, error) {
|
||||
category := req.Category
|
||||
if category == "" {
|
||||
category = "umm"
|
||||
}
|
||||
protocol := req.Protocol
|
||||
if protocol == "" {
|
||||
switch req.Implementation {
|
||||
case "sabnzbd":
|
||||
protocol = "nzb"
|
||||
case "qbittorrent":
|
||||
protocol = "torrent"
|
||||
default:
|
||||
protocol = "nzb"
|
||||
}
|
||||
}
|
||||
settings := req.Settings
|
||||
if settings == nil {
|
||||
settings = json.RawMessage("{}")
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
priority := 0
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
|
||||
var id int64
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO download_clients (name, implementation, url, api_key, category, priority, protocol, settings, enabled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
|
||||
req.Name, req.Implementation, req.URL, req.APIKey, category, priority, protocol, settings, enabled).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create download client: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) Update(ctx context.Context, id int64, req UpdateDownloadClientRequest) error {
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
idx := 1
|
||||
|
||||
addCol := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addCol("name", *req.Name)
|
||||
}
|
||||
if req.Implementation != nil {
|
||||
addCol("implementation", *req.Implementation)
|
||||
}
|
||||
if req.URL != nil {
|
||||
addCol("url", *req.URL)
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
addCol("api_key", *req.APIKey)
|
||||
}
|
||||
if req.Category != nil {
|
||||
addCol("category", *req.Category)
|
||||
}
|
||||
if req.Priority != nil {
|
||||
addCol("priority", *req.Priority)
|
||||
}
|
||||
if req.Protocol != nil {
|
||||
addCol("protocol", *req.Protocol)
|
||||
}
|
||||
if req.Settings != nil {
|
||||
addCol("settings", req.Settings)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
addCol("enabled", *req.Enabled)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
addCol("updated_at", time.Now())
|
||||
|
||||
query := fmt.Sprintf("UPDATE download_clients SET %s WHERE id = $%d",
|
||||
strings.Join(setClauses, ", "), idx)
|
||||
args = append(args, id)
|
||||
|
||||
tag, err := s.db.Pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update download client: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("download client not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM download_clients WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete download client: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("download client not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) GetClient(ctx context.Context, protocol string) (download.DownloadClient, *DownloadClientConfig, error) {
|
||||
row := s.db.Pool.QueryRow(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM download_clients WHERE enabled = true AND protocol = $1 ORDER BY priority ASC LIMIT 1", downloadClientColumns),
|
||||
protocol)
|
||||
|
||||
cfg, err := scanDownloadClientConfig(row)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("no enabled download client for protocol: %s", protocol)
|
||||
}
|
||||
|
||||
client, err := s.instantiateClient(cfg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return client, cfg, nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) GetAllEnabled(ctx context.Context, protocol string) ([]DownloadClientWithInfo, error) {
|
||||
rows, err := s.db.Pool.Query(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM download_clients WHERE enabled = true AND protocol = $1 ORDER BY priority ASC", downloadClientColumns),
|
||||
protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled download clients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clients []DownloadClientWithInfo
|
||||
for rows.Next() {
|
||||
cfg, err := scanDownloadClientConfig(rows)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan download client", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
client, err := s.instantiateClient(cfg)
|
||||
if err != nil {
|
||||
slog.Error("failed to instantiate download client", "error", err, "name", cfg.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
clients = append(clients, DownloadClientWithInfo{
|
||||
Client: client,
|
||||
Config: *cfg,
|
||||
})
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) Test(ctx context.Context, id int64) (*DownloadClientTestResult, error) {
|
||||
cfg, err := s.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := s.instantiateClient(cfg)
|
||||
if err != nil {
|
||||
return &DownloadClientTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
_, err = client.GetCompleted(ctx)
|
||||
if err != nil {
|
||||
return &DownloadClientTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
return &DownloadClientTestResult{Success: true}, nil
|
||||
}
|
||||
|
||||
func (s *DownloadClientService) instantiateClient(cfg *DownloadClientConfig) (download.DownloadClient, error) {
|
||||
apiKey := ""
|
||||
if cfg.APIKey != nil {
|
||||
apiKey = *cfg.APIKey
|
||||
}
|
||||
|
||||
switch cfg.Implementation {
|
||||
case "sabnzbd":
|
||||
return download.NewSABnzbdClient(cfg.URL, apiKey), nil
|
||||
case "qbittorrent":
|
||||
return download.NewQBittorrentClient(cfg.URL, apiKey), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown download client implementation: %s", cfg.Implementation)
|
||||
}
|
||||
}
|
||||
427
internal/service/import.go
Normal file
427
internal/service/import.go
Normal file
@@ -0,0 +1,427 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/download"
|
||||
)
|
||||
|
||||
type ImportResult struct {
|
||||
MediaID int64 `json:"media_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
SourcePath string `json:"source_path"`
|
||||
DestPath string `json:"dest_path"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Quality string `json:"quality"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type ImportReport struct {
|
||||
Imported int `json:"imported"`
|
||||
Skipped int `json:"skipped"`
|
||||
Errors int `json:"errors"`
|
||||
Results []ImportResult `json:"results"`
|
||||
}
|
||||
|
||||
type ImportService struct {
|
||||
db *db.DB
|
||||
downloadClientSvc *DownloadClientService
|
||||
namingSvc *NamingService
|
||||
matcherSvc *MatcherService
|
||||
mediaSvc *MediaService
|
||||
parser *ReleaseParser
|
||||
downloadDir string
|
||||
subtitleSvc *SubtitleService
|
||||
activitySvc *ActivityService
|
||||
}
|
||||
|
||||
func NewImportService(database *db.DB, dcSvc *DownloadClientService, nSvc *NamingService, mSvc *MatcherService, mediaSvc *MediaService, downloadDir string, subtitleSvc *SubtitleService, activitySvc *ActivityService) *ImportService {
|
||||
return &ImportService{
|
||||
db: database,
|
||||
downloadClientSvc: dcSvc,
|
||||
namingSvc: nSvc,
|
||||
matcherSvc: mSvc,
|
||||
mediaSvc: mediaSvc,
|
||||
parser: NewReleaseParser(),
|
||||
downloadDir: downloadDir,
|
||||
subtitleSvc: subtitleSvc,
|
||||
activitySvc: activitySvc,
|
||||
}
|
||||
}
|
||||
|
||||
var mediaExts = map[string]bool{
|
||||
".mkv": true,
|
||||
".mp4": true,
|
||||
".avi": true,
|
||||
".wmv": true,
|
||||
".flv": true,
|
||||
".webm": true,
|
||||
".mp3": true,
|
||||
".flac": true,
|
||||
".m4a": true,
|
||||
".m4b": true,
|
||||
".ogg": true,
|
||||
".opus": true,
|
||||
".epub": true,
|
||||
".pdf": true,
|
||||
".mobi": true,
|
||||
".azw3": true,
|
||||
}
|
||||
|
||||
func (s *ImportService) ProcessCompleted(ctx context.Context) (*ImportReport, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
report := &ImportReport{}
|
||||
|
||||
nzbClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "nzb")
|
||||
if err != nil {
|
||||
slog.Error("failed to get nzb clients", "error", err)
|
||||
}
|
||||
torrentClients, err := s.downloadClientSvc.GetAllEnabled(ctx, "torrent")
|
||||
if err != nil {
|
||||
slog.Error("failed to get torrent clients", "error", err)
|
||||
}
|
||||
|
||||
allClients := append(nzbClients, torrentClients...)
|
||||
|
||||
for _, client := range allClients {
|
||||
completed, err := client.Client.GetCompleted(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to get completed downloads", "error", err, "client", client.Config.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, dl := range completed {
|
||||
s.processDownload(ctx, dl, client, report)
|
||||
}
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func (s *ImportService) processDownload(ctx context.Context, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) {
|
||||
var exists bool
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
"SELECT EXISTS(SELECT 1 FROM media_files WHERE original_path = $1 AND deleted_at IS NULL)",
|
||||
dl.OutputPath).Scan(&exists)
|
||||
if err != nil {
|
||||
slog.Error("failed to check existing import", "error", err, "path", dl.OutputPath)
|
||||
report.Errors++
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
report.Skipped++
|
||||
return
|
||||
}
|
||||
|
||||
files, err := s.findMediaFiles(dl.Name)
|
||||
if err != nil {
|
||||
slog.Error("failed to find media files", "error", err, "download", dl.Name)
|
||||
report.Errors++
|
||||
return
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
slog.Warn("no media files found for download", "download", dl.Name)
|
||||
report.Skipped++
|
||||
return
|
||||
}
|
||||
|
||||
for _, filePath := range files {
|
||||
s.processFile(ctx, filePath, dl, client, report)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImportService) processFile(ctx context.Context, sourcePath string, dl download.CompletedDownload, client DownloadClientWithInfo, report *ImportReport) {
|
||||
fileCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
releaseInfo := s.parser.Parse(filepath.Base(sourcePath))
|
||||
|
||||
mediaType := "movie"
|
||||
if _, _, hasSE := parseSeasonEpisode(filepath.Base(sourcePath)); hasSE {
|
||||
mediaType = "series"
|
||||
}
|
||||
|
||||
match, err := s.matcherSvc.Match(fileCtx, dl.Name, mediaType)
|
||||
if err != nil {
|
||||
slog.Error("failed to match release to media", "error", err, "release", dl.Name)
|
||||
report.Errors++
|
||||
return
|
||||
}
|
||||
if match.Confidence == "none" {
|
||||
slog.Warn("no media match for release", "release", dl.Name, "path", sourcePath)
|
||||
report.Skipped++
|
||||
return
|
||||
}
|
||||
|
||||
result, err := s.importFile(fileCtx, sourcePath, match, releaseInfo, dl, client)
|
||||
if err != nil {
|
||||
slog.Error("failed to import file", "error", err, "source", sourcePath)
|
||||
report.Errors++
|
||||
return
|
||||
}
|
||||
|
||||
report.Imported++
|
||||
report.Results = append(report.Results, *result)
|
||||
}
|
||||
|
||||
func (s *ImportService) importFile(ctx context.Context, sourcePath string, match *MatchResult, releaseInfo ReleaseInfo, completed download.CompletedDownload, client DownloadClientWithInfo) (*ImportResult, error) {
|
||||
status := "importing"
|
||||
err := s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{
|
||||
Status: &status,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update media status to importing: %w", err)
|
||||
}
|
||||
|
||||
qualityTier := s.parser.MatchQuality(releaseInfo)
|
||||
qualityJSON, _ := json.Marshal(qualityTier)
|
||||
|
||||
year := 0
|
||||
if match.Year != nil {
|
||||
year = *match.Year
|
||||
}
|
||||
season := 0
|
||||
if match.Season != nil {
|
||||
season = *match.Season
|
||||
}
|
||||
episode := 0
|
||||
if match.Episode != nil {
|
||||
episode = *match.Episode
|
||||
}
|
||||
|
||||
namingData := NamingData{
|
||||
Title: match.Title,
|
||||
Year: year,
|
||||
Season: season,
|
||||
Episode: episode,
|
||||
Quality: qualityTier.Name,
|
||||
Ext: ExtractExt(filepath.Base(sourcePath)),
|
||||
ReleaseGroup: releaseInfo.ReleaseGroup,
|
||||
Resolution: releaseInfo.Resolution,
|
||||
Source: releaseInfo.Source,
|
||||
Codec: releaseInfo.VideoCodec,
|
||||
}
|
||||
|
||||
relativePath, err := s.namingSvc.Render(ctx, match.MediaType, namingData)
|
||||
if err != nil {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Naming template failed for media %d: %v", match.MediaID, err))
|
||||
return nil, fmt.Errorf("render naming template: %w", err)
|
||||
}
|
||||
|
||||
targetPath := filepath.Join(match.RootFolder, relativePath)
|
||||
|
||||
if !strings.HasPrefix(filepath.Clean(targetPath), filepath.Clean(match.RootFolder)) {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
return nil, fmt.Errorf("path traversal detected: target path escapes root folder")
|
||||
}
|
||||
|
||||
targetDir := filepath.Dir(targetPath)
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
return nil, fmt.Errorf("create target directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Link(sourcePath, targetPath); err != nil {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
s.logImportError(match.MediaID, match.MediaType, fmt.Sprintf("Hardlink failed for media %d: %v", match.MediaID, err))
|
||||
return nil, fmt.Errorf("hardlink file: %w", err)
|
||||
}
|
||||
|
||||
srcInfo, err := os.Stat(sourcePath)
|
||||
if err != nil {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
return nil, fmt.Errorf("stat source file: %w", err)
|
||||
}
|
||||
dstInfo, err := os.Stat(targetPath)
|
||||
if err != nil {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
return nil, fmt.Errorf("stat target file: %w", err)
|
||||
}
|
||||
if !os.SameFile(srcInfo, dstInfo) {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
return nil, fmt.Errorf("hardlink verification failed: files are not the same inode")
|
||||
}
|
||||
|
||||
fileSize := dstInfo.Size()
|
||||
|
||||
if s.subtitleSvc != nil {
|
||||
extractCtx, extractCancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
baseName := s.buildImportSubtitleBaseName(match, releaseInfo)
|
||||
extracted, err := s.subtitleSvc.ExtractSubtitles(extractCtx, targetPath, filepath.Dir(targetPath), baseName)
|
||||
if err != nil {
|
||||
slog.Error("failed to extract subtitles", "error", err, "path", targetPath)
|
||||
}
|
||||
if len(extracted) > 0 {
|
||||
slog.Info("extracted subtitles", "count", len(extracted), "media_id", match.MediaID)
|
||||
}
|
||||
extractCancel()
|
||||
}
|
||||
|
||||
_, err = s.db.Pool.Exec(ctx,
|
||||
`INSERT INTO media_files (media_id, media_type, path, original_path, file_name, file_size, quality, codec, resolution, source, is_hardlinked)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||
match.MediaID, match.MediaType, targetPath, sourcePath, filepath.Base(targetPath),
|
||||
fileSize, qualityJSON, ptrStr(releaseInfo.VideoCodec), ptrStr(releaseInfo.Resolution),
|
||||
ptrStr(releaseInfo.Source), true)
|
||||
if err != nil {
|
||||
s.rollbackStatus(ctx, match.MediaID, match.MediaType, "failed")
|
||||
return nil, fmt.Errorf("insert media file record: %w", err)
|
||||
}
|
||||
|
||||
availableStatus := "available"
|
||||
err = s.mediaSvc.Update(ctx, match.MediaID, match.MediaType, UpdateMediaRequest{
|
||||
Status: &availableStatus,
|
||||
CurrentQuality: qualityJSON,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to update media status to available", "error", err, "media_id", match.MediaID)
|
||||
}
|
||||
|
||||
if _, err := s.db.Pool.Exec(ctx, `UPDATE media SET has_files = true WHERE id = $1`, match.MediaID); err != nil {
|
||||
slog.Error("failed to update has_files", "error", err, "media_id", match.MediaID)
|
||||
}
|
||||
|
||||
// Log successful import activity
|
||||
if s.activitySvc != nil {
|
||||
s.activitySvc.LogAsync(LogEntry{
|
||||
EventType: "import",
|
||||
MediaID: &match.MediaID,
|
||||
MediaType: &match.MediaType,
|
||||
Title: fmt.Sprintf("Imported %s", filepath.Base(sourcePath)),
|
||||
Data: json.RawMessage(fmt.Sprintf(`{"source":"%s","dest":"%s","quality":"%s","size":%d}`,
|
||||
sourcePath, targetPath, qualityTier.Name, fileSize)),
|
||||
})
|
||||
}
|
||||
|
||||
_, err = s.db.Pool.Exec(ctx,
|
||||
`UPDATE download_queue SET status = 'imported', completed_at = NOW()
|
||||
WHERE media_id = $1 AND release_title = $2 AND status IN ('downloading', 'pending')`,
|
||||
match.MediaID, completed.Name)
|
||||
if err != nil {
|
||||
slog.Error("failed to update download queue", "error", err, "media_id", match.MediaID)
|
||||
}
|
||||
|
||||
if err := client.Client.Remove(ctx, completed.ID); err != nil {
|
||||
slog.Warn("failed to remove download client entry", "error", err, "id", completed.ID)
|
||||
}
|
||||
|
||||
return &ImportResult{
|
||||
MediaID: match.MediaID,
|
||||
MediaType: match.MediaType,
|
||||
SourcePath: sourcePath,
|
||||
DestPath: targetPath,
|
||||
FileSize: fileSize,
|
||||
Quality: qualityTier.Name,
|
||||
Status: "imported",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ImportService) rollbackStatus(ctx context.Context, mediaID int64, mediaType string, status string) {
|
||||
if err := s.mediaSvc.Update(ctx, mediaID, mediaType, UpdateMediaRequest{Status: &status}); err != nil {
|
||||
slog.Error("failed to rollback media status", "error", err, "media_id", mediaID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ImportService) findMediaFiles(downloadName string) ([]string, error) {
|
||||
downloadPath := filepath.Join(s.downloadDir, downloadName)
|
||||
cleanBase := filepath.Clean(s.downloadDir)
|
||||
|
||||
info, err := os.Stat(downloadPath)
|
||||
if err != nil {
|
||||
entries, err := os.ReadDir(s.downloadDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read download directory: %w", err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
candidate := filepath.Join(s.downloadDir, entry.Name())
|
||||
if strings.Contains(strings.ToLower(entry.Name()), strings.ToLower(downloadName)) {
|
||||
if entry.IsDir() {
|
||||
return s.walkMediaDir(candidate, cleanBase)
|
||||
}
|
||||
if mediaExts[filepath.Ext(entry.Name())] {
|
||||
return []string{candidate}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(filepath.Clean(downloadPath), cleanBase) {
|
||||
return nil, fmt.Errorf("path traversal detected: download path escapes download dir")
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
return s.walkMediaDir(downloadPath, cleanBase)
|
||||
}
|
||||
|
||||
if mediaExts[filepath.Ext(downloadPath)] {
|
||||
return []string{downloadPath}, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *ImportService) walkMediaDir(dir string, cleanBase string) ([]string, error) {
|
||||
var files []string
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(filepath.Clean(path), cleanBase) {
|
||||
return fmt.Errorf("path traversal detected: walked path escapes download dir")
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if mediaExts[filepath.Ext(path)] {
|
||||
files = append(files, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("walk download directory: %w", err)
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (s *ImportService) buildImportSubtitleBaseName(match *MatchResult, info ReleaseInfo) string {
|
||||
parts := []string{sanitize(match.Title)}
|
||||
if match.Year != nil {
|
||||
parts = append(parts, fmt.Sprintf("%d", *match.Year))
|
||||
}
|
||||
if match.Season != nil && match.Episode != nil {
|
||||
parts = append(parts, fmt.Sprintf("S%02dE%02d", *match.Season, *match.Episode))
|
||||
}
|
||||
return strings.Join(parts, ".")
|
||||
}
|
||||
|
||||
func ptrStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *ImportService) logImportError(mediaID int64, mediaType string, msg string) {
|
||||
if s.activitySvc != nil {
|
||||
s.activitySvc.LogAsync(LogEntry{
|
||||
EventType: "error",
|
||||
Title: msg,
|
||||
MediaID: &mediaID,
|
||||
MediaType: &mediaType,
|
||||
})
|
||||
}
|
||||
}
|
||||
557
internal/service/indexer.go
Normal file
557
internal/service/indexer.go
Normal file
@@ -0,0 +1,557 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/cardigann"
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type Indexer struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Implementation string `json:"implementation"`
|
||||
URL string `json:"url"`
|
||||
APIKey *string `json:"-"`
|
||||
Categories json.RawMessage `json:"categories"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
DisabledUntil *time.Time `json:"disabled_until,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type IndexerResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Implementation string `json:"implementation"`
|
||||
URL string `json:"url"`
|
||||
Categories json.RawMessage `json:"categories"`
|
||||
Settings json.RawMessage `json:"settings"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
LastSuccessAt *time.Time `json:"last_success_at,omitempty"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
DisabledUntil *time.Time `json:"disabled_until,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type IndexerTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
}
|
||||
|
||||
type IndexerStats struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TotalGrabs int `json:"total_grabs"`
|
||||
TotalFailed int `json:"total_failed"`
|
||||
SuccessRate float64 `json:"success_rate"`
|
||||
FailureCount int `json:"failure_count"`
|
||||
LastSuccess string `json:"last_success,omitempty"`
|
||||
}
|
||||
|
||||
type CreateIndexerRequest struct {
|
||||
Name string `json:"name"`
|
||||
Implementation string `json:"implementation"`
|
||||
URL string `json:"url"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
Categories json.RawMessage `json:"categories,omitempty"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateIndexerRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Implementation *string `json:"implementation,omitempty"`
|
||||
URL *string `json:"url,omitempty"`
|
||||
APIKey *string `json:"api_key,omitempty"`
|
||||
Categories json.RawMessage `json:"categories,omitempty"`
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Priority *int `json:"priority,omitempty"`
|
||||
}
|
||||
|
||||
const indexerColumns = `id, name, implementation, url, api_key, categories, settings,
|
||||
enabled, priority, last_success_at, failure_count, disabled_until, created_at, updated_at`
|
||||
|
||||
type IndexerService struct {
|
||||
db *db.DB
|
||||
cardigannEngine *cardigann.CardigannEngine
|
||||
}
|
||||
|
||||
func NewIndexerService(database *db.DB) *IndexerService {
|
||||
return &IndexerService{db: database}
|
||||
}
|
||||
|
||||
// SetCardigannEngine sets the Cardigann engine for advanced indexer testing.
|
||||
func (s *IndexerService) SetCardigannEngine(engine *cardigann.CardigannEngine) {
|
||||
s.cardigannEngine = engine
|
||||
}
|
||||
|
||||
// CardigannIndexerConfig holds the Cardigann-specific configuration stored in settings JSONB.
|
||||
type CardigannIndexerConfig struct {
|
||||
YAML string `json:"yaml"`
|
||||
Config map[string]string `json:"config"`
|
||||
}
|
||||
|
||||
// GetCardigannConfig extracts Cardigann configuration from indexer settings JSONB.
|
||||
func (s *IndexerService) GetCardigannConfig(settings json.RawMessage) (*CardigannIndexerConfig, error) {
|
||||
if len(settings) == 0 {
|
||||
return nil, fmt.Errorf("no settings provided")
|
||||
}
|
||||
var cfg CardigannIndexerConfig
|
||||
if err := json.Unmarshal(settings, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse cardigann config: %w", err)
|
||||
}
|
||||
if cfg.YAML == "" {
|
||||
return nil, fmt.Errorf("cardigann settings missing yaml field")
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func scanIndexer(scanner interface{ Scan(...interface{}) error }) (*Indexer, error) {
|
||||
var idx Indexer
|
||||
var apiKey sql.NullString
|
||||
var categories, settings []byte
|
||||
var lastSuccessAt, disabledUntil sql.NullTime
|
||||
|
||||
err := scanner.Scan(&idx.ID, &idx.Name, &idx.Implementation, &idx.URL, &apiKey,
|
||||
&categories, &settings, &idx.Enabled, &idx.Priority,
|
||||
&lastSuccessAt, &idx.FailureCount, &disabledUntil,
|
||||
&idx.CreatedAt, &idx.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if apiKey.Valid {
|
||||
idx.APIKey = &apiKey.String
|
||||
}
|
||||
idx.Categories = json.RawMessage(categories)
|
||||
idx.Settings = json.RawMessage(settings)
|
||||
if lastSuccessAt.Valid {
|
||||
idx.LastSuccessAt = &lastSuccessAt.Time
|
||||
}
|
||||
if disabledUntil.Valid {
|
||||
idx.DisabledUntil = &disabledUntil.Time
|
||||
}
|
||||
return &idx, nil
|
||||
}
|
||||
|
||||
func indexerToResponse(idx *Indexer) IndexerResponse {
|
||||
return IndexerResponse{
|
||||
ID: idx.ID,
|
||||
Name: idx.Name,
|
||||
Implementation: idx.Implementation,
|
||||
URL: idx.URL,
|
||||
Categories: idx.Categories,
|
||||
Settings: idx.Settings,
|
||||
Enabled: idx.Enabled,
|
||||
Priority: idx.Priority,
|
||||
LastSuccessAt: idx.LastSuccessAt,
|
||||
FailureCount: idx.FailureCount,
|
||||
DisabledUntil: idx.DisabledUntil,
|
||||
CreatedAt: idx.CreatedAt,
|
||||
UpdatedAt: idx.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IndexerService) List(ctx context.Context) ([]IndexerResponse, error) {
|
||||
rows, err := s.db.Pool.Query(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM indexers ORDER BY priority, name", indexerColumns))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list indexers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []IndexerResponse
|
||||
for rows.Next() {
|
||||
idx, err := scanIndexer(rows)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan indexer", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, indexerToResponse(idx))
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) Create(ctx context.Context, req CreateIndexerRequest) (int64, error) {
|
||||
categories := req.Categories
|
||||
if categories == nil {
|
||||
categories = json.RawMessage("[]")
|
||||
}
|
||||
settings := req.Settings
|
||||
if settings == nil {
|
||||
settings = json.RawMessage("{}")
|
||||
}
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = *req.Enabled
|
||||
}
|
||||
priority := 0
|
||||
if req.Priority != nil {
|
||||
priority = *req.Priority
|
||||
}
|
||||
|
||||
// For Cardigann indexers, extract URL from YAML definition
|
||||
url := req.URL
|
||||
if req.Implementation == "cardigann" {
|
||||
cfg, err := s.GetCardigannConfig(settings)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid cardigann settings: %w", err)
|
||||
}
|
||||
def, err := cardigann.ParseDefinition([]byte(cfg.YAML))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid cardigann YAML: %w", err)
|
||||
}
|
||||
if len(def.Links) > 0 {
|
||||
url = def.Links[0]
|
||||
}
|
||||
if req.Name == "" {
|
||||
req.Name = def.Name
|
||||
}
|
||||
}
|
||||
|
||||
var id int64
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO indexers (name, implementation, url, api_key, categories, settings, enabled, priority)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
|
||||
req.Name, req.Implementation, url, req.APIKey, categories, settings, enabled, priority).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create indexer: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) Update(ctx context.Context, id int64, req UpdateIndexerRequest) error {
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
idx := 1
|
||||
|
||||
addCol := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
addCol("name", *req.Name)
|
||||
}
|
||||
if req.Implementation != nil {
|
||||
addCol("implementation", *req.Implementation)
|
||||
}
|
||||
if req.URL != nil {
|
||||
addCol("url", *req.URL)
|
||||
}
|
||||
if req.APIKey != nil {
|
||||
addCol("api_key", *req.APIKey)
|
||||
}
|
||||
if req.Categories != nil {
|
||||
addCol("categories", req.Categories)
|
||||
}
|
||||
if req.Settings != nil {
|
||||
addCol("settings", req.Settings)
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
addCol("enabled", *req.Enabled)
|
||||
}
|
||||
if req.Priority != nil {
|
||||
addCol("priority", *req.Priority)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
addCol("updated_at", time.Now())
|
||||
|
||||
query := fmt.Sprintf("UPDATE indexers SET %s WHERE id = $%d",
|
||||
strings.Join(setClauses, ", "), idx)
|
||||
args = append(args, id)
|
||||
|
||||
tag, err := s.db.Pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update indexer: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("indexer not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := s.db.Pool.Exec(ctx, "DELETE FROM indexers WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete indexer: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("indexer not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) Test(ctx context.Context, id int64) (*IndexerTestResult, error) {
|
||||
row := s.db.Pool.QueryRow(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM indexers WHERE id = $1", indexerColumns), id)
|
||||
|
||||
idx, err := scanIndexer(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("indexer not found")
|
||||
}
|
||||
|
||||
// Cardigann indexers: parse YAML, perform connectivity check
|
||||
if idx.Implementation == "cardigann" {
|
||||
return s.testCardigannIndexer(ctx, idx)
|
||||
}
|
||||
|
||||
testURL := idx.URL
|
||||
switch idx.Implementation {
|
||||
case "newznab", "torznab":
|
||||
testURL = testURL + "/api?t=caps"
|
||||
if idx.APIKey != nil && *idx.APIKey != "" {
|
||||
testURL = testURL + "&apikey=" + *idx.APIKey
|
||||
}
|
||||
default:
|
||||
testURL = strings.TrimRight(testURL, "/")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", id)
|
||||
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", id)
|
||||
return &IndexerTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET last_success_at = NOW(), failure_count = 0, updated_at = NOW() WHERE id = $1", id)
|
||||
|
||||
return &IndexerTestResult{
|
||||
Success: true,
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) testCardigannIndexer(ctx context.Context, idx *Indexer) (*IndexerTestResult, error) {
|
||||
cfg, err := s.GetCardigannConfig(idx.Settings)
|
||||
if err != nil {
|
||||
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("invalid cardigann config: %v", err)}, nil
|
||||
}
|
||||
|
||||
def, err := cardigann.ParseDefinition([]byte(cfg.YAML))
|
||||
if err != nil {
|
||||
return &IndexerTestResult{Success: false, Error: fmt.Sprintf("invalid YAML: %v", err)}, nil
|
||||
}
|
||||
|
||||
// Use CardigannEngine for full test if available
|
||||
if s.cardigannEngine != nil {
|
||||
result, err := s.cardigannEngine.Test(ctx, def, cfg.Config)
|
||||
if err != nil {
|
||||
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", idx.ID)
|
||||
} else {
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET last_success_at = NOW(), failure_count = 0, updated_at = NOW() WHERE id = $1", idx.ID)
|
||||
}
|
||||
|
||||
return &IndexerTestResult{
|
||||
Success: result.Success,
|
||||
Error: result.Error,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Fallback: basic connectivity check to first link
|
||||
if len(def.Links) == 0 {
|
||||
return &IndexerTestResult{Success: false, Error: "definition has no links"}, nil
|
||||
}
|
||||
|
||||
testURL := def.Links[0]
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, testURL, nil)
|
||||
if err != nil {
|
||||
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", idx.ID)
|
||||
return &IndexerTestResult{Success: false, Error: err.Error()}, nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = failure_count + 1, updated_at = NOW() WHERE id = $1", idx.ID)
|
||||
return &IndexerTestResult{
|
||||
Success: false,
|
||||
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET last_success_at = NOW(), failure_count = 0, updated_at = NOW() WHERE id = $1", idx.ID)
|
||||
|
||||
return &IndexerTestResult{
|
||||
Success: true,
|
||||
StatusCode: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) ListEnabled(ctx context.Context) ([]Indexer, error) {
|
||||
rows, err := s.db.Pool.Query(ctx,
|
||||
fmt.Sprintf("SELECT %s FROM indexers WHERE enabled = true AND (disabled_until IS NULL OR disabled_until < NOW()) ORDER BY priority, name", indexerColumns))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list enabled indexers: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Indexer
|
||||
for rows.Next() {
|
||||
idx, err := scanIndexer(rows)
|
||||
if err != nil {
|
||||
slog.Error("failed to scan indexer", "error", err)
|
||||
continue
|
||||
}
|
||||
items = append(items, *idx)
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) RecordSuccess(ctx context.Context, id int64) error {
|
||||
_, err := s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = 0, last_success_at = NOW(), disabled_until = NULL, updated_at = NOW() WHERE id = $1", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("record indexer success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *IndexerService) RecordFailure(ctx context.Context, id int64) error {
|
||||
var failureCount int
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
"SELECT failure_count FROM indexers WHERE id = $1", id).Scan(&failureCount)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get indexer failure count: %w", err)
|
||||
}
|
||||
|
||||
failureCount++
|
||||
|
||||
if failureCount >= 5 {
|
||||
backoffMinutes := 1 << min(failureCount, 6)
|
||||
if backoffMinutes > 60 {
|
||||
backoffMinutes = 60
|
||||
}
|
||||
disabledUntil := time.Now().Add(time.Duration(backoffMinutes) * time.Minute)
|
||||
_, err = s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = $1, disabled_until = $2, updated_at = NOW() WHERE id = $3",
|
||||
failureCount, disabledUntil, id)
|
||||
slog.Warn("indexer auto-disabled after consecutive failures", "id", id, "failure_count", failureCount, "disabled_until", disabledUntil)
|
||||
} else {
|
||||
_, err = s.db.Pool.Exec(ctx,
|
||||
"UPDATE indexers SET failure_count = $1, updated_at = NOW() WHERE id = $2",
|
||||
failureCount, id)
|
||||
slog.Warn("indexer search failed", "id", id, "failure_count", failureCount)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("record indexer failure: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func MediaTypeToCategory(mediaType string) string {
|
||||
switch strings.ToLower(mediaType) {
|
||||
case "movie":
|
||||
return "2000"
|
||||
case "series", "episode":
|
||||
return "5000"
|
||||
case "music", "album":
|
||||
return "3000"
|
||||
case "book":
|
||||
return "7000"
|
||||
case "audiobook":
|
||||
return "3030"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *IndexerService) Stats(ctx context.Context, id int64) (*IndexerStats, error) {
|
||||
var name string
|
||||
var failureCount int
|
||||
var lastSuccessAt sql.NullTime
|
||||
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
"SELECT name, failure_count, last_success_at FROM indexers WHERE id = $1", id,
|
||||
).Scan(&name, &failureCount, &lastSuccessAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("indexer not found")
|
||||
}
|
||||
|
||||
var totalGrabs, totalFailed int
|
||||
s.db.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FILTER (WHERE action IN ('grabbed', 'imported')),
|
||||
COUNT(*) FILTER (WHERE action = 'failed')
|
||||
FROM download_history WHERE indexer = $1`, name,
|
||||
).Scan(&totalGrabs, &totalFailed)
|
||||
|
||||
successRate := 0.0
|
||||
total := totalGrabs + totalFailed
|
||||
if total > 0 {
|
||||
successRate = float64(totalGrabs) / float64(total) * 100
|
||||
}
|
||||
|
||||
result := &IndexerStats{
|
||||
ID: id,
|
||||
Name: name,
|
||||
TotalGrabs: totalGrabs,
|
||||
TotalFailed: totalFailed,
|
||||
SuccessRate: successRate,
|
||||
FailureCount: failureCount,
|
||||
}
|
||||
if lastSuccessAt.Valid {
|
||||
result.LastSuccess = lastSuccessAt.Time.Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
221
internal/service/matcher.go
Normal file
221
internal/service/matcher.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
)
|
||||
|
||||
type MatchResult struct {
|
||||
MediaID int64 `json:"media_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
Title string `json:"title"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
Season *int `json:"season,omitempty"`
|
||||
Episode *int `json:"episode,omitempty"`
|
||||
RootFolder string `json:"root_folder"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
type MatcherService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewMatcherService(database *db.DB) *MatcherService {
|
||||
return &MatcherService{db: database}
|
||||
}
|
||||
|
||||
var (
|
||||
seasonEpisodeRe = regexp.MustCompile(`(?i)[sS](\d{1,2})[eE](\d{1,2})`)
|
||||
altSeasonEpsRe = regexp.MustCompile(`(\d{1,2})[xX](\d{1,2})`)
|
||||
bracketRe2 = regexp.MustCompile(`\[.*?\]`)
|
||||
qualityTrailRe = regexp.MustCompile(`(?i)(?:[sS]\d{1,2}[eE]\d{1,2}|\d{3,4}[pi]|720|1080|2160|HDTV|WEB|BluRay|BRRip|BDRip|DVDRip|REMUX|x264|x265|HEVC|AAC|DTS|AC3|DD|FLAC).*$`)
|
||||
sepRe = regexp.MustCompile(`[._-]+`)
|
||||
punctRe = regexp.MustCompile(`[^\w\s]`)
|
||||
multiSpaceRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
func normalizeTitle(s string) string {
|
||||
s = strings.ToLower(s)
|
||||
s = punctRe.ReplaceAllString(s, " ")
|
||||
s = multiSpaceRe.ReplaceAllString(s, " ")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func parseSeasonEpisode(s string) (season, episode int, found bool) {
|
||||
if m := seasonEpisodeRe.FindStringSubmatch(s); m != nil {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return season, episode, true
|
||||
}
|
||||
if m := altSeasonEpsRe.FindStringSubmatch(s); m != nil {
|
||||
season = atoi(m[1])
|
||||
episode = atoi(m[2])
|
||||
return season, episode, true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func atoi(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func extractCleanTitle(releaseName string) string {
|
||||
cleaned := bracketRe2.ReplaceAllString(releaseName, " ")
|
||||
if m := seasonEpisodeRe.FindStringIndex(cleaned); m != nil {
|
||||
cleaned = cleaned[:m[0]]
|
||||
} else if m := qualityTrailRe.FindStringIndex(cleaned); m != nil {
|
||||
cleaned = cleaned[:m[0]]
|
||||
}
|
||||
cleaned = sepRe.ReplaceAllString(cleaned, " ")
|
||||
return normalizeTitle(cleaned)
|
||||
}
|
||||
|
||||
func levenshteinDistance(a, b string) int {
|
||||
la, lb := len(a), len(b)
|
||||
if la == 0 {
|
||||
return lb
|
||||
}
|
||||
if lb == 0 {
|
||||
return la
|
||||
}
|
||||
prev := make([]int, lb+1)
|
||||
curr := make([]int, lb+1)
|
||||
for j := 0; j <= lb; j++ {
|
||||
prev[j] = j
|
||||
}
|
||||
for i := 1; i <= la; i++ {
|
||||
curr[0] = i
|
||||
for j := 1; j <= lb; j++ {
|
||||
cost := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
curr[j] = minOf3(
|
||||
prev[j]+1,
|
||||
curr[j-1]+1,
|
||||
prev[j-1]+cost,
|
||||
)
|
||||
}
|
||||
prev, curr = curr, prev
|
||||
}
|
||||
return prev[lb]
|
||||
}
|
||||
|
||||
func minOf3(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *MatcherService) Match(ctx context.Context, releaseName string, mediaType string) (*MatchResult, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
season, episode, hasSE := parseSeasonEpisode(releaseName)
|
||||
cleanTitle := extractCleanTitle(releaseName)
|
||||
|
||||
if cleanTitle == "" {
|
||||
return &MatchResult{Confidence: "none"}, nil
|
||||
}
|
||||
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
qb.AddLiteral("monitored = true")
|
||||
|
||||
if mediaType == "series" || hasSE {
|
||||
qb.AddLiteral("media_type IN ('series', 'episode')")
|
||||
} else if mediaType != "" {
|
||||
qb.AddLiteral("media_type NOT IN ('series', 'episode')")
|
||||
qb.Add("media_type = $%d", mediaType)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT %s FROM media%s", mediaColumns, qb.Where())
|
||||
rows, err := s.db.Pool.Query(ctx, query, qb.Args()...)
|
||||
if err != nil {
|
||||
slog.Error("failed to query media for matching", "error", err)
|
||||
return nil, fmt.Errorf("query media candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
candidates, err := scanMediaRows(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan media candidates: %w", err)
|
||||
}
|
||||
|
||||
var exactMatch *Media
|
||||
var fuzzyMatch *Media
|
||||
var fuzzyDist int
|
||||
|
||||
for i := range candidates {
|
||||
c := &candidates[i]
|
||||
norm := normalizeTitle(c.Title)
|
||||
|
||||
if norm == cleanTitle {
|
||||
exactMatch = c
|
||||
break
|
||||
}
|
||||
|
||||
dist := levenshteinDistance(cleanTitle, norm)
|
||||
if dist <= 2 {
|
||||
if fuzzyMatch == nil || dist < fuzzyDist {
|
||||
fuzzyMatch = c
|
||||
fuzzyDist = dist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched := exactMatch
|
||||
confidence := "exact"
|
||||
if matched == nil && fuzzyMatch != nil {
|
||||
matched = fuzzyMatch
|
||||
confidence = "fuzzy"
|
||||
}
|
||||
|
||||
if matched == nil {
|
||||
return &MatchResult{Confidence: "none"}, nil
|
||||
}
|
||||
|
||||
result := &MatchResult{
|
||||
MediaID: matched.ID,
|
||||
MediaType: matched.MediaType,
|
||||
Title: matched.Title,
|
||||
Year: matched.Year,
|
||||
Confidence: confidence,
|
||||
}
|
||||
|
||||
if hasSE {
|
||||
result.Season = &season
|
||||
result.Episode = &episode
|
||||
}
|
||||
|
||||
if matched.RootFolderID != nil {
|
||||
var path string
|
||||
if err := s.db.Pool.QueryRow(ctx,
|
||||
"SELECT path FROM root_folders WHERE id = $1", *matched.RootFolderID).Scan(&path); err != nil {
|
||||
slog.Error("failed to query root folder", "error", err, "root_folder_id", *matched.RootFolderID)
|
||||
} else {
|
||||
result.RootFolder = path
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
621
internal/service/media.go
Normal file
621
internal/service/media.go
Normal file
@@ -0,0 +1,621 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TopherMayor/unified-media-manager/internal/db"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type Media struct {
|
||||
ID int64 `json:"id"`
|
||||
MediaType string `json:"media_type"`
|
||||
Title string `json:"title"`
|
||||
SortTitle string `json:"sort_title"`
|
||||
OriginalTitle *string `json:"original_title,omitempty"`
|
||||
Overview *string `json:"overview,omitempty"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Monitored bool `json:"monitored"`
|
||||
ExternalIDs json.RawMessage `json:"external_ids"`
|
||||
Metadata json.RawMessage `json:"metadata"`
|
||||
Images json.RawMessage `json:"images"`
|
||||
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
|
||||
RootFolderID *int64 `json:"root_folder_id,omitempty"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
|
||||
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
|
||||
QualityUpgradeNeeded bool `json:"quality_upgrade_needed"`
|
||||
AddedAt time.Time `json:"added_at"`
|
||||
LastSearchAt *time.Time `json:"last_search_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type MediaFile struct {
|
||||
ID int64 `json:"id"`
|
||||
MediaID int64 `json:"media_id"`
|
||||
Path string `json:"path"`
|
||||
OriginalPath *string `json:"original_path,omitempty"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Quality json.RawMessage `json:"quality"`
|
||||
Codec *string `json:"codec,omitempty"`
|
||||
Resolution *string `json:"resolution,omitempty"`
|
||||
Source *string `json:"source,omitempty"`
|
||||
TranscodeStatus string `json:"transcode_status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type MediaRelation struct {
|
||||
ID int64 `json:"id"`
|
||||
ParentID int64 `json:"parent_id"`
|
||||
ChildID int64 `json:"child_id"`
|
||||
Relation string `json:"relation"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
Season *int `json:"season,omitempty"`
|
||||
}
|
||||
|
||||
type MediaDetail struct {
|
||||
Media Media `json:"media"`
|
||||
Files []MediaFile `json:"files"`
|
||||
Relations []MediaRelation `json:"relations"`
|
||||
}
|
||||
|
||||
type MediaFilters struct {
|
||||
MediaType string
|
||||
Status string
|
||||
Monitored string
|
||||
Query string
|
||||
Tag string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type CreateMediaRequest struct {
|
||||
MediaType string `json:"media_type"`
|
||||
Title string `json:"title"`
|
||||
SortTitle string `json:"sort_title,omitempty"`
|
||||
OriginalTitle *string `json:"original_title,omitempty"`
|
||||
Overview *string `json:"overview,omitempty"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Monitored bool `json:"monitored"`
|
||||
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
|
||||
RootFolderID *int64 `json:"root_folder_id,omitempty"`
|
||||
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
|
||||
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateMediaRequest struct {
|
||||
Title *string `json:"title,omitempty"`
|
||||
SortTitle *string `json:"sort_title,omitempty"`
|
||||
OriginalTitle *string `json:"original_title,omitempty"`
|
||||
Overview *string `json:"overview,omitempty"`
|
||||
Year *int `json:"year,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Monitored *bool `json:"monitored,omitempty"`
|
||||
ExternalIDs json.RawMessage `json:"external_ids,omitempty"`
|
||||
Metadata json.RawMessage `json:"metadata,omitempty"`
|
||||
Images json.RawMessage `json:"images,omitempty"`
|
||||
QualityProfileID *int64 `json:"quality_profile_id,omitempty"`
|
||||
RootFolderID *int64 `json:"root_folder_id,omitempty"`
|
||||
CurrentQuality json.RawMessage `json:"current_quality,omitempty"`
|
||||
DesiredQuality json.RawMessage `json:"desired_quality,omitempty"`
|
||||
}
|
||||
|
||||
const mediaColumns = `id, media_type, title, sort_title, original_title, overview, year,
|
||||
release_date,
|
||||
status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id,
|
||||
current_quality, desired_quality,
|
||||
has_files,
|
||||
CASE
|
||||
WHEN has_files AND desired_quality IS NOT NULL
|
||||
AND current_quality IS NOT NULL
|
||||
AND current_quality::text != desired_quality::text
|
||||
THEN true
|
||||
ELSE false
|
||||
END AS quality_upgrade_needed,
|
||||
added_at, last_search_at, updated_at`
|
||||
|
||||
type MediaService struct {
|
||||
db *db.DB
|
||||
}
|
||||
|
||||
func NewMediaService(database *db.DB) *MediaService {
|
||||
return &MediaService{db: database}
|
||||
}
|
||||
|
||||
func scanMedia(scanner interface{ Scan(...interface{}) error }) (*Media, error) {
|
||||
var m Media
|
||||
var origTitle, overview sql.NullString
|
||||
var year sql.NullInt64
|
||||
var releaseDate sql.NullTime
|
||||
var qpID, rfID sql.NullInt64
|
||||
var lastSearchAt sql.NullTime
|
||||
var hasFiles bool
|
||||
|
||||
err := scanner.Scan(&m.ID, &m.MediaType, &m.Title, &m.SortTitle, &origTitle, &overview, &year,
|
||||
&releaseDate,
|
||||
&m.Status, &m.Monitored, &m.ExternalIDs, &m.Metadata, &m.Images, &qpID, &rfID,
|
||||
&m.CurrentQuality, &m.DesiredQuality, &hasFiles, &m.QualityUpgradeNeeded, &m.AddedAt, &lastSearchAt, &m.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if origTitle.Valid {
|
||||
m.OriginalTitle = &origTitle.String
|
||||
}
|
||||
if overview.Valid {
|
||||
m.Overview = &overview.String
|
||||
}
|
||||
if year.Valid {
|
||||
y := int(year.Int64)
|
||||
m.Year = &y
|
||||
}
|
||||
if releaseDate.Valid {
|
||||
m.ReleaseDate = &releaseDate.Time
|
||||
}
|
||||
if qpID.Valid {
|
||||
m.QualityProfileID = &qpID.Int64
|
||||
}
|
||||
if rfID.Valid {
|
||||
m.RootFolderID = &rfID.Int64
|
||||
}
|
||||
if lastSearchAt.Valid {
|
||||
m.LastSearchAt = &lastSearchAt.Time
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
type mediaWithTotal struct {
|
||||
Media
|
||||
total int
|
||||
}
|
||||
|
||||
func scanMediaRowWithTotal(scanner interface{ Scan(...interface{}) error }) (*mediaWithTotal, error) {
|
||||
var m Media
|
||||
var origTitle, overview sql.NullString
|
||||
var year sql.NullInt64
|
||||
var releaseDate sql.NullTime
|
||||
var qpID, rfID sql.NullInt64
|
||||
var lastSearchAt sql.NullTime
|
||||
var hasFiles bool
|
||||
var total int
|
||||
|
||||
err := scanner.Scan(&m.ID, &m.MediaType, &m.Title, &m.SortTitle, &origTitle, &overview, &year,
|
||||
&releaseDate,
|
||||
&m.Status, &m.Monitored, &m.ExternalIDs, &m.Metadata, &m.Images, &qpID, &rfID,
|
||||
&m.CurrentQuality, &m.DesiredQuality, &hasFiles, &m.QualityUpgradeNeeded, &m.AddedAt, &lastSearchAt, &m.UpdatedAt,
|
||||
&total)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if origTitle.Valid {
|
||||
m.OriginalTitle = &origTitle.String
|
||||
}
|
||||
if overview.Valid {
|
||||
m.Overview = &overview.String
|
||||
}
|
||||
if year.Valid {
|
||||
y := int(year.Int64)
|
||||
m.Year = &y
|
||||
}
|
||||
if releaseDate.Valid {
|
||||
m.ReleaseDate = &releaseDate.Time
|
||||
}
|
||||
if qpID.Valid {
|
||||
m.QualityProfileID = &qpID.Int64
|
||||
}
|
||||
if rfID.Valid {
|
||||
m.RootFolderID = &rfID.Int64
|
||||
}
|
||||
if lastSearchAt.Valid {
|
||||
m.LastSearchAt = &lastSearchAt.Time
|
||||
}
|
||||
return &mediaWithTotal{Media: m, total: total}, nil
|
||||
}
|
||||
|
||||
func scanMediaRows(rows pgx.Rows) ([]Media, error) {
|
||||
var results []Media
|
||||
for rows.Next() {
|
||||
m, err := scanMedia(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scan media row: %w", err)
|
||||
}
|
||||
results = append(results, *m)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func buildMediaFilters(filters *MediaFilters) *QueryBuilder {
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
|
||||
if filters.MediaType != "" {
|
||||
qb.Add("media_type = $%d", filters.MediaType)
|
||||
}
|
||||
if filters.Status != "" {
|
||||
qb.Add("status = $%d", filters.Status)
|
||||
}
|
||||
if filters.Monitored != "" {
|
||||
qb.Add("monitored = $%d", filters.Monitored == "true")
|
||||
}
|
||||
return qb
|
||||
}
|
||||
|
||||
func (s *MediaService) List(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
|
||||
qb := buildMediaFilters(&filters)
|
||||
|
||||
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
|
||||
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
|
||||
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("list media: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Media
|
||||
var total int
|
||||
for rows.Next() {
|
||||
m, err := scanMediaRowWithTotal(rows)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("scan media row: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
total = m.total
|
||||
}
|
||||
items = append(items, m.Media)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) GetByID(ctx context.Context, id int64, mediaType string) (*MediaDetail, error) {
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
qb.Add("id = $%d", id)
|
||||
if mediaType != "" {
|
||||
qb.Add("media_type = $%d", mediaType)
|
||||
}
|
||||
|
||||
row := s.db.Pool.QueryRow(ctx,
|
||||
"SELECT "+mediaColumns+" FROM media"+qb.Where(), qb.Args()...)
|
||||
|
||||
m, err := scanMedia(row)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get media: %w", err)
|
||||
}
|
||||
|
||||
detail := &MediaDetail{Media: *m}
|
||||
|
||||
fileRows, err := s.db.Pool.Query(ctx,
|
||||
`SELECT id, media_id, path, original_path, file_name, file_size, quality, codec, resolution, source, transcode_status, created_at
|
||||
FROM media_files WHERE media_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC`, id)
|
||||
if err == nil {
|
||||
defer fileRows.Close()
|
||||
for fileRows.Next() {
|
||||
var f MediaFile
|
||||
var origPath, codec, resolution, source sql.NullString
|
||||
if err := fileRows.Scan(&f.ID, &f.MediaID, &f.Path, &origPath, &f.FileName, &f.FileSize,
|
||||
&f.Quality, &codec, &resolution, &source, &f.TranscodeStatus, &f.CreatedAt); err != nil {
|
||||
slog.Error("failed to scan media file", "error", err)
|
||||
continue
|
||||
}
|
||||
if origPath.Valid {
|
||||
f.OriginalPath = &origPath.String
|
||||
}
|
||||
if codec.Valid {
|
||||
f.Codec = &codec.String
|
||||
}
|
||||
if resolution.Valid {
|
||||
f.Resolution = &resolution.String
|
||||
}
|
||||
if source.Valid {
|
||||
f.Source = &source.String
|
||||
}
|
||||
detail.Files = append(detail.Files, f)
|
||||
}
|
||||
}
|
||||
|
||||
relRows, err := s.db.Pool.Query(ctx,
|
||||
`SELECT id, parent_id, child_id, relation, position, season
|
||||
FROM media_relations WHERE parent_id = $1 OR child_id = $1 ORDER BY relation, position`, id)
|
||||
if err == nil {
|
||||
defer relRows.Close()
|
||||
for relRows.Next() {
|
||||
var r MediaRelation
|
||||
if err := relRows.Scan(&r.ID, &r.ParentID, &r.ChildID, &r.Relation, &r.Position, &r.Season); err != nil {
|
||||
slog.Error("failed to scan media relation", "error", err)
|
||||
continue
|
||||
}
|
||||
detail.Relations = append(detail.Relations, r)
|
||||
}
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) Create(ctx context.Context, req CreateMediaRequest) (int64, error) {
|
||||
if req.SortTitle == "" {
|
||||
req.SortTitle = req.Title
|
||||
}
|
||||
if req.Status == "" {
|
||||
req.Status = "unavailable"
|
||||
}
|
||||
if req.ExternalIDs == nil {
|
||||
req.ExternalIDs = json.RawMessage("{}")
|
||||
}
|
||||
if req.Metadata == nil {
|
||||
req.Metadata = json.RawMessage("{}")
|
||||
}
|
||||
if req.Images == nil {
|
||||
req.Images = json.RawMessage("[]")
|
||||
}
|
||||
|
||||
var id int64
|
||||
err := s.db.Pool.QueryRow(ctx,
|
||||
`INSERT INTO media (media_type, title, sort_title, original_title, overview, year,
|
||||
release_date,
|
||||
status, monitored, external_ids, metadata, images, quality_profile_id, root_folder_id,
|
||||
current_quality, desired_quality)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id`,
|
||||
req.MediaType, req.Title, req.SortTitle, req.OriginalTitle, req.Overview, req.Year,
|
||||
req.ReleaseDate,
|
||||
req.Status, req.Monitored, req.ExternalIDs, req.Metadata, req.Images,
|
||||
req.QualityProfileID, req.RootFolderID, req.CurrentQuality, req.DesiredQuality).Scan(&id)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create media: %w", err)
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) Update(ctx context.Context, id int64, mediaType string, req UpdateMediaRequest) error {
|
||||
var setClauses []string
|
||||
var args []interface{}
|
||||
idx := 1
|
||||
|
||||
addCol := func(col string, val interface{}) {
|
||||
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, idx))
|
||||
args = append(args, val)
|
||||
idx++
|
||||
}
|
||||
|
||||
if req.Title != nil {
|
||||
addCol("title", *req.Title)
|
||||
}
|
||||
if req.SortTitle != nil {
|
||||
addCol("sort_title", *req.SortTitle)
|
||||
}
|
||||
if req.OriginalTitle != nil {
|
||||
addCol("original_title", *req.OriginalTitle)
|
||||
}
|
||||
if req.Overview != nil {
|
||||
addCol("overview", *req.Overview)
|
||||
}
|
||||
if req.Year != nil {
|
||||
addCol("year", *req.Year)
|
||||
}
|
||||
if req.Status != nil {
|
||||
addCol("status", *req.Status)
|
||||
}
|
||||
if req.Monitored != nil {
|
||||
addCol("monitored", *req.Monitored)
|
||||
}
|
||||
if req.ExternalIDs != nil {
|
||||
addCol("external_ids", req.ExternalIDs)
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
addCol("metadata", req.Metadata)
|
||||
}
|
||||
if req.Images != nil {
|
||||
addCol("images", req.Images)
|
||||
}
|
||||
if req.QualityProfileID != nil {
|
||||
addCol("quality_profile_id", *req.QualityProfileID)
|
||||
}
|
||||
if req.RootFolderID != nil {
|
||||
addCol("root_folder_id", *req.RootFolderID)
|
||||
}
|
||||
if req.CurrentQuality != nil {
|
||||
addCol("current_quality", req.CurrentQuality)
|
||||
}
|
||||
if req.DesiredQuality != nil {
|
||||
addCol("desired_quality", req.DesiredQuality)
|
||||
}
|
||||
|
||||
if len(setClauses) == 0 {
|
||||
return fmt.Errorf("no fields to update")
|
||||
}
|
||||
|
||||
addCol("updated_at", time.Now())
|
||||
|
||||
query := fmt.Sprintf("UPDATE media SET %s WHERE id = $%d AND deleted_at IS NULL",
|
||||
strings.Join(setClauses, ", "), idx)
|
||||
args = append(args, id)
|
||||
|
||||
if mediaType != "" {
|
||||
query += fmt.Sprintf(" AND media_type = $%d", idx+1)
|
||||
args = append(args, mediaType)
|
||||
}
|
||||
|
||||
tag, err := s.db.Pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update media: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("media not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MediaService) Delete(ctx context.Context, id int64, mediaType string) error {
|
||||
query := "UPDATE media SET deleted_at = NOW(), updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL"
|
||||
args := []interface{}{id}
|
||||
|
||||
if mediaType != "" {
|
||||
query += " AND media_type = $2"
|
||||
args = append(args, mediaType)
|
||||
}
|
||||
|
||||
tag, err := s.db.Pool.Exec(ctx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete media: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("media not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MediaService) Search(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
|
||||
if filters.Query != "" {
|
||||
qb.Add("to_tsvector('english', coalesce(title, '')) @@ plainto_tsquery('english', $%d)", filters.Query)
|
||||
}
|
||||
if filters.MediaType != "" {
|
||||
qb.Add("media_type = $%d", filters.MediaType)
|
||||
}
|
||||
if filters.Status != "" {
|
||||
qb.Add("status = $%d", filters.Status)
|
||||
}
|
||||
if filters.Tag != "" {
|
||||
qb.Add("id IN (SELECT mt.media_id FROM media_tags mt JOIN tags t ON mt.tag_id = t.id WHERE t.name = $%d)", filters.Tag)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
|
||||
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
|
||||
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("search media: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Media
|
||||
var total int
|
||||
for rows.Next() {
|
||||
m, err := scanMediaRowWithTotal(rows)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("scan search results: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
total = m.total
|
||||
}
|
||||
items = append(items, m.Media)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) SearchMissing(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("monitored = true")
|
||||
qb.AddLiteral("status = 'unavailable'")
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
|
||||
if filters.MediaType != "" {
|
||||
qb.Add("media_type = $%d", filters.MediaType)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
|
||||
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
|
||||
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query missing media: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Media
|
||||
var total int
|
||||
for rows.Next() {
|
||||
m, err := scanMediaRowWithTotal(rows)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("scan missing media: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
total = m.total
|
||||
}
|
||||
items = append(items, m.Media)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *MediaService) SearchUpgrades(ctx context.Context, filters MediaFilters) ([]Media, int, error) {
|
||||
qb := NewQueryBuilder(1)
|
||||
qb.AddLiteral("deleted_at IS NULL")
|
||||
qb.AddLiteral("monitored = true")
|
||||
qb.AddLiteral("has_files = true")
|
||||
qb.AddLiteral("current_quality IS NOT NULL")
|
||||
qb.AddLiteral("desired_quality IS NOT NULL")
|
||||
qb.AddLiteral("current_quality::text != desired_quality::text")
|
||||
|
||||
if filters.MediaType != "" {
|
||||
qb.Add("media_type = $%d", filters.MediaType)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT %s, COUNT(*) OVER() AS total FROM media%s ORDER BY sort_title LIMIT $%d OFFSET $%d",
|
||||
mediaColumns, qb.Where(), qb.Idx(), qb.Idx()+1)
|
||||
args := append(qb.Args(), filters.PageSize, (filters.Page-1)*filters.PageSize)
|
||||
|
||||
rows, err := s.db.Pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("query upgrades: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []Media
|
||||
var total int
|
||||
for rows.Next() {
|
||||
m, err := scanMediaRowWithTotal(rows)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("scan upgrades: %w", err)
|
||||
}
|
||||
if total == 0 {
|
||||
total = m.total
|
||||
}
|
||||
items = append(items, m.Media)
|
||||
}
|
||||
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func CalcTotalPages(total, pageSize int) int {
|
||||
totalPages := total / pageSize
|
||||
if total%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
return totalPages
|
||||
}
|
||||
|
||||
func ParsePagination(pageStr, pageSizeStr string) (page, pageSize int) {
|
||||
page, _ = strconv.Atoi(pageStr)
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ = strconv.Atoi(pageSizeStr)
|
||||
if pageSize < 1 || pageSize > 100 {
|
||||
pageSize = 50
|
||||
}
|
||||
return
|
||||
}
|
||||
337
internal/service/media_detail.go
Normal file
337
internal/service/media_detail.go
Normal 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
|
||||
}
|
||||
335
internal/service/metadata.go
Normal file
335
internal/service/metadata.go
Normal 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
|
||||
}
|
||||
320
internal/service/musicbrainz.go
Normal file
320
internal/service/musicbrainz.go
Normal 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
118
internal/service/naming.go
Normal 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, ".")
|
||||
}
|
||||
673
internal/service/notification.go
Normal file
673
internal/service/notification.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
216
internal/service/notification_test.go
Normal file
216
internal/service/notification_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
199
internal/service/openlibrary.go
Normal file
199
internal/service/openlibrary.go
Normal 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
282
internal/service/quality.go
Normal 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
41
internal/service/query.go
Normal 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
226
internal/service/queue.go
Normal 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
195
internal/service/release.go
Normal 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
266
internal/service/request.go
Normal 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)
|
||||
}
|
||||
}
|
||||
78
internal/service/root_folder.go
Normal file
78
internal/service/root_folder.go
Normal 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
|
||||
}
|
||||
56
internal/service/safety.go
Normal file
56
internal/service/safety.go
Normal 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
|
||||
}
|
||||
92
internal/service/safety_test.go
Normal file
92
internal/service/safety_test.go
Normal 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
426
internal/service/search.go
Normal 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
|
||||
}
|
||||
378
internal/service/subtitle.go
Normal file
378
internal/service/subtitle.go
Normal 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
76
internal/service/tag.go
Normal 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
403
internal/service/tmdb.go
Normal 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
308
internal/service/tvdb.go
Normal 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
121
internal/service/user.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user