Browse Source

merge latest develop (#230)

* sync guids/provider ids (#227)

* sync guids from plex

* cleanup

* sync local guids

* sync jellyfin and emby guids

* add episodes to search index (#228)

* sync episode directors and writers

* display episode writers and directors

* remove missing episodes from search index

* show episodes in search results

* fix emby and jellyfin episode updates

* fix updating plex episodes

* don't delete channel logos on startup

* add episodes page; fix adding episodes to collection

* cleanup

* multi-part episode grouping fixes (#229)
pull/231/head
Jason Dove 5 years ago committed by GitHub
parent
commit
e506dd38a8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 16
      ErsatzTV.Application/MediaCards/Mapper.cs
  2. 5
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs
  3. 7
      ErsatzTV.Application/MediaCards/TelevisionEpisodeCardResultsViewModel.cs
  4. 9
      ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs
  5. 1
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  6. 13
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  7. 22
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  8. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodes.cs
  9. 56
      ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs
  10. 1
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  11. 17
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  12. 126
      ErsatzTV.Core.Tests/Scheduling/MultiPartEpisodeGrouperTests.cs
  13. 6
      ErsatzTV.Core/Domain/Metadata/EpisodeMetadata.cs
  14. 1
      ErsatzTV.Core/Domain/Metadata/Metadata.cs
  15. 8
      ErsatzTV.Core/Domain/Metadata/MetadataGuid.cs
  16. 204
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  17. 12
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  18. 4
      ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs
  19. 4
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs
  20. 4
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  21. 2
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  22. 8
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  23. 205
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  24. 129
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  25. 3
      ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs
  26. 9
      ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs
  27. 3
      ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs
  28. 16
      ErsatzTV.Core/Metadata/Nfo/UniqueIdNfo.cs
  29. 14
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  30. 44
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  31. 126
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  32. 50
      ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs
  33. 12
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/EpisodeMetadataConfiguration.cs
  34. 11
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MetadataGuidConfiguration.cs
  35. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MovieMetadataConfiguration.cs
  36. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/SeasonMetadataConfiguration.cs
  37. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ShowMetadataConfiguration.cs
  38. 1
      ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs
  39. 121
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  40. 117
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  41. 9
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  42. 47
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  43. 56
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  44. 44
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  45. 15
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  46. 142
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  47. 39
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  48. 13
      ErsatzTV.Infrastructure/Emby/IEmbyApi.cs
  49. 1
      ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs
  50. 9
      ErsatzTV.Infrastructure/Emby/Models/EmbyProviderIdsResponse.cs
  51. 10
      ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs
  52. 39
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  53. 1
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs
  54. 9
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinProviderIdsResponse.cs
  55. 2862
      ErsatzTV.Infrastructure/Migrations/20210527214039_Add_MetadataGuid.Designer.cs
  56. 124
      ErsatzTV.Infrastructure/Migrations/20210527214039_Add_MetadataGuid.cs
  57. 2886
      ErsatzTV.Infrastructure/Migrations/20210529014509_Add_EpisodeMetadataDirectorsWriters.Designer.cs
  58. 75
      ErsatzTV.Infrastructure/Migrations/20210529014509_Add_EpisodeMetadataDirectorsWriters.cs
  59. 111
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  60. 29
      ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs
  61. 5
      ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs
  62. 9
      ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs
  63. 10
      ErsatzTV.Infrastructure/Plex/Models/PlexGuidResponse.cs
  64. 29
      ErsatzTV.Infrastructure/Plex/Models/PlexMediaContainerResponse.cs
  65. 33
      ErsatzTV.Infrastructure/Plex/Models/PlexMediaResponse.cs
  66. 50
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  67. 5
      ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs
  68. 9
      ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs
  69. 27
      ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs
  70. 5
      ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.mcs.cs
  71. 17
      ErsatzTV.Infrastructure/Plex/Models/PlexXmlMetadataResponse.cs
  72. 23
      ErsatzTV.Infrastructure/Plex/Models/PlexXmlPartResponse.cs
  73. 278
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  74. 94
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  75. 166
      ErsatzTV/Pages/EpisodeList.razor
  76. 12
      ErsatzTV/Pages/MultiSelectBase.cs
  77. 69
      ErsatzTV/Pages/Search.razor
  78. 33
      ErsatzTV/Pages/TelevisionEpisodeList.razor

16
ErsatzTV.Application/MediaCards/Mapper.cs

@ -39,7 +39,8 @@ namespace ErsatzTV.Application.MediaCards @@ -39,7 +39,8 @@ namespace ErsatzTV.Application.MediaCards
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
Option<EmbyMediaSource> maybeEmby,
bool isSearchResult) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
@ -48,12 +49,21 @@ namespace ErsatzTV.Application.MediaCards @@ -48,12 +49,21 @@ namespace ErsatzTV.Application.MediaCards
() => string.Empty),
episodeMetadata.Episode.Season.ShowId,
episodeMetadata.Episode.SeasonId,
episodeMetadata.Episode.Season.SeasonNumber,
episodeMetadata.Episode.EpisodeNumber,
episodeMetadata.Title,
episodeMetadata.SortTitle,
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby));
isSearchResult
? GetPoster(
episodeMetadata.Episode.Season.SeasonMetadata.Head(),
maybeJellyfin,
maybeEmby)
: GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby),
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList());
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@ -101,7 +111,7 @@ namespace ErsatzTV.Application.MediaCards @@ -101,7 +111,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby))
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))

5
ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs

@ -4,6 +4,7 @@ using System.Threading; @@ -4,6 +4,7 @@ using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
@ -39,9 +40,9 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -39,9 +40,9 @@ namespace ErsatzTV.Application.MediaCards.Queries
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby)).ToList());
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby, false)).ToList());
return new TelevisionEpisodeCardResultsViewModel(count, results);
return new TelevisionEpisodeCardResultsViewModel(count, results, Option<SearchPageMap>.None);
}
}
}

7
ErsatzTV.Application/MediaCards/TelevisionEpisodeCardResultsViewModel.cs

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
using System.Collections.Generic;
using ErsatzTV.Core.Search;
using LanguageExt;
namespace ErsatzTV.Application.MediaCards
{
public record TelevisionEpisodeCardResultsViewModel(int Count, List<TelevisionEpisodeCardViewModel> Cards);
public record TelevisionEpisodeCardResultsViewModel(
int Count,
List<TelevisionEpisodeCardViewModel> Cards,
Option<SearchPageMap> PageMap);
}

9
ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
@ -9,13 +10,17 @@ namespace ErsatzTV.Application.MediaCards @@ -9,13 +10,17 @@ namespace ErsatzTV.Application.MediaCards
string ShowTitle,
int ShowId,
int SeasonId,
int Season,
int Episode,
string Title,
string SortTitle,
string Plot,
string Poster) : MediaCardViewModel(
string Poster,
List<string> Directors,
List<string> Writers) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
$"Episode {Episode}",
SortTitle,
Poster);
}

1
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -9,6 +9,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}

13
ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs

@ -41,6 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -41,6 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.EpisodeIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
@ -59,8 +60,9 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -59,8 +60,9 @@ namespace ErsatzTV.Application.MediaCollections.Commands
}
private async Task<Validation<BaseError, Unit>> Validate(AddItemsToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request))
.Apply((_, _, _) => Unit.Default);
(await CollectionMustExist(request), await ValidateMovies(request), await ValidateShows(request),
await ValidateEpisodes(request))
.Apply((_, _, _, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddItemsToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
@ -80,5 +82,12 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -80,5 +82,12 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist"));
}
}

22
ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs

@ -17,18 +17,16 @@ namespace ErsatzTV.Application.Search.Queries @@ -17,18 +17,16 @@ namespace ErsatzTV.Application.Search.Queries
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken)
{
List<int> movieIds = await _searchIndex.Search($"type:movie AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> showIds = await _searchIndex.Search($"type:show AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> artistIds = await _searchIndex.Search($"type:artist AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> musicVideoIds = await _searchIndex.Search($"type:music_video AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
CancellationToken cancellationToken) =>
new(
await GetIds("movie", request.Query),
await GetIds("show", request.Query),
await GetIds("episode", request.Query),
await GetIds("artist", request.Query),
await GetIds("music_video", request.Query));
return new SearchResultAllItemsViewModel(movieIds, showIds, artistIds, musicVideoIds);
}
private Task<List<int>> GetIds(string type, string query) =>
_searchIndex.Search($"type:{type} AND ({query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
}
}

8
ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodes.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Application.MediaCards;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexEpisodes
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionEpisodeCardResultsViewModel>;
}

56
ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs

@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes,
TelevisionEpisodeCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexEpisodesHandler(
ISearchIndex searchIndex,
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
QuerySearchIndexEpisodes request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionEpisodeCardViewModel> items = await _televisionRepository
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby, true)).ToList());
return new TelevisionEpisodeCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

1
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Search @@ -5,6 +5,7 @@ namespace ErsatzTV.Application.Search
public record SearchResultAllItemsViewModel(
List<int> MovieIds,
List<int> ShowIds,
List<int> EpisodeIds,
List<int> ArtistIds,
List<int> MusicVideoIds);
}

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

@ -11,17 +11,14 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -11,17 +11,14 @@ namespace ErsatzTV.Core.Tests.Fakes
public class FakeTelevisionRepository : ITelevisionRepository
{
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
public Task<int> GetShowCount() => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
@ -89,10 +86,18 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -89,10 +86,18 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
throw new NotSupportedException();
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
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) =>
throw new NotSupportedException();
public Task<bool> Update(Show show) => throw new NotSupportedException();

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

