Browse Source

support multi-episode files in local libraries (#240)

* add unused episode nfo reader

* move episode number from episode to episode metadata

* first pass at loading multi-episode metadata from nfo files

* fix episode scanning

* local multi-part episode fixes

* code cleanup
pull/241/head
Jason Dove 5 years ago committed by GitHub
parent
commit
3e3bbcf38e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      ErsatzTV.Application/MediaCards/Mapper.cs
  2. 14
      ErsatzTV.Application/Playouts/Mapper.cs
  3. 16
      ErsatzTV.Application/Television/Mapper.cs
  4. 15
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  5. 76
      ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs
  6. 239
      ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs
  7. 3
      ErsatzTV.Core.Tests/Scheduling/MultiPartEpisodeGrouperTests.cs
  8. 1
      ErsatzTV.Core/Domain/MediaItem/Episode.cs
  9. 1
      ErsatzTV.Core/Domain/Metadata/EpisodeMetadata.cs
  10. 4
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  11. 4
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  12. 12
      ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs
  13. 2
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  14. 3
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  15. 4
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  16. 55
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  17. 254
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  18. 201
      ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs
  19. 6
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  20. 5
      ErsatzTV.Core/Scheduling/ChronologicalMediaComparer.cs
  21. 2
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  22. 3
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  23. 3
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  24. 10
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  25. 10
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  26. 10
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  27. 2892
      ErsatzTV.Infrastructure/Migrations/20210603013933_Add_EpisodeMetadataEpisodeNumber.Designer.cs
  28. 24
      ErsatzTV.Infrastructure/Migrations/20210603013933_Add_EpisodeMetadataEpisodeNumber.cs
  29. 2892
      ErsatzTV.Infrastructure/Migrations/20210603014211_Update_EpisodeMetadataEpisodeNumber.Designer.cs
  30. 17
      ErsatzTV.Infrastructure/Migrations/20210603014211_Update_EpisodeMetadataEpisodeNumber.cs
  31. 2889
      ErsatzTV.Infrastructure/Migrations/20210603015235_Remove_EpisodeEpisodeNumber.Designer.cs
  32. 24
      ErsatzTV.Infrastructure/Migrations/20210603015235_Remove_EpisodeEpisodeNumber.cs
  33. 6
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  34. 2
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  35. 22
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  36. 5
      ErsatzTV.sln.DotSettings
  37. 3
      ErsatzTV/Startup.cs

2
ErsatzTV.Application/MediaCards/Mapper.cs

@ -50,7 +50,7 @@ namespace ErsatzTV.Application.MediaCards
episodeMetadata.Episode.Season.ShowId, episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId, episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.Season.SeasonNumber, episodeMetadata.Episode.Season.SeasonNumber,
episodeMetadata.Episode.EpisodeNumber, episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(em => em.EpisodeNumber, () => 0),
episodeMetadata.Title, episodeMetadata.Title,
episodeMetadata.SortTitle, episodeMetadata.SortTitle,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match( episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(

14
ErsatzTV.Application/Playouts/Mapper.cs

@ -1,4 +1,5 @@
using System; using System;
using System.Linq;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts namespace ErsatzTV.Application.Playouts
@ -31,9 +32,16 @@ namespace ErsatzTV.Application.Playouts
case Episode e: case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty); .Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone() var episodeNumbers = e.EpisodeMetadata.Map(em => em.EpisodeNumber).ToList();
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}") var episodeTitles = e.EpisodeMetadata.Map(em => em.Title).ToList();
.IfNone("[unknown episode]"); if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
{
return "[unknown episode]";
}
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
var titlesString = $"{string.Join('/', episodeTitles)}";
return $"{showTitle}s{e.Season.SeasonNumber:00}{numbersString} - {titlesString}";
case Movie m: case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"); return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv: case MusicVideo mv:

16
ErsatzTV.Application/Television/Mapper.cs

@ -57,14 +57,18 @@ namespace ErsatzTV.Application.Television
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby)) season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby))
.IfNone(string.Empty)); .IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) => internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode)
new( {
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
return new TelevisionEpisodeViewModel(
episode.Season.ShowId, episode.Season.ShowId,
episode.SeasonId, episode.SeasonId,
episode.EpisodeNumber, maybeMetadata.Map(em => em.EpisodeNumber).IfNone(0),
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty), maybeMetadata.Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty), maybeMetadata.Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty)); maybeMetadata.Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
}
private static string GetPoster( private static string GetPoster(
Metadata metadata, Metadata metadata,

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

@ -89,20 +89,13 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) => public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException(); public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<int> GetShowCount() => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException(); public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
public Task<bool> Update(Season season) => throw new NotSupportedException(); public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<bool> Update(Episode episode) => throw new NotSupportedException(); public Task<Unit> AddMetadata(Episode episode, EpisodeMetadata metadata) => throw new NotSupportedException();
} }
} }

