366 lines
11 KiB
Go
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
|
|
}
|
|
}
|