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 }