76
ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs

@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using FluentAssertions; using FluentAssertions;
@ -16,35 +15,33 @@ namespace ErsatzTV.Core.Tests.Metadata
private FallbackMetadataProvider _fallbackMetadataProvider; private FallbackMetadataProvider _fallbackMetadataProvider;
[Test] [Test]
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - s01e02.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S01E02.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - s1e2.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S1E2.mkv", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - s01e02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S01E02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - s1e2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S1E2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)] [TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)] [TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)] [TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)] [TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)] [TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", 1, 2)]
[TestCase( [TestCase(
"Awesome Show (2021) - S01E02 - Description; More Description (1080p QUALITY codec GROUP).mkv", "Awesome Show (2021) - S01E02 - Description; More Description (1080p QUALITY codec GROUP).mkv",
"Awesome Show (2021)",
1, 1,
2)] 2)]
[TestCase( [TestCase(
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv", "Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
"Awesome.Show",
1, 1,
2)] 2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode) public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, int season, int episode)
{ {
(EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata( List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode new Episode
{ {
LibraryPath = new LibraryPath(), LibraryPath = new LibraryPath(),
@ -60,10 +57,41 @@ namespace ErsatzTV.Core.Tests.Metadata
} }
}); });
metadata.Title.Should().Be(title); metadata.Count.Should().Be(1);
// TODO: how can we test season number? do we need to? // TODO: how can we test season number? do we need to?
// metadata.Season.Should().Be(season); // metadata.Season.Should().Be(season);
episodeNumber.Should().Be(episode); metadata.Head().EpisodeNumber.Should().Be(episode);
}
[Test]
[TestCase("Awesome Show - s01e02-s01e03.mkv", 1, 2, 3)]
[TestCase("Awesome Show - s01e02-whatever-s01e03-whatever2.mkv", 1, 2, 3)]
[TestCase("Awesome Show - s01e02e03.mkv", 1, 2, 3)]
[TestCase("Awesome Show - s01e02-03.mkv", 1, 2, 3)]
public void GetFallbackMetadata_Should_Handle_Two_Episode_Formats(
string path,
int season,
int episode1,
int episode2)
{
List<EpisodeMetadata> metadata = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode
{
LibraryPath = new LibraryPath(),
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = path }
}
}
}
});
metadata.Count.Should().Be(2);
metadata.Map(m => m.EpisodeNumber).Should().BeEquivalentTo(episode1, episode2);
} }
} }
} }

239
ErsatzTV.Core.Tests/Metadata/Nfo/EpisodeNfoReaderTests.cs

@ -0,0 +1,239 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ErsatzTV.Core.Metadata.Nfo;
using FluentAssertions;
using NUnit.Framework;
namespace ErsatzTV.Core.Tests.Metadata.Nfo
{
[TestFixture]
public class EpisodeNfoReaderTests
{
[Test]
public async Task One()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
}
[Test]
public async Task Two()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<showtitle>show</showtitle>
<title>episode-one</title>
<episode>1</episode>
<season>1</season>
</episodedetails>
<episodedetails>
<showtitle>show</showtitle>
<title>episode-two</title>
<episode>2</episode>
<season>1</season>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.All(nfo => nfo.ShowTitle == "show").Should().BeTrue();
result.All(nfo => nfo.Season == 1).Should().BeTrue();
result.Count(nfo => nfo.Title == "episode-one" && nfo.Episode == 1).Should().Be(1);
result.Count(nfo => nfo.Title == "episode-two" && nfo.Episode == 2).Should().Be(1);
}
[Test]
public async Task UniqueIds()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<uniqueid default=""true"" type=""tvdb"">12345</uniqueid>
<uniqueid default=""false"" type=""imdb"">tt54321</uniqueid>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].UniqueIds.Count.Should().Be(2);
result[0].UniqueIds.Count(id => id.Default && id.Type == "tvdb" && id.Guid == "12345").Should().Be(1);
result[0].UniqueIds.Count(id => !id.Default && id.Type == "imdb" && id.Guid == "tt54321").Should().Be(1);
}
[Test]
public async Task No_ContentRating()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<mpaa/>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].ContentRating.Should().BeNullOrEmpty();
}
[Test]
public async Task ContentRating()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<mpaa>US:Something</mpaa>
</episodedetails>
<episodedetails>
<mpaa>US:Something / US:SomethingElse</mpaa>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.ContentRating == "US:Something").Should().Be(1);
result.Count(nfo => nfo.ContentRating == "US:Something / US:SomethingElse").Should().Be(1);
}
[Test]
public async Task No_Plot()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<plot/>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].Plot.Should().BeNullOrEmpty();
}
[Test]
public async Task Plot()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<plot>Some Plot</plot>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].Plot.Should().Be("Some Plot");
}
[Test]
public async Task Actors()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<actor>
<name>Name 1</name>
<role>Role 1</role>
<thumb>Thumb 1</thumb>
</actor>
<actor>
<name>Name 2</name>
<role>Role 2</role>
<thumb>Thumb 2</thumb>
</actor>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(1);
result[0].Actors.Count.Should().Be(2);
result[0].Actors.Count(a => a.Name == "Name 1" && a.Role == "Role 1" && a.Thumb == "Thumb 1")
.Should().Be(1);
result[0].Actors.Count(a => a.Name == "Name 2" && a.Role == "Role 2" && a.Thumb == "Thumb 2")
.Should().Be(1);
}
[Test]
public async Task Writers()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<credits>Writer 1</credits>
</episodedetails>
<episodedetails>
<credits>Writer 2</credits>
<credits>Writer 3</credits>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.Writers.Count == 1 && nfo.Writers[0] == "Writer 1").Should().Be(1);
result.Count(nfo => nfo.Writers.Count == 2 && nfo.Writers[0] == "Writer 2" && nfo.Writers[1] == "Writer 3")
.Should().Be(1);
}
[Test]
public async Task Directors()
{
var reader = new EpisodeNfoReader();
var stream = new MemoryStream(
Encoding.UTF8.GetBytes(
@"<?xml version=""1.0"" encoding=""UTF-8"" standalone=""yes""?>
<!--created on whatever - comment-->
<episodedetails>
<director>Director 1</director>
</episodedetails>
<episodedetails>
<director>Director 2</director>
<director>Director 3</director>
</episodedetails>"));
List<TvShowEpisodeNfo> result = await reader.Read(stream);
result.Count.Should().Be(2);
result.Count(nfo => nfo.Directors.Count == 1 && nfo.Directors[0] == "Director 1").Should().Be(1);
result.Count(
nfo => nfo.Directors.Count == 2 && nfo.Directors[0] == "Director 2" &&
nfo.Directors[1] == "Director 3")
.Should().Be(1);
}
}
}

