Browse Source

add seasons to search index (#385)

* update trakt list items when re-adding existing list

* add seasons to search index
pull/386/head
Jason Dove 5 years ago committed by GitHub
parent
commit
a864d53327
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      CHANGELOG.md
  2. 20
      ErsatzTV.Application/MediaCards/Mapper.cs
  3. 3
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs
  4. 7
      ErsatzTV.Application/MediaCards/TelevisionSeasonCardResultsViewModel.cs
  5. 1
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  6. 15
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  7. 21
      ErsatzTV.Application/MediaCollections/Commands/TraktCommandBase.cs
  8. 1
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  9. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSeasons.cs
  10. 55
      ErsatzTV.Application/Search/Queries/QuerySearchIndexSeasonsHandler.cs
  11. 1
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  12. 6
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  13. 12
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  14. 2
      ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs
  15. 2
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs
  16. 1
      ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs
  17. 2
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  18. 12
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  19. 3
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  20. 20
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  21. 3
      ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListItemConfiguration.cs
  22. 4
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  23. 9
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  24. 30
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  25. 19
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  26. 39
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  27. 3284
      ErsatzTV.Infrastructure/Migrations/20210925181536_Add_TraktListItemDeleteSetNull.Designer.cs
  28. 37
      ErsatzTV.Infrastructure/Migrations/20210925181536_Add_TraktListItemDeleteSetNull.cs
  29. 3
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  30. 74
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  31. 10
      ErsatzTV/Pages/MultiSelectBase.cs
  32. 55
      ErsatzTV/Pages/Search.razor
  33. 157
      ErsatzTV/Pages/TelevisionSeasonSearchResults.razor
  34. 1
      ErsatzTV/Pages/TelevisionShowList.razor

2
CHANGELOG.md

@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- To re-download a Trakt list, simply add it again (no need to delete) - To re-download a Trakt list, simply add it again (no need to delete)
- See `Logs` for unmatched item details - See `Logs` for unmatched item details
- Trakt lists can only be scheduled by using Smart Collections - Trakt lists can only be scheduled by using Smart Collections
- Add seasons to search index
- This is needed because Trakt lists can contain seasons
### Fixed ### Fixed
- Fix local television scanner to properly update episode metadata when NFO files have been added/changed - Fix local television scanner to properly update episode metadata when NFO files have been added/changed

20
ErsatzTV.Application/MediaCards/Mapper.cs

@ -37,6 +37,26 @@ namespace ErsatzTV.Application.MediaCards
.IfNone(string.Empty), .IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString()); season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
SeasonMetadata seasonMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string showTitle = seasonMetadata.Season.Show.ShowMetadata.HeadOrNone().Match(
m => m.Title ?? string.Empty,
() => string.Empty);
return new TelevisionSeasonCardViewModel(
showTitle,
seasonMetadata.SeasonId,
seasonMetadata.Season.SeasonNumber,
showTitle,
GetSeasonName(seasonMetadata.Season.SeasonNumber),
$"{showTitle}_{seasonMetadata.Season.SeasonNumber:0000}",
GetPoster(seasonMetadata, maybeJellyfin, maybeEmby),
seasonMetadata.Season.SeasonNumber == 0 ? "S" : seasonMetadata.Season.SeasonNumber.ToString());
}
internal static TelevisionEpisodeCardViewModel ProjectToViewModel( internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata, EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin, Option<JellyfinMediaSource> maybeJellyfin,

3
ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs

@ -7,6 +7,7 @@ using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt; using LanguageExt;
using MediatR; using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper; using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries namespace ErsatzTV.Application.MediaCards.Queries
{ {
@ -41,7 +42,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize) .GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList()); .Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionSeasonCardResultsViewModel(count, results); return new TelevisionSeasonCardResultsViewModel(count, results, None);
} }
} }
} }

7
ErsatzTV.Application/MediaCards/TelevisionSeasonCardResultsViewModel.cs

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

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

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

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