@ -9,14 +9,17 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -9,14 +9,17 @@ namespace ErsatzTV.Core.Tests.Scheduling
public class MultiPartEpisodeGrouperTests
{
[Test]
public void NotGrouped_Grouped_NotGrouped()
[TestCase("Episode 1", "Episode 2 (1)", "Episode 3 (2)", "Episode 4")]
[TestCase("Episode 1 - More", "Episode 2 (1) - Title", "Episode 3 (2) - After", "Episode 4 - Dash")]
[TestCase("Episode 1", "Episode 2 Part 1", "Episode 3 Part 2", "Episode 4")]
public void NotGrouped_Grouped_NotGrouped(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1"),
NamedEpisode("Episode 2 (1)"),
NamedEpisode("Episode 3 (2)"),
NamedEpisode("Episode 4")
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three),
NamedEpisode(four)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
@ -29,13 +32,16 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -29,13 +32,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
[Test]
public void Grouped_NotGrouped()
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3")]
public void Grouped_NotGrouped(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1 (1)"),
NamedEpisode("Episode 2 (2)"),
NamedEpisode("Episode 3")
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
@ -47,15 +53,23 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -47,15 +53,23 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
[Test]
public void Grouped_NotGrouped_Grouped()
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3", "Episode 4 (1)", "Episode 5 (2)")]
[TestCase(
"Episode 1 (1) - More",
"Episode 2 (2) - Title",
"Episode 3 - After",
"Episode 4 (1) - Dash",
"Episode 5 (2) - Again")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3", "Episode 4 Part 1", "Episode 5 Part 2")]
public void Grouped_NotGrouped_Grouped(string one, string two, string three, string four, string five)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1 (1)"),
NamedEpisode("Episode 2 (2)"),
NamedEpisode("Episode 3"),
NamedEpisode("Episode 4 (1)"),
NamedEpisode("Episode 5 (2)")
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three),
NamedEpisode(four),
NamedEpisode(five)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
@ -69,14 +83,17 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -69,14 +83,17 @@ namespace ErsatzTV.Core.Tests.Scheduling
}
[Test]
public void Grouped_Grouped()
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3 (1)", "Episode 4 (2)")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 (1) - After", "Episode 4 (2) - Dash")]
[TestCase("Episode 1 Part 1", "Episode 2 Part 2", "Episode 3 Part 1", "Episode 4 Part 2")]
public void Grouped_Grouped(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode("Episode 1 (1)"),
NamedEpisode("Episode 2 (2)"),
NamedEpisode("Episode 3 (1)"),
NamedEpisode("Episode 4 (2)")
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three),
NamedEpisode(four)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
@ -88,6 +105,75 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -88,6 +105,75 @@ namespace ErsatzTV.Core.Tests.Scheduling
result[1].Additional[0].Should().Be(mediaItems[3]);
}
[Test]
[TestCase("Episode 1", "Episode 2 (2)", "Episode 3 (1)", "Episode 4 (2)")]
[TestCase("Episode 1 - More", "Episode 2 (2) - Title", "Episode 3 (1) - After", "Episode 4 (2) - Dash")]
[TestCase("Episode 1", "Episode 2 Part 2", "Episode 3 Part 1", "Episode 4 Part 2")]
public void Part2_Without_Part1(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three),
NamedEpisode(four)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
result.Count.Should().Be(3);
result[0].First.Should().Be(mediaItems[0]);
result[1].First.Should().Be(mediaItems[1]);
result[2].First.Should().Be(mediaItems[2]);
result[2].Additional[0].Should().Be(mediaItems[3]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 3 (3)", "Episode 4", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (3) - Title", "Episode 4 - After", "Episode 5 - Dash")]
[TestCase("Episode 1 Part 1", "Episode 3 Part 3", "Episode 4", "Episode 5")]
public void Skip_Part(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three),
NamedEpisode(four)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
result.Count.Should().Be(4);
result[0].First.Should().Be(mediaItems[0]);
result[1].First.Should().Be(mediaItems[1]);
result[2].First.Should().Be(mediaItems[2]);
result[3].First.Should().Be(mediaItems[3]);
}
[Test]
[TestCase("Episode 1 (1)", "Episode 3 (1)", "Episode 4 (2)", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (1) - Title", "Episode 4 (2) - After", "Episode 5 - Dash")]
[TestCase("Episode 1 Part 1", "Episode 3 Part 1", "Episode 4 Part 2", "Episode 5")]
public void Repeat_Part(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one),
NamedEpisode(two),
NamedEpisode(three),
NamedEpisode(four)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
result.Count.Should().Be(3);
result[0].First.Should().Be(mediaItems[0]);
result[1].First.Should().Be(mediaItems[1]);
result[1].Additional[0].Should().Be(mediaItems[2]);
result[2].First.Should().Be(mediaItems[3]);
}
private static Episode NamedEpisode(string title) =>
new()
{

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

@ -1,4 +1,6 @@ @@ -1,4 +1,6 @@
namespace ErsatzTV.Core.Domain
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class EpisodeMetadata : Metadata
{
@ -7,5 +9,7 @@ @@ -7,5 +9,7 @@
public string Tagline { get; set; }
public int EpisodeId { get; set; }
public Episode Episode { get; set; }
public List<Director> Directors { get; set; }
public List<Writer> Writers { get; set; }
}
}

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

@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Domain @@ -19,5 +19,6 @@ namespace ErsatzTV.Core.Domain
public List<Tag> Tags { get; set; }
public List<Studio> Studios { get; set; }
public List<Actor> Actors { get; set; }
public List<MetadataGuid> Guids { get; set; }
}
}

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

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

204
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -120,8 +120,6 @@ namespace ErsatzTV.Core.Emby @@ -120,8 +120,6 @@ namespace ErsatzTV.Core.Emby
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
var changed = false;
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@ -135,7 +133,6 @@ namespace ErsatzTV.Core.Emby @@ -135,7 +133,6 @@ namespace ErsatzTV.Core.Emby
"UPDATE: Etag has changed for show {Show}",
incoming.ShowMetadata.Head().Title);
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyShow> updated = await _televisionRepository.Update(incoming);
@ -148,7 +145,6 @@ namespace ErsatzTV.Core.Emby @@ -148,7 +145,6 @@ namespace ErsatzTV.Core.Emby
},
async () =>
{
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
@ -159,50 +155,45 @@ namespace ErsatzTV.Core.Emby @@ -159,50 +155,45 @@ namespace ErsatzTV.Core.Emby
}
});
if (changed)
{
List<EmbyItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
List<EmbyItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { incoming });
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@ -218,8 +209,6 @@ namespace ErsatzTV.Core.Emby @@ -218,8 +209,6 @@ namespace ErsatzTV.Core.Emby
{
foreach (EmbySeason incoming in seasons)
{
var changed = false;
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@ -234,7 +223,6 @@ namespace ErsatzTV.Core.Emby @@ -234,7 +223,6 @@ namespace ErsatzTV.Core.Emby
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@ -242,7 +230,6 @@ namespace ErsatzTV.Core.Emby @@ -242,7 +230,6 @@ namespace ErsatzTV.Core.Emby
},
async () =>
{
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@ -254,68 +241,68 @@ namespace ErsatzTV.Core.Emby @@ -254,68 +241,68 @@ namespace ErsatzTV.Core.Emby
await _televisionRepository.AddSeason(incoming);
});
if (changed)
{
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
await maybeEpisodes.Match(
async episodes =>
{
var validEpisodes = new List<EmbyEpisode>();
foreach (EmbyEpisode episode in episodes)
{
var validEpisodes = new List<EmbyEpisode>();
foreach (EmbyEpisode episode in episodes)
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping emby episode that does not exist at {Path}",
localPath);
}
else
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping emby episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
validEpisodes.Add(episode);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@ -354,7 +341,13 @@ namespace ErsatzTV.Core.Emby @@ -354,7 +341,13 @@ namespace ErsatzTV.Core.Emby
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
await _televisionRepository.Update(incoming);
Option<EmbyEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
}
catch (Exception ex)
{
@ -379,7 +372,10 @@ namespace ErsatzTV.Core.Emby @@ -379,7 +372,10 @@ namespace ErsatzTV.Core.Emby
seasonName,
incoming.EpisodeNumber);
await _televisionRepository.AddEpisode(incoming);
if (await _televisionRepository.AddEpisode(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{

12
ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
@ -46,7 +47,14 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -46,7 +47,14 @@ namespace ErsatzTV.Core.Interfaces.Plex
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, MediaVersion>> GetStatistics(
Task<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>> GetMovieMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token);

4
ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs

@ -16,10 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -16,10 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddSeason(EmbySeason season);
Task<Unit> Update(EmbySeason season);
Task<bool> AddEpisode(EmbyEpisode episode);
Task<Unit> Update(EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
Task<Unit> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds);
Task<List<int>> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds);
Task<Unit> DeleteEmptySeasons(EmbyLibrary library);
Task<List<int>> DeleteEmptyShows(EmbyLibrary library);
}

4
ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs

@ -16,10 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -16,10 +16,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddSeason(JellyfinSeason season);
Task<Unit> Update(JellyfinSeason season);
Task<bool> AddEpisode(JellyfinEpisode episode);
Task<Unit> Update(JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
Task<Unit> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds);
Task<List<int>> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds);
Task<Unit> DeleteEmptySeasons(JellyfinLibrary library);
Task<List<int>> DeleteEmptyShows(JellyfinLibrary library);
}

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

@ -28,5 +28,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -28,5 +28,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Unit> SetContentRating(ShowMetadata metadata, string contentRating);
Task<Unit> MarkAsExternal(MovieMetadata metadata);
Task<Unit> SetContentRating(MovieMetadata metadata, string contentRating);
Task<bool> RemoveGuid(MetadataGuid guid);
Task<bool> AddGuid(Domain.Metadata metadata, MetadataGuid guid);
Task<bool> RemoveDirector(Director director);
Task<bool> RemoveWriter(Writer writer);
}
}

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

@ -33,9 +33,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -33,9 +33,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds);
Task<bool> AddEmby(EmbyMovie movie);
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
Task<bool> RemoveDirector(Director director);
Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> RemoveWriter(Writer writer);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
}
}

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

@ -9,11 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -9,11 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface ITelevisionRepository
{
Task<bool> AllShowsExist(List<int> showIds);
Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId);
Task<int> GetShowCount();
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId);
Task<List<Season>> GetAllSeasons();
Task<Option<Season>> GetSeason(int seasonId);
@ -46,7 +46,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -46,7 +46,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddActor(EpisodeMetadata metadata, Actor actor);
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
}
}

