Browse Source

bug fixes and search tweaks (#781)

* fix movie nfo processing

* fix local movie fallback metadata

* use imagesharp again

* fix search edge case

* add show_genre and show_tag to search index
pull/782/head
Jason Dove 4 years ago committed by GitHub
parent
commit
e9be182bed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      CHANGELOG.md
  2. 2
      ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs
  3. 4
      ErsatzTV.Core/Interfaces/Images/IImageCache.cs
  4. 3
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  5. 65
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  6. 4
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  7. 18
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  8. 2
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  9. 41
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  10. 4
      ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs
  11. 4
      ErsatzTV.Infrastructure/Search/CustomQueryParser.cs
  12. 36
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  13. 4
      docs/user-guide/search.md

7
CHANGELOG.md

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- Fix processing local movie NFO metadata without a `year` value
- Fix processing local movie fallback metadata
- Fix search edge case where very recently added items (hours) would not be returned by relative date queries
### Added
- Add `show_genre` and `show_tag` to search index for seasons and episodes
## [0.5.5-beta] - 2022-05-03 ## [0.5.5-beta] - 2022-05-03
### Fixed ### Fixed

2
ErsatzTV.Core/FFmpeg/SongVideoGenerator.cs

@ -193,7 +193,7 @@ public class SongVideoGenerator : ISongVideoGenerator
{ {
string hash = hashes[NextRandom(hashes.Count)]; string hash = hashes[NextRandom(hashes.Count)];
backgroundPath = _imageCache.WriteBlurHash(hash, channel.FFmpegProfile.Resolution); backgroundPath = await _imageCache.WriteBlurHash(hash, channel.FFmpegProfile.Resolution);
videoVersion.Height = channel.FFmpegProfile.Resolution.Height; videoVersion.Height = channel.FFmpegProfile.Resolution.Height;
videoVersion.Width = channel.FFmpegProfile.Resolution.Width; videoVersion.Width = channel.FFmpegProfile.Resolution.Width;

4
ErsatzTV.Core/Interfaces/Images/IImageCache.cs

@ -8,6 +8,6 @@ public interface IImageCache
Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind); Task<Either<BaseError, string>> SaveArtworkToCache(Stream stream, ArtworkKind artworkKind);
Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind); Task<Either<BaseError, string>> CopyArtworkToCache(string path, ArtworkKind artworkKind);
string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight); string GetPathForImage(string fileName, ArtworkKind artworkKind, Option<int> maybeMaxHeight);
string CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y); Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y);
string WriteBlurHash(string blurHash, IDisplaySize targetSize); Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize);
} }

3
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -70,7 +70,8 @@ public class FallbackMetadataProvider : IFallbackMetadataProvider
Studios = new List<Studio>(), Studios = new List<Studio>(),
Actors = new List<Actor>(), Actors = new List<Actor>(),
Directors = new List<Director>(), Directors = new List<Director>(),
Writers = new List<Writer>() Writers = new List<Writer>(),
Guids = new List<MetadataGuid>()
}; };
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata; return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;

65
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -95,20 +95,23 @@ public abstract class LocalFolderScanner
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
Either<BaseError, bool> refreshResult = Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, mediaItem.Item); await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, mediaItem.Item);
refreshResult.Match(
result => foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
path,
error.Value);
}
foreach (bool result in refreshResult.RightToSeq())
{
if (result)
{ {
if (result) mediaItem.IsUpdated = true;
{ }
mediaItem.IsUpdated = true; }
}
},
error =>
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
path,
error.Value));
} }
return mediaItem; return mediaItem;
@ -200,9 +203,21 @@ public abstract class LocalFolderScanner
if (metadata is SongMetadata) if (metadata is SongMetadata)
{ {
artwork.BlurHash43 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 4, 3); artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
artwork.BlurHash54 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 5, 4); cacheName,
artwork.BlurHash64 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 6, 4); artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
} }
await _metadataRepository.UpdateArtworkPath(artwork); await _metadataRepository.UpdateArtworkPath(artwork);
@ -220,9 +235,21 @@ public abstract class LocalFolderScanner
if (metadata is SongMetadata) if (metadata is SongMetadata)
{ {
artwork.BlurHash43 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 4, 3); artwork.BlurHash43 = await _imageCache.CalculateBlurHash(
artwork.BlurHash54 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 5, 4); cacheName,
artwork.BlurHash64 = _imageCache.CalculateBlurHash(cacheName, artworkKind, 6, 4); artworkKind,
4,
3);
artwork.BlurHash54 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
5,
4);
artwork.BlurHash64 = await _imageCache.CalculateBlurHash(
cacheName,
artworkKind,
6,
4);
} }
metadata.Artwork.Add(artwork); metadata.Artwork.Add(artwork);