@ -52,6 +52,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
var allItems = request.MovieIds var allItems = request.MovieIds
.Append(request.ShowIds) .Append(request.ShowIds)
.Append(request.SeasonIds)
.Append(request.EpisodeIds) .Append(request.EpisodeIds)
.Append(request.ArtistIds) .Append(request.ArtistIds)
.Append(request.MusicVideoIds) .Append(request.MusicVideoIds)
@ -77,12 +78,15 @@ namespace ErsatzTV.Application.MediaCollections.Commands
return Unit.Default; return Unit.Default;
} }
private async Task<Validation<BaseError, Collection>> Validate(TvContext dbContext, AddItemsToCollection request) => private async Task<Validation<BaseError, Collection>> Validate(
TvContext dbContext,
AddItemsToCollection request) =>
(await CollectionMustExist(dbContext, request), (await CollectionMustExist(dbContext, request),
await ValidateMovies(request), await ValidateMovies(request),
await ValidateShows(request), await ValidateShows(request),
await ValidateSeasons(request),
await ValidateEpisodes(request)) await ValidateEpisodes(request))
.Apply((collection, _, _, _) => collection); .Apply((collection, _, _, _, _) => collection);
private static Task<Validation<BaseError, Collection>> CollectionMustExist( private static Task<Validation<BaseError, Collection>> CollectionMustExist(
TvContext dbContext, TvContext dbContext,
@ -106,6 +110,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => Unit.Default) .MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Show does not exist")); .Map(v => v.ToValidation<BaseError>("Show does not exist"));
private Task<Validation<BaseError, Unit>> ValidateSeasons(AddItemsToCollection request) =>
_televisionRepository.AllSeasonsExist(request.SeasonIds)
.Map(Optional)
.Filter(v => v == true)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Season does not exist"));
private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) => private Task<Validation<BaseError, Unit>> ValidateEpisodes(AddItemsToCollection request) =>
_televisionRepository.AllEpisodesExist(request.EpisodeIds) _televisionRepository.AllEpisodesExist(request.EpisodeIds)
.Map(Optional) .Map(Optional)

21
ErsatzTV.Application/MediaCollections/Commands/TraktCommandBase.cs

@ -82,12 +82,29 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList(); var toAdd = items.Filter(i => list.Items.All(i2 => i2.TraktId != i.TraktId)).ToList();
var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList(); var toRemove = list.Items.Filter(i => items.All(i2 => i2.TraktId != i.TraktId)).ToList();
var toUpdate = list.Items.Filter(i => !toRemove.Contains(i)).ToList();
// TODO: do we need to update?
list.Items.RemoveAll(toRemove.Contains); list.Items.RemoveAll(toRemove.Contains);
list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a))); list.Items.AddRange(toAdd.Map(a => ProjectItem(list, a)));
foreach (TraktListItem existing in toUpdate)
{
Option<TraktListItem> maybeIncoming = list.Items.Find(i => i.TraktId == existing.TraktId);
foreach (TraktListItem incoming in maybeIncoming)
{
existing.Kind = incoming.Kind;
existing.Rank = incoming.Rank;
existing.Title = incoming.Title;
existing.Year = incoming.Year;
existing.Season = incoming.Season;
existing.Episode = incoming.Episode;
existing.Guids.Clear();
existing.Guids.AddRange(incoming.Guids);
existing.MediaItemId = null;
existing.MediaItem = null;
}
}
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return list; return list;

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

@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Search.Queries
new( new(
await GetIds("movie", request.Query), await GetIds("movie", request.Query),
await GetIds("show", request.Query), await GetIds("show", request.Query),
await GetIds("season", request.Query),
await GetIds("episode", request.Query), await GetIds("episode", request.Query),
await GetIds("artist", request.Query), await GetIds("artist", request.Query),
await GetIds("music_video", request.Query)); await GetIds("music_video", request.Query));

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

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

