Browse Source

metadata improvements (#95)

* fix episode fallback metadata processing, fix show fallback metadata year parsing

* fix sort title for "a" and "an"

* add and index studio metadata

* minimize circular logging with search index errors

* update plex movie sort titles as needed

* properly escape search links

* force refreshing all movie/show metadata
pull/96/head v0.0.23-prealpha
Jason Dove 5 years ago committed by GitHub
parent
commit
4c70d61d48
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs
  2. 43
      ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs
  3. 3
      ErsatzTV.Application/Movies/Mapper.cs
  4. 3
      ErsatzTV.Application/Movies/MovieViewModel.cs
  5. 3
      ErsatzTV.Application/Television/Mapper.cs
  6. 3
      ErsatzTV.Application/Television/TelevisionShowViewModel.cs
  7. 3
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  8. 1
      ErsatzTV.Core/Domain/Metadata/Metadata.cs
  9. 8
      ErsatzTV.Core/Domain/Metadata/Studio.cs
  10. 2
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  11. 3
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  12. 2
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  13. 16
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  14. 102
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  15. 4
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  16. 16
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  17. 31
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  18. 22
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  19. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MovieMetadataConfiguration.cs
  20. 8
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ShowMetadataConfiguration.cs
  21. 11
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/StudioConfiguration.cs
  22. 8
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  23. 21
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  24. 4
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  25. 36
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  26. 1749
      ErsatzTV.Infrastructure/Migrations/20210321162257_Add_Studio.Designer.cs
  27. 75
      ErsatzTV.Infrastructure/Migrations/20210321162257_Add_Studio.cs
  28. 1749
      ErsatzTV.Infrastructure/Migrations/20210321182241_Reset_MetadataDateUpdated_Studio.Designer.cs
  29. 18
      ErsatzTV.Infrastructure/Migrations/20210321182241_Reset_MetadataDateUpdated_Studio.cs
  30. 67
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  31. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  32. 16
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  33. 15
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  34. 14
      ErsatzTV/Pages/Movie.razor
  35. 14
      ErsatzTV/Pages/TelevisionSeasonList.razor

8
ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs

@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
}

43
ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs

@ -1,43 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
{
private readonly ISearchRepository _searchRepository;
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
request.Query.Split(":").Head() switch
{
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
_ => TitleSearch(request.Query)
};
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> GenreSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
}
}

3
ErsatzTV.Application/Movies/Mapper.cs

@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Movies
Artwork(metadata, ArtworkKind.Poster), Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt), Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(), metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList()); metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList());
} }
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) => private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>

3
ErsatzTV.Application/Movies/MovieViewModel.cs

@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Movies
string Poster, string Poster,
string FanArt, string FanArt,
List<string> Genres, List<string> Genres,
List<string> Tags); List<string> Tags,
List<string> Studios);
} }

3
ErsatzTV.Application/Television/Mapper.cs

@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty), show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()), show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>())); show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone(new List<string>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) => internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new( new(

3
ErsatzTV.Application/Television/TelevisionShowViewModel.cs

@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Television
string Poster, string Poster,
string FanArt, string FanArt,
List<string> Genres, List<string> Genres,
List<string> Tags); List<string> Tags,
List<string> Studios);
} }

3
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -76,6 +76,9 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException(); throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException(); public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) => public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
throw new NotSupportedException(); throw new NotSupportedException();

1
ErsatzTV.Core/Domain/Metadata/Metadata.cs

@ -17,5 +17,6 @@ namespace ErsatzTV.Core.Domain
public List<Artwork> Artwork { get; set; } public List<Artwork> Artwork { get; set; }
public List<Genre> Genres { get; set; } public List<Genre> Genres { get; set; }
public List<Tag> Tags { get; set; } public List<Tag> Tags { get; set; }
public List<Studio> Studios { get; set; }
} }
} }

8
ErsatzTV.Core/Domain/Metadata/Studio.cs

@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Studio
{
public int Id { get; set; }
public string Name { get; set; }
}
}

2
ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs

@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface IMetadataRepository public interface IMetadataRepository
{ {
Task<bool> RemoveGenre(Genre genre); Task<bool> RemoveGenre(Genre genre);
Task<bool> RemoveTag(Tag tag);
Task<bool> RemoveStudio(Studio studio);
Task<bool> Update(Domain.Metadata metadata); Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata); Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion); Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);