205
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -120,8 +120,6 @@ namespace ErsatzTV.Core.Jellyfin @@ -120,8 +120,6 @@ namespace ErsatzTV.Core.Jellyfin
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
var changed = false;
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@ -135,7 +133,6 @@ namespace ErsatzTV.Core.Jellyfin @@ -135,7 +133,6 @@ namespace ErsatzTV.Core.Jellyfin
"UPDATE: Etag has changed for show {Show}",
incoming.ShowMetadata.Head().Title);
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinShow> updated = await _televisionRepository.Update(incoming);
@ -148,7 +145,6 @@ namespace ErsatzTV.Core.Jellyfin @@ -148,7 +145,6 @@ namespace ErsatzTV.Core.Jellyfin
},
async () =>
{
changed = true;
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
@ -159,50 +155,45 @@ namespace ErsatzTV.Core.Jellyfin @@ -159,50 +155,45 @@ namespace ErsatzTV.Core.Jellyfin
}
});
if (changed)
{
List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems(
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeSeasons.Match(
async seasons =>
{
await ProcessSeasons(
address,
apiKey,
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { incoming });
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
library,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@ -218,8 +209,6 @@ namespace ErsatzTV.Core.Jellyfin @@ -218,8 +209,6 @@ namespace ErsatzTV.Core.Jellyfin
{
foreach (JellyfinSeason incoming in seasons)
{
var changed = false;
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
@ -234,7 +223,6 @@ namespace ErsatzTV.Core.Jellyfin @@ -234,7 +223,6 @@ namespace ErsatzTV.Core.Jellyfin
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@ -242,7 +230,6 @@ namespace ErsatzTV.Core.Jellyfin @@ -242,7 +230,6 @@ namespace ErsatzTV.Core.Jellyfin
},
async () =>
{
changed = true;
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
@ -254,68 +241,69 @@ namespace ErsatzTV.Core.Jellyfin @@ -254,68 +241,69 @@ namespace ErsatzTV.Core.Jellyfin
await _televisionRepository.AddSeason(incoming);
});
if (changed)
{
List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
await _jellyfinApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
await _jellyfinApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
await maybeEpisodes.Match(
async episodes =>
{
var validEpisodes = new List<JellyfinEpisode>();
foreach (JellyfinEpisode episode in episodes)
{
var validEpisodes = new List<JellyfinEpisode>();
foreach (JellyfinEpisode episode in episodes)
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping jellyfin episode that does not exist at {Path}",
localPath);
}
else
{
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping jellyfin episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
validEpisodes.Add(episode);
}
}
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
return Task.CompletedTask;
});
}
}
@ -354,7 +342,13 @@ namespace ErsatzTV.Core.Jellyfin @@ -354,7 +342,13 @@ namespace ErsatzTV.Core.Jellyfin
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
await _televisionRepository.Update(incoming);
Option<JellyfinEpisode> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
}
catch (Exception ex)
{
@ -379,7 +373,10 @@ namespace ErsatzTV.Core.Jellyfin @@ -379,7 +373,10 @@ namespace ErsatzTV.Core.Jellyfin
seasonName,
incoming.EpisodeNumber);
await _televisionRepository.AddEpisode(incoming);
if (await _televisionRepository.AddEpisode(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{

129
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -212,6 +212,66 @@ namespace ErsatzTV.Core.Metadata @@ -212,6 +212,66 @@ namespace ErsatzTV.Core.Metadata
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
foreach (Director director in metadata.Directors
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _televisionRepository.AddDirector(existing, director))
{
updated = true;
}
}
foreach (Writer writer in existing.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
foreach (Writer writer in metadata.Writers
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _televisionRepository.AddWriter(existing, writer))
{
updated = true;
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
@ -261,17 +321,17 @@ namespace ErsatzTV.Core.Metadata @@ -261,17 +321,17 @@ namespace ErsatzTV.Core.Metadata
_movieRepository.AddActor);
foreach (Director director in existing.Directors
.Filter(g => metadata.Directors.All(g2 => g2.Name != g.Name)).ToList())
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Remove(director);
if (await _movieRepository.RemoveDirector(director))
if (await _metadataRepository.RemoveDirector(director))
{
updated = true;
}
}
foreach (Director director in metadata.Directors
.Filter(g => existing.Directors.All(g2 => g2.Name != g.Name)).ToList())
.Filter(d => existing.Directors.All(d2 => d2.Name != d.Name)).ToList())
{
existing.Directors.Add(director);
if (await _movieRepository.AddDirector(existing, director))
@ -281,17 +341,17 @@ namespace ErsatzTV.Core.Metadata @@ -281,17 +341,17 @@ namespace ErsatzTV.Core.Metadata
}
foreach (Writer writer in existing.Writers
.Filter(g => metadata.Writers.All(g2 => g2.Name != g.Name)).ToList())
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Remove(writer);
if (await _movieRepository.RemoveWriter(writer))
if (await _metadataRepository.RemoveWriter(writer))
{
updated = true;
}
}
foreach (Writer writer in metadata.Writers
.Filter(g => existing.Writers.All(g2 => g2.Name != g.Name)).ToList())
.Filter(w => existing.Writers.All(w2 => w2.Name != w.Name)).ToList())
{
existing.Writers.Add(writer);
if (await _movieRepository.AddWriter(existing, writer))
@ -300,6 +360,26 @@ namespace ErsatzTV.Core.Metadata @@ -300,6 +360,26 @@ namespace ErsatzTV.Core.Metadata
}
}
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
@ -345,6 +425,26 @@ namespace ErsatzTV.Core.Metadata @@ -345,6 +425,26 @@ namespace ErsatzTV.Core.Metadata
_televisionRepository.AddStudio,
_televisionRepository.AddActor);
foreach (MetadataGuid guid in existing.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
updated = true;
}
}
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => existing.Guids.All(g2 => g2.Guid != g.Guid)).ToList())
{
existing.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existing, guid))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
@ -522,7 +622,10 @@ namespace ErsatzTV.Core.Metadata @@ -522,7 +622,10 @@ namespace ErsatzTV.Core.Metadata
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList()
};
},
None);
@ -582,7 +685,12 @@ namespace ErsatzTV.Core.Metadata @@ -582,7 +685,12 @@ namespace ErsatzTV.Core.Metadata
Title = nfo.Title,
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
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);
},
@ -624,7 +732,10 @@ namespace ErsatzTV.Core.Metadata @@ -624,7 +732,10 @@ namespace ErsatzTV.Core.Metadata
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
Actors = Actors(nfo.Actors, dateAdded, dateUpdated),
Directors = nfo.Directors.Map(d => new Director { Name = d }).ToList(),
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList()
Writers = nfo.Writers.Map(w => new Writer { Name = w }).ToList(),
Guids = nfo.UniqueIds
.Map(id => new MetadataGuid { Guid = $"{id.Type}://{id.Guid}" })
.ToList()
};
},
None);

3
ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs

@ -45,5 +45,8 @@ namespace ErsatzTV.Core.Metadata.Nfo @@ -45,5 +45,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
}

9
ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs

@ -29,5 +29,14 @@ namespace ErsatzTV.Core.Metadata.Nfo @@ -29,5 +29,14 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("credits")]
public List<string> Writers { get; set; }
[XmlElement("director")]
public List<string> Directors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
}

3
ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs

@ -38,5 +38,8 @@ namespace ErsatzTV.Core.Metadata.Nfo @@ -38,5 +38,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
[XmlElement("uniqueid")]
public List<UniqueIdNfo> UniqueIds { get; set; }
}
}

16
ErsatzTV.Core/Metadata/Nfo/UniqueIdNfo.cs

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo
{
public class UniqueIdNfo
{
[XmlAttribute("default")]
public bool Default { get; set; }
[XmlAttribute("type")]
public string Type { get; set; }
[XmlText]
public string Guid { get; set; }
}
}

14
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -209,10 +209,20 @@ namespace ErsatzTV.Core.Metadata @@ -209,10 +209,20 @@ namespace ErsatzTV.Core.Metadata
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
maybeEpisode.IfLeft(
error => _logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value));
await maybeEpisode.Match(
async episode =>
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { episode });
},
error =>
{
_logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
// TODO: remove missing episodes?
return Unit.Default;
}

44
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@ -63,7 +64,7 @@ namespace ErsatzTV.Core.Plex @@ -63,7 +64,7 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateStatistics(existing, incoming, library, connection, token))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
@ -114,6 +115,7 @@ namespace ErsatzTV.Core.Plex @@ -114,6 +115,7 @@ namespace ErsatzTV.Core.Plex
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
@ -123,12 +125,18 @@ namespace ErsatzTV.Core.Plex @@ -123,12 +125,18 @@ namespace ErsatzTV.Core.Plex
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
Either<BaseError, Tuple<MovieMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetMovieMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async mediaVersion =>
async tuple =>
{
(MovieMetadata _, MediaVersion mediaVersion) = tuple;
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Statistics",
@ -259,7 +267,7 @@ namespace ErsatzTV.Core.Plex @@ -259,7 +267,7 @@ namespace ErsatzTV.Core.Plex
.ToList())
{
existingMetadata.Directors.Remove(director);
if (await _movieRepository.RemoveDirector(director))
if (await _metadataRepository.RemoveDirector(director))
{
result.IsUpdated = true;
}
@ -281,7 +289,7 @@ namespace ErsatzTV.Core.Plex @@ -281,7 +289,7 @@ namespace ErsatzTV.Core.Plex
.ToList())
{
existingMetadata.Writers.Remove(writer);
if (await _movieRepository.RemoveWriter(writer))
if (await _metadataRepository.RemoveWriter(writer))
{
result.IsUpdated = true;
}
@ -298,6 +306,28 @@ namespace ErsatzTV.Core.Plex @@ -298,6 +306,28 @@ namespace ErsatzTV.Core.Plex
}
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = fullMetadata.SortTitle;