3
ErsatzTV.Core.Tests/Scheduling/MultiPartEpisodeGrouperTests.cs

@ -302,10 +302,9 @@ namespace ErsatzTV.Core.Tests.Scheduling
DateTime? releaseDate = null) => DateTime? releaseDate = null) =>
new() new()
{ {
EpisodeNumber = episode,
EpisodeMetadata = new List<EpisodeMetadata> EpisodeMetadata = new List<EpisodeMetadata>
{ {
new() { Title = title, ReleaseDate = releaseDate } new() { Title = title, ReleaseDate = releaseDate, EpisodeNumber = episode }
}, },
Season = new Season Season = new Season
{ {

1
ErsatzTV.Core/Domain/MediaItem/Episode.cs

@ -6,7 +6,6 @@ namespace ErsatzTV.Core.Domain
[DebuggerDisplay("{EpisodeMetadata[0].Title}")] [DebuggerDisplay("{EpisodeMetadata[0].Title}")]
public class Episode : MediaItem public class Episode : MediaItem
{ {
public int EpisodeNumber { get; set; }
public int SeasonId { get; set; } public int SeasonId { get; set; }
public Season Season { get; set; } public Season Season { get; set; }
public List<EpisodeMetadata> EpisodeMetadata { get; set; } public List<EpisodeMetadata> EpisodeMetadata { get; set; }

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

@ -4,6 +4,7 @@ namespace ErsatzTV.Core.Domain
{ {
public class EpisodeMetadata : Metadata public class EpisodeMetadata : Metadata
{ {
public int EpisodeNumber { get; set; }
public string Outline { get; set; } public string Outline { get; set; }
public string Plot { get; set; } public string Plot { get; set; }
public string Tagline { get; set; } public string Tagline { get; set; }

4
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -335,7 +335,7 @@ namespace ErsatzTV.Core.Emby
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName, showName,
seasonName, seasonName,
incoming.EpisodeNumber); incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true; updateStatistics = true;
incoming.SeasonId = season.Id; incoming.SeasonId = season.Id;
@ -370,7 +370,7 @@ namespace ErsatzTV.Core.Emby
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}", "INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName, showName,
seasonName, seasonName,
incoming.EpisodeNumber); incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming)) if (await _televisionRepository.AddEpisode(incoming))
{ {

4
ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs

@ -1,4 +1,4 @@
using System; using System.Collections.Generic;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt; using LanguageExt;
@ -8,7 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{ {
ShowMetadata GetFallbackMetadataForShow(string showFolder); ShowMetadata GetFallbackMetadataForShow(string showFolder);
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder); ArtistMetadata GetFallbackMetadataForArtist(string artistFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode); List<EpisodeMetadata> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie); MovieMetadata GetFallbackMetadata(Movie movie);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo); Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
string GetSortTitle(string title); string GetSortTitle(string title);

12
ErsatzTV.Core/Interfaces/Metadata/Nfo/IEpisodeNfoReader.cs

@ -0,0 +1,12 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Metadata.Nfo;
namespace ErsatzTV.Core.Interfaces.Metadata.Nfo
{
public interface IEpisodeNfoReader
{
Task<List<TvShowEpisodeNfo>> Read(Stream input);
}
}

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

@ -47,7 +47,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
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<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys); Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber); Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director); Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer); Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
} }

