Browse Source

add actors to movies and shows (#172)

* add actor metadata

* show actors in ui

* get full movie/show metadata from plex

* store actor thumbnail url

* rework movie detail page

* metadata fixes

* rework show detail page

* rework artist page

* code cleanup
pull/173/head
Jason Dove 5 years ago committed by GitHub
parent
commit
d8d21996b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      ErsatzTV.Application/Configuration/Commands/SaveConfigElementByKey.cs
  2. 6
      ErsatzTV.Application/Configuration/Mapper.cs
  3. 2
      ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs
  4. 5
      ErsatzTV.Application/MediaCards/ActorCardViewModel.cs
  5. 4
      ErsatzTV.Application/MediaCards/ArtistCardViewModel.cs
  6. 9
      ErsatzTV.Application/MediaCards/Mapper.cs
  7. 4
      ErsatzTV.Application/Movies/Mapper.cs
  8. 4
      ErsatzTV.Application/Movies/MovieViewModel.cs
  9. 6
      ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs
  10. 8
      ErsatzTV.Application/Television/Mapper.cs
  11. 4
      ErsatzTV.Application/Television/TelevisionShowViewModel.cs
  12. 3
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  13. 12
      ErsatzTV.Core/Domain/Metadata/Actor.cs
  14. 1
      ErsatzTV.Core/Domain/Metadata/Metadata.cs
  15. 2
      ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs
  16. 12
      ErsatzTV.Core/Interfaces/Plex/IPlexServerApiClient.cs
  17. 2
      ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs
  18. 1
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  19. 1
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  20. 2
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  21. 6
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  22. 227
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  23. 19
      ErsatzTV.Core/Metadata/Nfo/ActorNfo.cs
  24. 3
      ErsatzTV.Core/Metadata/Nfo/MovieNfo.cs
  25. 6
      ErsatzTV.Core/Metadata/Nfo/TvShowEpisodeNfo.cs
  26. 3
      ErsatzTV.Core/Metadata/Nfo/TvShowNfo.cs
  27. 157
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  28. 155
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  29. 19
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ActorConfiguration.cs
  30. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/EpisodeMetadataConfiguration.cs
  31. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MovieMetadataConfiguration.cs
  32. 4
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ShowMetadataConfiguration.cs
  33. 2
      ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs
  34. 36
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  35. 32
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  36. 4
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  37. 67
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  38. 2
      ErsatzTV.Infrastructure/GitHub/IGitHubApi.cs
  39. 2256
      ErsatzTV.Infrastructure/Migrations/20210414210456_Add_Actor.Designer.cs
  40. 101
      ErsatzTV.Infrastructure/Migrations/20210414210456_Add_Actor.cs
  41. 2256
      ErsatzTV.Infrastructure/Migrations/20210414212448_Reset_MetadataDateUpdated_Actor.Designer.cs
  42. 25
      ErsatzTV.Infrastructure/Migrations/20210414212448_Reset_MetadataDateUpdated_Actor.cs
  43. 2269
      ErsatzTV.Infrastructure/Migrations/20210416122613_Add_Actor_Artwork.Designer.cs
  44. 45
      ErsatzTV.Infrastructure/Migrations/20210416122613_Add_Actor_Artwork.cs
  45. 109
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  46. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  47. 9
      ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs
  48. 160
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  49. 11
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  50. 90
      ErsatzTV/Pages/Artist.razor
  51. 11
      ErsatzTV/Pages/Collections.razor
  52. 4
      ErsatzTV/Pages/Index.razor
  53. 105
      ErsatzTV/Pages/Movie.razor
  54. 109
      ErsatzTV/Pages/TelevisionSeasonList.razor
  55. 2
      ErsatzTV/Pages/_Host.cshtml
  56. 7
      ErsatzTV/Shared/AddToCollectionDialog.razor
  57. 2
      ErsatzTV/Shared/AddToScheduleDialog.razor
  58. 2
      ErsatzTV/Shared/DeleteDialog.razor
  59. 2
      ErsatzTV/Shared/MainLayout.razor
  60. 17
      ErsatzTV/Shared/MediaCard.razor
  61. 2
      ErsatzTV/Shared/RemoveFromCollectionDialog.razor
  62. 1
      ErsatzTV/Shared/SignOutOfPlexDialog.razor
  63. 20
      ErsatzTV/wwwroot/css/site.css

4
ErsatzTV.Application/Configuration/Commands/SaveConfigElementByKey.cs

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
using ErsatzTV.Core.Domain;
using MediatR;
using LanguageExt;
namespace ErsatzTV.Application.Configuration.Commands
{
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : IRequest<LanguageExt.Unit>;
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : MediatR.IRequest<Unit>;
}

6
ErsatzTV.Application/Configuration/Mapper.cs

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
namespace ErsatzTV.Application.Configuration
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Configuration
{
internal static class Mapper
{
internal static ConfigElementViewModel ProjectToViewModel(Core.Domain.ConfigElement element) =>
internal static ConfigElementViewModel ProjectToViewModel(ConfigElement element) =>
new(element.Key, element.Value);
}
}

2
ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegSettingsHandler.cs

@ -88,7 +88,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands @@ -88,7 +88,7 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
private async Task Upsert(ConfigElementKey key, string value)
{
Option<ConfigElement> maybeElement = await _configElementRepository.Get(key);
Option<ConfigElement> maybeElement = await _configElementRepository.Get(key);
await maybeElement.Match(
ce =>
{

5
ErsatzTV.Application/MediaCards/ActorCardViewModel.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
namespace ErsatzTV.Application.MediaCards
{
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) :
MediaCardViewModel(Id, Name, Role, Name, Thumb);
}

4
ErsatzTV.Application/MediaCards/ArtistCardViewModel.cs

@ -6,7 +6,5 @@ @@ -6,7 +6,5 @@
Title,
Subtitle,
SortTitle,
Poster)
{
}
Poster);
}

9
ErsatzTV.Application/MediaCards/Mapper.cs

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@ -86,16 +85,14 @@ namespace ErsatzTV.Application.MediaCards @@ -86,16 +85,14 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(Actor actor) =>
new(actor.Id, actor.Name, actor.Role, actor.Artwork?.Path);
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
.Map(ci => ci.CustomIndex ?? 0)
.IfNone(0);
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
new(
items.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
items.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList());
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";

4
ErsatzTV.Application/Movies/Mapper.cs

@ -22,7 +22,9 @@ namespace ErsatzTV.Application.Movies @@ -22,7 +22,9 @@ namespace ErsatzTV.Application.Movies
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList(),
LanguagesForMovie(movie));
LanguagesForMovie(movie),
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
.ToList());
}
private static List<CultureInfo> LanguagesForMovie(Movie movie)

4
ErsatzTV.Application/Movies/MovieViewModel.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
namespace ErsatzTV.Application.Movies
{
@ -12,5 +13,6 @@ namespace ErsatzTV.Application.Movies @@ -12,5 +13,6 @@ namespace ErsatzTV.Application.Movies
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<CultureInfo> Languages);
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors);
}