126
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@ -70,17 +71,7 @@ namespace ErsatzTV.Core.Plex @@ -70,17 +71,7 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
if (result.IsAdded || incoming.ShowMetadata.Head().DateUpdated >
result.Item.ShowMetadata.Head().DateUpdated)
{
await ScanSeasons(library, result.Item, connection, token);
}
else
{
_logger.LogDebug(
"Skipping Plex show that has not been updated: {Show}",
incoming.ShowMetadata.Head().Title);
}
await ScanSeasons(library, result.Item, connection, token);
if (result.IsAdded)
{
@ -227,6 +218,28 @@ namespace ErsatzTV.Core.Plex @@ -227,6 +218,28 @@ namespace ErsatzTV.Core.Plex
}
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
@ -278,7 +291,7 @@ namespace ErsatzTV.Core.Plex @@ -278,7 +291,7 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateArtwork(existing, incoming));
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match(
async season => await ScanEpisodes(plexMediaSourceLibrary, season, connection, token),
@ -308,13 +321,31 @@ namespace ErsatzTV.Core.Plex @@ -308,13 +321,31 @@ namespace ErsatzTV.Core.Plex
});
}
private async Task<Either<BaseError, PlexSeason>> UpdateArtwork(PlexSeason existing, PlexSeason incoming)
private async Task<Either<BaseError, PlexSeason>> UpdateMetadataAndArtwork(
PlexSeason existing,
PlexSeason incoming)
{
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
@ -344,18 +375,34 @@ namespace ErsatzTV.Core.Plex @@ -344,18 +375,34 @@ namespace ErsatzTV.Core.Plex
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexEpisode> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(
existing => UpdateMetadataAndStatistics(
existing,
incoming,
plexMediaSourceLibrary,
connection,
token))
.BindT(existing => UpdateArtwork(existing, incoming));
maybeEpisode.IfLeft(
error => _logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value));
await maybeEpisode.Match(
async episode =>
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { episode });
},
error =>
{
_logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
var episodeKeys = episodeEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
},
@ -370,9 +417,10 @@ namespace ErsatzTV.Core.Plex @@ -370,9 +417,10 @@ namespace ErsatzTV.Core.Plex
});
}
private async Task<Either<BaseError, PlexEpisode>> UpdateStatistics(
private async Task<Either<BaseError, PlexEpisode>> UpdateMetadataAndStatistics(
PlexEpisode existing,
PlexEpisode incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
@ -381,12 +429,36 @@ namespace ErsatzTV.Core.Plex @@ -381,12 +429,36 @@ namespace ErsatzTV.Core.Plex
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
Either<BaseError, MediaVersion> maybeStatistics =
await _plexServerApiClient.GetStatistics(incoming.Key.Split("/").Last(), connection, token);
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeStatistics.Match(
async mediaVersion =>
async tuple =>
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
@ -399,7 +471,9 @@ namespace ErsatzTV.Core.Plex @@ -399,7 +471,9 @@ namespace ErsatzTV.Core.Plex
return Right<BaseError, PlexEpisode>(existing);
}
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(PlexEpisode existing, PlexEpisode incoming)
private async Task<Either<BaseError, PlexEpisode>> UpdateArtwork(
PlexEpisode existing,
PlexEpisode incoming)
{
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();

50
ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs

@ -15,15 +15,27 @@ namespace ErsatzTV.Core.Scheduling @@ -15,15 +15,27 @@ namespace ErsatzTV.Core.Scheduling
var groups = new List<GroupedMediaItem>();
GroupedMediaItem group = null;
var lastNumber = 0;
void AddUngrouped(MediaItem item)
{
if (group != null && lastNumber != 0)
{
groups.Add(group);
group = null;
lastNumber = 0;
}
groups.Add(new GroupedMediaItem(item, null));
}
foreach (MediaItem item in sortedMediaItems)
{
if (item is Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success)
string numberString = FindPartNumber(e);
if (numberString != null)
{
var number = int.Parse(match.Groups[1].Value);
var number = int.Parse(numberString);
if (number <= lastNumber && group != null)
{
groups.Add(group);
@ -48,28 +60,20 @@ namespace ErsatzTV.Core.Scheduling @@ -48,28 +60,20 @@ namespace ErsatzTV.Core.Scheduling
else
{
// this should never happen
throw new InvalidOperationException("Bad shuffle state");
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
}
lastNumber = number;
}
else
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber}");
AddUngrouped(item);
}
}
else
{
if (group != null && lastNumber != 0)
{
groups.Add(group);
group = null;
lastNumber = 0;
}
groups.Add(new GroupedMediaItem(item, null));
AddUngrouped(item);
}
}
else
@ -86,6 +90,20 @@ namespace ErsatzTV.Core.Scheduling @@ -86,6 +90,20 @@ namespace ErsatzTV.Core.Scheduling
return groups;
}
private static string FindPartNumber(Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success)
{
return match.Groups[1].Value;
}
const string PATTERN_2 = @"^.*Part (\d+)$";
Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2);
return match2.Success ? match2.Groups[1].Value : null;
}
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
{
var result = new MediaItem[mediaItemCount];

12
ErsatzTV.Infrastructure/Data/Configurations/Metadata/EpisodeMetadataConfiguration.cs

@ -17,6 +17,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -17,6 +17,18 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(em => em.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Directors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Writers)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

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

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

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

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

@ -13,6 +13,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -13,6 +13,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(em => em.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

@ -29,6 +29,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -29,6 +29,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.HasMany(sm => sm.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(mm => mm.Guids)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

1
ErsatzTV.Infrastructure/Data/Repositories/ArtworkRepository.cs

@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE A.ArtistMetadataId IS NULL AND A.EpisodeMetadataId IS NULL
AND A.SeasonMetadataId IS NULL AND A.ShowMetadataId IS NULL
AND A.MovieMetadataId IS NULL AND A.MusicVideoMetadataId IS NULL
AND A.ChannelId IS NULL
AND NOT EXISTS (SELECT * FROM Actor WHERE Actor.ArtworkId = A.Id)")
.Map(result => result.ToList());

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

@ -88,6 +88,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -88,6 +88,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Actors)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -177,6 +179,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -177,6 +179,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Actors.Add(actor);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@ -246,6 +263,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -246,6 +263,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == season.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -322,6 +341,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -322,6 +341,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@ -345,20 +379,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -345,20 +379,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Unit> Update(EmbyEpisode episode)
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<EmbyEpisode> maybeExisting = await dbContext.EmbyEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -402,6 +453,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -402,6 +453,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@ -422,7 +518,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -422,7 +518,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync();
return Unit.Default;
return maybeExisting;
}
public async Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds)
@ -454,14 +550,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -454,14 +550,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
public Task<Unit> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds) =>
_dbConnection.ExecuteAsync(
public async Task<List<int>> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode ee ON ee.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND ee.ItemId IN @EpisodeIds",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode je ON je.Id = m.Id
INNER JOIN EmbyEpisode ee ON ee.Id = m.Id
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit();
WHERE LP.LibraryId = @LibraryId AND ee.ItemId IN @EpisodeIds)",
new { LibraryId = library.Id, EpisodeIds = episodeIds });
return ids;
}
public async Task<Unit> DeleteEmptySeasons(EmbyLibrary library)
{

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

@ -88,6 +88,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -88,6 +88,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Actors)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.ShowMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -177,6 +179,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -177,6 +179,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Actors.Add(actor);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@ -263,6 +280,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -263,6 +280,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == season.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -322,6 +341,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -322,6 +341,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@ -345,20 +379,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -345,20 +379,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
public async Task<Unit> Update(JellyfinEpisode episode)
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinEpisode> maybeExisting = await dbContext.JellyfinEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -402,6 +453,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -402,6 +453,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
var paths = incomingMetadata.Artwork.Map(a => a.Path).ToList();
foreach (Artwork artworkToRemove in metadata.Artwork.Filter(a => !paths.Contains(a.Path)))
{
@ -422,7 +518,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -422,7 +518,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync();
return Unit.Default;
return maybeExisting;
}
public async Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds)
@ -454,14 +550,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -454,14 +550,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
public Task<Unit> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds) =>
_dbConnection.ExecuteAsync(
public async Task<List<int>> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode je ON je.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode je ON je.Id = m.Id
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit();
new { LibraryId = library.Id, EpisodeIds = episodeIds });
return ids;
}
public async Task<Unit> DeleteEmptySeasons(JellyfinLibrary library)
{

9
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -155,9 +155,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -155,9 +155,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Directors)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).EpisodeMetadata)
.ThenInclude(em => em.Writers)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Episode).Season)
.ThenInclude(s => s.SeasonMetadata)
.OrderBy(c => c.Id)
.SingleOrDefaultAsync(c => c.Id == id)
.Map(Optional);

47
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -354,10 +354,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -354,10 +354,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
List<int> movieIds = await context.PlexMovies.Map(pm => pm.Id).ToListAsync();
List<int> showIds = await context.PlexShows.Map(ps => ps.Id).ToListAsync();
List<int> episodeIds = await context.PlexEpisodes.Map(pe => pe.Id).ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
@ -404,6 +405,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -404,6 +405,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@ -439,7 +448,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -439,7 +448,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
@ -561,6 +570,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -561,6 +570,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@ -596,7 +613,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -596,7 +613,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@ -708,9 +725,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -708,9 +725,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(ps => ps.Id)
.ToListAsync();
List<int> episodeIds = await context.JellyfinEpisodes
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem)
@ -904,9 +926,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -904,9 +926,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(ps => ps.Id)
.ToListAsync();
List<int> episodeIds = await context.EmbyEpisodes
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
.Map(ps => ps.Id)
.ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
public Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds) =>
@ -941,6 +968,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -941,6 +968,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
@ -976,7 +1011,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -976,7 +1011,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
return movieIds.Append(showIds).Append(episodeIds).ToList();
}
}
}

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

@ -75,6 +75,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -75,6 +75,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
foreach (MetadataGuid guid in Optional(metadata.Guids).Flatten())
{
dbContext.Entry(guid).State = EntityState.Added;
}
if (metadata is MovieMetadata movieMetadata)
{
foreach (Director director in Optional(movieMetadata.Directors).Flatten())
@ -88,6 +93,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -88,6 +93,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
if (metadata is EpisodeMetadata episodeMetadata)
{
foreach (Director director in Optional(episodeMetadata.Directors).Flatten())
{
dbContext.Entry(director).State = EntityState.Added;
}
foreach (Writer writer in Optional(episodeMetadata.Writers).Flatten())
{
dbContext.Entry(writer).State = EntityState.Added;
}
}
return await dbContext.SaveChangesAsync() > 0;
}
@ -261,6 +279,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -261,6 +279,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"UPDATE MovieMetadata SET ContentRating = @ContentRating WHERE Id = @Id",
new { metadata.Id, ContentRating = contentRating }).ToUnit();
public Task<bool> RemoveGuid(MetadataGuid guid) =>
_dbConnection.ExecuteAsync("DELETE FROM MetadataGuid WHERE Id = @GuidId", new { GuidId = guid.Id })
.Map(result => result > 0);
public Task<bool> AddGuid(Metadata metadata, MetadataGuid guid) =>
metadata switch
{
MovieMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, MovieMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
ShowMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, ShowMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
SeasonMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, SeasonMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
EpisodeMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, EpisodeMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
ArtistMetadata =>
_dbConnection.ExecuteAsync(
"INSERT INTO MetadataGuid (Guid, ArtistMetadataId) VALUES (@Guid, @MetadataId)",
new { guid.Guid, MetadataId = metadata.Id }).Map(result => result > 0),
_ => throw new NotSupportedException()
};
public Task<bool> RemoveDirector(Director director) =>
_dbConnection.ExecuteAsync("DELETE FROM Director WHERE Id = @DirectorId", new { DirectorId = director.Id })
.Map(result => result > 0);
public Task<bool> RemoveWriter(Writer writer) =>
_dbConnection.ExecuteAsync("DELETE FROM Writer WHERE Id = @WriterId", new { WriterId = writer.Id })
.Map(result => result > 0);
public Task<bool> RemoveGenre(Genre genre) =>
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
.Map(result => result > 0);

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

