package service import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "strings" "time" "github.com/TopherMayor/unified-media-manager/internal/db" ) type MetadataSearchResult struct { ProviderID string `json:"provider_id"` Title string `json:"title"` Year *int `json:"year,omitempty"` MediaType string `json:"media_type"` Overview string `json:"overview,omitempty"` OriginalTitle string `json:"original_title,omitempty"` ExternalIDs json.RawMessage `json:"external_ids,omitempty"` } type MetadataDetails struct { ProviderID string `json:"provider_id"` Title string `json:"title"` OriginalTitle *string `json:"original_title,omitempty"` Overview *string `json:"overview,omitempty"` Year *int `json:"year,omitempty"` Ratings json.RawMessage `json:"ratings,omitempty"` ExternalIDs json.RawMessage `json:"external_ids,omitempty"` Metadata json.RawMessage `json:"metadata,omitempty"` Images []ImageResult `json:"images,omitempty"` } type ImageResult struct { URL string `json:"url"` Type string `json:"type"` Width int `json:"width,omitempty"` Height int `json:"height,omitempty"` } type SearchOptions struct { Year *int MediaType string Page int } type MetadataProvider interface { Name() string Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) GetImages(ctx context.Context, id string) ([]ImageResult, error) } type MetadataService struct { db *db.DB mediaSvc *MediaService providers map[string]MetadataProvider imageDir string httpClient *http.Client } func NewMetadataService(database *db.DB, mediaSvc *MediaService, imageDir string) *MetadataService { return &MetadataService{ db: database, mediaSvc: mediaSvc, providers: make(map[string]MetadataProvider), imageDir: imageDir, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } func (s *MetadataService) RegisterProvider(provider MetadataProvider) { s.providers[provider.Name()] = provider } func (s *MetadataService) GetCached(ctx context.Context, provider, providerID string) (*MetadataDetails, error) { var data []byte err := s.db.Pool.QueryRow(ctx, "SELECT data FROM metadata_cache WHERE provider = $1 AND provider_id = $2 AND expires_at > NOW()", provider, providerID).Scan(&data) if err != nil { return nil, nil } var details MetadataDetails if err := json.Unmarshal(data, &details); err != nil { return nil, nil } return &details, nil } func (s *MetadataService) SetCached(ctx context.Context, provider, providerID, mediaType string, data *MetadataDetails, ttl time.Duration) error { jsonData, err := json.Marshal(data) if err != nil { return fmt.Errorf("marshal cache data: %w", err) } _, err = s.db.Pool.Exec(ctx, `INSERT INTO metadata_cache (provider, provider_id, media_type, data, expires_at) VALUES ($1, $2, $3, $4, NOW() + $5::interval) ON CONFLICT (provider, provider_id) DO UPDATE SET data = $4, media_type = $3, cached_at = NOW(), expires_at = NOW() + $5::interval`, provider, providerID, mediaType, jsonData, fmt.Sprintf("%d seconds", int(ttl.Seconds()))) if err != nil { return fmt.Errorf("upsert metadata cache: %w", err) } return nil } func (s *MetadataService) DownloadImage(ctx context.Context, imageURL, mediaType, filename string) error { dir := filepath.Join(s.imageDir, mediaType) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("create image directory: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodGet, imageURL, nil) if err != nil { return fmt.Errorf("create image request: %w", err) } resp, err := s.httpClient.Do(req) if err != nil { return fmt.Errorf("download image: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("download image failed: status %d", resp.StatusCode) } contentType := resp.Header.Get("Content-Type") if !strings.HasPrefix(contentType, "image/") { return fmt.Errorf("invalid content type: %s", contentType) } destPath := filepath.Join(dir, filename) f, err := os.Create(destPath) if err != nil { return fmt.Errorf("create image file: %w", err) } defer f.Close() if _, err := io.Copy(f, resp.Body); err != nil { return fmt.Errorf("write image file: %w", err) } return nil } func (s *MetadataService) RefreshMetadata(ctx context.Context, mediaID int64, mediaType string) error { detail, err := s.mediaSvc.GetByID(ctx, mediaID, mediaType) if err != nil { return fmt.Errorf("get media: %w", err) } media := detail.Media var existingIDs map[string]interface{} if err := json.Unmarshal(media.ExternalIDs, &existingIDs); err != nil { existingIDs = make(map[string]interface{}) } var allMetadata map[string]interface{} if err := json.Unmarshal(media.Metadata, &allMetadata); err != nil { allMetadata = make(map[string]interface{}) } var existingImages []map[string]interface{} if err := json.Unmarshal(media.Images, &existingImages); err != nil { existingImages = []map[string]interface{}{} } var updatedOverview *string var updatedOriginalTitle *string var updatedYear *int for name, provider := range s.providers { providerID := "" if idVal, ok := existingIDs[name]; ok { providerID = fmt.Sprintf("%v", idVal) } if providerID == "" { results, err := provider.Search(ctx, media.Title, SearchOptions{ Year: media.Year, MediaType: mediaType, }) if err != nil { slog.Error("provider search failed", "provider", name, "error", err) continue } if len(results) == 0 { continue } providerID = results[0].ProviderID } cached, _ := s.GetCached(ctx, name, providerID) var metaDetails *MetadataDetails if cached != nil { metaDetails = cached } else { details, err := provider.GetDetails(ctx, providerID) if err != nil { slog.Error("provider get details failed", "provider", name, "error", err) continue } metaDetails = details if err := s.SetCached(ctx, name, providerID, mediaType, details, 7*24*time.Hour); err != nil { slog.Error("cache metadata failed", "provider", name, "error", err) } } existingIDs[name] = providerID if metaDetails.ExternalIDs != nil { var providerIDs map[string]interface{} if err := json.Unmarshal(metaDetails.ExternalIDs, &providerIDs); err == nil { for k, v := range providerIDs { existingIDs[k] = v } } } if metaDetails.Overview != nil && *metaDetails.Overview != "" { updatedOverview = metaDetails.Overview } if metaDetails.OriginalTitle != nil && *metaDetails.OriginalTitle != "" { updatedOriginalTitle = metaDetails.OriginalTitle } if metaDetails.Year != nil { updatedYear = metaDetails.Year } if metaDetails.Metadata != nil { var providerMeta map[string]interface{} if err := json.Unmarshal(metaDetails.Metadata, &providerMeta); err == nil { allMetadata[name] = providerMeta } } if metaDetails.Ratings != nil { var ratings map[string]interface{} if err := json.Unmarshal(metaDetails.Ratings, &ratings); err == nil { allMetadata[name+"_ratings"] = ratings } } images, err := provider.GetImages(ctx, providerID) if err != nil { slog.Error("provider get images failed", "provider", name, "error", err) continue } for _, img := range images { ext := filepath.Ext(img.URL) if ext == "" { ext = ".jpg" } filename := fmt.Sprintf("%s_%s_%d%s", name, img.Type, mediaID, ext) imgCtx, imgCancel := context.WithTimeout(ctx, 15*time.Second) if err := s.DownloadImage(imgCtx, img.URL, mediaType, filename); err != nil { slog.Error("download image failed", "provider", name, "error", err) imgCancel() continue } imgCancel() localPath := fmt.Sprintf("/api/images/%s/%s", mediaType, filename) existingImages = append(existingImages, map[string]interface{}{ "url": localPath, "type": img.Type, "width": img.Width, "height": img.Height, "source": name, }) } } externalIDsJSON, _ := json.Marshal(existingIDs) metadataJSON, _ := json.Marshal(allMetadata) imagesJSON, _ := json.Marshal(existingImages) updateReq := UpdateMediaRequest{ ExternalIDs: externalIDsJSON, Metadata: metadataJSON, Images: imagesJSON, } if updatedOverview != nil { updateReq.Overview = updatedOverview } if updatedOriginalTitle != nil { updateReq.OriginalTitle = updatedOriginalTitle } if updatedYear != nil { updateReq.Year = updatedYear } if err := s.mediaSvc.Update(ctx, mediaID, mediaType, updateReq); err != nil { return fmt.Errorf("update media metadata: %w", err) } return nil } func (s *MetadataService) RefreshAllMetadata(ctx context.Context) error { rows, err := s.db.Pool.Query(ctx, "SELECT id, media_type FROM media WHERE monitored = true AND deleted_at IS NULL") if err != nil { return fmt.Errorf("query monitored media: %w", err) } defer rows.Close() for rows.Next() { var id int64 var mediaType string if err := rows.Scan(&id, &mediaType); err != nil { slog.Error("scan media row", "error", err) continue } itemCtx, cancel := context.WithTimeout(ctx, 30*time.Second) if err := s.RefreshMetadata(itemCtx, id, mediaType); err != nil { slog.Error("refresh metadata failed", "media_id", id, "error", err) } cancel() } return nil }