6
ErsatzTV.Application/Playouts/Commands/CreatePlayoutHandler.cs

@ -66,9 +66,9 @@ namespace ErsatzTV.Application.Playouts.Commands @@ -66,9 +66,9 @@ namespace ErsatzTV.Application.Playouts.Commands
private async Task<Validation<BaseError, Channel>> ChannelMustNotHavePlayouts(Channel channel) =>
Optional(await _channelRepository.CountPlayouts(channel.Id))
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout.");
.Filter(count => count == 0)
.Map(_ => channel)
.ToValidation<BaseError>("Channel already has one playout.");
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
CreatePlayout createPlayout) =>

8
ErsatzTV.Application/Television/Mapper.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
@ -22,7 +23,12 @@ namespace ErsatzTV.Application.Television @@ -22,7 +23,12 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
.IfNone(new List<string>()),
LanguagesForShow(languages));
LanguagesForShow(languages),
show.ShowMetadata.HeadOrNone()
.Map(
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
.ToList())
.IfNone(new List<ActorCardViewModel>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
new(

4
ErsatzTV.Application/Television/TelevisionShowViewModel.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Globalization;
using ErsatzTV.Application.MediaCards;
namespace ErsatzTV.Application.Television
{
@ -13,5 +14,6 @@ namespace ErsatzTV.Application.Television @@ -13,5 +14,6 @@ namespace ErsatzTV.Application.Television
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<CultureInfo> Languages);
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors);
}

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

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

12
ErsatzTV.Core/Domain/Metadata/Actor.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
namespace ErsatzTV.Core.Domain
{
public class Actor
{
public int Id { get; set; }
public string Name { get; set; }
public string Role { get; set; }
public int? Order { get; set; }
public int? ArtworkId { get; set; }
public Artwork Artwork { get; set; }
}
}

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

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

2
ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs

@ -10,6 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -10,6 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary);
PlexLibrary library);
}
}

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

@ -34,6 +34,18 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -34,6 +34,18 @@ namespace ErsatzTV.Core.Interfaces.Plex
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, MovieMetadata>> GetMovieMetadata(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, ShowMetadata>> GetShowMetadata(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token);
Task<Either<BaseError, MediaVersion>> GetStatistics(
string key,
PlexConnection connection,

2
ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs

@ -10,6 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Plex @@ -10,6 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Plex
Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary);
PlexLibrary library);
}
}

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

@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveStudio(Studio studio);
Task<bool> RemoveStyle(Style style);
Task<bool> RemoveMood(Mood mood);
Task<bool> RemoveActor(Actor actor);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);

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

@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -20,6 +20,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
}

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

@ -42,6 +42,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -42,6 +42,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
Task<bool> AddTag(ShowMetadata metadata, Tag tag);
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
Task<bool> AddActor(ShowMetadata metadata, Actor actor);
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);

6
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -97,6 +97,7 @@ namespace ErsatzTV.Core.Metadata @@ -97,6 +97,7 @@ namespace ErsatzTV.Core.Metadata
{
metadata.Title = match.Groups[1].Value;
metadata.DateUpdated = DateTime.UtcNow;
metadata.Actors = new List<Actor>();
return Tuple(metadata, int.Parse(match.Groups[3].Value));
}
}
@ -122,6 +123,7 @@ namespace ErsatzTV.Core.Metadata @@ -122,6 +123,7 @@ namespace ErsatzTV.Core.Metadata
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.Actors = new List<Actor>();
metadata.DateUpdated = DateTime.UtcNow;
}
}
@ -166,6 +168,10 @@ namespace ErsatzTV.Core.Metadata @@ -166,6 +168,10 @@ namespace ErsatzTV.Core.Metadata
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.Actors = new List<Actor>();
metadata.DateUpdated = DateTime.UtcNow;
}
}