4
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -1019,7 +1019,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
year = nfo.Year; year = nfo.Year;
} }
DateTime releaseDate = new DateTimeOffset(year, 0, 0, 0, 0, 0, TimeSpan.Zero).UtcDateTime; DateTime releaseDate = year > 0
? new DateTimeOffset(year, 0, 0, 0, 0, 0, TimeSpan.Zero).UtcDateTime
: SystemTime.MinValueUtc;
foreach (DateTime premiered in nfo.Premiered) foreach (DateTime premiered in nfo.Premiered)
{ {

18
ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs

@ -52,6 +52,11 @@ public class SearchRepository : ISearchRepository
.Include(mi => (mi as Episode).Season) .Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(s => s.Genres)
.Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(s => s.Tags)
.Include(mi => (mi as Season).SeasonMetadata) .Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Genres) .ThenInclude(sm => sm.Genres)
.Include(mi => (mi as Season).SeasonMetadata) .Include(mi => (mi as Season).SeasonMetadata)
@ -62,6 +67,10 @@ public class SearchRepository : ISearchRepository
.ThenInclude(sm => sm.Actors) .ThenInclude(sm => sm.Actors)
.Include(mi => (mi as Season).Show) .Include(mi => (mi as Season).Show)
.ThenInclude(sm => sm.ShowMetadata) .ThenInclude(sm => sm.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(mi => (mi as Season).Show)
.ThenInclude(sm => sm.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
@ -187,6 +196,11 @@ public class SearchRepository : ISearchRepository
.Include(mi => (mi as Episode).Season) .Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show) .ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(s => s.Genres)
.Include(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(s => s.Tags)
.Include(mi => (mi as Season).SeasonMetadata) .Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Genres) .ThenInclude(sm => sm.Genres)
.Include(mi => (mi as Season).SeasonMetadata) .Include(mi => (mi as Season).SeasonMetadata)
@ -197,6 +211,10 @@ public class SearchRepository : ISearchRepository
.ThenInclude(sm => sm.Actors) .ThenInclude(sm => sm.Actors)
.Include(mi => (mi as Season).Show) .Include(mi => (mi as Season).Show)
.ThenInclude(sm => sm.ShowMetadata) .ThenInclude(sm => sm.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(mi => (mi as Season).Show)
.ThenInclude(sm => sm.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)

2
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -8,7 +8,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Blurhash.System.Drawing.Common" Version="2.2.0" /> <PackageReference Include="Blurhash.ImageSharp" Version="3.0.0" />
<PackageReference Include="CliWrap" Version="3.4.4" /> <PackageReference Include="CliWrap" Version="3.4.4" />
<PackageReference Include="Dapper" Version="2.0.123" /> <PackageReference Include="Dapper" Version="2.0.123" />
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" /> <PackageReference Include="Lucene.Net" Version="4.8.0-beta00016" />

41
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -1,15 +1,15 @@
using System.Drawing; using System.Security.Cryptography;
using System.Drawing.Imaging;
using System.Security.Cryptography;
using System.Text; using System.Text;
using Blurhash.ImageSharp;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg; using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using Decoder = System.Drawing.Common.Blurhash.Decoder; using SixLabors.ImageSharp;
using Encoder = System.Drawing.Common.Blurhash.Encoder; using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace ErsatzTV.Infrastructure.Images; namespace ErsatzTV.Infrastructure.Images;
@ -116,18 +116,31 @@ public class ImageCache : IImageCache
return Path.Combine(baseFolder, fileName); return Path.Combine(baseFolder, fileName);
} }
public string CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y) public async Task<string> CalculateBlurHash(string fileName, ArtworkKind artworkKind, int x, int y)
{ {
var encoder = new Encoder();
string targetFile = GetPathForImage(fileName, artworkKind, Option<int>.None); string targetFile = GetPathForImage(fileName, artworkKind, Option<int>.None);
// ReSharper disable once ConvertToUsingDeclaration // ReSharper disable once ConvertToUsingDeclaration
using (var image = Image.FromFile(targetFile)) using (FileStream fs = File.OpenRead(targetFile))
{ {
return encoder.Encode(image, x, y); using (var image = await Image.LoadAsync<Rgba32>(fs))
{
// resize before calculating blur hash; it doesn't need giant images
if (image.Height > 200)
{
image.Mutate(i => i.Resize(0, 200));
}
else if (image.Width > 200)
{
image.Mutate(i => i.Resize(200, 0));
}
return Blurhasher.Encode(image, x, y);
}
} }
} }
public string WriteBlurHash(string blurHash, IDisplaySize targetSize) public async Task<string> WriteBlurHash(string blurHash, IDisplaySize targetSize)
{ {
byte[] bytes = Encoding.UTF8.GetBytes(blurHash); byte[] bytes = Encoding.UTF8.GetBytes(blurHash);
string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", ""); string base64 = Convert.ToBase64String(bytes).Replace("+", "_").Replace("/", "-").Replace("=", "");
@ -137,11 +150,13 @@ public class ImageCache : IImageCache
string folder = Path.GetDirectoryName(targetFile); string folder = Path.GetDirectoryName(targetFile);
_localFileSystem.EnsureFolderExists(folder); _localFileSystem.EnsureFolderExists(folder);
var decoder = new Decoder();
// ReSharper disable once ConvertToUsingDeclaration // ReSharper disable once ConvertToUsingDeclaration
using (Image image = decoder.Decode(blurHash, targetSize.Width, targetSize.Height)) using (FileStream fs = File.OpenWrite(targetFile))
{ {
image.Save(targetFile, ImageFormat.Png); using (Image<Rgb24> image = Blurhasher.Decode(blurHash, targetSize.Width, targetSize.Height))
{
await image.SaveAsPngAsync(fs);
}
} }
} }