3
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -18,6 +18,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath); Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path); Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MovieMetadata metadata, Genre genre); Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys); Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
} }
} }

2
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -40,6 +40,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item); Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item); Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
Task<bool> AddGenre(ShowMetadata metadata, Genre genre); Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
Task<bool> AddTag(ShowMetadata metadata, Tag tag);
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys); Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys); Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys); Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);

16
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -23,7 +23,7 @@ namespace ErsatzTV.Core.Metadata
string path = episode.MediaVersions.Head().MediaFiles.Head().Path; string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path); string fileName = Path.GetFileName(path);
var metadata = new EpisodeMetadata var metadata = new EpisodeMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path }; { MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0); return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
} }
@ -48,6 +48,16 @@ namespace ErsatzTV.Core.Metadata
return title.Substring(4); return title.Substring(4);
} }
if (title.StartsWith("a ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(2);
}
if (title.StartsWith("an ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(3);
}
if (title.StartsWith("Æ")) if (title.StartsWith("Æ"))
{ {
return title.Replace("Æ", "E"); return title.Replace("Æ", "E");
@ -65,6 +75,7 @@ namespace ErsatzTV.Core.Metadata
if (match.Success) if (match.Success)
{ {
metadata.Title = match.Groups[1].Value; metadata.Title = match.Groups[1].Value;
metadata.DateUpdated = DateTime.UtcNow;
return Tuple(metadata, int.Parse(match.Groups[3].Value)); return Tuple(metadata, int.Parse(match.Groups[3].Value));
} }
} }
@ -89,6 +100,8 @@ namespace ErsatzTV.Core.Metadata
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1); metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Genres = new List<Genre>(); metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>(); metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
} }
} }
catch (Exception) catch (Exception)
@ -110,6 +123,7 @@ namespace ErsatzTV.Core.Metadata
metadata.Title = match.Groups[1].Value; metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value); metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1); metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.DateUpdated = DateTime.UtcNow;
} }
} }
catch (Exception) catch (Exception)