55
ErsatzTV.Application/Search/Queries/QuerySearchIndexSeasonsHandler.cs

@ -0,0 +1,55 @@
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
QuerySearchIndexSeasonsHandler : IRequestHandler<QuerySearchIndexSeasons, TelevisionSeasonCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexSeasonsHandler(
ISearchIndex searchIndex,
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
QuerySearchIndexSeasons 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<TelevisionSeasonCardViewModel> items = await _televisionRepository
.GetSeasonsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionSeasonCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

1
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

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

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

@ -11,6 +11,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public class FakeTelevisionRepository : ITelevisionRepository public class FakeTelevisionRepository : ITelevisionRepository
{ {
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException(); public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
public Task<bool> AllSeasonsExist(List<int> seasonIds) => throw new NotSupportedException();
public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException(); public Task<bool> AllEpisodesExist(List<int> episodeIds) => throw new NotSupportedException();
public Task<List<Show>> GetAllShows() => throw new NotSupportedException(); public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
@ -18,6 +20,8 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException(); public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException(); public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids) => throw new NotSupportedException();
public Task<List<EpisodeMetadata>> GetEpisodesForCards(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(); public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
@ -33,8 +37,6 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<List<Episode>> GetSeasonItems(int seasonId) => throw new NotSupportedException(); public Task<List<Episode>> GetSeasonItems(int seasonId) => throw new NotSupportedException();
public Task<Option<Episode>> GetEpisode(int episodeId) => throw new NotSupportedException();
public Task<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException(); public Task<int> GetEpisodeCount(int seasonId) => throw new NotSupportedException();
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) => public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>

12
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Emby
incoming.ShowId = show.Id; incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
await _televisionRepository.Update(incoming); foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
{
incoming.Show = show;
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
}
}, },
async () => async () =>
{ {
@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Emby
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title); incoming.SeasonMetadata.Head().Title);
await _televisionRepository.AddSeason(show, incoming); if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}); });
List<EmbyItemEtag> existingEpisodes = List<EmbyItemEtag> existingEpisodes =

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

@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddShow(EmbyShow show); Task<bool> AddShow(EmbyShow show);
Task<Option<EmbyShow>> Update(EmbyShow show); Task<Option<EmbyShow>> Update(EmbyShow show);
Task<bool> AddSeason(EmbyShow show, EmbySeason season); Task<bool> AddSeason(EmbyShow show, EmbySeason season);
Task<Unit> Update(EmbySeason season); Task<Option<EmbySeason>> Update(EmbySeason season);
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode); Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode); Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds); Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);

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

@ -14,7 +14,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddShow(JellyfinShow show); Task<bool> AddShow(JellyfinShow show);
Task<Option<JellyfinShow>> Update(JellyfinShow show); Task<Option<JellyfinShow>> Update(JellyfinShow show);
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season); Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
Task<Unit> Update(JellyfinSeason season); Task<Option<JellyfinSeason>> Update(JellyfinSeason season);
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode); Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode); Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds); Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);

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

@ -10,6 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> GetItemIdsToIndex(); Task<List<int>> GetItemIdsToIndex();
Task<Option<MediaItem>> GetItemToIndex(int id); Task<Option<MediaItem>> GetItemToIndex(int id);
Task<List<string>> GetLanguagesForShow(Show show); Task<List<string>> GetLanguagesForShow(Show show);
Task<List<string>> GetLanguagesForSeason(Season season);
Task<List<string>> GetLanguagesForArtist(Artist artist); Task<List<string>> GetLanguagesForArtist(Artist artist);
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes); Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
} }

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

