New: Merge import list tags across multiple lists

Previously, import list tag handling had two issues:

1. Movies already in the library were skipped entirely during list sync,
   so tags configured on import lists would never be applied to them.

2. When a movie appeared in multiple import lists, only tags from one list
   would be applied due to premature deduplication in the fetch service.

This change ensures that tags from all import lists are properly merged,
both for existing movies in the library and for new movies being imported.

For existing movies:
- Tags from all import lists referencing the movie are collected
- New tags are merged with existing movie tags (preserving current tags)
- A single batch update is performed for efficiency

For new movies appearing in multiple lists:
- The movie is added once (using settings from the first list, like usual)
- Tags from all lists are merged before the movie is added
- Removed premature `DistinctBy` in `FetchAndParseImportListService` that was
  discarding duplicate entries across lists

Performance optimizations:
- Changed `dbMovies` from List to `HashSet` for O(1) lookups
- Changed moviesToAdd from List to `Dictionary` for O(1) lookups
- Tag service is queried once and reused for all logging
- Added `FindByTmdbId(List<int>)` to `IMovieService` for batch lookups
This commit is contained in:
Ricardo Amaral 2026-01-13 18:15:51 +00:00
parent 89110c2cc8
commit c280b9afb0
4 changed files with 104 additions and 47 deletions

View file