102
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -23,16 +23,19 @@ namespace ErsatzTV.Core.Metadata
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider( public LocalMetadataProvider(
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger) ILogger<LocalMetadataProvider> logger)
{ {
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
@ -138,7 +141,7 @@ namespace ErsatzTV.Core.Metadata
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) => private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match( Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
existing => async existing =>
{ {
existing.Outline = metadata.Outline; existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot; existing.Plot = metadata.Plot;
@ -158,29 +161,49 @@ namespace ErsatzTV.Core.Metadata
.ToList()) .ToList())
{ {
existing.Genres.Remove(genre); existing.Genres.Remove(genre);
await _metadataRepository.RemoveGenre(genre);
} }
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)) foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{ {
existing.Genres.Add(genre); existing.Genres.Add(genre);
await _movieRepository.AddGenre(existing, genre);
} }
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name)) foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList()) .ToList())
{ {
existing.Tags.Remove(tag); existing.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
} }
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name)) foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList()) .ToList())
{ {
existing.Tags.Add(tag); existing.Tags.Add(tag);
await _movieRepository.AddTag(existing, tag);
} }
return _metadataRepository.Update(existing); foreach (Studio studio in existing.Studios
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
await _metadataRepository.RemoveStudio(studio);
}
foreach (Studio studio in metadata.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
await _movieRepository.AddStudio(existing, studio);
}
return await _metadataRepository.Update(existing);
}, },
() => async () =>
{ {
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
@ -188,12 +211,12 @@ namespace ErsatzTV.Core.Metadata
metadata.MovieId = movie.Id; metadata.MovieId = movie.Id;
movie.MovieMetadata = new List<MovieMetadata> { metadata }; movie.MovieMetadata = new List<MovieMetadata> { metadata };
return _metadataRepository.Add(metadata); return await _metadataRepository.Add(metadata);
}); });
private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) => private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match( Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
existing => async existing =>
{ {
existing.Outline = metadata.Outline; existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot; existing.Plot = metadata.Plot;
@ -213,29 +236,49 @@ namespace ErsatzTV.Core.Metadata
.ToList()) .ToList())
{ {
existing.Genres.Remove(genre); existing.Genres.Remove(genre);
await _metadataRepository.RemoveGenre(genre);
} }
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name)) foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{ {
existing.Genres.Add(genre); existing.Genres.Add(genre);
await _televisionRepository.AddGenre(existing, genre);
} }
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name)) foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList()) .ToList())
{ {
existing.Tags.Remove(tag); existing.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
} }
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name)) foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList()) .ToList())
{ {
existing.Tags.Add(tag); existing.Tags.Add(tag);
await _televisionRepository.AddTag(existing, tag);
} }
return _metadataRepository.Update(existing); foreach (Studio studio in existing.Studios
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
await _metadataRepository.RemoveStudio(studio);
}
foreach (Studio studio in metadata.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
await _televisionRepository.AddStudio(existing, studio);
}
return await _metadataRepository.Update(existing);
}, },
() => async () =>
{ {
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) ? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
@ -243,7 +286,7 @@ namespace ErsatzTV.Core.Metadata
metadata.ShowId = show.Id; metadata.ShowId = show.Id;
show.ShowMetadata = new List<ShowMetadata> { metadata }; show.ShowMetadata = new List<ShowMetadata> { metadata };
return _metadataRepository.Add(metadata); return await _metadataRepository.Add(metadata);
}); });
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName) private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
@ -294,10 +337,11 @@ namespace ErsatzTV.Core.Metadata
Plot = nfo.Plot, Plot = nfo.Plot,
Outline = nfo.Outline, Outline = nfo.Outline,
Tagline = nfo.Tagline, Tagline = nfo.Tagline,
Year = nfo.Year, Year = GetYear(nfo.Year, nfo.Premiered),
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1), ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(), Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList() Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
}, },
None); None);
} }
@ -322,7 +366,7 @@ namespace ErsatzTV.Core.Metadata
MetadataKind = MetadataKind.Sidecar, MetadataKind = MetadataKind.Sidecar,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName), DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title, Title = nfo.Title,
ReleaseDate = GetAired(nfo.Aired), ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot Plot = nfo.Plot
}; };
return Tuple(metadata, nfo.Episode); return Tuple(metadata, nfo.Episode);
@ -354,7 +398,8 @@ namespace ErsatzTV.Core.Metadata
Outline = nfo.Outline, Outline = nfo.Outline,
Tagline = nfo.Tagline, Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(), Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList() Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
}, },
None); None);
} }
@ -365,21 +410,38 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private static DateTime? GetAired(string aired) private static int? GetYear(int year, string premiered)
{ {
if (string.IsNullOrWhiteSpace(aired)) if (year > 1000)
{
return year;
}
if (string.IsNullOrWhiteSpace(premiered))
{ {
return null; return null;
} }
if (DateTime.TryParse(aired, out DateTime parsed)) if (DateTime.TryParse(premiered, out DateTime parsed))
{ {
return parsed; return parsed.Year;
} }
return null; return null;
} }
private static DateTime? GetAired(int year, string aired)
{
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null;
if (string.IsNullOrWhiteSpace(aired))
{
return fallback;
}
return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback;
}
[XmlRoot("movie")] [XmlRoot("movie")]
public class MovieNfo public class MovieNfo
{ {
@ -409,6 +471,9 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("tag")] [XmlElement("tag")]
public List<string> Tags { get; set; } public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
} }
[XmlRoot("tvshow")] [XmlRoot("tvshow")]
@ -437,6 +502,9 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("tag")] [XmlElement("tag")]
public List<string> Tags { get; set; } public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
} }
[XmlRoot("episodedetails")] [XmlRoot("episodedetails")]

4
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -159,7 +159,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }
@ -181,7 +181,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }

16
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -200,7 +200,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }
@ -225,7 +225,11 @@ namespace ErsatzTV.Core.Metadata
}, },
async () => async () =>
{ {
if (!Optional(episode.EpisodeMetadata).Flatten().Any()) bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
m => m.DateUpdated == DateTime.MinValue,
true);
if (shouldUpdate)
{ {
string path = episode.MediaVersions.Head().MediaFiles.Head().Path; string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path); _logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
@ -237,7 +241,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }
@ -260,7 +264,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }
@ -279,7 +283,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }
@ -298,7 +302,7 @@ namespace ErsatzTV.Core.Metadata
} }
catch (Exception ex) catch (Exception ex)
{ {
return BaseError.New(ex.Message); return BaseError.New(ex.ToString());
} }
} }

