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