Sync from /srv/compose/unified-media-manager
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user