227
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -183,7 +183,7 @@ namespace ErsatzTV.Core.Metadata @@ -183,7 +183,7 @@ namespace ErsatzTV.Core.Metadata
}
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
existing =>
async existing =>
{
existing.Outline = metadata.Outline;
existing.Plot = metadata.Plot;
@ -204,9 +204,17 @@ namespace ErsatzTV.Core.Metadata @@ -204,9 +204,17 @@ namespace ErsatzTV.Core.Metadata
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
return _metadataRepository.Update(existing);
bool updated = await UpdateMetadataCollections(
existing,
metadata,
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
(_, _) => Task.FromResult(false),
_televisionRepository.AddActor);
return await _metadataRepository.Update(existing) || updated;
},
() =>
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
@ -214,7 +222,7 @@ namespace ErsatzTV.Core.Metadata @@ -214,7 +222,7 @@ namespace ErsatzTV.Core.Metadata
metadata.EpisodeId = episode.Id;
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
return _metadataRepository.Add(metadata);
return await _metadataRepository.Add(metadata);
});
return true;
@ -248,7 +256,8 @@ namespace ErsatzTV.Core.Metadata @@ -248,7 +256,8 @@ namespace ErsatzTV.Core.Metadata
metadata,
_movieRepository.AddGenre,
_movieRepository.AddTag,
_movieRepository.AddStudio);
_movieRepository.AddStudio,
_movieRepository.AddActor);
return await _metadataRepository.Update(existing) || updated;
},
@ -291,7 +300,8 @@ namespace ErsatzTV.Core.Metadata @@ -291,7 +300,8 @@ namespace ErsatzTV.Core.Metadata
metadata,
_televisionRepository.AddGenre,
_televisionRepository.AddTag,
_televisionRepository.AddStudio);
_televisionRepository.AddStudio,
_televisionRepository.AddActor);
return await _metadataRepository.Update(existing) || updated;
},
@ -427,7 +437,8 @@ namespace ErsatzTV.Core.Metadata @@ -427,7 +437,8 @@ namespace ErsatzTV.Core.Metadata
metadata,
_musicVideoRepository.AddGenre,
_musicVideoRepository.AddTag,
_musicVideoRepository.AddStudio);
_musicVideoRepository.AddStudio,
(_, _) => Task.FromResult(false));
return await _metadataRepository.Update(existing) || updated;
},
@ -449,20 +460,27 @@ namespace ErsatzTV.Core.Metadata @@ -449,20 +460,27 @@ namespace ErsatzTV.Core.Metadata
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo;
return maybeNfo.Match<Option<ShowMetadata>>(
nfo => new ShowMetadata
nfo =>
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Year = GetYear(nfo.Year, nfo.Premiered),
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
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()
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
return new ShowMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Year = GetYear(nfo.Year, nfo.Premiered),
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
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)
};
},
None);
}
@ -510,14 +528,18 @@ namespace ErsatzTV.Core.Metadata @@ -510,14 +528,18 @@ namespace ErsatzTV.Core.Metadata
return maybeNfo.Match<Option<Tuple<EpisodeMetadata, int>>>(
nfo =>
{
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
var metadata = new EpisodeMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
ReleaseDate = GetAired(0, nfo.Aired),
Plot = nfo.Plot
Plot = nfo.Plot,
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
};
return Tuple(metadata, nfo.Episode);
},
@ -537,20 +559,27 @@ namespace ErsatzTV.Core.Metadata @@ -537,20 +559,27 @@ namespace ErsatzTV.Core.Metadata
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<MovieNfo> maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo;
return maybeNfo.Match<Option<MovieMetadata>>(
nfo => new MovieMetadata
nfo =>
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Year = nfo.Year,
ReleaseDate = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
DateTime dateAdded = DateTime.UtcNow;
DateTime dateUpdated = File.GetLastWriteTimeUtc(nfoFileName);
return new MovieMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = dateAdded,
DateUpdated = dateUpdated,
Title = nfo.Title,
Year = nfo.Year,
ReleaseDate = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList(),
Actors = Actors(nfo.Actors, dateAdded, dateUpdated)
};
},
None);
}
@ -598,74 +627,132 @@ namespace ErsatzTV.Core.Metadata @@ -598,74 +627,132 @@ namespace ErsatzTV.Core.Metadata
T incoming,
Func<T, Genre, Task<bool>> addGenre,
Func<T, Tag, Task<bool>> addTag,
Func<T, Studio, Task<bool>> addStudio)
Func<T, Studio, Task<bool>> addStudio,
Func<T, Actor, Task<bool>> addActor)
where T : Domain.Metadata
{
var updated = false;
foreach (Genre genre in existing.Genres.Filter(g => incoming.Genres.All(g2 => g2.Name != g.Name))
.ToList())
if (existing is not EpisodeMetadata)
{
existing.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
foreach (Genre genre in existing.Genres.Filter(g => incoming.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
updated = true;
existing.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
updated = true;
}
}
}
foreach (Genre genre in incoming.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
if (await addGenre(existing, genre))
foreach (Genre genre in incoming.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
updated = true;
existing.Genres.Add(genre);
if (await addGenre(existing, genre))
{
updated = true;
}
}
}
foreach (Tag tag in existing.Tags.Filter(t => incoming.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
foreach (Tag tag in existing.Tags.Filter(t => incoming.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
updated = true;
existing.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{
updated = true;
}
}
foreach (Tag tag in incoming.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
if (await addTag(existing, tag))
{
updated = true;
}
}
foreach (Studio studio in existing.Studios
.Filter(s => incoming.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
updated = true;
}
}
foreach (Studio studio in incoming.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
if (await addStudio(existing, studio))
{
updated = true;
}
}
}
foreach (Tag tag in incoming.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
foreach (Actor actor in existing.Actors
.Filter(a => incoming.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existing.Tags.Add(tag);
if (await addTag(existing, tag))
existing.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{
updated = true;
}
}
foreach (Studio studio in existing.Studios
.Filter(s => incoming.Studios.All(s2 => s2.Name != s.Name))
foreach (Actor actor in incoming.Actors
.Filter(a => existing.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existing.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
existing.Actors.Add(actor);
if (await addActor(existing, actor))
{
updated = true;
}
}
foreach (Studio studio in incoming.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
return updated;
}
private List<Actor> Actors(List<ActorNfo> actorNfos, DateTime dateAdded, DateTime dateUpdated)
{
var result = new List<Actor>();
for (var i = 0; i < actorNfos.Count; i++)
{
existing.Studios.Add(studio);
if (await addStudio(existing, studio))
ActorNfo actorNfo = actorNfos[i];
var actor = new Actor
{
updated = true;
Name = actorNfo.Name,
Role = actorNfo.Role,
Order = actorNfo.Order ?? i
};
if (!string.IsNullOrWhiteSpace(actorNfo.Thumb))
{
actor.Artwork = new Artwork
{
Path = actorNfo.Thumb,
ArtworkKind = ArtworkKind.Thumbnail,
DateAdded = dateAdded,
DateUpdated = dateUpdated
};
}
result.Add(actor);
}
return updated;
return result;
}
}
}

19
ErsatzTV.Core/Metadata/Nfo/ActorNfo.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo
{
public class ActorNfo
{
[XmlElement("name")]
public string Name { get; set; }
[XmlElement("role")]
public string Role { get; set; }
[XmlElement("order")]
public int? Order { get; set; }
[XmlElement("thumb")]
public string Thumb { get; set; }
}
}

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

@ -36,5 +36,8 @@ namespace ErsatzTV.Core.Metadata.Nfo @@ -36,5 +36,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
}
}

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Xml.Serialization;
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo
{
@ -25,5 +26,8 @@ namespace ErsatzTV.Core.Metadata.Nfo @@ -25,5 +26,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
}
}

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

@ -32,5 +32,8 @@ namespace ErsatzTV.Core.Metadata.Nfo @@ -32,5 +32,8 @@ namespace ErsatzTV.Core.Metadata.Nfo
[XmlElement("studio")]
public List<string> Studios { get; set; }
[XmlElement("actor")]
public List<ActorNfo> Actors { get; set; }
}
}

