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

14
ErsatzTV.Application/Playouts/Mapper.cs

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

16
ErsatzTV.Application/Television/Mapper.cs

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

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

@ -89,20 +89,13 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -89,20 +89,13 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
throw new NotSupportedException();
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException();
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) =>
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
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 @@ @@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using FluentAssertions;
@ -16,35 +15,33 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -16,35 +15,33 @@ namespace ErsatzTV.Core.Tests.Metadata
private FallbackMetadataProvider _fallbackMetadataProvider;
[Test]
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", "Awesome Show (2021)", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - s01e02.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2.mkv", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - s01e02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - S01E02 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - s1e2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show (2021) - S1E2 - Episode Title.mkv", 1, 2)]
[TestCase("Awesome Show - s01e02 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - S01E02 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - s1e2 - Episode Title-720p.mkv", 1, 2)]
[TestCase("Awesome Show - S1E2 - Episode Title-720p.mkv", 1, 2)]
[TestCase(
"Awesome Show (2021) - S01E02 - Description; More Description (1080p QUALITY codec GROUP).mkv",
"Awesome Show (2021)",
1,
2)]
[TestCase(
"Awesome.Show.S01E02.Description.more.Description.QUAlity.codec.CODEC-GROUP.mkv",
"Awesome.Show",
1,
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
{
LibraryPath = new LibraryPath(),
@ -60,10 +57,41 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -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?
// 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 @@ @@ -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 @@ -302,10 +302,9 @@ namespace ErsatzTV.Core.Tests.Scheduling
DateTime? releaseDate = null) =>
new()
{
EpisodeNumber = episode,
EpisodeMetadata = new List<EpisodeMetadata>
{
new() { Title = title, ReleaseDate = releaseDate }
new() { Title = title, ReleaseDate = releaseDate, EpisodeNumber = episode }
},
Season = new Season
{

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

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

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

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

4
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

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

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

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

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

@ -0,0 +1,12 @@ @@ -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 @@ -47,7 +47,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
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> AddWriter(EpisodeMetadata metadata, Writer writer);
}

3
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -182,7 +182,8 @@ namespace ErsatzTV.Core.Iptv @@ -182,7 +182,8 @@ namespace ErsatzTV.Core.Iptv
if (!isSameCustomShow)
{
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)
{
xml.WriteStartElement("episode-num");

4
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

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

55
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

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

254
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -6,6 +6,7 @@ using System.Threading.Tasks; @@ -6,6 +6,7 @@ using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Metadata.Nfo;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata.Nfo;
using LanguageExt;
@ -17,11 +18,11 @@ namespace ErsatzTV.Core.Metadata @@ -17,11 +18,11 @@ namespace ErsatzTV.Core.Metadata
public class LocalMetadataProvider : ILocalMetadataProvider
{
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 ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository;
private readonly IEpisodeNfoReader _episodeNfoReader;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
@ -39,6 +40,7 @@ namespace ErsatzTV.Core.Metadata @@ -39,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
IEpisodeNfoReader episodeNfoReader,
ILogger<LocalMetadataProvider> logger)
{
_metadataRepository = metadataRepository;
@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata @@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_episodeNfoReader = episodeNfoReader;
_logger = logger;
}
@ -110,10 +113,7 @@ namespace ErsatzTV.Core.Metadata @@ -110,10 +113,7 @@ namespace ErsatzTV.Core.Metadata
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName) =>
LoadEpisodeMetadata(episode, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
LoadEpisodeMetadata(episode, nfoFileName).Bind(metadata => ApplyMetadataUpdate(episode, metadata));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
@ -174,116 +174,133 @@ namespace ErsatzTV.Core.Metadata @@ -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;
if (episode.EpisodeNumber != episodeNumber)
episode.EpisodeMetadata ??= new List<EpisodeMetadata>();
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(
async existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
foreach (EpisodeMetadata metadata in toAdd)
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.EpisodeId = episode.Id;
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.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;
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
existing.Tagline = metadata.Tagline;
existing.Title = metadata.Title;
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))
if (existing.DateAdded == DateTime.MinValue)
{
updated = true;
existing.DateAdded = metadata.DateAdded;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
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;
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
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
updated = true;
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
{
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 _televisionRepository.AddWriter(existing, writer))
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
updated = true;
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(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))
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
updated = true;
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{
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))
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
updated = true;
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.EpisodeId = episode.Id;
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
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.Add(metadata);
});
return await _metadataRepository.Update(existing) || updated;
},
() => Task.FromResult(false));
}
return true;
}
@ -665,36 +682,45 @@ namespace ErsatzTV.Core.Metadata @@ -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
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
nfo =>
List<TvShowEpisodeNfo> nfos = await _episodeNfoReader.Read(fileStream);
var result = new List<EpisodeMetadata>();
foreach (TvShowEpisodeNfo nfo in nfos)
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
MetadataKind = MetadataKind.Sidecar,
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
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
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);
result.Add(metadata);
}
return result;
}
catch (Exception ex)
{
@ -883,7 +909,7 @@ namespace ErsatzTV.Core.Metadata @@ -883,7 +909,7 @@ namespace ErsatzTV.Core.Metadata
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>();

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

@ -0,0 +1,201 @@ @@ -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 @@ -360,8 +360,10 @@ namespace ErsatzTV.Core.Metadata
await LocateThumbnail(episode).IfSomeAsync(
async posterFile =>
{
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
foreach (EpisodeMetadata metadata in episode.EpisodeMetadata)
{
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
}
});
return episode;

5
ErsatzTV.Core/Scheduling/ChronologicalMediaComparer.cs

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

2
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -522,7 +522,7 @@ namespace ErsatzTV.Core.Scheduling @@ -522,7 +522,7 @@ namespace ErsatzTV.Core.Scheduling
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
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]");
case Movie m:
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 @@ -423,9 +423,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
episode.Id = existing.Id;
existing.Etag = episode.Etag;
existing.EpisodeNumber = episode.EpisodeNumber;
// metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title;
@ -435,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -435,6 +435,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
metadata.EpisodeNumber = incomingMetadata.EpisodeNumber;
// thumbnail
Artwork incomingThumbnail =

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

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

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