3
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -182,7 +182,8 @@ namespace ErsatzTV.Core.Iptv
if (!isSameCustomShow) if (!isSameCustomShow)
{ {
int s = Optional(episode.Season?.SeasonNumber).IfNone(0); int s = Optional(episode.Season?.SeasonNumber).IfNone(0);
int e = episode.EpisodeNumber; // TODO: multi-episode?
int e = episode.EpisodeMetadata.Head().EpisodeNumber;
if (s > 0 && e > 0) if (s > 0 && e > 0)
{ {
xml.WriteStartElement("episode-num"); xml.WriteStartElement("episode-num");

4
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -336,7 +336,7 @@ namespace ErsatzTV.Core.Jellyfin
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName, showName,
seasonName, seasonName,
incoming.EpisodeNumber); incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true; updateStatistics = true;
incoming.SeasonId = season.Id; incoming.SeasonId = season.Id;
@ -371,7 +371,7 @@ namespace ErsatzTV.Core.Jellyfin
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}", "INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName, showName,
seasonName, seasonName,
incoming.EpisodeNumber); incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(incoming)) if (await _televisionRepository.AddEpisode(incoming))
{ {

55
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -26,13 +26,20 @@ namespace ErsatzTV.Core.Metadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder }; { MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
} }
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode) public List<EpisodeMetadata> GetFallbackMetadata(Episode episode)
{ {
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 baseMetadata = new EpisodeMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow }; {
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0); MetadataKind = MetadataKind.Fallback,
Title = fileName ?? path,
DateAdded = DateTime.UtcNow,
EpisodeNumber = 0
};
return fileName != null
? GetEpisodeMetadata(fileName, baseMetadata)
: new List<EpisodeMetadata> { baseMetadata };
} }
public MovieMetadata GetFallbackMetadata(Movie movie) public MovieMetadata GetFallbackMetadata(Movie movie)
@ -87,18 +94,40 @@ namespace ErsatzTV.Core.Metadata
return title; return title;
} }
private Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata) private static List<EpisodeMetadata> GetEpisodeMetadata(string fileName, EpisodeMetadata baseMetadata)
{ {
var result = new List<EpisodeMetadata>();
try try
{ {
const string PATTERN = @"^(.*?)[.\s-]+[sS](\d+)[eE](\d+).*\.\w+$"; const string PATTERN = @"[sS]\d+[eE]([e\-\d{1,2}]+)";
Match match = Regex.Match(fileName, PATTERN); MatchCollection matches = Regex.Matches(fileName, PATTERN);
if (match.Success) if (matches.Count > 0)
{ {
metadata.Title = match.Groups[1].Value; foreach (Match match in matches)
metadata.DateUpdated = DateTime.UtcNow; {
metadata.Actors = new List<Actor>(); string[] split = match.Groups[1].Value.Replace('e', '-').Split('-');
return Tuple(metadata, int.Parse(match.Groups[3].Value)); foreach (string ep in split)
{
if (!int.TryParse(ep, out int episodeNumber))
{
continue;
}
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Fallback,
EpisodeNumber = episodeNumber,
DateAdded = baseMetadata.DateAdded,
DateUpdated = baseMetadata.DateAdded,
Actors = new List<Actor>()
};
result.Add(metadata);
}
}
return result;
} }
} }
catch (Exception) catch (Exception)
@ -106,7 +135,7 @@ namespace ErsatzTV.Core.Metadata
// ignored // ignored
} }
return Tuple(metadata, 0); return result;
} }
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata) private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)

