Sync from /srv/compose/unified-media-manager

This commit is contained in:
Christopher Mayor
2026-04-24 10:45:19 -07:00
commit 7dbd00e537
132 changed files with 25394 additions and 0 deletions

View 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
}