31
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -158,6 +158,37 @@ namespace ErsatzTV.Core.Plex
} }
} }
foreach (Studio studio in existingMetadata.Studios
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
if (incomingMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = incomingMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
}
// TODO: update other metadata? // TODO: update other metadata?
} }

22
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -131,6 +131,28 @@ namespace ErsatzTV.Core.Plex
result.IsUpdated = true; result.IsUpdated = true;
} }
} }
foreach (Studio studio in existingMetadata.Studios
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _televisionRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
} }
return result; return result;

4
ErsatzTV.Infrastructure/Data/Configurations/Metadata/MovieMetadataConfiguration.cs

@ -21,6 +21,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(mm => mm.Tags) builder.HasMany(mm => mm.Tags)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
} }
} }
} }

8
ErsatzTV.Infrastructure/Data/Configurations/Metadata/ShowMetadataConfiguration.cs

@ -14,11 +14,15 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Genres) builder.HasMany(sm => sm.Genres)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags) builder.HasMany(sm => sm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Studios)
.WithOne() .WithOne()
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
} }

11
ErsatzTV.Infrastructure/Data/Configurations/Metadata/StudioConfiguration.cs

@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class StudioConfiguration : IEntityTypeConfiguration<Studio>
{
public void Configure(EntityTypeBuilder<Studio> builder) => builder.ToTable("Studio");
}
}

8
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -112,5 +112,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<bool> RemoveGenre(Genre genre) => public Task<bool> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }) _dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
.Map(result => result > 0); .Map(result => result > 0);
public Task<bool> RemoveTag(Tag tag) =>
_dbConnection.ExecuteAsync("DELETE FROM Tag WHERE Id = @TagId", new { TagId = tag.Id })
.Map(result => result > 0);
public Task<bool> RemoveStudio(Studio studio) =>
_dbConnection.ExecuteAsync("DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id })
.Map(result => result > 0);
} }
} }

21
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -41,6 +41,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(m => m.Genres) .ThenInclude(m => m.Genres)
.Include(m => m.MovieMetadata) .Include(m => m.MovieMetadata)
.ThenInclude(m => m.Tags) .ThenInclude(m => m.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Studios)
.OrderBy(m => m.Id) .OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == movieId) .SingleOrDefaultAsync(m => m.Id == movieId)
.Map(Optional); .Map(Optional);
@ -56,6 +58,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags) .ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
@ -82,6 +86,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags) .ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata) .Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions) .Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles) .ThenInclude(mv => mv.MediaFiles)
@ -165,6 +171,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)", "INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0); new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddTag(MovieMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStudio(MovieMetadata metadata, Studio studio) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Studio (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys) public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
{ {
List<int> ids = await _dbConnection.QueryAsync<int>( List<int> ids = await _dbConnection.QueryAsync<int>(
@ -185,6 +201,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return ids; return ids;
} }
public Task<bool> UpdateSortTitle(MovieMetadata movieMetadata) =>
_dbConnection.ExecuteAsync(
@"UPDATE MovieMetadata SET SortTitle = @SortTitle WHERE Id = @Id",
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie( private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext, TvContext dbContext,
int libraryPathId, int libraryPathId,

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

@ -37,10 +37,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Movie).MovieMetadata) .Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Tags) .ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Studios)
.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)
.ThenInclude(mm => mm.Tags) .ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Studios)
.OrderBy(mi => mi.Id) .OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id) .SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional); .Map(Optional);