@ -101,11 +101,8 @@ public ImportListFetchResult Fetch()
if (!importListReports.AnyFailure)
{
var alreadyMapped = result.Movies.Where(x => importListReports.Movies.Any(r => r.TmdbId == x.TmdbId));
var listMovies = MapMovieReports(importListReports.Movies.Where(x => result.Movies.All(r => r.TmdbId != x.TmdbId))).Where(x => x.TmdbId > 0).ToList();
var listMovies = MapMovieReports(importListReports.Movies).Where(x => x.TmdbId > 0).ToList();
listMovies.AddRange(alreadyMapped);
listMovies = listMovies.DistinctBy(x => x.TmdbId).ToList();
listMovies.ForEach(m => m.ListId = importList.Definition.Id);
result.Movies.AddRange(listMovies);
@ -129,8 +126,6 @@ public ImportListFetchResult Fetch()
Task.WaitAll(taskList.ToArray());
result.Movies = result.Movies.DistinctBy(r => new { r.TmdbId, r.ImdbId, r.Title }).ToList();
_logger.Debug("Found {0} total reports from {1} lists", result.Movies.Count, result.SyncedLists);
return result;

View file

@ -8,6 +8,7 @@
using NzbDrone.Core.ImportLists.ImportListMovies;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Movies;
using NzbDrone.Core.Tags;
namespace NzbDrone.Core.ImportLists
{
@ -21,6 +22,7 @@ public class ImportListSyncService : IExecute<ImportListSyncCommand>
private readonly IConfigService _configService;
private readonly IImportListExclusionService _listExclusionService;
private readonly IImportListMovieService _listMovieService;
private readonly ITagService _tagService;
public ImportListSyncService(IImportListFactory importListFactory,
IFetchAndParseImportList listFetcherAndParser,
@ -29,6 +31,7 @@ public ImportListSyncService(IImportListFactory importListFactory,
IConfigService configService,
IImportListExclusionService listExclusionService,
IImportListMovieService listMovieService,
ITagService tagService,
Logger logger)
{
_importListFactory = importListFactory;
@ -37,6 +40,7 @@ public ImportListSyncService(IImportListFactory importListFactory,
_addMovieService = addMovieService;
_listExclusionService = listExclusionService;
_listMovieService = listMovieService;
_tagService = tagService;
_logger = logger;
_configService = configService;
}
@ -74,7 +78,7 @@ private void SyncList(ImportListDefinition definition)
ProcessListItems(listItemsResult);
}
private void ProcessMovieReport(ImportListDefinition importList, ImportListMovie report, List<ImportListExclusion> listExclusions, List<int> dbMovies, List<Movie> moviesToAdd)
private void ProcessMovieReport(ImportListDefinition importList, ImportListMovie report, List<ImportListExclusion> listExclusions, HashSet<int> dbMovies, Dictionary<int, Movie> moviesToAdd, Dictionary<int, HashSet<int>> existingMovieTagUpdates, Dictionary<int, string> allTags)
{
if (report.TmdbId == 0 || !importList.EnableAuto)
{
@ -84,7 +88,20 @@ private void ProcessMovieReport(ImportListDefinition importList, ImportListMovie
// Check to see if movie in DB
if (dbMovies.Contains(report.TmdbId))
{
_logger.Debug("{0} [{1}] Rejected, Movie Exists in DB", report.TmdbId, report.Title);
_logger.Debug("{0} [{1}] Movie Exists in DB, checking tags", report.TmdbId, report.Title);
// Collect tags to add to existing movies
if (importList.Tags.Any())
{
if (!existingMovieTagUpdates.TryGetValue(report.TmdbId, out var tagsToAdd))
{
tagsToAdd = new HashSet<int>();
existingMovieTagUpdates[report.TmdbId] = tagsToAdd;
}
tagsToAdd.UnionWith(importList.Tags);
}
return;
}
@ -97,54 +114,59 @@ private void ProcessMovieReport(ImportListDefinition importList, ImportListMovie
return;
}
// Append Artist if not already in DB or already on add list
if (moviesToAdd.All(s => s.TmdbId != report.TmdbId))
// Check if movie is already on the add list (from another import list)
if (moviesToAdd.TryGetValue(report.TmdbId, out var existingMovie))
{
var monitorType = importList.Monitor;
moviesToAdd.Add(new Movie
// Merge tags from this list into the existing movie
if (importList.Tags.Any())
{
Monitored = monitorType != MonitorTypes.None,
RootFolderPath = importList.RootFolderPath,
QualityProfileId = importList.QualityProfileId,
MinimumAvailability = importList.MinimumAvailability,
Tags = importList.Tags,
TmdbId = report.TmdbId,
Title = report.Title,
Year = report.Year,
ImdbId = report.ImdbId,
AddOptions = new AddMovieOptions
var newTags = importList.Tags.Except(existingMovie.Tags).ToList();
if (newTags.Any())
{
SearchForMovie = monitorType != MonitorTypes.None && importList.SearchOnAdd,
Monitor = monitorType,
AddMethod = AddMovieMethod.List
var tagNames = newTags.Select(id => allTags.TryGetValue(id, out var name) ? name : id.ToString());
_logger.Debug("{0} [{1}] Merging {2} tags from list {3}: [{4}]", report.TmdbId, report.Title, newTags.Count, importList.Name, string.Join(", ", tagNames));
existingMovie.Tags = existingMovie.Tags.Union(importList.Tags).ToHashSet();
}
});
}
return;
}
var monitorType = importList.Monitor;
var initialTagNames = importList.Tags.Select(id => allTags.TryGetValue(id, out var name) ? name : id.ToString());
_logger.Debug("{0} [{1}] Adding to import queue from list {2} with tags: [{3}]", report.TmdbId, report.Title, importList.Name, string.Join(", ", initialTagNames));
moviesToAdd[report.TmdbId] = new Movie
{
Monitored = monitorType != MonitorTypes.None,
RootFolderPath = importList.RootFolderPath,
QualityProfileId = importList.QualityProfileId,
MinimumAvailability = importList.MinimumAvailability,
Tags = importList.Tags,
TmdbId = report.TmdbId,
Title = report.Title,
Year = report.Year,
ImdbId = report.ImdbId,
AddOptions = new AddMovieOptions
{
SearchForMovie = monitorType != MonitorTypes.None && importList.SearchOnAdd,
Monitor = monitorType,
AddMethod = AddMovieMethod.List
}
};
}
private void ProcessListItems(ImportListFetchResult listFetchResult)
{
listFetchResult.Movies = listFetchResult.Movies.DistinctBy(x =>
{
if (x.TmdbId != 0)
{
return x.TmdbId.ToString();
}
if (x.ImdbId.IsNotNullOrWhiteSpace())
{
return x.ImdbId;
}
return x.Title;
}).ToList();
var listedMovies = listFetchResult.Movies.ToList();
var importExclusions = _listExclusionService.All();
var dbMovies = _movieService.AllMovieTmdbIds();
var moviesToAdd = new List<Movie>();
var dbMovies = _movieService.AllMovieTmdbIds().ToHashSet();
var moviesToAdd = new Dictionary<int, Movie>();
var existingMovieTagUpdates = new Dictionary<int, HashSet<int>>();
var allTags = _tagService.All().ToDictionary(t => t.Id, t => t.Label);
var groupedMovies = listedMovies.GroupBy(x => x.ListId);
@ -156,7 +178,7 @@ private void ProcessListItems(ImportListFetchResult listFetchResult)
{
if (movie.TmdbId != 0)
{
ProcessMovieReport(importList, movie, importExclusions, dbMovies, moviesToAdd);
ProcessMovieReport(importList, movie, importExclusions, dbMovies, moviesToAdd, existingMovieTagUpdates, allTags);
}
}
}
@ -164,7 +186,41 @@ private void ProcessListItems(ImportListFetchResult listFetchResult)
if (moviesToAdd.Any())
{
_logger.ProgressInfo("Adding {0} movies from your auto enabled lists to library", moviesToAdd.Count);
_addMovieService.AddMovies(moviesToAdd, true);
_addMovieService.AddMovies(moviesToAdd.Values.ToList(), true);
}
if (existingMovieTagUpdates.Any())
{
UpdateTagsForExistingMovies(existingMovieTagUpdates, allTags);
}
}
private void UpdateTagsForExistingMovies(Dictionary<int, HashSet<int>> existingMovieTagUpdates, Dictionary<int, string> allTags)
{
var moviesToUpdate = new List<Movie>();
var existingMovies = _movieService.FindByTmdbId(existingMovieTagUpdates.Keys.ToList());
foreach (var movie in existingMovies)
{
if (existingMovieTagUpdates.TryGetValue(movie.TmdbId, out var tagsFromLists))
{
var newTags = tagsFromLists.Except(movie.Tags).ToList();
if (newTags.Any())
{
movie.Tags = movie.Tags.Union(tagsFromLists).ToHashSet();
moviesToUpdate.Add(movie);
var tagNames = newTags.Select(id => allTags.TryGetValue(id, out var name) ? name : id.ToString());
_logger.Debug("Adding {0} tags to {1}: {2}", newTags.Count, movie.Title, string.Join(", ", tagNames));
}
}
}
if (moviesToUpdate.Any())
{
_logger.ProgressInfo("Updating tags for {0} movies from import lists", moviesToUpdate.Count);
_movieService.UpdateMovie(moviesToUpdate, true);
}
}

View file

@ -220,7 +220,7 @@ public Movie FindByTmdbId(int tmdbid)
public List<Movie> FindByTmdbId(List<int> tmdbids)
{
return Query(x => tmdbids.Contains(x.TmdbId));
return Query(x => tmdbids.Contains(x.MovieMetadata.Value.TmdbId));
}
public List<Movie> GetMoviesByFileId(int fileId)

View file

@ -24,6 +24,7 @@ public interface IMovieService
List<Movie> AddMovies(List<Movie> newMovies);
Movie FindByImdbId(string imdbid);
Movie FindByTmdbId(int tmdbid);
List<Movie> FindByTmdbId(List<int> tmdbids);
Movie FindByTitle(string title);
Movie FindByTitle(string title, int year);
Movie FindByTitle(List<string> titles, int? year, List<string> otherTitles, List<Movie> candidates);
@ -195,6 +196,11 @@ public Movie FindByTmdbId(int tmdbid)
return _movieRepository.FindByTmdbId(tmdbid);
}
public List<Movie> FindByTmdbId(List<int> tmdbids)
{
return _movieRepository.FindByTmdbId(tmdbids);
}
public Movie FindByPath(string path)
{
return _movieRepository.FindByPath(path);