Sync from /srv/compose/unified-media-manager
This commit is contained in:
335
internal/service/metadata.go
Normal file
335
internal/service/metadata.go
Normal file
@@ -0,0 +1,335 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user