36
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -53,6 +53,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Genres) .ThenInclude(sm => sm.Genres)
.Include(s => s.ShowMetadata) .Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Tags) .ThenInclude(sm => sm.Tags)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.OrderBy(s => s.Id) .OrderBy(s => s.Id)
.SingleOrDefaultAsync() .SingleOrDefaultAsync()
.Map(Optional); .Map(Optional);
@ -354,11 +356,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<PlexShow> maybeExisting = await dbContext.PlexShows Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking() .AsNoTracking()
.Include(i => i.ShowMetadata) .Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(sm => sm.Genres)
.Include(i => i.ShowMetadata) .Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Tags) .ThenInclude(sm => sm.Tags)
.Include(i => i.ShowMetadata) .Include(i => i.ShowMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(sm => sm.Studios)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.LibraryPath) .Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library) .ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
@ -464,9 +468,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync( _dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)", "INSERT INTO Genre (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0); new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Studio (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
{ {
List<int> ids = await _dbConnection.QueryAsync<int>( List<int> ids = await _dbConnection.QueryAsync<int>(
@ -504,6 +518,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
SeasonMetadata = new List<SeasonMetadata> SeasonMetadata = new List<SeasonMetadata>
{ {
new() new()
{
DateAdded = DateTime.UtcNow
}
} }
}; };
await dbContext.Seasons.AddAsync(season); await dbContext.Seasons.AddAsync(season);
@ -524,6 +541,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
try try
{ {
if (dbContext.MediaFiles.Any(mf => mf.Path == path))
{
return BaseError.New("Multi-episode files are not yet supported");
}
var episode = new Episode var episode = new Episode
{ {
LibraryPathId = libraryPathId, LibraryPathId = libraryPathId,
@ -532,6 +554,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
new() new()
{ {
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue, DateUpdated = DateTime.MinValue,
MetadataKind = MetadataKind.Fallback MetadataKind = MetadataKind.Fallback
} }
@ -605,6 +628,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
try try
{ {
if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path))
{
return BaseError.New("Multi-episode files are not yet supported");
}
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
await dbContext.PlexEpisodes.AddAsync(item); await dbContext.PlexEpisodes.AddAsync(item);

1749
ErsatzTV.Infrastructure/Migrations/20210321162257_Add_Studio.Designer.cs generated

File diff suppressed because it is too large Load Diff

75
ErsatzTV.Infrastructure/Migrations/20210321162257_Add_Studio.cs

@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Studio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"Studio",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Studio", x => x.Id);
table.ForeignKey(
"FK_Studio_EpisodeMetadata_EpisodeMetadataId",
x => x.EpisodeMetadataId,
"EpisodeMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Studio_MovieMetadata_MovieMetadataId",
x => x.MovieMetadataId,
"MovieMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Studio_SeasonMetadata_SeasonMetadataId",
x => x.SeasonMetadataId,
"SeasonMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Studio_ShowMetadata_ShowMetadataId",
x => x.ShowMetadataId,
"ShowMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Studio_EpisodeMetadataId",
"Studio",
"EpisodeMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_MovieMetadataId",
"Studio",
"MovieMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_SeasonMetadataId",
"Studio",
"SeasonMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_ShowMetadataId",
"Studio",
"ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"Studio");
}
}

1749
ErsatzTV.Infrastructure/Migrations/20210321182241_Reset_MetadataDateUpdated_Studio.Designer.cs generated

File diff suppressed because it is too large Load Diff

18
ErsatzTV.Infrastructure/Migrations/20210321182241_Reset_MetadataDateUpdated_Studio.cs

@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MetadataDateUpdated_Studio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

67
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -845,6 +845,42 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ShowMetadata"); b.ToTable("ShowMetadata");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("Studio");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag", "ErsatzTV.Core.Domain.Tag",
b => b =>
@ -1499,6 +1535,29 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Show"); b.Navigation("Show");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Studios")
.HasForeignKey("EpisodeMetadataId");
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Studios")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
.HasForeignKey("SeasonMetadataId");
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Studios")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag", "ErsatzTV.Core.Domain.Tag",
b => b =>
@ -1744,6 +1803,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres"); b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -1765,6 +1826,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres"); b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -1794,6 +1857,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres"); b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags"); b.Navigation("Tags");
}); });
@ -1805,6 +1870,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres"); b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags"); b.Navigation("Tags");
}); });

1
ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public int AddedAt { get; set; } public int AddedAt { get; set; }
public int UpdatedAt { get; set; } public int UpdatedAt { get; set; }
public int Index { get; set; } public int Index { get; set; }
public string Studio { get; set; }
public List<PlexMediaResponse> Media { get; set; } public List<PlexMediaResponse> Media { get; set; }
public List<PlexGenreResponse> Genre { get; set; } public List<PlexGenreResponse> Genre { get; set; }
} }