157
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -45,10 +45,10 @@ namespace ErsatzTV.Core.Plex @@ -45,10 +45,10 @@ namespace ErsatzTV.Core.Plex
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
PlexLibrary library)
{
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
plexMediaSourceLibrary,
library,
connection,
token);
@ -58,13 +58,13 @@ namespace ErsatzTV.Core.Plex @@ -58,13 +58,13 @@ namespace ErsatzTV.Core.Plex
foreach (PlexMovie incoming in movieEntries)
{
decimal percentCompletion = (decimal) movieEntries.IndexOf(incoming) / movieEntries.Count;
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(plexMediaSourceLibrary, incoming)
.GetOrAdd(library, incoming)
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeMovie.Match(
@ -92,16 +92,16 @@ namespace ErsatzTV.Core.Plex @@ -92,16 +92,16 @@ namespace ErsatzTV.Core.Plex
}
var movieKeys = movieEntries.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
await _searchIndex.RemoveItems(ids);
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
library.Name,
error.Value);
return Task.CompletedTask;
@ -148,73 +148,112 @@ namespace ErsatzTV.Core.Plex @@ -148,73 +148,112 @@ namespace ErsatzTV.Core.Plex
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
PlexMovie incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
if (incoming.MovieMetadata.Head().DateUpdated > existingMetadata.DateUpdated)
{
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Metadata",
existing.MediaVersions.Head().MediaFiles.Head().Path);
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
}
Either<BaseError, MovieMetadata> maybeMetadata =
await _plexServerApiClient.GetMovieMetadata(
library,
incoming.Key.Split("/").Last(),
connection,
token);
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _movieRepository.AddGenre(existingMetadata, genre))
await maybeMetadata.Match(
async fullMetadata =>
{
result.IsUpdated = true;
}
}
foreach (Genre genre in existingMetadata.Genres
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
foreach (Genre genre in fullMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _movieRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
if (incomingMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = incomingMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in fullMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
foreach (Actor actor in existingMetadata.Actors
.Filter(a => fullMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existingMetadata.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{
result.IsUpdated = true;
}
}
foreach (Actor actor in fullMetadata.Actors
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existingMetadata.Actors.Add(actor);
if (await _movieRepository.AddActor(existingMetadata, actor))
{
result.IsUpdated = true;
}
}
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = fullMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
},
_ => Task.CompletedTask);
// TODO: update other metadata?
}

155
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -46,10 +46,10 @@ namespace ErsatzTV.Core.Plex @@ -46,10 +46,10 @@ namespace ErsatzTV.Core.Plex
public async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary plexMediaSourceLibrary)
PlexLibrary library)
{
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
plexMediaSourceLibrary,
library,
connection,
token);
@ -59,18 +59,18 @@ namespace ErsatzTV.Core.Plex @@ -59,18 +59,18 @@ namespace ErsatzTV.Core.Plex
foreach (PlexShow incoming in showEntries)
{
decimal percentCompletion = (decimal) showEntries.IndexOf(incoming) / showEntries.Count;
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, percentCompletion));
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.GetOrAddPlexShow(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeShow.Match(
async result =>
{
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
await ScanSeasons(library, result.Item, connection, token);
if (result.IsAdded)
{
@ -95,10 +95,10 @@ namespace ErsatzTV.Core.Plex @@ -95,10 +95,10 @@ namespace ErsatzTV.Core.Plex
var showKeys = showEntries.Map(s => s.Key).ToList();
List<int> ids =
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
await _televisionRepository.RemoveMissingPlexShows(library, showKeys);
await _searchIndex.RemoveItems(ids);
await _mediator.Publish(new LibraryScanProgress(plexMediaSourceLibrary.Id, 0));
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
return Unit.Default;
@ -107,7 +107,7 @@ namespace ErsatzTV.Core.Plex @@ -107,7 +107,7 @@ namespace ErsatzTV.Core.Plex
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
plexMediaSourceLibrary.Name,
library.Name,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
@ -116,61 +116,98 @@ namespace ErsatzTV.Core.Plex @@ -116,61 +116,98 @@ namespace ErsatzTV.Core.Plex
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
MediaItemScanResult<PlexShow> result,
PlexShow incoming)
PlexShow incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
// TODO: this probably doesn't work
// plex doesn't seem to update genres returned by the main library call
foreach (Genre genre in existingMetadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _televisionRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _televisionRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
if (result.IsUpdated)
if (incoming.ShowMetadata.Head().DateUpdated > existingMetadata.DateUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
Either<BaseError, ShowMetadata> maybeMetadata =
await _plexServerApiClient.GetShowMetadata(
library,
incoming.Key.Replace("/children", string.Empty).Split("/").Last(),
connection,
token);
await maybeMetadata.Match(
async fullMetadata =>
{
foreach (Genre genre in existingMetadata.Genres
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
}
foreach (Genre genre in fullMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _televisionRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
}
foreach (Studio studio in fullMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _televisionRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
}
foreach (Actor actor in existingMetadata.Actors
.Filter(a => fullMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existingMetadata.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{
result.IsUpdated = true;
}
}
foreach (Actor actor in fullMetadata.Actors
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existingMetadata.Actors.Add(actor);
if (await _televisionRepository.AddActor(existingMetadata, actor))
{
result.IsUpdated = true;
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
},
_ => Task.CompletedTask);
}
return result;

19
ErsatzTV.Infrastructure/Data/Configurations/Metadata/ActorConfiguration.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ActorConfiguration : IEntityTypeConfiguration<Actor>
{
public void Configure(EntityTypeBuilder<Actor> builder)
{
builder.ToTable("Actor");
builder.HasOne(a => a.Artwork)
.WithOne()
.HasForeignKey<Actor>(a => a.ArtworkId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

4
ErsatzTV.Infrastructure/Data/Configurations/Metadata/EpisodeMetadataConfiguration.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(em => em.Actors)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

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

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

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

2
ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs

@ -13,8 +13,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -13,8 +13,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class ChannelRepository : IChannelRepository
{
private readonly TvContext _dbContext;
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
public ChannelRepository(TvContext dbContext, IDbConnection dbConnection)
{

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

@ -22,6 +22,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -22,6 +22,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection = dbConnection;
}
public Task<bool> RemoveActor(Actor actor) =>
_dbConnection.ExecuteAsync("DELETE FROM Actor WHERE Id = @ActorId", new { ActorId = actor.Id })
.Map(result => result > 0);
public async Task<bool> Update(Metadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -33,16 +37,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -33,16 +37,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
dbContext.Entry(metadata).State = EntityState.Added;
foreach (Genre genre in metadata.Genres)
foreach (Genre genre in Optional(metadata.Genres).Flatten())
{
dbContext.Entry(genre).State = EntityState.Added;
}
foreach (Tag tag in metadata.Tags)
foreach (Tag tag in Optional(metadata.Tags).Flatten())
{
dbContext.Entry(tag).State = EntityState.Added;
}
foreach (Studio studio in Optional(metadata.Studios).Flatten())
{
dbContext.Entry(studio).State = EntityState.Added;
}
if (metadata is ArtistMetadata artistMetadata)
{
foreach (Style style in Optional(artistMetadata.Styles).Flatten())
{
dbContext.Entry(style).State = EntityState.Added;
}
foreach (Mood mood in Optional(artistMetadata.Moods).Flatten())
{
dbContext.Entry(mood).State = EntityState.Added;
}
}
foreach (Actor actor in Optional(metadata.Actors).Flatten())
{
dbContext.Entry(actor).State = EntityState.Added;
if (actor.Artwork != null)
{
dbContext.Entry(actor.Artwork).State = EntityState.Added;
}
}
return await dbContext.SaveChangesAsync() > 0;
}

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

@ -43,6 +43,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -43,6 +43,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(m => m.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Studios)
.Include(m => m.MovieMetadata)
.ThenInclude(m => m.Actors)
.ThenInclude(a => a.Artwork)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.OrderBy(m => m.Id)
@ -62,6 +65,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -62,6 +65,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.MediaVersions)
@ -92,6 +97,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -92,6 +97,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
@ -187,6 +194,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -187,6 +194,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"INSERT INTO Studio (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<bool> AddActor(MovieMetadata metadata, Actor actor)
{
int? artworkId = null;
if (actor.Artwork != null)
{
artworkId = await _dbConnection.QuerySingleAsync<int>(
@"INSERT INTO Artwork (ArtworkKind, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @DateAdded, @DateUpdated, @Path);
SELECT last_insert_rowid()",
new
{
ArtworkKind = (int) actor.Artwork.ArtworkKind,
actor.Artwork.DateAdded,
actor.Artwork.DateUpdated,
actor.Artwork.Path
});
}
return await _dbConnection.ExecuteAsync(
"INSERT INTO Actor (Name, Role, \"Order\", MovieMetadataId, ArtworkId) VALUES (@Name, @Role, @Order, @MetadataId, @ArtworkId)",
new { actor.Name, actor.Role, actor.Order, MetadataId = metadata.Id, ArtworkId = artworkId })
.Map(result => result > 0);
}
public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(

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

@ -39,6 +39,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -39,6 +39,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Movie).MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(mi => (mi as Movie).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Show).ShowMetadata)
@ -47,6 +49,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -47,6 +49,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Tags)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Actors)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)

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

@ -55,6 +55,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -55,6 +55,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Tags)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync()
.Map(Optional);
@ -188,6 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -188,6 +191,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.OrderBy(em => em.Episode.EpisodeNumber)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
@ -217,6 +221,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -217,6 +221,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Tags)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(s => s.Id)
@ -239,6 +246,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -239,6 +246,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
var show = new Show
{
LibraryPathId = libraryPathId,
@ -283,6 +291,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -283,6 +291,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
Option<Episode> maybeExisting = await dbContext.Episodes
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
@ -382,6 +392,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -382,6 +392,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
@ -420,6 +432,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -420,6 +432,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
@ -507,6 +521,56 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -507,6 +521,56 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
"INSERT INTO Studio (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<bool> AddActor(ShowMetadata metadata, Actor actor)
{
int? artworkId = null;
if (actor.Artwork != null)
{
artworkId = await _dbConnection.QuerySingleAsync<int>(
@"INSERT INTO Artwork (ArtworkKind, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @DateAdded, @DateUpdated, @Path);
SELECT last_insert_rowid()",
new
{
ArtworkKind = (int) actor.Artwork.ArtworkKind,
actor.Artwork.DateAdded,
actor.Artwork.DateUpdated,
actor.Artwork.Path
});
}
return await _dbConnection.ExecuteAsync(
"INSERT INTO Actor (Name, Role, \"Order\", ShowMetadataId, ArtworkId) VALUES (@Name, @Role, @Order, @MetadataId, @ArtworkId)",
new { actor.Name, actor.Role, actor.Order, MetadataId = metadata.Id, ArtworkId = artworkId })
.Map(result => result > 0);
}
public async Task<bool> AddActor(EpisodeMetadata metadata, Actor actor)
{
int? artworkId = null;
if (actor.Artwork != null)
{
artworkId = await _dbConnection.QuerySingleAsync<int>(
@"INSERT INTO Artwork (ArtworkKind, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @DateAdded, @DateUpdated, @Path);
SELECT last_insert_rowid()",
new
{
ArtworkKind = (int) actor.Artwork.ArtworkKind,
actor.Artwork.DateAdded,
actor.Artwork.DateUpdated,
actor.Artwork.Path
});
}
return await _dbConnection.ExecuteAsync(
"INSERT INTO Actor (Name, Role, \"Order\", EpisodeMetadataId, ArtworkId) VALUES (@Name, @Role, @Order, @MetadataId, @ArtworkId)",
new { actor.Name, actor.Role, actor.Order, MetadataId = metadata.Id, ArtworkId = artworkId })
.Map(result => result > 0);
}
public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@ -582,7 +646,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -582,7 +646,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
DateAdded = DateTime.UtcNow,
DateUpdated = DateTime.MinValue,
MetadataKind = MetadataKind.Fallback
MetadataKind = MetadataKind.Fallback,
Actors = new List<Actor>()
}
},
MediaVersions = new List<MediaVersion>

2
ErsatzTV.Infrastructure/GitHub/IGitHubApi.cs

@ -10,7 +10,7 @@ namespace ErsatzTV.Infrastructure.GitHub @@ -10,7 +10,7 @@ namespace ErsatzTV.Infrastructure.GitHub
{
[Get("/repos/jasongdove/ErsatzTV/releases")]
public Task<List<GitHubTag>> GetReleases();
[Get("/repos/jasongdove/ErsatzTV/releases/tags/{tag}")]
public Task<GitHubTag> GetTag(string tag);
}

2256
ErsatzTV.Infrastructure/Migrations/20210414210456_Add_Actor.Designer.cs generated

File diff suppressed because it is too large Load Diff

101
ErsatzTV.Infrastructure/Migrations/20210414210456_Add_Actor.cs

@ -0,0 +1,101 @@ @@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Actor : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"Actor",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
Role = table.Column<string>("TEXT", nullable: true),
Order = table.Column<int>("INTEGER", nullable: true),
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true),
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
MusicVideoMetadataId = table.Column<int>("INTEGER", nullable: true),
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Actor", x => x.Id);
table.ForeignKey(
"FK_Actor_ArtistMetadata_ArtistMetadataId",
x => x.ArtistMetadataId,
"ArtistMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Actor_EpisodeMetadata_EpisodeMetadataId",
x => x.EpisodeMetadataId,
"EpisodeMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Actor_MovieMetadata_MovieMetadataId",
x => x.MovieMetadataId,
"MovieMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_Actor_MusicVideoMetadata_MusicVideoMetadataId",
x => x.MusicVideoMetadataId,
"MusicVideoMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Actor_SeasonMetadata_SeasonMetadataId",
x => x.SeasonMetadataId,
"SeasonMetadata",
"Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
"FK_Actor_ShowMetadata_ShowMetadataId",
x => x.ShowMetadataId,
"ShowMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Actor_ArtistMetadataId",
"Actor",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Actor_EpisodeMetadataId",
"Actor",
"EpisodeMetadataId");
migrationBuilder.CreateIndex(
"IX_Actor_MovieMetadataId",
"Actor",
"MovieMetadataId");
migrationBuilder.CreateIndex(
"IX_Actor_MusicVideoMetadataId",
"Actor",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_Actor_SeasonMetadataId",
"Actor",
"SeasonMetadataId");
migrationBuilder.CreateIndex(
"IX_Actor_ShowMetadataId",
"Actor",
"ShowMetadataId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"Actor");
}
}

2256
ErsatzTV.Infrastructure/Migrations/20210414212448_Reset_MetadataDateUpdated_Actor.Designer.cs generated

File diff suppressed because it is too large Load Diff

25
ErsatzTV.Infrastructure/Migrations/20210414212448_Reset_MetadataDateUpdated_Actor.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_MetadataDateUpdated_Actor : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(@"UPDATE EpisodeMetadata SET DateUpdated = '0001-01-01 00:00:00'");
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind IN (1, 2))");
migrationBuilder.Sql(
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE MediaKind IN (1, 2)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

2269
ErsatzTV.Infrastructure/Migrations/20210416122613_Add_Actor_Artwork.Designer.cs generated

File diff suppressed because it is too large Load Diff

45
ErsatzTV.Infrastructure/Migrations/20210416122613_Add_Actor_Artwork.cs

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Actor_Artwork : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
"ArtworkId",
"Actor",
"INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
"IX_Actor_ArtworkId",
"Actor",
"ArtworkId",
unique: true);
migrationBuilder.AddForeignKey(
"FK_Actor_Artwork_ArtworkId",
"Actor",
"ArtworkId",
"Artwork",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
"FK_Actor_Artwork_ArtworkId",
"Actor");
migrationBuilder.DropIndex(
"IX_Actor_ArtworkId",
"Actor");
migrationBuilder.DropColumn(
"ArtworkId",
"Actor");
}
}
}