254
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using System.Xml.Serialization; using System.Xml.Serialization;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata.Nfo; using ErsatzTV.Core.Metadata.Nfo;
using LanguageExt; using LanguageExt;
@ -17,11 +18,11 @@ namespace ErsatzTV.Core.Metadata
public class LocalMetadataProvider : ILocalMetadataProvider public class LocalMetadataProvider : ILocalMetadataProvider
{ {
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo)); private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo)); private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo)); private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo)); private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository; private readonly IArtistRepository _artistRepository;
private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
@ -39,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
IMusicVideoRepository musicVideoRepository, IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader,
ILogger<LocalMetadataProvider> logger) ILogger<LocalMetadataProvider> logger)
{ {
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_musicVideoRepository = musicVideoRepository; _musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader;
_logger = logger; _logger = logger;
} }
@ -110,10 +113,7 @@ namespace ErsatzTV.Core.Metadata
() => Task.FromResult(false))); () => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) => public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
LoadEpisodeMetadata(episode, nfoFileName).Bind( LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) => public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind( LoadArtistMetadata(nfoFileName).Bind(
@ -174,116 +174,133 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber) private async Task<bool> ApplyMetadataUpdate(Episode episode, List<EpisodeMetadata> episodeMetadata)
{ {
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber; episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
if (episode.EpisodeNumber != episodeNumber)
var toUpdate = episode.EpisodeMetadata
.Where(em => episodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
.ToList();
var toRemove = episode.EpisodeMetadata.Except(toUpdate).ToList();
var toAdd = episodeMetadata
.Where(em => episode.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
.ToList();
foreach (EpisodeMetadata metadata in toRemove)
{ {
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber); await _televisionRepository.RemoveMetadata(episode, metadata);
} }
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match( foreach (EpisodeMetadata metadata in toAdd)
async existing => {
{ metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
existing.Outline = metadata.Outline; ? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
existing.Plot = metadata.Plot; : metadata.SortTitle;
existing.Tagline = metadata.Tagline; metadata.EpisodeId = episode.Id;
existing.Title = metadata.Title; metadata.Episode = episode;
episode.EpisodeMetadata.Add(metadata);
await _metadataRepository.Add(metadata);
}
if (existing.DateAdded == DateTime.MinValue) foreach (EpisodeMetadata metadata in toUpdate)
{
Option<EpisodeMetadata> maybeExisting =
episode.EpisodeMetadata.Find(em => em.EpisodeNumber == metadata.EpisodeNumber);
await maybeExisting.Match(
async existing =>
{ {
existing.DateAdded = metadata.DateAdded; existing.Outline = metadata.Outline;
} existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.DateUpdated = metadata.DateUpdated; existing.Title = metadata.Title;
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;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
foreach (Director director in existing.Directors if (existing.DateAdded == DateTime.MinValue)
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{ {
updated = true; existing.DateAdded = metadata.DateAdded;
} }
}
foreach (Director director in metadata.Directors existing.DateUpdated = metadata.DateUpdated;
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList()) existing.MetadataKind = metadata.MetadataKind;
{ existing.OriginalTitle = metadata.OriginalTitle;
existing.Directors.Add(director); existing.ReleaseDate = metadata.ReleaseDate;
if (await _televisionRepository.AddDirector(existing, director)) existing.Year = metadata.Year;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{ {
updated = true; existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
} }
}
foreach (Writer writer in existing.Writers foreach (Director director in metadata.Directors
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList()) .Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{ {
updated = true; existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
{
updated = true;
}
} }
}
foreach (Writer writer in metadata.Writers foreach (Writer writer in existing.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList()) .Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{ {
updated = true; existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
} }
}
foreach (MetadataGuid guid in existing.Guids foreach (Writer writer in metadata.Writers
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList()) .Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{ {
updated = true; existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{
updated = true;
}
} }
}
foreach (MetadataGuid guid in metadata.Guids foreach (MetadataGuid guid in existing.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList()) .Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{ {
updated = true; existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
} }
}
return await _metadataRepository.Update(existing) || updated; foreach (MetadataGuid guid in metadata.Guids
}, .Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
async () => {
{ existing.Guids.Add(guid);
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle) if (await _metadataRepository.AddGuid(existing, guid))
? _fallbackMetadataProvider.GetSortTitle(metadata.Title) {
: metadata.SortTitle; updated = true;
metadata.EpisodeId = episode.Id; }
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata }; }
return await _metadataRepository.Add(metadata); return await _metadataRepository.Update(existing) || updated;
}); },
() => Task.FromResult(false));
}
return true; return true;
} }
@ -665,36 +682,45 @@ namespace ErsatzTV.Core.Metadata
} }
} }
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName) private async Task<List<EpisodeMetadata>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{ {
try try
{ {
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read); await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo; List<TvShowEpisodeNfo> nfos = await _episodeNfoReader.Read(fileStream);
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>( var result = new List<EpisodeMetadata>();
nfo => foreach (TvShowEpisodeNfo nfo in nfos)
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata
{ {
DateTime dateAdded = DateTime.UtcNow; MetadataKind = MetadataKind.Sidecar,
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName); DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(nfo.Title),
EpisodeNumber = nfo.Episode,
Year = GetYear(0, nfo.Aired),
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Artwork = new List<Artwork>()
};
var metadata = new EpisodeMetadata result.Add(metadata);
{ }
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded, return result;
DateUpdated = dateUpdated,
Title = nfo.Title,
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList(),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
};
return Tuple(metadata, nfo.Episode);
},
None);
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -883,7 +909,7 @@ namespace ErsatzTV.Core.Metadata
return updated; return updated;
} }
private List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated) private static List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
{ {
var result = new List<Actor>(); var result = new List<Actor>();

201
ErsatzTV.Core/Metadata/Nfo/EpisodeNfoReader.cs

@ -0,0 +1,201 @@
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
namespace ErsatzTV.Core.Metadata.Nfo
{
public class EpisodeNfoReader : IEpisodeNfoReader
{
public async Task<List<TvShowEpisodeNfo>> Read(Stream input)
{
var result = new List<TvShowEpisodeNfo>();
var settings = new XmlReaderSettings { Async = true, ConformanceLevel = ConformanceLevel.Fragment };
using var reader = XmlReader.Create(input, settings);
TvShowEpisodeNfo nfo = null;
while (await reader.ReadAsync())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
switch (reader.Name.ToLowerInvariant())
{
case "episodedetails":
nfo = new TvShowEpisodeNfo
{
UniqueIds = new List<UniqueIdNfo>(),
Actors = new List<ActorNfo>(),
Writers = new List<string>(),
Directors = new List<string>()
};
break;
case "title":
await ReadTitle(reader, nfo);
break;
case "showtitle":
await ReadShowTitle(reader, nfo);
break;
case "episode":
await ReadEpisode(reader, nfo);
break;
case "season":
await ReadSeason(reader, nfo);
break;
case "uniqueid":
await ReadUniqueId(reader, nfo);
break;
case "mpaa":
await ReadContentRating(reader, nfo);
break;
case "aired":
// TODO: parse the date here
await ReadAired(reader, nfo);
break;
case "plot":
await ReadPlot(reader, nfo);
break;
case "actor":
ReadActor(reader, nfo);
break;
case "credits":
await ReadWriter(reader, nfo);
break;
case "director":
await ReadDirector(reader, nfo);
break;
}
break;
case XmlNodeType.EndElement:
switch (reader.Name.ToLowerInvariant())
{
case "episodedetails":
if (nfo != null)
{
result.Add(nfo);
}
break;
}
break;
}
}
return result;
}
private static async Task ReadTitle(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Title = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadShowTitle(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.ShowTitle = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadEpisode(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int episode);
nfo.Episode = episode;
}
}
private static async Task ReadSeason(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
bool _ = int.TryParse(await reader.ReadElementContentAsStringAsync(), out int season);
nfo.Season = season;
}
}
private static async Task ReadUniqueId(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
var uniqueId = new UniqueIdNfo();
reader.MoveToAttribute("default");
uniqueId.Default = bool.TryParse(reader.Value, out bool def) && def;
reader.MoveToAttribute("type");
uniqueId.Type = reader.Value;
reader.MoveToElement();
uniqueId.Guid = await reader.ReadElementContentAsStringAsync();
nfo.UniqueIds.Add(uniqueId);
}
}
private static async Task ReadContentRating(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.ContentRating = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadAired(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Aired = await reader.ReadElementContentAsStringAsync();
}
}
private static async Task ReadPlot(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
nfo.Plot = await reader.ReadElementContentAsStringAsync();
}
}
private static void ReadActor(XmlReader reader, TvShowEpisodeNfo nfo)
{
if (nfo != null)
{
var actor = new ActorNfo();
var element = (XElement) XNode.ReadFrom(reader);
XElement name = element.Element("name");
if (name != null)
{
actor.Name = name.Value;
}
XElement role = element.Element("role");
if (role != null)
{
actor.Role = role.Value;
}
XElement thumb = element.Element("thumb");
if (thumb != null)
{
actor.Thumb = thumb.Value;
}
nfo.Actors.Add(actor);
}
}
private static async Task ReadWriter(XmlReader reader, TvShowEpisodeNfo nfo) =>
nfo?.Writers.Add(await reader.ReadElementContentAsStringAsync());
private static async Task ReadDirector(XmlReader reader, TvShowEpisodeNfo nfo) =>
nfo?.Directors.Add(await reader.ReadElementContentAsStringAsync());
}
}