@ -119,6 +119,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -119,6 +119,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Directors)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
@ -328,6 +330,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -328,6 +330,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Directors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -447,6 +451,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -447,6 +451,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@ -565,6 +584,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -565,6 +584,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
@ -684,6 +705,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -684,6 +705,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
@ -737,19 +773,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -737,19 +773,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return maybeExisting;
}
public Task<bool> RemoveDirector(Director director) =>
_dbConnection.ExecuteAsync("DELETE FROM Director WHERE Id = @DirectorId", new { DirectorId = director.Id })
.Map(result => result > 0);
public Task<bool> AddDirector(MovieMetadata metadata, Director director) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Director (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { director.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> RemoveWriter(Writer writer) =>
_dbConnection.ExecuteAsync("DELETE FROM Writer WHERE Id = @WriterId", new { WriterId = writer.Id })
.Map(result => result > 0);
public Task<bool> AddWriter(MovieMetadata metadata, Writer writer) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Writer (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",

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

@ -47,6 +47,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -47,6 +47,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Writers)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Genres)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Tags)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Studios)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Actors)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Directors)
.Include(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Writers)
.Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(em => em.Streams)
.Include(mi => (mi as Episode).Season)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata)

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

@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowIds = showIds })
.Map(c => c == showIds.Count);
public Task<bool> AllEpisodesExist(List<int> episodeIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds",
new { EpisodeIds = episodeIds })
.Map(c => c == episodeIds.Count);
public async Task<List<Show>> GetAllShows()
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -63,39 +69,35 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -63,39 +69,35 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public async Task<int> GetShowCount()
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ShowMetadata
.AsNoTracking()
.GroupBy(sm => new { sm.Title, sm.Year })
.CountAsync();
}
public async Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ShowMetadata.FromSqlRaw(
@"SELECT * FROM ShowMetadata WHERE Id IN
(SELECT MIN(Id) FROM ShowMetadata GROUP BY Title, Year, MetadataKind HAVING MetadataKind = MAX(MetadataKind))
ORDER BY SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.Include(mm => mm.Artwork)
.OrderBy(mm => mm.SortTitle)
.Filter(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.SortTitle)
.ToListAsync();
}
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ShowMetadata
return await dbContext.EpisodeMetadata
.AsNoTracking()
.Filter(sm => ids.Contains(sm.ShowId))
.Include(sm => sm.Artwork)
.OrderBy(sm => sm.SortTitle)
.Filter(em => ids.Contains(em.EpisodeId))
.Include(em => em.Artwork)
.Include(em => em.Directors)
.Include(em => em.Writers)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.OrderBy(em => em.SortTitle)
.ToListAsync();
}
@ -187,6 +189,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -187,6 +189,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.AsNoTracking()
.Filter(em => em.Episode.SeasonId == seasonId)
.Include(em => em.Artwork)
.Include(em => em.Directors)
.Include(em => em.Writers)
.Include(em => em.Episode)
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
@ -225,6 +229,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -225,6 +229,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(s => s.Id)
@ -248,6 +254,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -248,6 +254,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Guids ??= new List<MetadataGuid>();
var show = new Show
{
LibraryPathId = libraryPathId,
@ -274,6 +281,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -274,6 +281,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<Season> maybeExisting = await dbContext.Seasons
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.OrderBy(s => s.ShowId)
.ThenBy(s => s.SeasonNumber)
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
@ -293,12 +302,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -293,12 +302,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Genres)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Tags)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Studios)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.Season)
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
@ -398,6 +422,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -398,6 +422,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(a => a.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(i => i.Key)
@ -415,7 +441,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -415,7 +441,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@ -431,6 +459,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -431,6 +459,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
@ -438,6 +476,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -438,6 +476,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.ThenInclude(a => a.Artwork)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(e => e.Season)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@ -456,15 +499,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -456,15 +499,27 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE P.Key = @ShowKey AND ps.Key not in @Keys)",
new { ShowKey = showKey, Keys = seasonKeys }).ToUnit();
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
_dbConnection.ExecuteAsync(
public async Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN Episode e ON m.Id = e.Id
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN PlexSeason P on P.Id = e.SeasonId
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys",
new { SeasonKey = seasonKey, Keys = episodeKeys }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN Episode e ON m.Id = e.Id
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN PlexSeason P on P.Id = e.SeasonId
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys)",
new { SeasonKey = seasonKey, Keys = episodeKeys }).ToUnit();
new { SeasonKey = seasonKey, Keys = episodeKeys });
return ids;
}
public async Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber)
{
@ -475,6 +530,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -475,6 +530,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Director (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { director.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Writer (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { writer.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<Episode>> GetShowItems(int showId)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@ -613,7 +678,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -613,7 +678,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
new()
{
DateAdded = DateTime.UtcNow
DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>()
}
}
};
@ -651,7 +717,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -651,7 +717,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue,
MetadataKind = MetadataKind.Fallback,
Actors = new List<Actor>()
Actors = new List<Actor>(),
Guids = new List<MetadataGuid>(),
Writers = new List<Writer>(),
Directors = new List<Director>(),
Genres = new List<Genre>(),
Tags = new List<Tag>(),
Studios = new List<Studio>()
}
},
MediaVersions = new List<MediaVersion>
@ -668,6 +740,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -668,6 +740,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
};
await dbContext.Episodes.AddAsync(episode);
await dbContext.SaveChangesAsync();
await dbContext.Entry(episode).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return episode;
}
catch (Exception ex)
@ -730,10 +805,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -730,10 +805,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
item.LibraryPathId = library.Paths.Head().Id;
EpisodeMetadata metadata = item.EpisodeMetadata.Head();
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Directors ??= new List<Director>();
metadata.Writers ??= new List<Writer>();
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();
return item;
}
catch (Exception ex)

39
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -244,7 +244,8 @@ namespace ErsatzTV.Infrastructure.Emby @@ -244,7 +244,8 @@ namespace ErsatzTV.Infrastructure.Emby
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@ -365,7 +366,8 @@ namespace ErsatzTV.Infrastructure.Emby @@ -365,7 +366,8 @@ namespace ErsatzTV.Infrastructure.Emby
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@ -429,7 +431,8 @@ namespace ErsatzTV.Infrastructure.Emby @@ -429,7 +431,8 @@ namespace ErsatzTV.Infrastructure.Emby
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
Year = item.ProductionYear,
DateAdded = dateAdded,
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
@ -540,7 +543,10 @@ namespace ErsatzTV.Infrastructure.Emby @@ -540,7 +543,10 @@ namespace ErsatzTV.Infrastructure.Emby
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
};
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
@ -561,5 +567,30 @@ namespace ErsatzTV.Infrastructure.Emby @@ -561,5 +567,30 @@ namespace ErsatzTV.Infrastructure.Emby
return metadata;
}
private List<MetadataGuid> GuidsFromProviderIds(EmbyProviderIdsResponse providerIds)
{
var result = new List<MetadataGuid>();
if (providerIds != null)
{
if (!string.IsNullOrWhiteSpace(providerIds.Imdb))
{
result.Add(new MetadataGuid { Guid = $"imdb://{providerIds.Imdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tmdb))
{
result.Add(new MetadataGuid { Guid = $"tmdb://{providerIds.Tmdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tvdb))
{
result.Add(new MetadataGuid { Guid = $"tvdb://{providerIds.Tvdb}" });
}
}
return result;
}
}
}

13
ErsatzTV.Infrastructure/Emby/IEmbyApi.cs

@ -28,7 +28,7 @@ namespace ErsatzTV.Infrastructure.Emby @@ -28,7 +28,7 @@ namespace ErsatzTV.Infrastructure.Emby
string parentId,
[Query]
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating",
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Movie",
[Query]
@ -42,7 +42,7 @@ namespace ErsatzTV.Infrastructure.Emby @@ -42,7 +42,7 @@ namespace ErsatzTV.Infrastructure.Emby
string parentId,
[Query]
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating",
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Series",
[Query]
@ -55,10 +55,12 @@ namespace ErsatzTV.Infrastructure.Emby @@ -55,10 +55,12 @@ namespace ErsatzTV.Infrastructure.Emby
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Taglines",
string fields = "Path,DateCreated,Etag,Taglines,ProviderIds",
[Query]
string includeItemTypes = "Season",
[Query]
string excludeLocationTypes = "Virtual",
[Query]
bool recursive = true);
[Get("/Items")]
@ -68,10 +70,13 @@ namespace ErsatzTV.Infrastructure.Emby @@ -68,10 +70,13 @@ namespace ErsatzTV.Infrastructure.Emby
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType",
string fields =
"Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType,ProviderIds,People",
[Query]
string includeItemTypes = "Episode",
[Query]
string excludeLocationTypes = "Virtual",
[Query]
bool recursive = true);
}
}

1
ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Emby.Models @@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Emby.Models
public List<string> Genres { get; set; }
public List<string> Tags { get; set; }
public int ProductionYear { get; set; }
public EmbyProviderIdsResponse ProviderIds { get; set; }
public string PremiereDate { get; set; }
public List<EmbyMediaStreamResponse> MediaStreams { get; set; }
public List<EmbyMediaSourceResponse> MediaSources { get; set; }

9
ErsatzTV.Infrastructure/Emby/Models/EmbyProviderIdsResponse.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Infrastructure.Emby.Models
{
public class EmbyProviderIdsResponse
{
public string Imdb { get; set; }
public string Tmdb { get; set; }
public string Tvdb { get; set; }
}
}

10
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -34,7 +34,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -34,7 +34,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating",
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Movie",
[Query]
@ -49,7 +50,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -49,7 +50,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating",
string fields =
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,OfficialRating,ProviderIds",
[Query]
string includeItemTypes = "Series",
[Query]
@ -64,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -64,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Taglines",
string fields = "Path,DateCreated,Etag,Taglines,ProviderIds",
[Query]
string includeItemTypes = "Season",
[Query]
@ -79,7 +81,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -79,7 +81,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin
[Query]
string parentId,
[Query]
string fields = "Path,DateCreated,Etag,Overview",
string fields = "Path,DateCreated,Etag,Overview,ProviderIds,People",
[Query]
string includeItemTypes = "Episode",
[Query]

