Files
silo-server/internal/metadata/merge.go
T

366 lines
11 KiB
Go

package metadata
import (
"slices"
"strconv"
"strings"
"github.com/Silo-Server/silo-server/internal/models"
)
// MergeMetadata merges source into target respecting locked fields and merge mode.
func MergeMetadata(source, target *MetadataResult, locked []MetadataField, mode MergeMode) {
if source == nil {
return
}
isLocked := func(f MetadataField) bool {
return slices.Contains(locked, f)
}
// Scalar fields
if !isLocked(FieldName) {
mergeScalar(&target.Title, source.Title, mode)
mergeScalar(&target.OriginalTitle, source.OriginalTitle, mode)
mergeScalar(&target.SortTitle, source.SortTitle, mode)
}
if !isLocked(FieldOverview) {
mergeScalar(&target.Overview, source.Overview, mode)
mergeScalar(&target.Tagline, source.Tagline, mode)
}
if !isLocked(FieldRuntime) {
mergeInt(&target.Runtime, source.Runtime, mode)
}
if !isLocked(FieldContentRating) {
mergeScalar(&target.ContentRating, source.ContentRating, mode)
}
if !isLocked(FieldRating) {
mergeFloat(&target.Ratings.IMDB, source.Ratings.IMDB, mode)
mergeFloat(&target.Ratings.TMDB, source.Ratings.TMDB, mode)
mergeFloat(&target.Ratings.RTCritic, source.Ratings.RTCritic, mode)
mergeFloat(&target.Ratings.RTAudience, source.Ratings.RTAudience, mode)
}
// Always fill year (not lockable — identity field)
mergeInt(&target.Year, source.Year, mode)
mergeScalar(&target.OriginalLanguage, source.OriginalLanguage, mode)
mergeScalar(&target.ReleaseDate, source.ReleaseDate, mode)
// Series fields (not lockable — structural)
mergeInt(&target.SeasonCount, source.SeasonCount, mode)
mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode)
mergeScalar(&target.LastAirDate, source.LastAirDate, mode)
mergeScalar(&target.AirTime, source.AirTime, mode)
// Genres follow provider priority during FillEmpty instead of unioning tags
// from later providers, which can create noisy hybrid classifications.
if !isLocked(FieldGenres) {
mergePrioritizedStringSlice(&target.Genres, source.Genres, mode)
}
// Accumulate other arrays
if !isLocked(FieldStudios) {
mergeStringSlice(&target.Studios, source.Studios, mode)
mergeStringSlice(&target.Networks, source.Networks, mode)
}
if !isLocked(FieldTags) {
mergeStringSlice(&target.Countries, source.Countries, mode)
mergeStringSlice(&target.Keywords, source.Keywords, mode)
}
// Smart merge people (unified cast/crew)
if !isLocked(FieldCast) || !isLocked(FieldCrew) {
mergePeople(&target.People, source.People, mode)
}
// Images
if !isLocked(FieldImages) {
mergeScalar(&target.PosterPath, source.PosterPath, mode)
mergeScalar(&target.PosterThumbhash, source.PosterThumbhash, mode)
mergeScalar(&target.BackdropPath, source.BackdropPath, mode)
mergeScalar(&target.BackdropThumbhash, source.BackdropThumbhash, mode)
mergeScalar(&target.LogoPath, source.LogoPath, mode)
}
// Provider IDs always accumulate, never overwrite
mergeProviderIDs(target, source)
}
// MergeGlobalMetadata merges only provider-invariant fields, preserving any
// language-specific presentation fields already stored on the target.
func MergeGlobalMetadata(source, target *MetadataResult, locked []MetadataField, mode MergeMode) {
if source == nil {
return
}
isLocked := func(f MetadataField) bool {
return slices.Contains(locked, f)
}
if !isLocked(FieldRuntime) {
mergeInt(&target.Runtime, source.Runtime, mode)
}
if !isLocked(FieldContentRating) {
mergeScalar(&target.ContentRating, source.ContentRating, mode)
}
if !isLocked(FieldRating) {
mergeFloat(&target.Ratings.IMDB, source.Ratings.IMDB, mode)
mergeFloat(&target.Ratings.TMDB, source.Ratings.TMDB, mode)
mergeFloat(&target.Ratings.RTCritic, source.Ratings.RTCritic, mode)
mergeFloat(&target.Ratings.RTAudience, source.Ratings.RTAudience, mode)
}
mergeInt(&target.Year, source.Year, mode)
mergeScalar(&target.OriginalTitle, source.OriginalTitle, mode)
mergeScalar(&target.OriginalLanguage, source.OriginalLanguage, mode)
mergeScalar(&target.ReleaseDate, source.ReleaseDate, mode)
mergeInt(&target.SeasonCount, source.SeasonCount, mode)
mergeScalar(&target.FirstAirDate, source.FirstAirDate, mode)
mergeScalar(&target.LastAirDate, source.LastAirDate, mode)
mergeScalar(&target.AirTime, source.AirTime, mode)
if !isLocked(FieldGenres) {
mergePrioritizedStringSlice(&target.Genres, source.Genres, mode)
}
if !isLocked(FieldStudios) {
mergeStringSlice(&target.Studios, source.Studios, mode)
mergeStringSlice(&target.Networks, source.Networks, mode)
}
if !isLocked(FieldTags) {
mergeStringSlice(&target.Countries, source.Countries, mode)
mergeStringSlice(&target.Keywords, source.Keywords, mode)
}
if !isLocked(FieldCast) || !isLocked(FieldCrew) {
mergePeople(&target.People, source.People, mode)
}
mergeProviderIDs(target, source)
}
// MergeSeasonResult merges source into target using the standard metadata
// fallback contract for season-level fields.
func MergeSeasonResult(source, target *SeasonResult, mode MergeMode) {
if source == nil {
return
}
mergeScalar(&target.ContentID, source.ContentID, mode)
mergeInt(&target.SeasonNumber, source.SeasonNumber, mode)
mergeScalar(&target.Title, source.Title, mode)
mergeScalar(&target.Overview, source.Overview, mode)
mergeScalar(&target.AirDate, source.AirDate, mode)
mergeScalar(&target.PosterPath, source.PosterPath, mode)
mergeScalar(&target.PosterThumbhash, source.PosterThumbhash, mode)
}
// MergeEpisodeResult merges source into target using the standard metadata
// fallback contract for episode-level fields.
func MergeEpisodeResult(source, target *EpisodeResult, mode MergeMode) {
if source == nil {
return
}
mergeScalar(&target.ContentID, source.ContentID, mode)
mergeInt(&target.SeasonNumber, source.SeasonNumber, mode)
mergeInt(&target.EpisodeNumber, source.EpisodeNumber, mode)
mergeScalar(&target.Title, source.Title, mode)
mergeScalar(&target.Overview, source.Overview, mode)
mergeScalar(&target.AirDate, source.AirDate, mode)
mergeInt(&target.Runtime, source.Runtime, mode)
mergeFloat(&target.Ratings.IMDB, source.Ratings.IMDB, mode)
mergeFloat(&target.Ratings.TMDB, source.Ratings.TMDB, mode)
mergeFloat(&target.Ratings.RTCritic, source.Ratings.RTCritic, mode)
mergeFloat(&target.Ratings.RTAudience, source.Ratings.RTAudience, mode)
mergeScalar(&target.StillPath, source.StillPath, mode)
mergeScalar(&target.StillThumbhash, source.StillThumbhash, mode)
mergeProviderIDMap(&target.ProviderIDs, source.ProviderIDs)
}
// MergePersonDetail merges source into target using the standard metadata
// fallback contract for person-level fields.
func MergePersonDetail(source, target *PersonDetailResult, mode MergeMode) {
if source == nil {
return
}
mergeScalar(&target.Name, source.Name, mode)
mergeScalar(&target.SortName, source.SortName, mode)
mergeScalar(&target.Bio, source.Bio, mode)
mergeScalar(&target.BirthDate, source.BirthDate, mode)
mergeScalar(&target.DeathDate, source.DeathDate, mode)
mergeScalar(&target.Birthplace, source.Birthplace, mode)
mergeScalar(&target.Homepage, source.Homepage, mode)
mergeScalar(&target.PhotoPath, source.PhotoPath, mode)
mergeScalar(&target.PhotoThumbhash, source.PhotoThumbhash, mode)
mergeProviderIDMap(&target.ProviderIDs, source.ProviderIDs)
}
// mergeProviderIDs adds new IDs without overwriting existing ones.
func mergeProviderIDs(target, source *MetadataResult) {
if len(source.ProviderIDs) == 0 {
return
}
if target.ProviderIDs == nil {
target.ProviderIDs = make(map[string]string)
}
for k, v := range source.ProviderIDs {
if v == "" {
continue
}
if _, exists := target.ProviderIDs[k]; !exists {
target.ProviderIDs[k] = v
}
}
}
func mergeProviderIDMap(target *map[string]string, source map[string]string) {
if len(source) == 0 {
return
}
if *target == nil {
*target = make(map[string]string)
}
for key, value := range source {
if value == "" {
continue
}
if (*target)[key] == "" {
(*target)[key] = value
}
}
}
func mergeScalar(target *string, source string, mode MergeMode) {
if source == "" {
return
}
if mode == MergeReplaceUnlocked || *target == "" {
*target = source
}
}
func mergeInt(target *int, source int, mode MergeMode) {
if source == 0 {
return
}
if mode == MergeReplaceUnlocked || *target == 0 {
*target = source
}
}
func mergeFloat(target *float64, source float64, mode MergeMode) {
if source == 0 {
return
}
if mode == MergeReplaceUnlocked || *target == 0 {
*target = source
}
}
func mergeStringSlice(target *[]string, source []string, mode MergeMode) {
if len(source) == 0 {
return
}
if mode == MergeReplaceUnlocked {
*target = source
return
}
// FillEmpty: accumulate unique
if len(*target) == 0 {
*target = source
return
}
seen := make(map[string]bool, len(*target))
for _, s := range *target {
seen[strings.ToLower(s)] = true
}
for _, s := range source {
if !seen[strings.ToLower(s)] {
*target = append(*target, s)
seen[strings.ToLower(s)] = true
}
}
}
func mergePrioritizedStringSlice(target *[]string, source []string, mode MergeMode) {
if len(source) == 0 {
return
}
if mode == MergeReplaceUnlocked || len(*target) == 0 {
*target = source
}
}
func mergePeople(target *[]models.ItemPerson, source []models.ItemPerson, mode MergeMode) {
if len(source) == 0 {
return
}
if mode == MergeReplaceUnlocked {
*target = source
return
}
if len(*target) == 0 {
*target = source
return
}
byTmdbID := indexPeopleBy(*target, func(p models.ItemPerson) string { return p.TmdbID })
byTvdbID := indexPeopleBy(*target, func(p models.ItemPerson) string { return p.TvdbID })
byImdbID := indexPeopleBy(*target, func(p models.ItemPerson) string { return p.ImdbID })
byPlexGUID := indexPeopleBy(*target, func(p models.ItemPerson) string { return p.PlexGUID })
byNameKind := indexPeopleBy(*target, func(p models.ItemPerson) string {
return strings.ToLower(p.Name) + "|" + strconv.Itoa(int(p.Kind))
})
for _, sp := range source {
if idx, ok := findPersonMatch(sp, byTmdbID, byTvdbID, byImdbID, byPlexGUID, byNameKind); ok {
mergePersonFields(&(*target)[idx], sp)
} else {
*target = append(*target, sp)
}
}
}
func indexPeopleBy(people []models.ItemPerson, keyFn func(models.ItemPerson) string) map[string]int {
m := make(map[string]int, len(people))
for i, p := range people {
if key := keyFn(p); key != "" {
m[key] = i
}
}
return m
}
func findPersonMatch(sp models.ItemPerson, maps ...map[string]int) (int, bool) {
keys := []string{sp.TmdbID, sp.TvdbID, sp.ImdbID, sp.PlexGUID,
strings.ToLower(sp.Name) + "|" + strconv.Itoa(int(sp.Kind))}
for i, m := range maps {
if i < len(keys) {
if idx, ok := m[keys[i]]; ok && keys[i] != "" {
return idx, true
}
}
}
return 0, false
}
func mergePersonFields(dst *models.ItemPerson, src models.ItemPerson) {
if dst.TmdbID == "" {
dst.TmdbID = src.TmdbID
}
if dst.ImdbID == "" {
dst.ImdbID = src.ImdbID
}
if dst.TvdbID == "" {
dst.TvdbID = src.TvdbID
}
if dst.PlexGUID == "" {
dst.PlexGUID = src.PlexGUID
}
if dst.PhotoPath == "" {
dst.PhotoPath = src.PhotoPath
}
if dst.PhotoThumbhash == "" {
dst.PhotoThumbhash = src.PhotoThumbhash
}
if dst.Character == "" {
dst.Character = src.Character
}
}