@ -197,7 +197,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -197,7 +197,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.OrderBy(em => em.Episode.EpisodeNumber)
.OrderBy(em => em.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
@ -521,12 +521,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -521,12 +521,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
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(
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
new { EpisodeNumber = episodeNumber, episode.Id });
@"DELETE FROM EpisodeMetadata WHERE Id = @MetadataId",
new { MetadataId = metadata.Id });
return Unit.Default;
}

10
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -512,11 +512,6 @@ namespace ErsatzTV.Infrastructure.Emby @@ -512,11 +512,6 @@ namespace ErsatzTV.Infrastructure.Emby
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
};
if (item.IndexNumber.HasValue)
{
episode.EpisodeNumber = item.IndexNumber.Value;
}
return episode;
}
catch (Exception ex)
@ -549,6 +544,11 @@ namespace ErsatzTV.Infrastructure.Emby @@ -549,6 +544,11 @@ namespace ErsatzTV.Infrastructure.Emby
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))
{
metadata.ReleaseDate = releaseDate;

10
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -568,11 +568,6 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -568,11 +568,6 @@ namespace ErsatzTV.Infrastructure.Jellyfin
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
};
if (item.IndexNumber.HasValue)
{
episode.EpisodeNumber = item.IndexNumber.Value;
}
return episode;
}
catch (Exception ex)
@ -605,6 +600,11 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -605,6 +600,11 @@ namespace ErsatzTV.Infrastructure.Jellyfin
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))
{
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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ -348,6 +348,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int>("EpisodeId")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
@ -1421,9 +1424,6 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1421,9 +1424,6 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<int>("SeasonId")
.HasColumnType("INTEGER");

2
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

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

22
ErsatzTV.Infrastructure/Search/SearchIndex.cs

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

5
ErsatzTV.sln.DotSettings

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
<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/=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/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fanart/@EntryIndexedValue">True</s:Boolean>
@ -32,6 +33,7 @@ @@ -32,6 +33,7 @@
<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/=mpaa/@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/=muxpreload/@EntryIndexedValue">True</s:Boolean>
@ -41,7 +43,10 @@ @@ -41,7 +43,10 @@
<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/=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/=uniqueid/@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/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

3
ErsatzTV/Startup.cs

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

Loading…
Cancel
Save