39
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -290,7 +290,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -290,7 +290,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@ -416,7 +417,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -416,7 +417,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
Actors = Optional(item.People).Flatten().Collect(r => ProjectToActor(r, dateAdded)).ToList(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
// set order on actors
@ -485,7 +487,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -485,7 +487,8 @@ namespace ErsatzTV.Infrastructure.Jellyfin
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
Year = item.ProductionYear,
DateAdded = dateAdded,
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds)
};
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
@ -596,7 +599,10 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -596,7 +599,10 @@ namespace ErsatzTV.Infrastructure.Jellyfin
Tags = new List<Tag>(),
Studios = new List<Studio>(),
Actors = new List<Actor>(),
Artwork = new List<Artwork>()
Artwork = new List<Artwork>(),
Guids = GuidsFromProviderIds(item.ProviderIds),
Directors = Optional(item.People).Flatten().Collect(r => ProjectToDirector(r)).ToList(),
Writers = Optional(item.People).Flatten().Collect(r => ProjectToWriter(r)).ToList()
};
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
@ -617,5 +623,30 @@ namespace ErsatzTV.Infrastructure.Jellyfin @@ -617,5 +623,30 @@ namespace ErsatzTV.Infrastructure.Jellyfin
return metadata;
}
private List<MetadataGuid> GuidsFromProviderIds(JellyfinProviderIdsResponse providerIds)
{
var result = new List<MetadataGuid>();
if (providerIds != null)
{
if (!string.IsNullOrWhiteSpace(providerIds.Imdb))
{
result.Add(new MetadataGuid { Guid = $"imdb://{providerIds.Imdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tmdb))
{
result.Add(new MetadataGuid { Guid = $"tmdb://{providerIds.Tmdb}" });
}
if (!string.IsNullOrWhiteSpace(providerIds.Tvdb))
{
result.Add(new MetadataGuid { Guid = $"tvdb://{providerIds.Tvdb}" });
}
}
return result;
}
}
}

1
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs

@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin.Models @@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Jellyfin.Models
public List<string> Genres { get; set; }
public List<string> Tags { get; set; }
public int ProductionYear { get; set; }
public JellyfinProviderIdsResponse ProviderIds { get; set; }
public string PremiereDate { get; set; }
public List<JellyfinMediaStreamResponse> MediaStreams { get; set; }
public string LocationType { get; set; }

9
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinProviderIdsResponse.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models
{
public class JellyfinProviderIdsResponse
{
public string Imdb { get; set; }
public string Tmdb { get; set; }
public string Tvdb { get; set; }
}
}

2862
ErsatzTV.Infrastructure/Migrations/20210527214039_Add_MetadataGuid.Designer.cs generated

File diff suppressed because it is too large Load Diff

124
ErsatzTV.Infrastructure/Migrations/20210527214039_Add_MetadataGuid.cs

@ -0,0 +1,124 @@ @@ -0,0 +1,124 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MetadataGuid : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// local and plex
migrationBuilder.Sql("UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE SeasonMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql("UPDATE EpisodeMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(
@"UPDATE LibraryFolder SET Etag = NULL WHERE LibraryPathId IN
(SELECT LibraryPathId FROM LibraryPath LP
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE L.MediaKind = 1)");
// emby
migrationBuilder.Sql("UPDATE EmbyMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbySeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE EmbyEpisode SET Etag = NULL");
// jellyfin
migrationBuilder.Sql("UPDATE JellyfinMovie SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinShow SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinSeason SET Etag = NULL");
migrationBuilder.Sql("UPDATE JellyfinEpisode SET Etag = NULL");
migrationBuilder.CreateTable(
name: "MetadataGuid",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Guid = table.Column<string>(type: "TEXT", nullable: true),
ArtistMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
EpisodeMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
MovieMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
MusicVideoMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>(type: "INTEGER", nullable: true),
ShowMetadataId = table.Column<int>(type: "INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MetadataGuid", x => x.Id);
table.ForeignKey(
name: "FK_MetadataGuid_ArtistMetadata_ArtistMetadataId",
column: x => x.ArtistMetadataId,
principalTable: "ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_MetadataGuid_EpisodeMetadata_EpisodeMetadataId",
column: x => x.EpisodeMetadataId,
principalTable: "EpisodeMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataGuid_MovieMetadata_MovieMetadataId",
column: x => x.MovieMetadataId,
principalTable: "MovieMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataGuid_MusicVideoMetadata_MusicVideoMetadataId",
column: x => x.MusicVideoMetadataId,
principalTable: "MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_MetadataGuid_SeasonMetadata_SeasonMetadataId",
column: x => x.SeasonMetadataId,
principalTable: "SeasonMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_MetadataGuid_ShowMetadata_ShowMetadataId",
column: x => x.ShowMetadataId,
principalTable: "ShowMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_ArtistMetadataId",
table: "MetadataGuid",
column: "ArtistMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_EpisodeMetadataId",
table: "MetadataGuid",
column: "EpisodeMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_MovieMetadataId",
table: "MetadataGuid",
column: "MovieMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_MusicVideoMetadataId",
table: "MetadataGuid",
column: "MusicVideoMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_SeasonMetadataId",
table: "MetadataGuid",
column: "SeasonMetadataId");
migrationBuilder.CreateIndex(
name: "IX_MetadataGuid_ShowMetadataId",
table: "MetadataGuid",
column: "ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "MetadataGuid");
}
}
}

2886
ErsatzTV.Infrastructure/Migrations/20210529014509_Add_EpisodeMetadataDirectorsWriters.Designer.cs generated

File diff suppressed because it is too large Load Diff

75
ErsatzTV.Infrastructure/Migrations/20210529014509_Add_EpisodeMetadataDirectorsWriters.cs

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EpisodeMetadataDirectorsWriters : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EpisodeMetadataId",
table: "Writer",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "EpisodeMetadataId",
table: "Director",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Writer_EpisodeMetadataId",
table: "Writer",
column: "EpisodeMetadataId");
migrationBuilder.CreateIndex(
name: "IX_Director_EpisodeMetadataId",
table: "Director",
column: "EpisodeMetadataId");
migrationBuilder.AddForeignKey(
name: "FK_Director_EpisodeMetadata_EpisodeMetadataId",
table: "Director",
column: "EpisodeMetadataId",
principalTable: "EpisodeMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Writer_EpisodeMetadata_EpisodeMetadataId",
table: "Writer",
column: "EpisodeMetadataId",
principalTable: "EpisodeMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Director_EpisodeMetadata_EpisodeMetadataId",
table: "Director");
migrationBuilder.DropForeignKey(
name: "FK_Writer_EpisodeMetadata_EpisodeMetadataId",
table: "Writer");
migrationBuilder.DropIndex(
name: "IX_Writer_EpisodeMetadataId",
table: "Writer");
migrationBuilder.DropIndex(
name: "IX_Director_EpisodeMetadataId",
table: "Director");
migrationBuilder.DropColumn(
name: "EpisodeMetadataId",
table: "Writer");
migrationBuilder.DropColumn(
name: "EpisodeMetadataId",
table: "Director");
}
}
}

111
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -274,6 +274,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -274,6 +274,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -282,6 +285,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -282,6 +285,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.ToTable("Director");
@ -740,6 +745,50 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -740,6 +745,50 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaVersion");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Guid")
.HasColumnType("TEXT");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("MetadataGuid");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b =>
{
b.Property<int>("Id")
@ -1281,6 +1330,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1281,6 +1330,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
@ -1289,6 +1341,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1289,6 +1341,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.ToTable("Writer");
@ -1770,6 +1824,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1770,6 +1824,11 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Director", b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Directors")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Directors")
.HasForeignKey("MovieMetadataId")
@ -1957,6 +2016,37 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1957,6 +2016,37 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MetadataGuid", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Guids")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Guids")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Guids")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Guids")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Guids")
.HasForeignKey("SeasonMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Guids")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Mood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
@ -2255,6 +2345,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2255,6 +2345,11 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder.Entity("ErsatzTV.Core.Domain.Writer", b =>
{
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Writers")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Writers")
.HasForeignKey("MovieMetadataId")
@ -2572,6 +2667,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2572,6 +2667,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Moods");
b.Navigation("Studios");
@ -2599,11 +2696,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2599,11 +2696,17 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Artwork");
b.Navigation("Directors");
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
b.Navigation("Writers");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
@ -2645,6 +2748,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2645,6 +2748,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
@ -2660,6 +2765,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2660,6 +2765,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
@ -2687,6 +2794,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2687,6 +2794,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");
@ -2700,6 +2809,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2700,6 +2809,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Genres");
b.Navigation("Guids");
b.Navigation("Studios");
b.Navigation("Tags");

29
ErsatzTV.Infrastructure/Plex/IPlexServerApi.cs

@ -4,15 +4,16 @@ using Refit; @@ -4,15 +4,16 @@ using Refit;
namespace ErsatzTV.Infrastructure.Plex
{
[Headers("Accept: application/json")]
public interface IPlexServerApi
{
[Get("/library/sections")]
[Headers("Accept: application/json")]
public Task<PlexMediaContainerResponse<PlexMediaContainerDirectoryContent<PlexLibraryResponse>>> GetLibraries(
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/sections/{key}/all")]
[Headers("Accept: application/json")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetLibrarySectionContents(
string key,
@ -20,15 +21,33 @@ namespace ErsatzTV.Infrastructure.Plex @@ -20,15 +21,33 @@ namespace ErsatzTV.Infrastructure.Plex
string token);
[Get("/library/metadata/{key}")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetMetadata(
[Headers("Accept: text/xml")]
public Task<PlexXmlVideoMetadataResponseContainer>
GetVideoMetadata(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}")]
[Headers("Accept: text/xml")]
public Task<PlexXmlDirectoryMetadataResponseContainer>
GetDirectoryMetadata(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}/children")]
public Task<PlexMediaContainerResponse<PlexMediaContainerMetadataContent<PlexMetadataResponse>>>
GetChildren(
[Headers("Accept: text/xml")]
public Task<PlexXmlSeasonsMetadataResponseContainer>
GetShowChildren(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);
[Get("/library/metadata/{key}/children")]
[Headers("Accept: text/xml")]
public Task<PlexXmlEpisodesMetadataResponseContainer>
GetSeasonChildren(
string key,
[Query] [AliasAs("X-Plex-Token")]
string token);

5
ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs

@ -1,7 +1,10 @@ @@ -1,7 +1,10 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexDirectorResponse
{
[XmlAttribute("tag")]
public string Tag { get; set; }
}
}

9
ErsatzTV.Infrastructure/Plex/Models/PlexGenreResponse.cs

@ -1,9 +1,16 @@ @@ -1,9 +1,16 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexGenreResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("filter")]
public string Filter { get; set; }
[XmlAttribute("tag")]
public string Tag { get; set; }
}
}

10
ErsatzTV.Infrastructure/Plex/Models/PlexGuidResponse.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexGuidResponse
{
[XmlAttribute("id")]
public string Id { get; set; }
}
}

29
ErsatzTV.Infrastructure/Plex/Models/PlexMediaContainerResponse.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
@ -16,4 +17,32 @@ namespace ErsatzTV.Infrastructure.Plex.Models @@ -16,4 +17,32 @@ namespace ErsatzTV.Infrastructure.Plex.Models
{
public List<T> Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlVideoMetadataResponseContainer
{
[XmlElement("Video")]
public PlexXmlMetadataResponse Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlDirectoryMetadataResponseContainer
{
[XmlElement("Directory")]
public PlexXmlMetadataResponse Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlSeasonsMetadataResponseContainer
{
[XmlElement("Directory")]
public List<PlexXmlMetadataResponse> Metadata { get; set; }
}
[XmlRoot("MediaContainer", Namespace = null)]
public class PlexXmlEpisodesMetadataResponseContainer
{
[XmlElement("Video")]
public List<PlexXmlMetadataResponse> Metadata { get; set; }
}
}

33
ErsatzTV.Infrastructure/Plex/Models/PlexMediaResponse.cs

@ -1,21 +1,50 @@ @@ -1,21 +1,50 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexMediaResponse
public class PlexMediaResponse<T>
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("duration")]
public int Duration { get; set; }
[XmlAttribute("bitrate")]
public int Bitrate { get; set; }
[XmlAttribute("width")]
public int Width { get; set; }
[XmlAttribute("height")]
public int Height { get; set; }
[XmlAttribute("aspectRatio")]
public double AspectRatio { get; set; }
[XmlAttribute("audioChannels")]
public int AudioChannels { get; set; }
[XmlAttribute("audioCodec")]
public string AudioCodec { get; set; }
[XmlAttribute("videoCodec")]
public string VideoCodec { get; set; }
[XmlAttribute("videoResulution")]
public string VideoResolution { get; set; }
[XmlAttribute("videoProfile")]
public string VideoProfile { get; set; }
[XmlAttribute("container")]
public string Container { get; set; }
[XmlAttribute("videoFrameRate")]
public string VideoFrameRate { get; set; }
public List<PlexPartResponse> Part { get; set; }
[XmlElement("Part")]
public List<T> Part { get; set; }
}
}

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

@ -1,26 +1,74 @@ @@ -1,26 +1,74 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexMetadataResponse
{
[XmlAttribute("key")]
public string Key { get; set; }
[XmlAttribute("title")]
public string Title { get; set; }
[XmlAttribute("contentRating")]
public string ContentRating { get; set; }
[XmlAttribute("summary")]
public string Summary { get; set; }
[XmlAttribute("year")]
public int Year { get; set; }
[XmlAttribute("tagline")]
public string Tagline { get; set; }
[XmlAttribute("thumb")]
public string Thumb { get; set; }
[XmlAttribute("art")]
public string Art { get; set; }
[XmlAttribute("originallyAvailableAt")]
public string OriginallyAvailableAt { get; set; }
[XmlAttribute("addedAt")]
public long AddedAt { get; set; }
[XmlAttribute("updatedAt")]
public long UpdatedAt { get; set; }
[XmlAttribute("index")]
public int Index { get; set; }
[XmlAttribute("studio")]
public string Studio { get; set; }
public List<PlexMediaResponse> Media { get; set; }
[XmlAttribute("rating")]
public double Rating { get; set; }
[XmlAttribute("audienceRating")]
public double AudienceRating { get; set; }
[XmlAttribute("audienceRatingImage")]
public string AudienceRatingImage { get; set; }
[XmlAttribute("ratingImage")]
public string RatingImage { get; set; }
[XmlIgnore]
public virtual List<PlexMediaResponse<PlexPartResponse>> Media { get; set; }
[XmlElement("Genre")]
public List<PlexGenreResponse> Genre { get; set; }
[XmlElement("Role")]
public List<PlexRoleResponse> Role { get; set; }
[XmlElement("Director")]
public List<PlexDirectorResponse> Director { get; set; }
[XmlElement("Writer")]
public List<PlexWriterResponse> Writer { get; set; }
}
}

5
ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs

@ -1,6 +1,4 @@ @@ -1,6 +1,4 @@
using System.Collections.Generic;
namespace ErsatzTV.Infrastructure.Plex.Models
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexPartResponse
{
@ -8,6 +6,5 @@ namespace ErsatzTV.Infrastructure.Plex.Models @@ -8,6 +6,5 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public List<PlexStreamResponse> Stream { get; set; }
}
}

