From c29788bc3f96b30e9ee6330b6f7b025b308d6585 Mon Sep 17 00:00:00 2001 From: Jason Dove <1695733+jasongdove@users.noreply.github.com> Date: Sat, 19 Jul 2025 21:56:13 +0000 Subject: [PATCH] add movie nfo country to search index (#2173) --- CHANGELOG.md | 2 ++ ErsatzTV.Core/Domain/Metadata/Tag.cs | 1 + .../Search/ElasticSearchIndex.cs | 8 ++++-- .../Search/LowercaseKeywordAnalyzer.cs | 15 +++++++++++ .../Search/LuceneSearchIndex.cs | 16 +++++++++--- .../Search/Models/ElasticSearchItem.cs | 3 +++ .../Search/SearchQueryParser.cs | 4 ++- .../Core/Metadata/Nfo/MovieNfoReaderTests.cs | 1 + .../Core/Metadata/LocalMetadataProvider.cs | 12 ++++++++- .../Core/Metadata/Nfo/MovieNfo.cs | 26 ++++++------------- .../Core/Metadata/Nfo/MovieNfoReader.cs | 7 +++++ 11 files changed, 69 insertions(+), 26 deletions(-) create mode 100644 ErsatzTV.Infrastructure/Search/LowercaseKeywordAnalyzer.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 592cc761..ebd2855f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - `Not So Great (Part Three)` - Add Trakt List option `Auto Refresh` to automatically update list from trakt.tv once each day - Add Trakt List option `Generate Playlist` to automatically generate ETV Playlist from matched Trakt List items +- Read `country` field from movie NFO files and include in search index as `country` ### Changed - Allow `Other Video` libraries and `Image` libraries to use the same folders @@ -143,6 +144,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Fix bug where playout mode `Multiple` would ignore fixed start time - Fix block playout EPG generation to use `XMLTV Time Zone` setting - Fix adding "official" Trakt lists +- Fix searching for `collection` names with spaces or other special characters, e.g. `collection:"Movies - Action"` ## [25.2.0] - 2025-06-24 ### Added diff --git a/ErsatzTV.Core/Domain/Metadata/Tag.cs b/ErsatzTV.Core/Domain/Metadata/Tag.cs index f211611b..335c1194 100644 --- a/ErsatzTV.Core/Domain/Metadata/Tag.cs +++ b/ErsatzTV.Core/Domain/Metadata/Tag.cs @@ -3,6 +3,7 @@ public class Tag { public static readonly string PlexNetworkTypeId = "319"; + public static readonly string NfoCountryTypeId = "nfo/country"; public int Id { get; set; } public string Name { get; set; } diff --git a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs index b75b54cf..5db1c75b 100644 --- a/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/ElasticSearchIndex.cs @@ -320,8 +320,12 @@ public class ElasticSearchIndex : ISearchIndex AddedDate = GetAddedDate(metadata.DateAdded), Plot = metadata.Plot ?? string.Empty, Genre = metadata.Genres.Map(g => g.Name).ToList(), - Tag = metadata.Tags.Map(t => t.Name).ToList(), - TagFull = metadata.Tags.Map(t => t.Name).ToList(), + Tag = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name) + .ToList(), + TagFull = metadata.Tags.Where(t => string.IsNullOrWhiteSpace(t.ExternalTypeId)).Map(t => t.Name) + .ToList(), + Country = metadata.Tags.Where(t => t.ExternalTypeId == Tag.NfoCountryTypeId).Map(t => t.Name) + .ToList(), Studio = metadata.Studios.Map(s => s.Name).ToList(), Actor = metadata.Actors.Map(a => a.Name).ToList(), Director = metadata.Directors.Map(d => d.Name).ToList(), diff --git a/ErsatzTV.Infrastructure/Search/LowercaseKeywordAnalyzer.cs b/ErsatzTV.Infrastructure/Search/LowercaseKeywordAnalyzer.cs new file mode 100644 index 00000000..5a4a076d --- /dev/null +++ b/ErsatzTV.Infrastructure/Search/LowercaseKeywordAnalyzer.cs @@ -0,0 +1,15 @@ +using Lucene.Net.Analysis; +using Lucene.Net.Analysis.Core; +using Lucene.Net.Util; + +namespace ErsatzTV.Infrastructure.Search; + +public sealed class LowercaseKeywordAnalyzer(LuceneVersion matchVersion) : Analyzer +{ + protected override TokenStreamComponents CreateComponents(string fieldName, TextReader reader) + { + Tokenizer tokenizer = new KeywordTokenizer(reader); + TokenStream result = new LowerCaseFilter(matchVersion, tokenizer); + return new TokenStreamComponents(tokenizer, result); + } +} \ No newline at end of file diff --git a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs index b86d9627..a95d066c 100644 --- a/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs +++ b/ErsatzTV.Infrastructure/Search/LuceneSearchIndex.cs @@ -35,6 +35,7 @@ public sealed class LuceneSearchIndex : ISearchIndex internal const string GenreField = "genre"; internal const string TagField = "tag"; internal const string TagFullField = "tag_full"; + internal const string CountryField = "country"; internal const string PlotField = "plot"; internal const string LibraryNameField = "library_name"; internal const string LibraryIdField = "library_id"; @@ -116,7 +117,7 @@ public sealed class LuceneSearchIndex : ISearchIndex return Task.FromResult(directoryExists && fileExists); } - public int Version => 47; + public int Version => 48; public async Task Initialize( ILocalFileSystem localFileSystem, @@ -473,8 +474,15 @@ public sealed class LuceneSearchIndex : ISearchIndex foreach (Tag tag in metadata.Tags) { - doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); - doc.Add(new StringField(TagFullField, tag.Name, Field.Store.NO)); + if (tag.ExternalTypeId == Tag.NfoCountryTypeId) + { + doc.Add(new TextField(CountryField, tag.Name, Field.Store.NO)); + } + else + { + doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); + doc.Add(new StringField(TagFullField, tag.Name, Field.Store.NO)); + } } foreach (Studio studio in metadata.Studios) @@ -1465,7 +1473,7 @@ public sealed class LuceneSearchIndex : ISearchIndex { foreach (Collection collection in collections) { - doc.Add(new StringField(CollectionField, collection.Name.ToLowerInvariant(), Field.Store.NO)); + doc.Add(new TextField(CollectionField, collection.Name.ToLowerInvariant(), Field.Store.NO)); } } diff --git a/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs b/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs index ffcfac95..1b7afd1d 100644 --- a/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs +++ b/ErsatzTV.Infrastructure/Search/Models/ElasticSearchItem.cs @@ -88,6 +88,9 @@ public class ElasticSearchItem : MinimalElasticSearchItem [JsonPropertyName(LuceneSearchIndex.TagFullField)] public List TagFull { get; set; } + [JsonPropertyName(LuceneSearchIndex.CountryField)] + public List Country { get; set; } + [JsonPropertyName(LuceneSearchIndex.StudioField)] public List Studio { get; set; } diff --git a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs index 56c41b39..3a4fdc3f 100644 --- a/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs +++ b/ErsatzTV.Infrastructure/Search/SearchQueryParser.cs @@ -19,6 +19,7 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach { using var defaultAnalyzer = new CustomAnalyzer(LuceneSearchIndex.AppLuceneVersion); using var keywordAnalyzer = new KeywordAnalyzer(); + using var lowercaseKeywordAnalyzer = new LowercaseKeywordAnalyzer(LuceneSearchIndex.AppLuceneVersion); var customAnalyzers = new Dictionary { // StringField should use KeywordAnalyzer @@ -38,7 +39,8 @@ public partial class SearchQueryParser(ISmartCollectionCache smartCollectionCach { LuceneSearchIndex.VideoCodecField, keywordAnalyzer }, { LuceneSearchIndex.VideoDynamicRangeField, keywordAnalyzer }, { LuceneSearchIndex.TagFullField, keywordAnalyzer }, - { LuceneSearchIndex.CollectionField, keywordAnalyzer }, + + { LuceneSearchIndex.CollectionField, lowercaseKeywordAnalyzer }, { LuceneSearchIndex.PlotField, new StandardAnalyzer(LuceneSearchIndex.AppLuceneVersion) } }; diff --git a/ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MovieNfoReaderTests.cs b/ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MovieNfoReaderTests.cs index 6342cb8c..4a41deda 100644 --- a/ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MovieNfoReaderTests.cs +++ b/ErsatzTV.Scanner.Tests/Core/Metadata/Nfo/MovieNfoReaderTests.cs @@ -191,6 +191,7 @@ https://www.themoviedb.org/movie/11-star-wars")); // nfo.Tagline.ShouldBeNullOrEmpty(); nfo.Genres.ShouldBeEquivalentTo(new List { "SuperHero" }); nfo.Tags.ShouldBeEquivalentTo(new List { "TV Recording" }); + nfo.Countries.ShouldBeEquivalentTo(new List { "USA" }); nfo.Studios.ShouldBeEquivalentTo(new List { "Warner Bros. Pictures" }); nfo.Actors.ShouldBeEquivalentTo( new List diff --git a/ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs b/ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs index 78fdc6f5..7133baf9 100644 --- a/ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs +++ b/ErsatzTV.Scanner/Core/Metadata/LocalMetadataProvider.cs @@ -1333,6 +1333,16 @@ public class LocalMetadataProvider : ILocalMetadataProvider releaseDate = premiered; } + var tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(); + foreach (string country in nfo.Countries) + { + tags.Add(new Tag + { + Name = country, + ExternalTypeId = Tag.NfoCountryTypeId + }); + } + return new MovieMetadata { MetadataKind = MetadataKind.Sidecar, @@ -1347,7 +1357,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider Outline = nfo.Outline, // Tagline = nfo.Tagline, Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(), - Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(), + Tags = tags, Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(), Actors = Actors(nfo.Actors, dateAdded, dateUpdated), Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(), diff --git a/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfo.cs b/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfo.cs index 70947e99..74f3afd1 100644 --- a/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfo.cs +++ b/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfo.cs @@ -2,17 +2,6 @@ public class MovieNfo { - public MovieNfo() - { - Genres = new List(); - Tags = new List(); - Studios = new List(); - Actors = new List(); - Writers = new List(); - Directors = new List(); - UniqueIds = new List(); - } - public string? Title { get; set; } public string? SortTitle { get; set; } public string? Outline { get; set; } @@ -23,11 +12,12 @@ public class MovieNfo public string? Plot { get; set; } // public string? Tagline { get; set; } - public List Genres { get; } - public List Tags { get; } - public List Studios { get; } - public List Actors { get; } - public List Writers { get; } - public List Directors { get; } - public List UniqueIds { get; } + public List Genres { get; } = []; + public List Tags { get; } = []; + public List Studios { get; } = []; + public List Actors { get; } = []; + public List Writers { get; } = []; + public List Directors { get; } = []; + public List UniqueIds { get; } = []; + public List Countries { get; } = []; } diff --git a/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfoReader.cs b/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfoReader.cs index ba9d3b5f..8da4ee50 100644 --- a/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfoReader.cs +++ b/ErsatzTV.Scanner/Core/Metadata/Nfo/MovieNfoReader.cs @@ -99,6 +99,13 @@ public class MovieNfoReader : NfoReader, IMovieNfoReader case "tag": await ReadStringContent(reader, nfo, (movie, tag) => movie.Tags.Add(tag), fileName); break; + case "country": + await ReadStringContent( + reader, + nfo, + (movie, country) => movie.Countries.Add(country), + fileName); + break; case "studio": await ReadStringContent( reader,