109
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -16,6 +16,64 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -16,6 +16,64 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.4");
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Actor",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ArtworkId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("Order")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.HasColumnType("TEXT");
b.Property<int?>("SeasonMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("ShowMetadataId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("ArtworkId")
.IsUnique();
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId");
b.ToTable("Actor");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
@ -1391,6 +1449,45 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1391,6 +1449,45 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexShow");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Actor",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Actors")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.Artwork", "Artwork")
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Actor", "ArtworkId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Actors")
.HasForeignKey("EpisodeMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
.WithMany("Actors")
.HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Actors")
.HasForeignKey("MusicVideoMetadataId");
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Actors")
.HasForeignKey("SeasonMetadataId");
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
.WithMany("Actors")
.HasForeignKey("ShowMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Artwork");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
@ -2189,6 +2286,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2189,6 +2286,8 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
@ -2217,6 +2316,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2217,6 +2316,8 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.EpisodeMetadata",
b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
@ -2247,6 +2348,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2247,6 +2348,8 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
@ -2260,6 +2363,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2260,6 +2363,8 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
@ -2291,6 +2396,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2291,6 +2396,8 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.SeasonMetadata",
b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");
@ -2304,6 +2411,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2304,6 +2411,8 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.ShowMetadata",
b =>
{
b.Navigation("Actors");
b.Navigation("Artwork");
b.Navigation("Genres");

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

@ -18,5 +18,6 @@ namespace ErsatzTV.Infrastructure.Plex.Models @@ -18,5 +18,6 @@ namespace ErsatzTV.Infrastructure.Plex.Models
public string Studio { get; set; }
public List<PlexMediaResponse> Media { get; set; }
public List<PlexGenreResponse> Genre { get; set; }
public List<PlexRoleResponse> Role { get; set; }
}
}

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

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Infrastructure.Plex.Models
{
public class PlexRoleResponse
{
public string Tag { get; set; }
public string Role { get; set; }
public string Thumb { get; set; }
}
}