4
ErsatzTV.Infrastructure/Search/CustomMultiFieldQueryParser.cs

@ -46,7 +46,7 @@ public class CustomMultiFieldQueryParser : MultiFieldQueryParser
{ {
if (field == "released_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime start)) if (field == "released_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime start))
{ {
var todayString = DateTime.Today.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd"); var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", dateString, todayString, true, true); return base.GetRangeQuery("release_date", dateString, todayString, true, true);
@ -61,7 +61,7 @@ public class CustomMultiFieldQueryParser : MultiFieldQueryParser
if (field == "added_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedStart)) if (field == "added_inthelast" && CustomQueryParser.ParseStart(queryText, out DateTime addedStart))
{ {
var todayString = DateTime.Today.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = addedStart.ToString("yyyyMMdd"); var dateString = addedStart.ToString("yyyyMMdd");
return base.GetRangeQuery("added_date", dateString, todayString, true, true); return base.GetRangeQuery("added_date", dateString, todayString, true, true);

4
ErsatzTV.Infrastructure/Search/CustomQueryParser.cs

@ -53,7 +53,7 @@ public class CustomQueryParser : QueryParser
{ {
if (field == "released_inthelast" && ParseStart(queryText, out DateTime start)) if (field == "released_inthelast" && ParseStart(queryText, out DateTime start))
{ {
var todayString = DateTime.Today.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = start.ToString("yyyyMMdd"); var dateString = start.ToString("yyyyMMdd");
return base.GetRangeQuery("release_date", dateString, todayString, true, true); return base.GetRangeQuery("release_date", dateString, todayString, true, true);
@ -68,7 +68,7 @@ public class CustomQueryParser : QueryParser
if (field == "added_inthelast" && ParseStart(queryText, out DateTime addedStart)) if (field == "added_inthelast" && ParseStart(queryText, out DateTime addedStart))
{ {
var todayString = DateTime.Today.ToString("yyyyMMdd"); var todayString = DateTime.UtcNow.ToString("yyyyMMdd");
var dateString = addedStart.ToString("yyyyMMdd"); var dateString = addedStart.ToString("yyyyMMdd");
return base.GetRangeQuery("added_date", dateString, todayString, true, true); return base.GetRangeQuery("added_date", dateString, todayString, true, true);

36
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -54,6 +54,8 @@ public sealed class SearchIndex : ISearchIndex
private const string StateField = "state"; private const string StateField = "state";
private const string AlbumArtistField = "album_artist"; private const string AlbumArtistField = "album_artist";
private const string ShowTitleField = "show_title"; private const string ShowTitleField = "show_title";
private const string ShowGenreField = "show_genre";
private const string ShowTagField = "show_tag";
internal const string MinutesField = "minutes"; internal const string MinutesField = "minutes";
internal const string HeightField = "height"; internal const string HeightField = "height";
@ -85,7 +87,7 @@ public sealed class SearchIndex : ISearchIndex
_initialized = false; _initialized = false;
} }
public int Version => 23; public int Version => 24;
public async Task<bool> Initialize( public async Task<bool> Initialize(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
@ -585,9 +587,20 @@ public sealed class SearchIndex : ISearchIndex
new StringField(JumpLetterField, GetJumpLetter(showMetadata), Field.Store.YES), new StringField(JumpLetterField, GetJumpLetter(showMetadata), Field.Store.YES),
new StringField(StateField, season.State.ToString(), Field.Store.NO), new StringField(StateField, season.State.ToString(), Field.Store.NO),
new Int32Field(SeasonNumberField, season.SeasonNumber, Field.Store.NO), new Int32Field(SeasonNumberField, season.SeasonNumber, Field.Store.NO),
new TextField(ShowTitleField, season.Show?.ShowMetadata.Head().Title, Field.Store.NO) new TextField(ShowTitleField, showMetadata.Title, Field.Store.NO)
}; };
// add some show fields to help filter shows within a particular show
foreach (Genre genre in showMetadata.Genres)
{
doc.Add(new TextField(ShowGenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in showMetadata.Tags)
{
doc.Add(new TextField(ShowTagField, tag.Name, Field.Store.NO));
}
List<string> languages = await searchRepository.GetLanguagesForSeason(season); List<string> languages = await searchRepository.GetLanguagesForSeason(season);
await AddLanguages(searchRepository, doc, languages); await AddLanguages(searchRepository, doc, languages);
@ -797,10 +810,25 @@ public sealed class SearchIndex : ISearchIndex
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES), new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES),
new StringField(StateField, episode.State.ToString(), Field.Store.NO), new StringField(StateField, episode.State.ToString(), Field.Store.NO),
new Int32Field(SeasonNumberField, episode.Season?.SeasonNumber ?? 0, Field.Store.NO), new Int32Field(SeasonNumberField, episode.Season?.SeasonNumber ?? 0, Field.Store.NO),
new Int32Field(EpisodeNumberField, metadata.EpisodeNumber, Field.Store.NO), new Int32Field(EpisodeNumberField, metadata.EpisodeNumber, Field.Store.NO)
new TextField(ShowTitleField, episode.Season?.Show?.ShowMetadata.Head().Title, Field.Store.NO)
}; };
// add some show fields to help filter episodes within a particular show
foreach (ShowMetadata showMetadata in Optional(episode.Season?.Show?.ShowMetadata).Flatten())
{
doc.Add(new TextField(ShowTitleField, showMetadata.Title, Field.Store.NO));
foreach (Genre genre in showMetadata.Genres)
{
doc.Add(new TextField(ShowGenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in showMetadata.Tags)
{
doc.Add(new TextField(ShowTagField, tag.Name, Field.Store.NO));
}
}
if (!string.IsNullOrWhiteSpace(metadata.Title)) if (!string.IsNullOrWhiteSpace(metadata.Title))
{ {
doc.Add(new TextField(TitleField, metadata.Title, Field.Store.NO)); doc.Add(new TextField(TitleField, metadata.Title, Field.Store.NO));

4
docs/user-guide/search.md

@ -55,6 +55,8 @@ The following fields are available for searching seasons:
- `library_name`: The name of the library that contains the season - `library_name`: The name of the library that contains the season
- `season_number`: The season number - `season_number`: The season number
- `show_title`: The title of the show that contains the season - `show_title`: The title of the show that contains the season
- `show_genre`: The genre of the show that contains the season
- `show_tag`: The tag of the show that contains the season
- `type`: Always `season` - `type`: Always `season`
### Episodes ### Episodes
@ -75,6 +77,8 @@ The following fields are available for searching episodes:
- `season_number`: The episode season number - `season_number`: The episode season number
- `episode_number`: The episode number - `episode_number`: The episode number
- `show_title`: The title of the show that contains the episode - `show_title`: The title of the show that contains the episode
- `show_genre`: The genre of the show that contains the episode
- `show_tag`: The tag of the show that contains the episode
- `type`: Always `episode` - `type`: Always `episode`
### Artists ### Artists

Loading…
Cancel
Save