16
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -177,9 +177,15 @@ namespace ErsatzTV.Infrastructure.Plex
DateAdded = dateAdded, DateAdded = dateAdded,
DateUpdated = lastWriteTime, DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>() Tags = new List<Tag>(),
Studios = new List<Studio>()
}; };
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{ {
metadata.ReleaseDate = releaseDate; metadata.ReleaseDate = releaseDate;
@ -279,9 +285,15 @@ namespace ErsatzTV.Infrastructure.Plex
DateAdded = dateAdded, DateAdded = dateAdded,
DateUpdated = lastWriteTime, DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(), Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>() Tags = new List<Tag>(),
Studios = new List<Studio>()
}; };
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate)) if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{ {
metadata.ReleaseDate = releaseDate; metadata.ReleaseDate = releaseDate;

15
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -38,6 +38,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string TitleAndYearField = "title_and_year"; private const string TitleAndYearField = "title_and_year";
private const string JumpLetterField = "jump_letter"; private const string JumpLetterField = "jump_letter";
private const string ReleaseDateField = "release_date"; private const string ReleaseDateField = "release_date";
private const string StudioField = "studio";
private const string MovieType = "movie"; private const string MovieType = "movie";
private const string ShowType = "show"; private const string ShowType = "show";
@ -48,7 +49,7 @@ namespace ErsatzTV.Infrastructure.Search
private readonly ILogger<SearchIndex> _logger; private readonly ILogger<SearchIndex> _logger;
private readonly ISearchRepository _searchRepository; private readonly ISearchRepository _searchRepository;
public SearchIndex( public SearchIndex(
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ISearchRepository searchRepository, ISearchRepository searchRepository,
@ -263,10 +264,16 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
} }
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc); writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
} }
catch (Exception ex) catch (Exception ex)
{ {
metadata.Movie = null;
_logger.LogWarning(ex, "Error indexing movie with metadata {@Metadata}", metadata); _logger.LogWarning(ex, "Error indexing movie with metadata {@Metadata}", metadata);
} }
} }
@ -316,10 +323,16 @@ namespace ErsatzTV.Infrastructure.Search
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO)); doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
} }
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc); writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
} }
catch (Exception ex) catch (Exception ex)
{ {
metadata.Show = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata); _logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
} }
} }

14
ErsatzTV/Pages/Movie.razor

@ -43,13 +43,23 @@
</div> </div>
</div> </div>
</div> </div>
@if (_movie.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _movie.Studios.OrderBy(s => s))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_movie.Genres.Any()) @if (_movie.Genres.Any())
{ {
<MudText GutterBottom="true">Genres</MudText> <MudText GutterBottom="true">Genres</MudText>
<div class="mb-2"> <div class="mb-2">
@foreach (string genre in _movie.Genres.OrderBy(g => g)) @foreach (string genre in _movie.Genres.OrderBy(g => g))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
} }
</div> </div>
} }
@ -59,7 +69,7 @@
<div> <div>
@foreach (string tag in _movie.Tags.OrderBy(t => t)) @foreach (string tag in _movie.Tags.OrderBy(t => t))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
} }
</div> </div>
} }

14
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -58,13 +58,23 @@
</div> </div>
</div> </div>
</div> </div>
@if (_show.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _show.Studios.OrderBy(g => g))
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
}
</div>
}
@if (_show.Genres.Any()) @if (_show.Genres.Any())
{ {
<MudText GutterBottom="true">Genres</MudText> <MudText GutterBottom="true">Genres</MudText>
<div class="mb-2"> <div class="mb-2">
@foreach (string genre in _show.Genres.OrderBy(g => g)) @foreach (string genre in _show.Genres.OrderBy(g => g))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{genre.ToLowerInvariant()}%22")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
} }
</div> </div>
} }
@ -74,7 +84,7 @@
<div> <div>
@foreach (string tag in _show.Tags.OrderBy(t => t)) @foreach (string tag in _show.Tags.OrderBy(t => t))
{ {
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{tag.ToLowerInvariant()}%22")"/> <MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
} }
</div> </div>
} }

Loading…
Cancel
Save