6
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -360,8 +360,10 @@ namespace ErsatzTV.Core.Metadata
await LocateThumbnail(episode).IfSomeAsync( await LocateThumbnail(episode).IfSomeAsync(
async posterFile => async posterFile =>
{ {
EpisodeMetadata metadata = episode.EpisodeMetadata.Head(); foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail); {
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
}
}); });
return episode; return episode;

5
ErsatzTV.Core/Scheduling/ChronologicalMediaComparer.cs

@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Scheduling namespace ErsatzTV.Core.Scheduling
@ -65,13 +66,13 @@ namespace ErsatzTV.Core.Scheduling
int episode1 = x switch int episode1 = x switch
{ {
Episode e => e.EpisodeNumber, Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
_ => int.MaxValue _ => int.MaxValue
}; };
int episode2 = y switch int episode2 = y switch
{ {
Episode e => e.EpisodeNumber, Episode e => e.EpisodeMetadata.Max(em => em.EpisodeNumber),
_ => int.MaxValue _ => int.MaxValue
}; };

2
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -522,7 +522,7 @@ namespace ErsatzTV.Core.Scheduling
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone() string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty); .Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone() return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}") .Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{em.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]"); .IfNone("[unknown episode]");
case Movie m: case Movie m:
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]"); return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]");