9
ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs

@ -1,9 +1,16 @@ @@ -1,9 +1,16 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexRoleResponse
{
[XmlAttribute("tag")]
public string Tag { get; set; }
[XmlAttribute("role")]
public string Role { get; set; }
[XmlAttribute("thumb")]
public string Thumb { get; set; }
}
}

27
ErsatzTV.Infrastructure/Plex/Models/PlexStreamResponse.cs

@ -1,18 +1,43 @@ @@ -1,18 +1,43 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexStreamResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("index")]
public int Index { get; set; }
[XmlAttribute("default")]
public bool Default { get; set; }
[XmlAttribute("forced")]
public bool Forced { get; set; }
[XmlAttribute("languageCode")]
public string LanguageCode { get; set; }
[XmlAttribute("streamType")]
public int StreamType { get; set; }
[XmlAttribute("codec")]
public string Codec { get; set; }
[XmlAttribute("profile")]
public string Profile { get; set; }
[XmlAttribute("channels")]
public int Channels { get; set; }
[XmlAttribute("anamorphic")]
public bool Anamorphic { get; set; }
[XmlAttribute("pixelAspectRatio")]
public string PixelAspectRatio { get; set; }
[XmlAttribute("scanType")]
public string ScanType { get; set; }
}
}

5
ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.mcs.cs

@ -1,7 +1,10 @@ @@ -1,7 +1,10 @@
namespace ErsatzTV.Infrastructure.Plex.Models
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexWriterResponse
{
[XmlAttribute("tag")]
public string Tag { get; set; }
}
}

17
ErsatzTV.Infrastructure/Plex/Models/PlexXmlMetadataResponse.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexXmlMetadataResponse : PlexMetadataResponse
{
[XmlAttribute("guid")]
public string PlexGuid { get; set; }
[XmlElement("Media")]
public new List<PlexMediaResponse<PlexXmlPartResponse>> Media { get; set; }
[XmlElement("Guid")]
public List<PlexGuidResponse> Guid { get; set; }
}
}

23
ErsatzTV.Infrastructure/Plex/Models/PlexXmlPartResponse.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexXmlPartResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("key")]
public string Key { get; set; }
[XmlAttribute("duration")]
public int Duration { get; set; }
[XmlAttribute("file")]
public string File { get; set; }
[XmlElement("Stream")]
public List<PlexStreamResponse> Stream { get; set; }
}
}

278
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Xml.Serialization;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
@ -87,9 +88,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -87,9 +88,9 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.MediaContainer.Metadata)
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetShowChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.Metadata)
.Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
@ -106,9 +107,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -106,9 +107,9 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetSeasonChildren(season.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
.Map(list => list.Map(metadata => ProjectToEpisode(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
@ -125,12 +126,13 @@ namespace ErsatzTV.Infrastructure.Plex @@ -125,12 +126,13 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone())
.MapT(response => ProjectToMovieMetadata(response, library.MediaSourceId))
.MapT(response => ProjectToMovieMetadata(response.Metadata, library.MediaSourceId))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
@ -147,10 +149,10 @@ namespace ErsatzTV.Infrastructure.Plex @@ -147,10 +149,10 @@ namespace ErsatzTV.Infrastructure.Plex
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
.Map(r => r.MediaContainer.Metadata.HeadOrNone())
.MapT(response => ProjectToShowMetadata(response, library.MediaSourceId))
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetDirectoryMetadata(key, token.AuthToken)
.Map(Optional)
.MapT(response => ProjectToShowMetadata(response.Metadata, library.MediaSourceId))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
@ -159,20 +161,30 @@ namespace ErsatzTV.Infrastructure.Plex @@ -159,20 +161,30 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
public async Task<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>> GetMovieMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
IPlexServerApi service = XmlServiceFor(connection.Uri);
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
.HeadOrNone())
.BindT(ProjectToMediaVersion)
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone());
return maybeResponse.Match(
response =>
{
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<MovieMetadata, MediaVersion>>>(
version => Tuple(ProjectToMovieMetadata(response.Metadata, library.MediaSourceId), version),
() => BaseError.New("Unable to locate metadata"));
},
() => BaseError.New("Unable to locate metadata"));
}
catch (Exception ex)
{
@ -180,6 +192,57 @@ namespace ErsatzTV.Infrastructure.Plex @@ -180,6 +192,57 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>> GetEpisodeMetadataAndStatistics(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = XmlServiceFor(connection.Uri);
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone());
return maybeResponse.Match(
response =>
{
Option<MediaVersion> maybeVersion = ProjectToMediaVersion(response.Metadata);
return maybeVersion.Match<Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>>>(
version => Tuple(
ProjectToEpisodeMetadata(response.Metadata, library.MediaSourceId),
version),
() => BaseError.New("Unable to locate metadata"));
},
() => BaseError.New("Unable to locate metadata"));
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private static IPlexServerApi XmlServiceFor(string uri)
{
var overrides = new XmlAttributeOverrides();
var attrs = new XmlAttributes { XmlIgnore = true };
overrides.Add(typeof(PlexMetadataResponse), "Media", attrs);
return RestService.For<IPlexServerApi>(
uri,
new RefitSettings
{
ContentSerializer = new XmlContentSerializer(
new XmlContentSerializerSettings
{
XmlAttributeOverrides = overrides
})
});
}
private static Option<PlexLibrary> Project(PlexLibraryResponse response) =>
response.Type switch
{
@ -205,7 +268,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -205,7 +268,7 @@ namespace ErsatzTV.Infrastructure.Plex
private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexMediaResponse<PlexPartResponse> media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@ -268,6 +331,23 @@ namespace ErsatzTV.Infrastructure.Plex @@ -268,6 +331,23 @@ namespace ErsatzTV.Infrastructure.Plex
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList()
};
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
@ -311,7 +391,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -311,7 +391,7 @@ namespace ErsatzTV.Infrastructure.Plex
return metadata;
}
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
private Option<MediaVersion> ProjectToMediaVersion(PlexXmlMetadataResponse response)
{
List<PlexStreamResponse> streams = response.Media.Head().Part.Head().Stream;
DateTime dateUpdated = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@ -418,6 +498,23 @@ namespace ErsatzTV.Infrastructure.Plex @@ -418,6 +498,23 @@ namespace ErsatzTV.Infrastructure.Plex
.ToList()
};
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
@ -461,7 +558,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -461,7 +558,7 @@ namespace ErsatzTV.Infrastructure.Plex
return metadata;
}
private PlexSeason ProjectToSeason(PlexMetadataResponse response, int mediaSourceId)
private PlexSeason ProjectToSeason(PlexXmlMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@ -476,6 +573,16 @@ namespace ErsatzTV.Infrastructure.Plex @@ -476,6 +573,16 @@ namespace ErsatzTV.Infrastructure.Plex
DateUpdated = lastWriteTime
};
metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(response.PlexGuid))
{
string normalized = NormalizeGuid(response.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
@ -516,10 +623,48 @@ namespace ErsatzTV.Infrastructure.Plex @@ -516,10 +623,48 @@ namespace ErsatzTV.Infrastructure.Plex
return season;
}
private PlexEpisode ProjectToEpisode(PlexMetadataResponse response, int mediaSourceId)
private PlexEpisode ProjectToEpisode(PlexXmlMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse<PlexXmlPartResponse> media = response.Media.Head();
PlexXmlPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
EpisodeMetadata metadata = ProjectToEpisodeMetadata(response, mediaSourceId);
var version = new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
},
// specifically omit stream details
Streams = new List<MediaStream>()
};
var episode = new PlexEpisode
{
Key = response.Key,
EpisodeNumber = response.Index,
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version }
};
return episode;
}
private EpisodeMetadata ProjectToEpisodeMetadata(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@ -534,9 +679,28 @@ namespace ErsatzTV.Infrastructure.Plex @@ -534,9 +679,28 @@ namespace ErsatzTV.Infrastructure.Plex
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList()
.ToList(),
Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(),
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList()
};
if (response is PlexXmlMetadataResponse xml)
{
metadata.Guids = Optional(xml.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
if (!string.IsNullOrWhiteSpace(xml.PlexGuid))
{
string normalized = NormalizeGuid(xml.PlexGuid);
if (!string.IsNullOrWhiteSpace(normalized) && metadata.Guids.All(g => g.Guid != normalized))
{
metadata.Guids.Add(new MetadataGuid { Guid = normalized });
}
}
}
else
{
metadata.Guids = new List<MetadataGuid>();
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
@ -557,36 +721,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -557,36 +721,7 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Artwork.Add(artwork);
}
var version = new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
},
// specifically omit stream details
Streams = new List<MediaStream>()
};
var episode = new PlexEpisode
{
Key = response.Key,
EpisodeNumber = response.Index,
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version }
};
return episode;
return metadata;
}
private Actor ProjectToModel(PlexRoleResponse role, DateTime dateAdded, DateTime lastWriteTime)
@ -605,5 +740,32 @@ namespace ErsatzTV.Infrastructure.Plex @@ -605,5 +740,32 @@ namespace ErsatzTV.Infrastructure.Plex
return actor;
}
private string NormalizeGuid(string guid)
{
if (guid.StartsWith("plex://show") ||
guid.StartsWith("plex://season") ||
guid.StartsWith("plex://episode") ||
guid.StartsWith("plex://movie"))
{
return guid;
}
if (guid.StartsWith("com.plexapp.agents.thetvdb"))
{
string strip1 = guid.Replace("com.plexapp.agents.thetvdb://", string.Empty);
string strip2 = strip1.Split("?").Head();
return $"tvdb://{strip2}";
}
if (guid.StartsWith("com.plexapp.agents.themoviedb"))
{
string strip1 = guid.Replace("com.plexapp.agents.themoviedb://", string.Empty);
string strip2 = strip1.Split("?").Head();
return $"tmdb://{strip2}";
}
throw new NotSupportedException(guid);
}
}
}