@ -9,10 +9,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface ITelevisionRepository public interface ITelevisionRepository
{ {
Task<bool> AllShowsExist(List<int> showIds); Task<bool> AllShowsExist(List<int> showIds);
Task<bool> AllSeasonsExist(List<int> seasonIds);
Task<bool> AllEpisodesExist(List<int> episodeIds); Task<bool> AllEpisodesExist(List<int> episodeIds);
Task<List<Show>> GetAllShows(); Task<List<Show>> GetAllShows();
Task<Option<Show>> GetShow(int showId); Task<Option<Show>> GetShow(int showId);
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids); Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids);
Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids); Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids);
Task<List<Episode>> GetShowItems(int showId); Task<List<Episode>> GetShowItems(int showId);
Task<List<Season>> GetAllSeasons(); Task<List<Season>> GetAllSeasons();

12
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -227,7 +227,11 @@ namespace ErsatzTV.Core.Jellyfin
incoming.ShowId = show.Id; incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
await _televisionRepository.Update(incoming); foreach (JellyfinSeason updated in await _televisionRepository.Update(incoming))
{
incoming.Show = show;
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
}
}, },
async () => async () =>
{ {
@ -238,7 +242,11 @@ namespace ErsatzTV.Core.Jellyfin
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title); incoming.SeasonMetadata.Head().Title);
await _televisionRepository.AddSeason(show, incoming); if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}); });
List<JellyfinItemEtag> existingEpisodes = List<JellyfinItemEtag> existingEpisodes =

3
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -184,6 +184,9 @@ namespace ErsatzTV.Core.Metadata
{ {
await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder); await ScanEpisodes(libraryPath, ffprobePath, season, seasonFolder);
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag); await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
season.Show = show;
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
}, },
error => error =>
{ {

20
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -314,13 +314,19 @@ namespace ErsatzTV.Core.Plex
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming)); .BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
await maybeSeason.Match( await maybeSeason.Match(
async season => await ScanEpisodes( async season =>
library, {
pathReplacements, await ScanEpisodes(
season, library,
connection, pathReplacements,
token, season,
ffprobePath), connection,
token,
ffprobePath);
season.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
},
error => error =>
{ {
_logger.LogWarning( _logger.LogWarning(

3
ErsatzTV.Infrastructure/Data/Configurations/Collection/TraktListItemConfiguration.cs

@ -11,7 +11,8 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
builder.ToTable("TraktListItem"); builder.ToTable("TraktListItem");
builder.HasOne(i => i.MediaItem) builder.HasOne(i => i.MediaItem)
.WithMany(mi => mi.TraktListItems); .WithMany(mi => mi.TraktListItems)
.OnDelete(DeleteBehavior.SetNull);
builder.HasMany(i => i.Guids) builder.HasMany(i => i.Guids)
.WithOne(g => g.TraktListItem) .WithOne(g => g.TraktListItem)

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

@ -269,7 +269,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
} }
} }
public async Task<Unit> Update(EmbySeason season) public async Task<Option<EmbySeason>> Update(EmbySeason season)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons
@ -378,7 +378,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
return Unit.Default; return maybeExisting;
} }
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode) public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)

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

@ -286,7 +286,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
} }
} }
public async Task<Unit> Update(JellyfinSeason season) public async Task<Option<JellyfinSeason>> Update(JellyfinSeason season)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
@ -374,11 +374,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{ {
metadata.Artwork.Remove(artworkToRemove); metadata.Artwork.Remove(artworkToRemove);
} }
}
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(existing.LibraryPath).Reference(lp => lp.Library).LoadAsync();
}
return Unit.Default; return maybeExisting;
} }
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode) public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)

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

@ -365,6 +365,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexSeason ps ON ps.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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@ -391,7 +399,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList(); return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
} }
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) => public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
@ -530,6 +538,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinSeason js ON js.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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@ -556,7 +572,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList(); return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
} }
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId) public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
@ -928,6 +944,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
List<int> seasonIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.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( await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN @"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m (SELECT m.Id FROM MediaItem m
@ -954,7 +978,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE l.Id IN @ids)", WHERE l.Id IN @ids)",
new { ids = libraryIds }); new { ids = libraryIds });
return movieIds.Append(showIds).Append(episodeIds).ToList(); return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
} }
} }
} }

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

