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 }