94
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -56,6 +56,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -56,6 +56,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private const string EpisodeType = "episode";
private readonly List<CultureInfo> _cultureInfos;
private readonly ILogger<SearchIndex> _logger;
@ -71,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -71,7 +72,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false;
}
public int Version => 12;
public int Version => 13;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@ -114,6 +115,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -114,6 +115,9 @@ namespace ErsatzTV.Infrastructure.Search
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
case Episode episode:
UpdateEpisode(episode);
break;
}
}
}
@ -143,6 +147,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -143,6 +147,9 @@ namespace ErsatzTV.Infrastructure.Search
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
case Episode episode:
UpdateEpisode(episode);
break;
}
}
@ -577,6 +584,83 @@ namespace ErsatzTV.Infrastructure.Search @@ -577,6 +584,83 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private void UpdateEpisode(Episode episode)
{
Option<EpisodeMetadata> maybeMetadata = episode.EpisodeMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
EpisodeMetadata metadata = maybeMetadata.ValueUnsafe();
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)
};
AddLanguages(doc, episode.MediaVersions);
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
foreach (Actor actor in metadata.Actors)
{
doc.Add(new TextField(ActorField, actor.Name, Field.Store.NO));
}
foreach (Director director in metadata.Directors)
{
doc.Add(new TextField(DirectorField, director.Name, Field.Store.NO));
}
foreach (Writer writer in metadata.Writers)
{
doc.Add(new TextField(WriterField, writer.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, episode.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.Episode = null;
_logger.LogWarning(ex, "Error indexing episode with metadata {@Metadata}", metadata);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
private Query ParseQuery(string searchQuery, QueryParser parser)
@ -595,7 +679,13 @@ namespace ErsatzTV.Infrastructure.Search @@ -595,7 +679,13 @@ namespace ErsatzTV.Infrastructure.Search
}
private static string GetTitleAndYear(Metadata metadata) =>
$"{metadata.Title}_{metadata.Year}".ToLowerInvariant();
metadata switch
{
EpisodeMetadata em =>
$"{em.Title}_{em.Year}_{em.Episode.Season.SeasonNumber}_{em.Episode.EpisodeNumber}"
.ToLowerInvariant(),
_ => $"{metadata.Title}_{metadata.Year}".ToLowerInvariant()
};
private static string GetJumpLetter(Metadata metadata)
{

166
ErsatzTV/Pages/EpisodeList.razor

@ -0,0 +1,166 @@ @@ -0,0 +1,166 @@
@page "/media/tv/episodes"
@page "/media/tv/episodes/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<EpisodeList>
@inject NavigationManager _navigationManager
@inject ChannelWriter<IBackgroundServiceRequest> _channel
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;">
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6">
@if (IsSelectMode())
{
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText>
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddSelectionToCollection())">
Add To Collection
</MudButton>
<MudButton Class="ml-3"
Variant="Variant.Filled"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="@(_ => ClearSelection())">
Clear Selection
</MudButton>
</div>
}
else
{
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText>
<div style="max-width: 300px; width: 33%;">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@PrevPage"
Disabled="@(PageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</div>
}
</div>
</MudPaper>
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px">
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
<FragmentLetterAnchor TCard="TelevisionEpisodeCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link="@($"/media/tv/seasons/{context.SeasonId}#episode-{context.EpisodeId}")"
Subtitle="@($"{context.ShowTitle} - S{context.Season} E{context.Episode}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(context, e))"
IsSelected="@IsSelected(context)"
IsSelectMode="@IsSelectMode()"/>
</FragmentLetterAnchor>
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/tv/episodes"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private TelevisionEpisodeCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
string query = new Uri(_navigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
return RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:episode" : $"type:episode AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexEpisodes(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/tv/episodes/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
_navigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/tv/episodes/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
_navigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
}
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

12
ErsatzTV/Pages/MultiSelectBase.cs

@ -97,17 +97,19 @@ namespace ErsatzTV.Pages @@ -97,17 +97,19 @@ namespace ErsatzTV.Pages
protected Task AddSelectionToCollection() => AddItemsToCollection(
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
_selectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
List<int> showIds,
List<int> episodeIds,
List<int> artistIds,
List<int> musicVideoIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + artistIds.Count + musicVideoIds.Count;
int count = movieIds.Count + showIds.Count + episodeIds.Count + artistIds.Count + musicVideoIds.Count;
var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
@ -117,7 +119,13 @@ namespace ErsatzTV.Pages @@ -117,7 +119,13 @@ namespace ErsatzTV.Pages
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddItemsToCollection(collection.Id, movieIds, showIds, artistIds, musicVideoIds);
var request = new AddItemsToCollection(
collection.Id,
movieIds,
showIds,
episodeIds,
artistIds,
musicVideoIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(

69
ErsatzTV/Pages/Search.razor

@ -45,6 +45,11 @@ @@ -45,6 +45,11 @@
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
if (_episodes.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
}
if (_artists.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
@ -120,6 +125,34 @@ @@ -120,6 +125,34 @@
</MudContainer>
}
@if (_episodes.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "episodes" } })">
Episodes
</MudText>
@if (_episodes.Count > 50)
{
<MudLink Href="@GetEpisodesLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionEpisodeCardViewModel card in _episodes.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
AddToCollectionClicked="@AddToCollection"
Link="@($"/media/tv/seasons/{card.SeasonId}#episode-{card.EpisodeId}")"
Subtitle="@($"{card.ShowTitle} - S{card.Season} E{card.Episode}")"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_artists.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
@ -181,6 +214,7 @@ @@ -181,6 +214,7 @@
private string _query;
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos;
private ArtistCardResultsViewModel _artists;
@ -194,6 +228,7 @@ @@ -194,6 +228,7 @@
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
}
@ -205,6 +240,7 @@ @@ -205,6 +240,7 @@
{
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.ToList();
@ -257,6 +293,27 @@ @@ -257,6 +293,27 @@
}
}
if (card is TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is ArtistCardViewModel artist)
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } };
@ -320,6 +377,16 @@ @@ -320,6 +377,16 @@
return uri;
}
private string GetEpisodesLink()
{
var uri = "/media/tv/episodes/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
return uri;
}
private string GetArtistsLink()
{
var uri = "/media/music/artists/page/1";
@ -343,7 +410,7 @@ @@ -343,7 +410,7 @@
private async Task AddAllToCollection(MouseEventArgs _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query));
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.ArtistIds, results.MusicVideoIds, "search results");
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.EpisodeIds, results.ArtistIds, results.MusicVideoIds, "search results");
}
}

33
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -106,6 +106,39 @@ @@ -106,6 +106,39 @@
</div>
</MudCardContent>
</div>
<div class="pl-3 pt-3">
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
</div>
@if (episode.Directors.Any())
{
var sorted = episode.Directors.OrderBy(w => w).ToList();
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Directors:&nbsp;</MudText>
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(sorted.Head())}%22")">@sorted.Head()</MudLink>
@foreach (string director in sorted.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=director%3a%22{Uri.EscapeDataString(director)}%22")">@director</MudLink>
}
</div>
}
@if (episode.Writers.Any())
{
var sorted = episode.Writers.OrderBy(w => w).ToList();
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Writers:&nbsp;</MudText>
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(sorted.Head())}%22")">@sorted.Head()</MudLink>
@foreach (string writer in sorted.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=writer%3a%22{Uri.EscapeDataString(writer)}%22")">@writer</MudLink>
}
</div>
}
</div>
</MudCard>
}
</MudContainer>

Loading…
Cancel
Save