@ -65,6 +65,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(mi => (mi as Episode).MediaVersions) .Include(mi => (mi as Episode).MediaVersions)
.ThenInclude(em => em.Streams) .ThenInclude(em => em.Streams)
.Include(mi => (mi as Episode).Season) .Include(mi => (mi as Episode).Season)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Genres)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Tags)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Studios)
.Include(mi => (mi as Season).SeasonMetadata)
.ThenInclude(sm => sm.Actors)
.Include(mi => (mi as Season).Show)
.ThenInclude(sm => sm.ShowMetadata)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
.ThenInclude(mm => mm.Genres) .ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Show).ShowMetadata) .Include(mi => (mi as Show).ShowMetadata)
@ -104,6 +114,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId", WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList()); new { ShowId = show.Id }).Map(result => result.ToList());
public Task<List<string>> GetLanguagesForSeason(Season season) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion MV ON MediaStream.MediaVersionId = MV.Id
INNER JOIN Episode E ON MV.EpisodeId = E.Id
WHERE MediaStreamKind = 2 AND E.SeasonId = @SeasonId",
new { SeasonId = season.Id }).Map(result => result.ToList());
public Task<List<string>> GetLanguagesForArtist(Artist artist) => public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
_dbConnection.QueryAsync<string>( _dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language @"SELECT DISTINCT Language

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

@ -31,6 +31,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { ShowIds = showIds }) new { ShowIds = showIds })
.Map(c => c == showIds.Count); .Map(c => c == showIds.Count);
public Task<bool> AllSeasonsExist(List<int> seasonIds) =>
_dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Season WHERE Id in @SeasonIds",
new { SeasonIds = seasonIds })
.Map(c => c == seasonIds.Count);
public Task<bool> AllEpisodesExist(List<int> episodeIds) => public Task<bool> AllEpisodesExist(List<int> episodeIds) =>
_dbConnection.QuerySingleAsync<int>( _dbConnection.QuerySingleAsync<int>(
"SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds", "SELECT COUNT(*) FROM Episode WHERE Id in @EpisodeIds",
@ -80,6 +86,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
public async Task<List<SeasonMetadata>> GetSeasonsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.SeasonMetadata
.AsNoTracking()
.Filter(s => ids.Contains(s.SeasonId))
.Include(s => s.Season.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(sm => sm.Artwork)
.ToListAsync()
.Map(
list => list
.OrderBy(s => s.Season.Show.ShowMetadata.HeadOrNone().Match(sm => sm.SortTitle, () => string.Empty))
.ThenBy(s => s.Season.SeasonNumber)
.ToList());
}
public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids) public async Task<List<EpisodeMetadata>> GetEpisodesForCards(List<int> ids)
{ {
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); await using TvContext dbContext = _dbContextFactory.CreateDbContext();
@ -274,6 +297,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
.Include(s => s.SeasonMetadata) .Include(s => s.SeasonMetadata)
.ThenInclude(sm => sm.Guids) .ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(s => s.ShowId) .OrderBy(s => s.ShowId)
.ThenBy(s => s.SeasonNumber) .ThenBy(s => s.SeasonNumber)
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber); .SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
@ -439,6 +466,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
.Include(i => i.SeasonMetadata) .Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Guids) .ThenInclude(sm => sm.Guids)
.Include(s => s.LibraryPath)
.ThenInclude(l => l.Library)
.Include(s => s.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key) .OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key); .SingleOrDefaultAsync(i => i.Key == item.Key);
@ -678,10 +709,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
DateAdded = DateTime.UtcNow, DateAdded = DateTime.UtcNow,
Guids = new List<MetadataGuid>() Guids = new List<MetadataGuid>()
} }
} },
TraktListItems = new List<TraktListItem>()
}; };
await dbContext.Seasons.AddAsync(season); await dbContext.Seasons.AddAsync(season);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(season).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return season; return season;
} }
catch (Exception ex) catch (Exception ex)
@ -782,6 +818,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.PlexSeasons.AddAsync(item); await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return item; return item;
} }
catch (Exception ex) catch (Exception ex)