3
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -423,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
episode.Id = existing.Id; episode.Id = existing.Id;
existing.Etag = episode.Etag; existing.Etag = episode.Etag;
existing.EpisodeNumber = episode.EpisodeNumber;
// metadata // metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head(); EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title; metadata.Title = incomingMetadata.Title;
@ -435,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.DateAdded = incomingMetadata.DateAdded; metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow; metadata.DateUpdated = DateTime.UtcNow;
metadata.ReleaseDate = incomingMetadata.ReleaseDate; metadata.ReleaseDate = incomingMetadata.ReleaseDate;
metadata.EpisodeNumber = incomingMetadata.EpisodeNumber;
// thumbnail // thumbnail
Artwork incomingThumbnail = Artwork incomingThumbnail =

3
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -423,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
episode.Id = existing.Id; episode.Id = existing.Id;
existing.Etag = episode.Etag; existing.Etag = episode.Etag;
existing.EpisodeNumber = episode.EpisodeNumber;
// metadata // metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head(); EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title; metadata.Title = incomingMetadata.Title;
@ -435,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.DateAdded = incomingMetadata.DateAdded; metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow; metadata.DateUpdated = DateTime.UtcNow;
metadata.ReleaseDate = incomingMetadata.ReleaseDate; metadata.ReleaseDate = incomingMetadata.ReleaseDate;
metadata.EpisodeNumber = metadata.EpisodeNumber;
// thumbnail // thumbnail
Artwork incomingThumbnail = Artwork incomingThumbnail =

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

@ -197,7 +197,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata) .ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors) .ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork) .ThenInclude(a => a.Artwork)
.OrderBy(em => em.Episode.EpisodeNumber) .OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize) .Skip((pageNumber - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
@ -521,12 +521,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return ids; return ids;
} }
public async Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) public async Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata)
{ {
episode.EpisodeNumber = episodeNumber; episode.EpisodeMetadata.Remove(metadata);
await _dbConnection.ExecuteAsync( await _dbConnection.ExecuteAsync(
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id", @"DELETE FROM EpisodeMetadata WHERE Id = @MetadataId",
new { EpisodeNumber = episodeNumber, episode.Id }); new { MetadataId = metadata.Id });
return Unit.Default; return Unit.Default;
} }

10
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -512,11 +512,6 @@ namespace ErsatzTV.Infrastructure.Emby
EpisodeMetadata = new List<EpisodeMetadata> { metadata } EpisodeMetadata = new List<EpisodeMetadata> { metadata }
}; };
if (item.IndexNumber.HasValue)
{
episode.EpisodeNumber = item.IndexNumber.Value;
}
return episode; return episode;
} }
catch (Exception ex) catch (Exception ex)
@ -549,6 +544,11 @@ namespace ErsatzTV.Infrastructure.Emby
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList() Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
}; };
if (item.IndexNumber.HasValue)
{
metadata.EpisodeNumber = item.IndexNumber.Value;
}
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
{ {
metadata.ReleaseDate = releaseDate; metadata.ReleaseDate = releaseDate;

10
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -568,11 +568,6 @@ namespace ErsatzTV.Infrastructure.Jellyfin
EpisodeMetadata = new List<EpisodeMetadata> { metadata } EpisodeMetadata = new List<EpisodeMetadata> { metadata }
}; };
if (item.IndexNumber.HasValue)
{
episode.EpisodeNumber = item.IndexNumber.Value;
}
return episode; return episode;
} }
catch (Exception ex) catch (Exception ex)
@ -605,6 +600,11 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList() Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
}; };
if (item.IndexNumber.HasValue)
{
metadata.EpisodeNumber = item.IndexNumber.Value;
}
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
{ {
metadata.ReleaseDate = releaseDate; metadata.ReleaseDate = releaseDate;

2892
ErsatzTV.Infrastructure/Migrations/20210603013933_Add_EpisodeMetadataEpisodeNumber.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20210603013933_Add_EpisodeMetadataEpisodeNumber.cs

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EpisodeMetadataEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "EpisodeMetadata",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "EpisodeMetadata");
}
}
}

2892
ErsatzTV.Infrastructure/Migrations/20210603014211_Update_EpisodeMetadataEpisodeNumber.Designer.cs generated

File diff suppressed because it is too large Load Diff

17
ErsatzTV.Infrastructure/Migrations/20210603014211_Update_EpisodeMetadataEpisodeNumber.cs

