329 lines
8.5 KiB
Go
329 lines
8.5 KiB
Go
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
|
|
}
|