3284
ErsatzTV.Infrastructure/Migrations/20210925181536_Add_TraktListItemDeleteSetNull.Designer.cs generated

File diff suppressed because it is too large Load Diff

37
ErsatzTV.Infrastructure/Migrations/20210925181536_Add_TraktListItemDeleteSetNull.cs

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_TraktListItemDeleteSetNull : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TraktListItem_MediaItem_MediaItemId",
table: "TraktListItem");
migrationBuilder.AddForeignKey(
name: "FK_TraktListItem_MediaItem_MediaItemId",
table: "TraktListItem",
column: "MediaItemId",
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_TraktListItem_MediaItem_MediaItemId",
table: "TraktListItem");
migrationBuilder.AddForeignKey(
name: "FK_TraktListItem_MediaItem_MediaItemId",
table: "TraktListItem",
column: "MediaItemId",
principalTable: "MediaItem",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

3
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -2690,7 +2690,8 @@ namespace ErsatzTV.Infrastructure.Migrations
{ {
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem") b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany("TraktListItems") .WithMany("TraktListItems")
.HasForeignKey("MediaItemId"); .HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ErsatzTV.Core.Domain.TraktList", "TraktList") b.HasOne("ErsatzTV.Core.Domain.TraktList", "TraktList")
.WithMany("Items") .WithMany("Items")

74
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -55,6 +55,7 @@ namespace ErsatzTV.Infrastructure.Search
public const string MovieType = "movie"; public const string MovieType = "movie";
public const string ShowType = "show"; public const string ShowType = "show";
public const string SeasonType = "season";
public const string ArtistType = "artist"; public const string ArtistType = "artist";
public const string MusicVideoType = "music_video"; public const string MusicVideoType = "music_video";
public const string EpisodeType = "episode"; public const string EpisodeType = "episode";
@ -73,7 +74,7 @@ namespace ErsatzTV.Infrastructure.Search
_initialized = false; _initialized = false;
} }
public int Version => 15; public int Version => 16;
public Task<bool> Initialize(ILocalFileSystem localFileSystem) public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{ {
@ -107,6 +108,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show: case Show show:
await UpdateShow(searchRepository, show); await UpdateShow(searchRepository, show);
break; break;
case Season season:
await UpdateSeason(searchRepository, season);
break;
case Artist artist: case Artist artist:
await UpdateArtist(searchRepository, artist); await UpdateArtist(searchRepository, artist);
break; break;
@ -201,6 +205,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show: case Show show:
await UpdateShow(searchRepository, show); await UpdateShow(searchRepository, show);
break; break;
case Season season:
await UpdateSeason(searchRepository, season);
break;
case Artist artist: case Artist artist:
await UpdateArtist(searchRepository, artist); await UpdateArtist(searchRepository, artist);
break; break;
@ -471,6 +478,71 @@ namespace ErsatzTV.Infrastructure.Search
} }
} }
private async Task UpdateSeason(ISearchRepository searchRepository, Season season)
{
Option<SeasonMetadata> maybeMetadata = season.SeasonMetadata.HeadOrNone();
Option<ShowMetadata> maybeShowMetadata = season.Show.ShowMetadata.HeadOrNone();
if (maybeMetadata.IsSome && maybeShowMetadata.IsSome)
{
SeasonMetadata metadata = maybeMetadata.ValueUnsafe();
ShowMetadata showMetadata = maybeShowMetadata.ValueUnsafe();
try
{
var seasonTitle = $"{showMetadata.Title} - S{season.SeasonNumber}";
string sortTitle = $"{showMetadata.SortTitle}_{season.SeasonNumber:0000}"
.ToLowerInvariant();
string titleAndYear = $"{showMetadata.Title}_{showMetadata.Year}_{season.SeasonNumber}"
.ToLowerInvariant();
var doc = new Document
{
new StringField(IdField, season.Id.ToString(), Field.Store.YES),
new StringField(TypeField, SeasonType, Field.Store.YES),
new TextField(TitleField, seasonTitle, Field.Store.NO),
new StringField(SortTitleField, sortTitle, Field.Store.NO),
new TextField(LibraryNameField, season.LibraryPath.Library.Name, Field.Store.NO),
new StringField(LibraryIdField, season.LibraryPath.Library.Id.ToString(), Field.Store.NO),
new StringField(TitleAndYearField, titleAndYear, Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(showMetadata), Field.Store.YES)
};
List<string> languages = await searchRepository.GetLanguagesForSeason(season);
await AddLanguages(searchRepository, doc, languages);
if (!string.IsNullOrWhiteSpace(showMetadata.ContentRating))
{
foreach (string contentRating in (showMetadata.ContentRating ?? string.Empty).Split("/")
.Map(x => x.Trim()).Where(x => !string.IsNullOrWhiteSpace(x)))
{
doc.Add(new StringField(ContentRatingField, contentRating, Field.Store.NO));
}
}
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
foreach (TraktListItem item in season.TraktListItems)
{
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, season.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.Season = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
}
}
}
private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist) private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist)
{ {
Option<ArtistMetadata> maybeMetadata = artist.ArtistMetadata.HeadOrNone(); Option<ArtistMetadata> maybeMetadata = artist.ArtistMetadata.HeadOrNone();

10
ErsatzTV/Pages/MultiSelectBase.cs

@ -78,9 +78,7 @@ namespace ErsatzTV.Pages
int finish = sorted.IndexOf(card); int finish = sorted.IndexOf(card);
if (start > finish) if (start > finish)
{ {
int temp = start; (start, finish) = (finish, start);
start = finish;
finish = temp;
} }
for (int i = start; i < finish; i++) for (int i = start; i < finish; i++)
@ -97,6 +95,7 @@ namespace ErsatzTV.Pages
protected Task AddSelectionToCollection() => AddItemsToCollection( protected Task AddSelectionToCollection() => AddItemsToCollection(
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(), _selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(), _selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId).ToList(),
_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(), _selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId).ToList(),
_selectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(), _selectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList()); _selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
@ -104,12 +103,14 @@ namespace ErsatzTV.Pages
protected async Task AddItemsToCollection( protected async Task AddItemsToCollection(
List<int> movieIds, List<int> movieIds,
List<int> showIds, List<int> showIds,
List<int> seasonIds,
List<int> episodeIds, List<int> episodeIds,
List<int> artistIds, List<int> artistIds,
List<int> musicVideoIds, List<int> musicVideoIds,
string entityName = "selected items") string entityName = "selected items")
{ {
int count = movieIds.Count + showIds.Count + episodeIds.Count + artistIds.Count + musicVideoIds.Count; int count = movieIds.Count + showIds.Count + seasonIds.Count + episodeIds.Count + artistIds.Count +
musicVideoIds.Count;
var parameters = new DialogParameters var parameters = new DialogParameters
{ { "EntityType", count.ToString() }, { "EntityName", entityName } }; { { "EntityType", count.ToString() }, { "EntityName", entityName } };
@ -123,6 +124,7 @@ namespace ErsatzTV.Pages
collection.Id, collection.Id,
movieIds, movieIds,
showIds, showIds,
seasonIds,
episodeIds, episodeIds,
artistIds, artistIds,
musicVideoIds); musicVideoIds);

55
ErsatzTV/Pages/Search.razor

@ -44,6 +44,11 @@
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink> <MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
} }
if (_seasons?.Count > 0)
{
<MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#seasons")" Style="margin-bottom: auto; margin-top: auto">@_seasons.Count Seasons</MudLink>
}
if (_episodes?.Count > 0) 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> <MudLink Class="ml-4" Href="@(_navigationManager.Uri.Split("#").Head() + "#episodes")" Style="margin-bottom: auto; margin-top: auto">@_episodes.Count Episodes</MudLink>
@ -134,6 +139,33 @@
</MudContainer> </MudContainer>
} }
@if (_seasons?.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", "seasons" } })">
Seasons
</MudText>
@if (_seasons.Count > 50)
{
<MudLink Href="@GetSeasonsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionSeasonCardViewModel card in _seasons.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionSeasonId}")"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_episodes?.Count > 0) @if (_episodes?.Count > 0)
{ {
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;"> <div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
@ -223,6 +255,7 @@
private string _query; private string _query;
private MovieCardResultsViewModel _movies; private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows; private TelevisionShowCardResultsViewModel _shows;
private TelevisionSeasonCardResultsViewModel _seasons;
private TelevisionEpisodeCardResultsViewModel _episodes; private TelevisionEpisodeCardResultsViewModel _episodes;
private MusicVideoCardResultsViewModel _musicVideos; private MusicVideoCardResultsViewModel _musicVideos;
private ArtistCardResultsViewModel _artists; private ArtistCardResultsViewModel _artists;
@ -234,6 +267,7 @@
{ {
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50)); _movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50)); _shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_seasons = await Mediator.Send(new QuerySearchIndexSeasons($"type:season AND ({_query})", 1, 50));
_episodes = await Mediator.Send(new QuerySearchIndexEpisodes($"type:episode 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)); _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)); _artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
@ -246,6 +280,7 @@
{ {
return _movies.Cards.OrderBy(m => m.SortTitle) return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle)) .Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_seasons.Cards.OrderBy(s => s.SortTitle))
.Append(_episodes.Cards.OrderBy(ep => ep.SortTitle)) .Append(_episodes.Cards.OrderBy(ep => ep.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle)) .Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle)) .Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
@ -385,6 +420,17 @@
return uri; return uri;
} }
private string GetSeasonsLink()
{
var uri = "/media/tv/seasons/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
return uri;
}
private string GetEpisodesLink() private string GetEpisodesLink()
{ {
var uri = "/media/tv/episodes/page/1"; var uri = "/media/tv/episodes/page/1";
@ -421,7 +467,14 @@
private async Task AddAllToCollection(MouseEventArgs _) private async Task AddAllToCollection(MouseEventArgs _)
{ {
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query)); SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query));
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.EpisodeIds, results.ArtistIds, results.MusicVideoIds, "search results"); await AddItemsToCollection(
results.MovieIds,
results.ShowIds,
results.SeasonIds,
results.EpisodeIds,
results.ArtistIds,
results.MusicVideoIds,
"search results");
} }
private async Task SaveAsSmartCollection(MouseEventArgs _) private async Task SaveAsSmartCollection(MouseEventArgs _)

157
ErsatzTV/Pages/TelevisionSeasonSearchResults.razor

@ -0,0 +1,157 @@
@page "/media/tv/seasons"
@page "/media/tv/seasons/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using ErsatzTV.Extensions
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<TelevisionShowList>
@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="TelevisionSeasonCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link="@($"/media/tv/seasons/{context.TelevisionSeasonId}")"
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/seasons"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private TelevisionSeasonCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
_query = _navigationManager.Uri.GetSearchQuery();
return RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:season" : $"type:season AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexSeasons(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/tv/seasons/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_navigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/tv/seasons/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
(string key, string value) = _query.EncodeQuery();
uri = $"{uri}?{key}={value}";
}
_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 TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.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 AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}");
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

1
ErsatzTV/Pages/TelevisionShowList.razor

@ -1,7 +1,6 @@
@page "/media/tv/shows" @page "/media/tv/shows"
@page "/media/tv/shows/page/{PageNumber:int}" @page "/media/tv/shows/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess @using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using ErsatzTV.Application.MediaCards @using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.MediaCollections.Commands

Loading…
Cancel
Save