336 lines
9.4 KiB
Go
336 lines
9.4 KiB
Go
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
|
|
}
|