160
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -116,6 +116,48 @@ namespace ErsatzTV.Infrastructure.Plex @@ -116,6 +116,48 @@ namespace ErsatzTV.Infrastructure.Plex
}
}
public async Task<Either<BaseError, MovieMetadata>> GetMovieMetadata(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
try
{
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetMetadata(key, token.AuthToken)
.Map(
r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0)
.HeadOrNone())
.MapT(response => ProjectToMovieMetadata(response, library.MediaSourceId))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task<Either<BaseError, ShowMetadata>> GetShowMetadata(
PlexLibrary library,
string key,
PlexConnection connection,
PlexServerAuthToken token)
{
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))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task<Either<BaseError, MediaVersion>> GetStatistics(
string key,
PlexConnection connection,
@ -167,6 +209,44 @@ namespace ErsatzTV.Infrastructure.Plex @@ -167,6 +209,44 @@ namespace ErsatzTV.Infrastructure.Plex
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
MovieMetadata metadata = ProjectToMovieMetadata(response, mediaSourceId);
var version = new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
// specifically omit sample aspect ratio
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
},
Streams = new List<MediaStream>()
};
var movie = new PlexMovie
{
Key = response.Key,
MovieMetadata = new List<MovieMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version }
};
return movie;
}
private MovieMetadata ProjectToMovieMetadata(PlexMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new MovieMetadata
{
Title = response.Title,
@ -178,7 +258,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -178,7 +258,9 @@ namespace ErsatzTV.Infrastructure.Plex
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>(),
Studios = new List<Studio>()
Studios = new List<Studio>(),
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList()
};
if (!string.IsNullOrWhiteSpace(response.Studio))
@ -221,35 +303,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -221,35 +303,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,
// specifically omit sample aspect ratio
DateAdded = dateAdded,
DateUpdated = lastWriteTime,
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
},
Streams = new List<MediaStream>()
};
var movie = new PlexMovie
{
Key = response.Key,
MovieMetadata = new List<MovieMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version }
};
return movie;
return metadata;
}
private Option<MediaVersion> ProjectToMediaVersion(PlexMetadataResponse response)
@ -324,6 +378,19 @@ namespace ErsatzTV.Infrastructure.Plex @@ -324,6 +378,19 @@ namespace ErsatzTV.Infrastructure.Plex
}
private PlexShow ProjectToShow(PlexMetadataResponse response, int mediaSourceId)
{
ShowMetadata metadata = ProjectToShowMetadata(response, mediaSourceId);
var show = new PlexShow
{
Key = response.Key,
ShowMetadata = new List<ShowMetadata> { metadata }
};
return show;
}
private ShowMetadata ProjectToShowMetadata(PlexMetadataResponse response, int mediaSourceId)
{
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
@ -339,7 +406,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -339,7 +406,9 @@ namespace ErsatzTV.Infrastructure.Plex
DateUpdated = lastWriteTime,
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
Tags = new List<Tag>(),
Studios = new List<Studio>()
Studios = new List<Studio>(),
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList()
};
if (!string.IsNullOrWhiteSpace(response.Studio))
@ -382,13 +451,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -382,13 +451,7 @@ namespace ErsatzTV.Infrastructure.Plex
metadata.Artwork.Add(artwork);
}
var show = new PlexShow
{
Key = response.Key,
ShowMetadata = new List<ShowMetadata> { metadata }
};
return show;
return metadata;
}
private PlexSeason ProjectToSeason(PlexMetadataResponse response, int mediaSourceId)
@ -460,7 +523,9 @@ namespace ErsatzTV.Infrastructure.Plex @@ -460,7 +523,9 @@ namespace ErsatzTV.Infrastructure.Plex
Year = response.Year,
Tagline = response.Tagline,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
DateUpdated = lastWriteTime,
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList()
};
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
@ -514,5 +579,22 @@ namespace ErsatzTV.Infrastructure.Plex @@ -514,5 +579,22 @@ namespace ErsatzTV.Infrastructure.Plex
return episode;
}
private Actor ProjectToModel(PlexRoleResponse role, DateTime dateAdded, DateTime lastWriteTime)
{
var actor = new Actor { Name = role.Tag, Role = role.Role };
if (!string.IsNullOrWhiteSpace(role.Thumb))
{
actor.Artwork = new Artwork
{
Path = role.Thumb,
ArtworkKind = ArtworkKind.Thumbnail,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
}
return actor;
}
}
}