@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_EpisodeMetadataEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE EpisodeMetadata SET EpisodeNumber = (SELECT EpisodeNumber FROM Episode WHERE Id = EpisodeMetadata.EpisodeId)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

2889
ErsatzTV.Infrastructure/Migrations/20210603015235_Remove_EpisodeEpisodeNumber.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20210603015235_Remove_EpisodeEpisodeNumber.cs

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_EpisodeEpisodeNumber : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EpisodeNumber",
table: "Episode");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeNumber",
table: "Episode",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
}
}

6
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -348,6 +348,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("EpisodeId") b.Property<int>("EpisodeId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("MetadataKind") b.Property<int>("MetadataKind")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -1421,9 +1424,6 @@ namespace ErsatzTV.Infrastructure.Migrations
{ {
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem"); b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonId") b.Property<int>("SeasonId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

2
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -655,7 +655,6 @@ namespace ErsatzTV.Infrastructure.Plex
var episode = new PlexEpisode var episode = new PlexEpisode
{ {
Key = response.Key, Key = response.Key,
EpisodeNumber = response.Index,
EpisodeMetadata = new List<EpisodeMetadata> { metadata }, EpisodeMetadata = new List<EpisodeMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version } MediaVersions = new List<MediaVersion> { version }
}; };
@ -673,6 +672,7 @@ namespace ErsatzTV.Infrastructure.Plex
MetadataKind = MetadataKind.External, MetadataKind = MetadataKind.External,
Title = response.Title, Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title), SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
EpisodeNumber = response.Index,
Plot = response.Summary, Plot = response.Summary,
Year = response.Year, Year = response.Year,
Tagline = response.Tagline, Tagline = response.Tagline,

22
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -593,17 +593,15 @@ namespace ErsatzTV.Infrastructure.Search
try try
{ {
var doc = new Document var doc = new Document();
{ doc.Add(new StringField(IdField, episode.Id.ToString(), Field.Store.YES));
new StringField(IdField, episode.Id.ToString(), Field.Store.YES), doc.Add(new StringField(TypeField, EpisodeType, Field.Store.NO));
new StringField(TypeField, EpisodeType, Field.Store.NO), doc.Add(new TextField(TitleField, metadata.Title, Field.Store.NO));
new TextField(TitleField, metadata.Title, Field.Store.NO), doc.Add(new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO));
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO), doc.Add(new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO));
new TextField(LibraryNameField, episode.LibraryPath.Library.Name, Field.Store.NO), doc.Add(new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO));
new StringField(LibraryIdField, episode.LibraryPath.Library.Id.ToString(), Field.Store.NO), doc.Add(new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO));
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO), doc.Add(new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES));
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
AddLanguages(doc, episode.MediaVersions); AddLanguages(doc, episode.MediaVersions);
@ -682,7 +680,7 @@ namespace ErsatzTV.Infrastructure.Search
metadata switch metadata switch
{ {
EpisodeMetadata em => EpisodeMetadata em =>
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.Episode.EpisodeNumber}" $"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.EpisodeNumber}"
.ToLowerInvariant(), .ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant() _ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
}; };

5
ErsatzTV.sln.DotSettings

@ -13,6 +13,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Emby/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Emby/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=episodedetails/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fanart/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=fanart/@EntryIndexedValue">True</s:Boolean>
@ -32,6 +33,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=maxrate/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=maxrate/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=movflags/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=movflags/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mpaa/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=mpegts/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=mpegts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxdelay/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=muxdelay/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxpreload/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=muxpreload/@EntryIndexedValue">True</s:Boolean>
@ -41,7 +43,10 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=showtitle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvdb/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=tvshow/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=uniqueid/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Vaapi/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=Vaapi/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=xmltv/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=xmltv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

3
ErsatzTV/Startup.cs

@ -17,6 +17,7 @@ using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking; using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Runtime; using ErsatzTV.Core.Interfaces.Runtime;
@ -24,6 +25,7 @@ using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Jellyfin; using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Metadata.Nfo;
using ErsatzTV.Core.Plex; using ErsatzTV.Core.Plex;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
using ErsatzTV.Formatters; using ErsatzTV.Formatters;
@ -252,6 +254,7 @@ namespace ErsatzTV
}); });
services.AddScoped<IJellyfinSecretStore, JellyfinSecretStore>(); services.AddScoped<IJellyfinSecretStore, JellyfinSecretStore>();
services.AddScoped<IEmbySecretStore, EmbySecretStore>(); services.AddScoped<IEmbySecretStore, EmbySecretStore>();
services.AddScoped<IEpisodeNfoReader, EpisodeNfoReader>();
services.AddHostedService<EndpointValidatorService>(); services.AddHostedService<EndpointValidatorService>();
services.AddHostedService<DatabaseMigratorService>(); services.AddHostedService<DatabaseMigratorService>();

Loading…
Cancel
Save