Browse Source

support movie nfo metadata in other video libraries (#788)

* add other video nfo metadata

* update docs
pull/789/head
Jason Dove 4 years ago committed by GitHub
parent
commit
1431b33a98
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  3. 247
      ErsatzTV.Core.Tests/Metadata/Nfo/OtherVideoNfoReaderTests.cs
  4. 6
      ErsatzTV.Core/Domain/Metadata/OtherVideoMetadata.cs
  5. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  6. 8
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IOtherVideoNfoReader.cs
  7. 7
      ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs
  8. 286
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  9. 52
      ErsatzTV.Core/Metadata/Nfo/OtherVideoNfo.cs
  10. 114
      ErsatzTV.Core/Metadata/Nfo/OtherVideoNfoReader.cs
  11. 34
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  12. 28
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/OtherVideoMetadataConfiguration.cs
  13. 4
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  14. 78
      ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs
  15. 28
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  16. 4279
      ErsatzTV.Infrastructure/Migrations/20220506005328_Expand_OtherVideoMetadata.Designer.cs
  17. 209
      ErsatzTV.Infrastructure/Migrations/20220506005328_Expand_OtherVideoMetadata.cs
  18. 48
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  19. 48
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  20. 1
      ErsatzTV/Startup.cs
  21. 2
      README.md
  22. 27
      docs/user-guide/local-libraries.md
  23. 22
      docs/user-guide/search.md

3
CHANGELOG.md

@ -13,10 +13,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -13,10 +13,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix occasional erroneous log messages when HLS channel playback times out because all clients have left
- Fix fallback filler playback
- Fix stream continuity when error messages are displayed
- Fix duplicate scanning within other video libraries (i.e. folders would be scanned multiple times)
### Added
- Add `show_genre` and `show_tag` to search index for seasons and episodes
- Use `aired` value to source release date from music video nfo metadata
- Add NFO metadata support to `Other Video` libraries
- `Other Video` NFO metadata must be in the movie NFO metadata format
## [0.5.5-beta] - 2022-05-03
### Fixed

3
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.Locking; @@ -5,6 +5,7 @@ using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using Humanizer;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.MediaSources;
@ -147,7 +148,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei @@ -147,7 +148,7 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
sw.Elapsed.Humanize());
}
else
{

247
ErsatzTV.Core.Tests/Metadata/Nfo/OtherVideoNfoReaderTests.cs

@ -0,0 +1,247 @@ @@ -0,0 +1,247 @@
using System.Text;
using Bugsnag;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using Moq;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo;
[TestFixture]
public class OtherVideoNfoReaderTests
{
[SetUp]
public void SetUp() => _otherVideoNfoReader = new OtherVideoNfoReader(new Mock<IClient>().Object);
private OtherVideoNfoReader _otherVideoNfoReader;
[Test]
public async Task ParsingNfo_Should_Return_Error()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsLeft.Should().BeTrue();
}
[Test]
public async Task MetadataNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie></movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task CombinationNfo_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<movie></movie>
https://www.themoviedb.org/movie/11-star-wars"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
}
[Test]
public async Task FullSample_Should_Return_Nfo()
{
await using var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes"" ?>
<movie>
<title>Zack Snyder&apos;s Justice League</title>
<originaltitle>Zack Snyder&apos;s Justice League</originaltitle>
<sorttitle>Justice League 2</sorttitle>
<ratings>
<rating name=""imdb"" max=""10"" default=""true"">
<value>8.300000</value>
<votes>197786</votes>
</rating>
<rating name=""themoviedb"" max=""10"">
<value>8.700000</value>
<votes>3461</votes>
</rating>
<rating name=""trakt"" max=""10"">
<value>8.195670</value>
<votes>4247</votes>
</rating>
</ratings>
<userrating>0</userrating>
<top250>140</top250>
<outline></outline>
<plot>Determined to ensure Superman&apos;s ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.</plot>
<tagline></tagline>
<runtime>242</runtime>
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdb873f474.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""poster"" preview="""">https://image.tmdb.org/t/p/original/tnAuB8q5vv7Ax9UAEje5Xi4BXik.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviethumb/zack-snyders-justice-league-6050310135cf6.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""landscape"" preview="""">https://image.tmdb.org/t/p/original/wcYBuOZDP6Vi8Ye4qax3Zx9dCan.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""keyart"" preview="""">https://assets.fanart.tv/fanart/movies/791373/movieposter/zack-snyders-justice-league-603fdba9bdd16.jpg</thumb>
<thumb spoof="""" cache="""" aspect=""clearlogo"" preview="""">https://assets.fanart.tv/fanart/movies/791373/hdmovielogo/zack-snyders-justice-league-5ed3f2e4952e9.png</thumb>
<thumb spoof="""" cache="""" aspect=""banner"" preview="""">https://assets.fanart.tv/fanart/movies/791373/moviebanner/zack-snyders-justice-league-6050049514d4c.jpg</thumb>
<fanart>
<thumb colors="""" preview=""https://assets.fanart.tv/preview/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg"">https://assets.fanart.tv/fanart/movies/791373/moviebackground/zack-snyders-justice-league-5fee5b9fe0e0d.jpg</thumb>
<thumb colors="""" preview=""https://image.tmdb.org/t/p/w780/43NwryODVEsbBDC0jK3wYfVyb5q.jpg"">https://image.tmdb.org/t/p/original/43NwryODVEsbBDC0jK3wYfVyb5q.jpg</thumb>
</fanart>
<mpaa>Australia:M</mpaa>
<playcount>0</playcount>
<lastplayed></lastplayed>
<id>791373</id>
<uniqueid type=""imdb"">tt12361974</uniqueid>
<uniqueid type=""tmdb"" default=""true"">791373</uniqueid>
<genre>SuperHero</genre>
<tag>TV Recording</tag>
<set>
<name>Justice League Collection</name>
<overview>Based on the DC Comics superhero team</overview>
</set>
<country>USA</country>
<credits>Chris Terrio</credits>
<director>Zack Snyder</director>
<premiered>2021-03-18</premiered>
<year>2021</year>
<status></status>
<code></code>
<aired></aired>
<studio>Warner Bros. Pictures</studio>
<trailer></trailer>
<fileinfo>
<streamdetails>
<video>
<codec>hevc</codec>
<aspect>1.777778</aspect>
<width>1920</width>
<height>1080</height>
<durationinseconds>14528</durationinseconds>
<stereomode></stereomode>
</video>
<audio>
<codec>ac3</codec>
<language>eng</language>
<channels>6</channels>
</audio>
<audio>
<codec>ac3</codec>
<language>fre</language>
<channels>6</channels>
</audio>
<subtitle>
<language>eng</language>
</subtitle>
</streamdetails>
</fileinfo>
<actor>
<name>Ben Affleck</name>
<role>Bruce Wayne / Batman</role>
<order>0</order>
<thumb>https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg</thumb>
</actor>
<actor>
<name>Henry Cavill</name>
<role>Clark Kent / Superman / Kal-El</role>
<order>1</order>
<thumb>https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg</thumb>
</actor>
<actor>
<name>Gal Gadot</name>
<role>Diana Prince / Wonder Woman</role>
<order>2</order>
<thumb>https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg</thumb>
</actor>
<resume>
<position>0.000000</position>
<total>0.000000</total>
</resume>
<dateadded>2021-03-26 11:35:50</dateadded>
</movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (OtherVideoNfo nfo in result.RightToSeq())
{
nfo.Title.Should().Be("Zack Snyder's Justice League");
nfo.SortTitle.Should().Be("Justice League 2");
nfo.Outline.Should().BeNullOrEmpty();
nfo.Year.Should().Be(2021);
nfo.ContentRating.Should().Be("Australia:M");
nfo.Premiered.IsSome.Should().BeTrue();
foreach (DateTime premiered in nfo.Premiered)
{
premiered.Should().Be(new DateTime(2021, 03, 18));
}
nfo.Plot.Should().Be(
"Determined to ensure Superman's ultimate sacrifice was not in vain, Bruce Wayne aligns forces with Diana Prince with plans to recruit a team of metahumans to protect the world from an approaching threat of catastrophic proportions.");
nfo.Tagline.Should().BeNullOrEmpty();
nfo.Genres.Should().BeEquivalentTo(new List<string> { "SuperHero" });
nfo.Tags.Should().BeEquivalentTo(new List<string> { "TV Recording" });
nfo.Studios.Should().BeEquivalentTo(new List<string> { "Warner Bros. Pictures" });
nfo.Actors.Should().BeEquivalentTo(
new List<ActorNfo>
{
new()
{
Name = "Ben Affleck", Order = 0, Role = "Bruce Wayne / Batman",
Thumb = "https://image.tmdb.org/t/p/original/u525jeDOzg9hVdvYfeehTGnw7Aa.jpg"
},
new()
{
Name = "Henry Cavill", Order = 1, Role = "Clark Kent / Superman / Kal-El",
Thumb = "https://image.tmdb.org/t/p/original/hErUwonrQgY5Y7RfxOfv8Fq11MB.jpg"
},
new()
{
Name = "Gal Gadot", Order = 2, Role = "Diana Prince / Wonder Woman",
Thumb = "https://image.tmdb.org/t/p/original/fysvehTvU6bE3JgxaOTRfvQJzJ4.jpg"
}
});
nfo.Writers.Should().BeEquivalentTo(new List<string> { "Chris Terrio" });
nfo.Directors.Should().BeEquivalentTo(new List<string> { "Zack Snyder" });
nfo.UniqueIds.Should().BeEquivalentTo(
new List<UniqueIdNfo>
{
new() { Type = "imdb", Guid = "tt12361974", Default = false },
new() { Type = "tmdb", Guid = "791373", Default = true }
});
}
}
[Test]
public async Task MetadataNfo_With_Tag_Should_Return_Nfo()
{
await using var stream = new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><tag>Test Tag</tag></movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (OtherVideoNfo nfo in result.RightToSeq())
{
nfo.Tags.Should().BeEquivalentTo(new List<string> { "Test Tag" });
}
}
[Test]
public async Task MetadataNfo_With_Outline_Should_Return_Nfo()
{
await using var stream =
new MemoryStream(Encoding.UTF8.GetBytes(@"<movie><outline>Test Outline</outline></movie>"));
Either<BaseError, OtherVideoNfo> result = await _otherVideoNfoReader.Read(stream);
result.IsRight.Should().BeTrue();
foreach (OtherVideoNfo nfo in result.RightToSeq())
{
nfo.Outline.Should().Be("Test Outline");
}
}
}

6
ErsatzTV.Core/Domain/Metadata/OtherVideoMetadata.cs

@ -2,6 +2,12 @@ @@ -2,6 +2,12 @@
public class OtherVideoMetadata : Metadata
{
public string ContentRating { get; set; }
public string Outline { get; set; }
public string Plot { get; set; }
public string Tagline { get; set; }
public int OtherVideoId { get; set; }
public OtherVideo OtherVideo { get; set; }
public List<Director> Directors { get; set; }
public List<Writer> Writers { get; set; }
}

1
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -11,6 +11,7 @@ public interface ILocalMetadataProvider @@ -11,6 +11,7 @@ public interface ILocalMetadataProvider
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName);
Task<bool> RefreshTagMetadata(Song song, string ffprobePath);
Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode);

8
ErsatzTV.Core/Interfaces/Metadata/Nfo/IOtherVideoNfoReader.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo;
public interface IOtherVideoNfoReader
{
Task<Either<BaseError, OtherVideoNfo>> Read(Stream input);
}

7
ErsatzTV.Core/Interfaces/Repositories/IOtherVideoRepository.cs

@ -8,9 +8,12 @@ public interface IOtherVideoRepository @@ -8,9 +8,12 @@ public interface IOtherVideoRepository
Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<IEnumerable<string>> FindOtherVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(OtherVideoMetadata metadata, Genre genre);
Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag);
Task<bool> AddStudio(OtherVideoMetadata metadata, Studio studio);
Task<bool> AddActor(OtherVideoMetadata metadata, Actor actor);
Task<bool> AddDirector(OtherVideoMetadata metadata, Director director);
Task<bool> AddWriter(OtherVideoMetadata metadata, Writer writer);
Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids);
// Task<int> GetOtherVideoCount(int artistId);
// Task<List<OtherVideoMetadata>> GetPagedOtherVideos(int artistId, int pageNumber, int pageSize);
}

286
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -24,6 +24,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -24,6 +24,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoNfoReader _musicVideoNfoReader;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IOtherVideoNfoReader _otherVideoNfoReader;
private readonly IOtherVideoRepository _otherVideoRepository;
private readonly ISongRepository _songRepository;
private readonly ITelevisionRepository _televisionRepository;
@ -44,6 +45,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -44,6 +45,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
IArtistNfoReader artistNfoReader,
IMusicVideoNfoReader musicVideoNfoReader,
ITvShowNfoReader tvShowNfoReader,
IOtherVideoNfoReader otherVideoNfoReader,
ILocalStatisticsProvider localStatisticsProvider,
IClient client,
ILogger<LocalMetadataProvider> logger)
@ -62,6 +64,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -62,6 +64,7 @@ public class LocalMetadataProvider : ILocalMetadataProvider
_artistNfoReader = artistNfoReader;
_musicVideoNfoReader = musicVideoNfoReader;
_tvShowNfoReader = tvShowNfoReader;
_otherVideoNfoReader = otherVideoNfoReader;
_localStatisticsProvider = localStatisticsProvider;
_client = client;
_logger = logger;
@ -107,38 +110,77 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -107,38 +110,77 @@ public class LocalMetadataProvider : ILocalMetadataProvider
return fallbackMetadata;
}
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
LoadMovieMetadata(movie, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(movie, metadata),
() => Task.FromResult(false)));
public async Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName)
{
Option<MovieMetadata> maybeMetadata = await LoadMovieMetadata(movie, nfoFileName);
foreach (MovieMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(movie, metadata);
}
return false;
}
public async Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName)
{
Option<ShowMetadata> maybeMetadata = await LoadTelevisionShowMetadata(nfoFileName);
foreach (ShowMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(televisionShow, metadata);
}
return false;
}
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName) =>
LoadTelevisionShowMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(televisionShow, metadata),
() => Task.FromResult(false)));
public async Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName)
{
List<EpisodeMetadata> metadata = await LoadEpisodeMetadata(episode, nfoFileName);
return await ApplyMetadataUpdate(episode, metadata);
}
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
public async Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName)
{
Option<ArtistMetadata> maybeMetadata = await LoadArtistMetadata(nfoFileName);
foreach (ArtistMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(artist, metadata);
}
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(artist, metadata),
() => Task.FromResult(false)));
return false;
}
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
LoadMusicVideoMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => RefreshFallbackMetadata(musicVideo)));
public async Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName)
{
Option<MusicVideoMetadata> maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
foreach (MusicVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(musicVideo, metadata);
}
public Task<bool> RefreshTagMetadata(Song song, string ffprobePath) =>
LoadSongMetadata(song, ffprobePath).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(song, metadata),
() => RefreshFallbackMetadata(song)));
return await RefreshFallbackMetadata(musicVideo);
}
public async Task<bool> RefreshSidecarMetadata(OtherVideo otherVideo, string nfoFileName)
{
Option<OtherVideoMetadata> maybeMetadata = await LoadOtherVideoMetadata(nfoFileName);
foreach (OtherVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(otherVideo, metadata);
}
return await RefreshFallbackMetadata(otherVideo);
}
public async Task<bool> RefreshTagMetadata(Song song, string ffprobePath)
{
Option<SongMetadata> maybeMetadata = await LoadSongMetadata(song, ffprobePath);
foreach (SongMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(song, metadata);
}
return await RefreshFallbackMetadata(song);
}
public Task<bool> RefreshFallbackMetadata(Movie movie) =>
ApplyMetadataUpdate(movie, _fallbackMetadataProvider.GetFallbackMetadata(movie));
@ -149,20 +191,38 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -149,20 +191,38 @@ public class LocalMetadataProvider : ILocalMetadataProvider
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
public Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(otherVideo).Match(
metadata => ApplyMetadataUpdate(otherVideo, metadata),
() => Task.FromResult(false));
public async Task<bool> RefreshFallbackMetadata(OtherVideo otherVideo)
{
Option<OtherVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(otherVideo);
foreach (OtherVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(otherVideo, metadata);
}
return false;
}
public Task<bool> RefreshFallbackMetadata(Song song) =>
_fallbackMetadataProvider.GetFallbackMetadata(song).Match(
metadata => ApplyMetadataUpdate(song, metadata),
() => Task.FromResult(false));
public async Task<bool> RefreshFallbackMetadata(Song song)
{
Option<SongMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(song);
foreach (SongMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(song, metadata);
}
return false;
}
public async Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = _fallbackMetadataProvider.GetFallbackMetadata(musicVideo);
foreach (MusicVideoMetadata metadata in maybeMetadata)
{
return await ApplyMetadataUpdate(musicVideo, metadata);
}
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false));
return false;
}
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
@ -767,6 +827,10 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -767,6 +827,10 @@ public class LocalMetadataProvider : ILocalMetadataProvider
Option<OtherVideoMetadata> maybeMetadata = Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone();
foreach (OtherVideoMetadata existing in maybeMetadata)
{
existing.ContentRating = metadata.ContentRating;
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
if (existing.DateAdded == SystemTime.MinValueUtc)
@ -776,6 +840,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -776,6 +840,9 @@ public class LocalMetadataProvider : ILocalMetadataProvider
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
@ -784,10 +851,70 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -784,10 +851,70 @@ public class LocalMetadataProvider : ILocalMetadataProvider
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
_otherVideoRepository.AddGenre,
_otherVideoRepository.AddTag,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false));
_otherVideoRepository.AddStudio,
_otherVideoRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _otherVideoRepository.AddDirector(existing, director))
{
updated = true;
}
}
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _otherVideoRepository.AddWriter(existing, writer))
{
updated = true;
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
}
@ -1068,6 +1195,81 @@ public class LocalMetadataProvider : ILocalMetadataProvider @@ -1068,6 +1195,81 @@ public class LocalMetadataProvider : ILocalMetadataProvider
}
}
private async Task<Option<OtherVideoMetadata>> LoadOtherVideoMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Either<BaseError, OtherVideoNfo> maybeNfo = await _otherVideoNfoReader.Read(fileStream);
foreach (BaseError error in maybeNfo.LeftToSeq())
{
_logger.LogInformation(
"Failed to read OtherVideo nfo metadata from {Path}: {Error}",
nfoFileName,
error.ToString());
return None;
}
foreach (OtherVideoNfo nfo in maybeNfo.RightToSeq())
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var year = 0;
if (nfo.Year > 1000)
{
year = nfo.Year;
}
DateTime releaseDate = year > 0
? new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero).UtcDateTime
: SystemTime.MinValueUtc;
foreach (DateTime premiered in nfo.Premiered)
{
if (year == 0)
{
year = premiered.Year;
}
releaseDate = premiered;
}
return new OtherVideoMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
SortTitle = nfo.SortTitle,
Year = year,
ContentRating = nfo.ContentRating,
ReleaseDate = releaseDate,
Plot = nfo.Plot,
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(),
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(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList()
};
}
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read OtherVideo nfo metadata from {Path}", nfoFileName);
_client.Notify(ex);
}
return None;
}
private static int? GetYear(int? year, Option<DateTime> premiered)
{
if (year is > 1000)

52
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfo.cs

@ -0,0 +1,52 @@ @@ -0,0 +1,52 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo;
[XmlRoot("movie")]
public class OtherVideoNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("sorttitle")]
public string SortTitle { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public Option<DateTime> Premiered { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}

114
ErsatzTV.Core/Metadata/Nfo/OtherVideoNfoReader.cs

@ -0,0 +1,114 @@ @@ -0,0 +1,114 @@
using System.Xml;
using Bugsnag;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
namespace ErsatzTV.Core.Metadata.Nfo;
public class OtherVideoNfoReader : NfoReader<OtherVideoNfo>, IOtherVideoNfoReader
{
private readonly IClient _client;
public OtherVideoNfoReader(IClient client) => _client = client;
public async Task<Either<BaseError, OtherVideoNfo>> Read(Stream input)
{
try
{
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
using var reader = XmlReader.Create(input, settings);
OtherVideoNfo nfo = null;
var done = false;
while (!done && await reader.ReadAsync())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
switch (reader.Name.ToLowerInvariant())
{
case "movie":
nfo = new OtherVideoNfo
{
Genres = new List<string>(),
Tags = new List<string>(),
Studios = new List<string>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>(),
UniqueIds = new List<UniqueIdNfo>()
};
break;
case "title":
await ReadStringContent(reader, nfo, (movie, title) => movie.Title = title);
break;
case "sorttitle":
await ReadStringContent(reader, nfo, (movie, sortTitle) => movie.SortTitle = sortTitle);
break;
case "outline":
await ReadStringContent(reader, nfo, (movie, outline) => movie.Outline = outline);
break;
case "year":
await ReadIntContent(reader, nfo, (movie, year) => movie.Year = year);
break;
case "mpaa":
await ReadStringContent(
reader,
nfo,
(movie, contentRating) => movie.ContentRating = contentRating);
break;
case "premiered":
await ReadDateTimeContent(
reader,
nfo,
(movie, premiered) => movie.Premiered = premiered);
break;
case "plot":
await ReadStringContent(reader, nfo, (movie, plot) => movie.Plot = plot);
break;
case "genre":
await ReadStringContent(reader, nfo, (movie, genre) => movie.Genres.Add(genre));
break;
case "tag":
await ReadStringContent(reader, nfo, (movie, tag) => movie.Tags.Add(tag));
break;
case "studio":
await ReadStringContent(reader, nfo, (movie, studio) => movie.Studios.Add(studio));
break;
case "actor":
ReadActor(reader, nfo, (movie, actor) => movie.Actors.Add(actor));
break;
case "credits":
await ReadStringContent(reader, nfo, (movie, writer) => movie.Writers.Add(writer));
break;
case "director":
await ReadStringContent(
reader,
nfo,
(movie, director) => movie.Directors.Add(director));
break;
case "uniqueid":
await ReadUniqueId(reader, nfo, (movie, uniqueid) => movie.UniqueIds.Add(uniqueid));
break;
}
break;
case XmlNodeType.EndElement:
if (reader.Name == "movie")
{
done = true;
}
break;
}
}
return Optional(nfo).ToEither((BaseError)new FailedToReadNfo());
}
catch (Exception ex)
{
_client.Notify(ex);
return new FailedToReadNfo(ex.ToString());
}
}
}

34
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -77,15 +77,17 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -77,15 +77,17 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
var foldersCompleted = 0;
var allFolders = new System.Collections.Generic.HashSet<string>();
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
if (ShouldIncludeFolder(libraryPath.Path) && allFolders.Add(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
@ -115,6 +117,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -115,6 +117,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
.Filter(ShouldIncludeFolder)
.Filter(allFolders.Add)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
@ -205,17 +208,40 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -205,17 +208,40 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
try
{
OtherVideo otherVideo = result.Item;
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
Option<string> maybeNfoFile = new List<string> { Path.ChangeExtension(path, "nfo") }
.Filter(_localFileSystem.FileExists)
.HeadOrNone();
if (maybeNfoFile.IsNone)
{
if (!Optional(otherVideo.OtherVideoMetadata).Flatten().Any())
{
otherVideo.OtherVideoMetadata ??= new List<OtherVideoMetadata>();
string path = otherVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(otherVideo))
{
result.IsUpdated = true;
}
}
}
foreach (string nfoFile in maybeNfoFile)
{
bool shouldUpdate = Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
if (await _localMetadataProvider.RefreshSidecarMetadata(otherVideo, nfoFile))
{
result.IsUpdated = true;
}
}
}
return result;
}

28
ErsatzTV.Infrastructure/Data/Configurations/Metadata/OtherVideoMetadataConfiguration.cs

@ -10,11 +10,35 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid @@ -10,11 +10,35 @@ public class OtherVideoMetadataConfiguration : IEntityTypeConfiguration<OtherVid
{
builder.ToTable("OtherVideoMetadata");
builder.HasMany(mm => mm.Artwork)
builder.HasMany(ovm => ovm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Tags)
builder.HasMany(ovm => ovm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Tags)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Studios)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Directors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Writers)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(ovm => ovm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);

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

@ -465,6 +465,10 @@ public class MetadataRepository : IMetadataRepository @@ -465,6 +465,10 @@ public class MetadataRepository : IMetadataRepository
await dbContext.Connection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, ArtistMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
OtherVideoMetadata =>
await dbContext.Connection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, OtherVideoMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
_ => throw new NotSupportedException()
};
}

78
ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs

@ -20,10 +20,23 @@ public class OtherVideoRepository : IOtherVideoRepository @@ -20,10 +20,23 @@ public class OtherVideoRepository : IOtherVideoRepository
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<OtherVideo> maybeExisting = await dbContext.OtherVideos
.AsNoTracking()
.Include(ov => ov.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Artwork)
.Include(ov => ov.OtherVideoMetadata)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Genres)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Studios)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Guids)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Actors)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Directors)
.Include(i => i.OtherVideoMetadata)
.ThenInclude(ovm => ovm.Writers)
.Include(ov => ov.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(ov => ov.MediaVersions)
@ -82,6 +95,14 @@ public class OtherVideoRepository : IOtherVideoRepository @@ -82,6 +95,14 @@ public class OtherVideoRepository : IOtherVideoRepository
return ids;
}
public async Task<bool> AddGenre(OtherVideoMetadata metadata, Genre genre)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Genre (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
public async Task<bool> AddTag(OtherVideoMetadata metadata, Tag tag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -90,6 +111,57 @@ public class OtherVideoRepository : IOtherVideoRepository @@ -90,6 +111,57 @@ public class OtherVideoRepository : IOtherVideoRepository
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<bool> AddStudio(OtherVideoMetadata metadata, Studio studio)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Studio (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
public async Task<bool> AddActor(OtherVideoMetadata metadata, Actor actor)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
int? artworkId = null;
if (actor.Artwork != null)
{
artworkId = await dbContext.Connection.QuerySingleAsync<int>(
@"INSERT INTO Artwork (ArtworkKind, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @DateAdded, @DateUpdated, @Path);
SELECT last_insert_rowid()",
new
{
ArtworkKind = (int)actor.Artwork.ArtworkKind,
actor.Artwork.DateAdded,
actor.Artwork.DateUpdated,
actor.Artwork.Path
});
}
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Actor (Name, Role, \"Order\", OtherVideoMetadataId, ArtworkId) VALUES (@Name, @Role, @Order, @MetadataId, @ArtworkId)",
new { actor.Name, actor.Role, actor.Order, MetadataId = metadata.Id, ArtworkId = artworkId })
.Map(result => result > 0);
}
public async Task<bool> AddDirector(OtherVideoMetadata metadata, Director director)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Director (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { director.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
public async Task<bool> AddWriter(OtherVideoMetadata metadata, Writer writer)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Writer (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
public async Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();

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

@ -98,9 +98,19 @@ public class SearchRepository : ISearchRepository @@ -98,9 +98,19 @@ public class SearchRepository : ISearchRepository
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(mm => mm.Tags)
.ThenInclude(ovm => ovm.Genres)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Studios)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Actors)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Directors)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Writers)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.ThenInclude(ovm => ovm.Streams)
.Include(mi => (mi as Song).SongMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Song).SongMetadata)
@ -242,9 +252,19 @@ public class SearchRepository : ISearchRepository @@ -242,9 +252,19 @@ public class SearchRepository : ISearchRepository
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(mm => mm.Tags)
.ThenInclude(ovm => ovm.Genres)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Tags)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Studios)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Actors)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Directors)
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
.ThenInclude(ovm => ovm.Writers)
.Include(mi => (mi as OtherVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.ThenInclude(ovm => ovm.Streams)
.Include(mi => (mi as Song).SongMetadata)
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Song).SongMetadata)

4279
ErsatzTV.Infrastructure/Migrations/20220506005328_Expand_OtherVideoMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

209
ErsatzTV.Infrastructure/Migrations/20220506005328_Expand_OtherVideoMetadata.cs

@ -0,0 +1,209 @@ @@ -0,0 +1,209 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Expand_OtherVideoMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio");
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Writer",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ContentRating",
table: "OtherVideoMetadata",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Outline",
table: "OtherVideoMetadata",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Plot",
table: "OtherVideoMetadata",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Tagline",
table: "OtherVideoMetadata",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OtherVideoMetadataId",
table: "Director",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Writer_OtherVideoMetadataId",
table: "Writer",
column: "OtherVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Director_OtherVideoMetadataId",
table: "Director",
column: "OtherVideoMetadataId");
migrationBuilder.AddForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Director_OtherVideoMetadata_OtherVideoMetadataId",
table: "Director",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Writer_OtherVideoMetadata_OtherVideoMetadataId",
table: "Writer",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor");
migrationBuilder.DropForeignKey(
name: "FK_Director_OtherVideoMetadata_OtherVideoMetadataId",
table: "Director");
migrationBuilder.DropForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre");
migrationBuilder.DropForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid");
migrationBuilder.DropForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio");
migrationBuilder.DropForeignKey(
name: "FK_Writer_OtherVideoMetadata_OtherVideoMetadataId",
table: "Writer");
migrationBuilder.DropIndex(
name: "IX_Writer_OtherVideoMetadataId",
table: "Writer");
migrationBuilder.DropIndex(
name: "IX_Director_OtherVideoMetadataId",
table: "Director");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Writer");
migrationBuilder.DropColumn(
name: "ContentRating",
table: "OtherVideoMetadata");
migrationBuilder.DropColumn(
name: "Outline",
table: "OtherVideoMetadata");
migrationBuilder.DropColumn(
name: "Plot",
table: "OtherVideoMetadata");
migrationBuilder.DropColumn(
name: "Tagline",
table: "OtherVideoMetadata");
migrationBuilder.DropColumn(
name: "OtherVideoMetadataId",
table: "Director");
migrationBuilder.AddForeignKey(
name: "FK_Actor_OtherVideoMetadata_OtherVideoMetadataId",
table: "Actor",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Genre_OtherVideoMetadata_OtherVideoMetadataId",
table: "Genre",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_MetadataGuid_OtherVideoMetadata_OtherVideoMetadataId",
table: "MetadataGuid",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_Studio_OtherVideoMetadata_OtherVideoMetadataId",
table: "Studio",
column: "OtherVideoMetadataId",
principalTable: "OtherVideoMetadata",
principalColumn: "Id");
}
}
}

48
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -388,12 +388,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -388,12 +388,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.ToTable("Director", (string)null);
});
@ -1296,6 +1301,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1296,6 +1301,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
@ -1311,12 +1319,21 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1311,12 +1319,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("OtherVideoId")
.HasColumnType("INTEGER");
b.Property<string>("Outline")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
@ -2142,12 +2159,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2142,12 +2159,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("OtherVideoMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("OtherVideoMetadataId");
b.ToTable("Writer", (string)null);
});
@ -2603,7 +2625,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2603,7 +2625,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Actors")
.HasForeignKey("OtherVideoMetadataId");
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors")
@ -2735,6 +2758,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2735,6 +2758,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Directors")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Directors")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b =>
@ -2835,7 +2863,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2835,7 +2863,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Genres")
.HasForeignKey("OtherVideoMetadataId");
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres")
@ -3001,7 +3030,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3001,7 +3030,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Guids")
.HasForeignKey("OtherVideoMetadataId");
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
@ -3429,7 +3459,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3429,7 +3459,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Studios")
.HasForeignKey("OtherVideoMetadataId");
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios")
@ -3573,6 +3604,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -3573,6 +3604,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("Writers")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.OtherVideoMetadata", null)
.WithMany("Writers")
.HasForeignKey("OtherVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Emby.EmbyPathInfo", b =>
@ -4054,6 +4090,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -4054,6 +4090,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Artwork");
b.Navigation("Directors");
b.Navigation("Genres");
b.Navigation("Guids");
@ -4063,6 +4101,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -4063,6 +4101,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Subtitles");
b.Navigation("Tags");
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>

48
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -952,13 +952,61 @@ public sealed class SearchIndex : ISearchIndex @@ -952,13 +952,61 @@ public sealed class SearchIndex : ISearchIndex
doc.Add(new Int32Field(WidthField, version.Width, Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
{
foreach (string contentRating in (metadata.ContentRating ?? string.Empty).Split("/")
.Map(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)))
{
doc.Add(new StringField(ContentRatingField, contentRating, Field.Store.NO));
}
}
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
doc.Add(new StringField(AddedDateField, metadata.DateAdded.ToString("yyyyMMdd"), Field.Store.NO));
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
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));
}
foreach (Actor actor in metadata.Actors)
{
doc.Add(new TextField(ActorField, actor.Name, Field.Store.NO));
}
foreach (Director director in metadata.Directors)
{
doc.Add(new TextField(DirectorField, director.Name, Field.Store.NO));
}
foreach (Writer writer in metadata.Writers)
{
doc.Add(new TextField(WriterField, writer.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, otherVideo.Id.ToString()), doc);
}
catch (Exception ex)

1
ErsatzTV/Startup.cs

@ -415,6 +415,7 @@ public class Startup @@ -415,6 +415,7 @@ public class Startup
services.AddScoped<IArtistNfoReader, ArtistNfoReader>();
services.AddScoped<IMusicVideoNfoReader, MusicVideoNfoReader>();
services.AddScoped<ITvShowNfoReader, TvShowNfoReader>();
services.AddScoped<IOtherVideoNfoReader, OtherVideoNfoReader>();
// services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>));

2
README.md

@ -19,7 +19,7 @@ Want to join the community or have a question? Join us on [Discord](https://disc @@ -19,7 +19,7 @@ Want to join the community or have a question? Join us on [Discord](https://disc
- [Emby](https://emby.media/) media and metadata
- Song and music video libraries
- Pre-roll, mid-roll, post-roll filler options
- Picture-based subtitle burn-in
- Subtitle burn-in
## License

27
docs/user-guide/local-libraries.md

@ -13,12 +13,19 @@ Each movie folder may contain a `movie.nfo` file, or an NFO file with exactly th @@ -13,12 +13,19 @@ Each movie folder may contain a `movie.nfo` file, or an NFO file with exactly th
ErsatzTV will read the following fields from the movie NFO:
- Title
- Sort Title
- Outline
- Year
- MPAA
- Premiered
- Plot
- Genre(s)
- Tag(s)
- Studio(s)
- Actor(s)
- Credit(s)
- Director(s)
- Unique Id(s)
### Movie Fallback Metadata
@ -115,6 +122,26 @@ When no artist NFO is found, the artist metadata will only contain a name, which @@ -115,6 +122,26 @@ When no artist NFO is found, the artist metadata will only contain a name, which
The `Other Videos` library has no folder requirements, but folders can be a useful source of metadata.
### NFO Metadata
Each other video may have a corresponding NFO file with exactly the same name, except for the `.nfo` extension. The NFO must use the movie format. See [Kodi Wiki](https://kodi.wiki/view/NFO_files/Movies) for more information.
ErsatzTV will read the following fields from the other video NFO:
- Title
- Sort Title
- Outline
- Year
- MPAA
- Premiered
- Plot
- Genre(s)
- Tag(s)
- Studio(s)
- Actor(s)
- Credit(s)
- Director(s)
- Unique Id(s)
### Other Video Fallback Metadata
Other videos will have a tag added to their metadata for every containing folder, including the top-level folder. As an example, consider adding a commercials folder with the following files:

22
docs/user-guide/search.md

@ -114,12 +114,22 @@ The following fields are available for searching music videos: @@ -114,12 +114,22 @@ The following fields are available for searching music videos:
The following fields are available for searching other videos:
- `title`: The filename of the video (without extension)
- `tag`: All of the video's parent folders
- `minutes`: the rounded-up whole number duration of the video in minutes
- `added_date`: The date the other video was added to ErsatzTV (YYYYMMDD)
- `height`: The other video height
- `width`: The other video width
- `title`: The NFO title or the filename of the video (without extension)
- `genre`: The video genre
- `tag`: The video tag
- `plot`: The video plot
- `studio`: The video studio
- `actor`: An actor from the video
- `director`: A director from the video
- `writer`: A writer from the video
- `library_name`: The name of the library that contains the video
- `content_rating`: The video content rating (case-sensitive)
- `language`: The video audio stream language
- `release_date`: The video release date (YYYYMMDD)
- `added_date`: The date the video was added to ErsatzTV (YYYYMMDD)
- `minutes`: The rounded-up whole number duration of the video in minutes
- `height`: The video height
- `width`: The video width
- `type`: Always `other_video`
### Songs

Loading…
Cancel
Save