11
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -43,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -43,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string LanguageField = "language";
private const string StyleField = "style";
private const string MoodField = "mood";
private const string ActorField = "actor";
private const string MovieType = "movie";
private const string ShowType = "show";
@ -286,6 +287,11 @@ namespace ErsatzTV.Infrastructure.Search @@ -286,6 +287,11 @@ namespace ErsatzTV.Infrastructure.Search
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));
}
_writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
}
catch (Exception ex)
@ -382,6 +388,11 @@ namespace ErsatzTV.Infrastructure.Search @@ -382,6 +388,11 @@ namespace ErsatzTV.Infrastructure.Search
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));
}
_writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
}
catch (Exception ex)

90
ErsatzTV/Pages/Artist.razor

@ -58,46 +58,58 @@ @@ -58,46 +58,58 @@
</div>
</div>
</div>
@if (_artist.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _artist.Languages.OrderBy(l => l.EnglishName))
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
}
</div>
}
</div>
}
@if (_artist.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _artist.Genres.OrderBy(g => g))
@if (_sortedGenres.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
}
</div>
}
</div>
}
@if (_artist.Styles.Any())
{
<MudText GutterBottom="true">Styles</MudText>
<div class="mb-2">
@foreach (string style in _artist.Styles.OrderBy(g => g))
@if (_sortedStyles.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@style" Class="mr-2 mb-2" Link="@($"/search?query=style%3a%22{Uri.EscapeDataString(style.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Styles:&nbsp;</MudText>
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(_sortedStyles.Head())}%22")">@_sortedStyles.Head()</MudLink>
@foreach (string style in _sortedStyles.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=style%3a%22{Uri.EscapeDataString(style)}%22")">@style</MudLink>
}
</div>
}
</div>
}
@if (_artist.Moods.Any())
{
<MudText GutterBottom="true">Moods</MudText>
<div class="mb-2">
@foreach (string mood in _artist.Moods.OrderBy(g => g))
@if (_sortedMoods.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@mood" Class="mr-2 mb-2" Link="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Moods:&nbsp;</MudText>
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(_sortedMoods.Head())}%22")">@_sortedMoods.Head()</MudLink>
@foreach (string mood in _sortedMoods.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood)}%22")">@mood</MudLink>
}
</div>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
@ -139,13 +151,25 @@ @@ -139,13 +151,25 @@
public int ArtistId { get; set; }
private ArtistViewModel _artist;
private List<CultureInfo> _sortedLanguages = new();
private List<string> _sortedGenres = new();
private List<string> _sortedStyles = new();
private List<string> _sortedMoods = new();
private MusicVideoCardResultsViewModel _musicVideos;
protected override Task OnParametersSetAsync() => RefreshData();
private async Task RefreshData()
{
await Mediator.Send(new GetArtistById(ArtistId)).IfSomeAsync(vm => _artist = vm);
await Mediator.Send(new GetArtistById(ArtistId)).IfSomeAsync(vm =>
{
_artist = vm;
_sortedLanguages = _artist.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedGenres = _artist.Genres.OrderBy(g => g).ToList();
_sortedStyles = _artist.Styles.OrderBy(s => s).ToList();
_sortedMoods = _artist.Moods.OrderBy(m => m).ToList();
});
_musicVideos = await Mediator.Send(new GetMusicVideoCards(ArtistId, 1, 100));
}

11
ErsatzTV/Pages/Collections.razor

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
@page "/media/collections"
@using ErsatzTV.Application.Configuration.Commands
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@using ErsatzTV.Application.Configuration.Queries
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.Configuration.Commands
@inject IDialogService Dialog
@inject IMediator Mediator
@ -56,11 +56,8 @@ @@ -56,11 +56,8 @@
private int _rowsPerPage;
protected override async Task OnParametersSetAsync()
{
_rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
}
protected override async Task OnParametersSetAsync() => _rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.CollectionsPageSize))
.Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10));
private async Task DeleteMediaCollection(MediaCardViewModel vm)
{

4
ErsatzTV/Pages/Index.razor

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
@page "/"
@using Microsoft.Extensions.Caching.Memory
@using System.Reflection
@using ErsatzTV.Core.Interfaces.GitHub
@using Microsoft.Extensions.Caching.Memory
@inject IGitHubApiClient _gitHubApiClient
@inject IMemoryCache _memoryCache
@ -60,7 +60,7 @@ @@ -60,7 +60,7 @@
}
}
}
catch (Exception ex)
catch (Exception _)
{
// ignore
}

105
ErsatzTV/Pages/Movie.razor

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
@using ErsatzTV.Application.Movies
@using ErsatzTV.Application.Movies.Queries
@using System.Globalization
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@inject IMediator Mediator
@ -44,47 +45,74 @@ @@ -44,47 +45,74 @@
</div>
</div>
</div>
@if (_movie.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _movie.Languages.OrderBy(l => l.EnglishName))
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
}
</div>
}
</div>
}
@if (_movie.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _movie.Studios.OrderBy(s => s))
@if (_sortedStudios.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
}
</div>
}
</div>
}
@if (_movie.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _movie.Genres.OrderBy(g => g))
@if (_sortedGenres.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
}
</div>
}
</div>
}
@if (_movie.Tags.Any())
{
<MudText GutterBottom="true">Tags</MudText>
<div>
@foreach (string tag in _movie.Tags.OrderBy(t => t))
@if (_sortedTags.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
}
</div>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
@if (_movie.Actors.Any())
{
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Actors</MudText>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (ActorCardViewModel actor in _movie.Actors)
{
<MediaCard Data="@actor"
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
IsRemoteArtwork="true"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
@code {
@ -92,11 +120,22 @@ @@ -92,11 +120,22 @@
public int MovieId { get; set; }
private MovieViewModel _movie;
private List<CultureInfo> _sortedLanguages = new();
private List<string> _sortedStudios = new();
private List<string> _sortedGenres = new();
private List<string> _sortedTags = new();
protected override Task OnParametersSetAsync() => RefreshData();
private Task RefreshData() =>
Mediator.Send(new GetMovieById(MovieId)).IfSomeAsync(vm => _movie = vm);
Mediator.Send(new GetMovieById(MovieId)).IfSomeAsync(vm =>
{
_movie = vm;
_sortedLanguages = _movie.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedStudios = _movie.Studios.OrderBy(s => s).ToList();
_sortedGenres = _movie.Genres.OrderBy(g => g).ToList();
_sortedTags = _movie.Tags.OrderBy(t => t).ToList();
});
private async Task AddToCollection()
{

109
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -59,48 +59,63 @@ @@ -59,48 +59,63 @@
</div>
</div>
</div>
@if (_show.Languages.Any())
{
<MudText GutterBottom="true">Languages</MudText>
<div class="mb-2">
@foreach (CultureInfo language in _show.Languages.OrderBy(l => l.EnglishName))
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedLanguages.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@language.EnglishName" Class="mr-2 mb-2" Link="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(_sortedLanguages.Head().EnglishName.ToLowerInvariant())}%22")">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=language%3a%22{Uri.EscapeDataString(language.EnglishName.ToLowerInvariant())}%22")">@language.EnglishName</MudLink>
}
</div>
}
</div>
}
@if (_show.Studios.Any())
{
<MudText GutterBottom="true">Studios</MudText>
<div class="mb-2">
@foreach (string studio in _show.Studios.OrderBy(g => g))
@if (_sortedStudios.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(_sortedStudios.Head())}%22")">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio)}%22")">@studio</MudLink>
}
</div>
}
</div>
}
@if (_show.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _show.Genres.OrderBy(g => g))
@if (_sortedGenres.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(_sortedGenres.Head())}%22")">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre)}%22")">@genre</MudLink>
}
</div>
}
</div>
}
@if (_show.Tags.Any())
{
<MudText GutterBottom="true">Tags</MudText>
<div>
@foreach (string tag in _show.Tags.OrderBy(t => t))
@if (_sortedTags.Any())
{
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(_sortedTags.Head())}%22")">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag)}%22")">@tag</MudLink>
}
</div>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Seasons</MudText>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid mt-8">
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
{
<MediaCard Data="@card" Placeholder="@card.Placeholder"
@ -108,6 +123,21 @@ @@ -108,6 +123,21 @@
AddToCollectionClicked="@AddSeasonToCollection"/>
}
</MudContainer>
@if (_show.Actors.Any())
{
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Actors</MudText>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="media-card-grid">
@foreach (ActorCardViewModel actor in _show.Actors)
{
<MediaCard Data="@actor"
Link="@($"/search?query=actor%3a%22{Uri.EscapeDataString(actor.Name.ToLowerInvariant())}%22")"
IsRemoteArtwork="true"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudContainer>
}
@code {
@ -115,6 +145,10 @@ @@ -115,6 +145,10 @@
public int ShowId { get; set; }
private TelevisionShowViewModel _show;
private List<CultureInfo> _sortedLanguages = new();
private List<string> _sortedStudios = new();
private List<string> _sortedGenres = new();
private List<string> _sortedTags = new();
private int _pageSize => 100;
private readonly int _pageNumber = 1;
@ -126,7 +160,14 @@ @@ -126,7 +160,14 @@
private async Task RefreshData()
{
await Mediator.Send(new GetTelevisionShowById(ShowId))
.IfSomeAsync(vm => _show = vm);
.IfSomeAsync(vm =>
{
_show = vm;
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
});
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, _pageNumber, _pageSize));
}

2
ErsatzTV/Pages/_Host.cshtml

@ -43,7 +43,7 @@ @@ -43,7 +43,7 @@
function enableSorting() {
$("#sortable-collection").sortable("option", "disabled", false);
}
function styleMarkdown() {
$("h2").addClass("mud-typography mud-typography-h4");
$("h3").addClass("mud-typography mud-typography-h5");

7
ErsatzTV/Shared/AddToCollectionDialog.razor

@ -61,9 +61,10 @@ @@ -61,9 +61,10 @@
private List<MediaCollectionViewModel> _collections;
private MediaCollectionViewModel _selectedCollection;
private record DummyModel;
private DummyModel _dummyModel = new();
private readonly DummyModel _dummyModel = new();
private bool CanSubmit() =>
_selectedCollection != null && (_selectedCollection != _newCollection || !string.IsNullOrWhiteSpace(_newCollectionName));
@ -119,7 +120,7 @@ @@ -119,7 +120,7 @@
private async Task Cancel(MouseEventArgs e)
{
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
// this is gross, but [enter] seems to sometimes trigger cancel instead of submit
if (e.Detail == 0)
{
await Submit();

2
ErsatzTV/Shared/AddToScheduleDialog.razor

@ -56,7 +56,7 @@ @@ -56,7 +56,7 @@
private void Submit() => MudDialog.Close(DialogResult.Ok(_selectedSchedule));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")

2
ErsatzTV/Shared/DeleteDialog.razor

@ -46,7 +46,7 @@ @@ -46,7 +46,7 @@
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")

2
ErsatzTV/Shared/MainLayout.razor

@ -76,7 +76,7 @@ @@ -76,7 +76,7 @@
private record SearchModel;
private SearchModel _dummyModel = new();
private readonly SearchModel _dummyModel = new();
private MudTheme _ersatzTvTheme => new()
{

17
ErsatzTV/Shared/MediaCard.razor

@ -119,6 +119,9 @@ @@ -119,6 +119,9 @@
[Parameter]
public Color SelectColor { get; set; } = Color.Tertiary;
[Parameter]
public bool IsRemoteArtwork { get; set; }
private string GetPlaceholder(string sortTitle)
{
if (Placeholder != null)
@ -130,9 +133,17 @@ @@ -130,9 +133,17 @@
return char.IsDigit(first) || !char.IsLetter(first) ? "#" : first.ToString();
}
private string ArtworkForItem() => string.IsNullOrWhiteSpace(Data.Poster)
? "position: relative"
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover; background-position: center";
private string ArtworkForItem()
{
if (IsRemoteArtwork)
{
return $"position: relative; background-image: url({Data.Poster}); background-size: cover; background-position: center";
}
return string.IsNullOrWhiteSpace(Data.Poster)
? "position: relative"
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover; background-position: center";
}
private string PathForArtwork() => ArtworkKind switch
{

2
ErsatzTV/Shared/RemoveFromCollectionDialog.razor

@ -41,7 +41,7 @@ @@ -41,7 +41,7 @@
private void Submit() => MudDialog.Close(DialogResult.Ok(true));
private void Cancel() => MudDialog.Cancel();
private void OnKeyDown(KeyboardEventArgs e)
{
if (e.Code is "Enter" or "NumpadEnter")

1
ErsatzTV/Shared/SignOutOfPlexDialog.razor

@ -30,4 +30,5 @@ @@ -30,4 +30,5 @@
Submit();
}
}
}

20
ErsatzTV/wwwroot/css/site.css

@ -74,9 +74,7 @@ @@ -74,9 +74,7 @@
border-radius: 4px;
}
.app-bar form {
flex-grow: 1;
}
.app-bar form { flex-grow: 1; }
.fanart-container {
position: relative;
@ -126,18 +124,10 @@ @@ -126,18 +124,10 @@
justify-content: space-around;
}
.release-notes ul {
list-style: unset;
}
.release-notes ul { list-style: unset; }
.release-notes > ul {
margin-top: 10px;
}
.release-notes > ul { margin-top: 10px; }
.release-notes ul > li {
margin-left: 30px;
}
.release-notes ul > li { margin-left: 30px; }
.release-notes h3 {
margin-top: 20px;
}
.release-notes h3 { margin-top: 20px; }
Loading…
Cancel
Save