Browse Source

add artists as owners of music videos (#154)

* clean up genre, tag, studio orphans

* enforce foreign keys at connection level

* wip

* fix fragment scroll offset

* fix see all link for music videos

* add fake artist metadata

* not null artist id

* add artist scanning

* remove improperly named music videos

* code cleanup

* add artists to search results and collections

* clean up music video metadata / artist

* add artist view

* show music videos on artist page

* add music video artwork placeholder
pull/155/head
Jason Dove 5 years ago committed by GitHub
parent
commit
2b26a5411c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 14
      ErsatzTV.Application/Artists/ArtistViewModel.cs
  2. 27
      ErsatzTV.Application/Artists/Mapper.cs
  3. 7
      ErsatzTV.Application/Artists/Queries/GetArtistById.cs
  4. 21
      ErsatzTV.Application/Artists/Queries/GetArtistByIdHandler.cs
  5. 11
      ErsatzTV.Application/MediaCards/ArtistCardResultsViewModel.cs
  6. 12
      ErsatzTV.Application/MediaCards/ArtistCardViewModel.cs
  7. 1
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  8. 14
      ErsatzTV.Application/MediaCards/Mapper.cs
  9. 2
      ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs
  10. 7
      ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCards.cs
  11. 33
      ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs
  12. 8
      ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollection.cs
  13. 68
      ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs
  14. 1
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  15. 10
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  16. 5
      ErsatzTV.Application/Playouts/Mapper.cs
  17. 4
      ErsatzTV.Application/Search/Queries/QuerySearchIndexAllItemsHandler.cs
  18. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexArtists.cs
  19. 44
      ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs
  20. 2
      ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs
  21. 10
      ErsatzTV.Core/Domain/MediaItem/Artist.cs
  22. 2
      ErsatzTV.Core/Domain/MediaItem/MusicVideo.cs
  23. 15
      ErsatzTV.Core/Domain/Metadata/ArtistMetadata.cs
  24. 8
      ErsatzTV.Core/Domain/Metadata/Mood.cs
  25. 1
      ErsatzTV.Core/Domain/Metadata/MusicVideoMetadata.cs
  26. 8
      ErsatzTV.Core/Domain/Metadata/Style.cs
  27. 4
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  28. 3
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  29. 25
      ErsatzTV.Core/Interfaces/Repositories/IArtistRepository.cs
  30. 2
      ErsatzTV.Core/Interfaces/Repositories/IMetadataRepository.cs
  31. 9
      ErsatzTV.Core/Interfaces/Repositories/IMusicVideoRepository.cs
  32. 4
      ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs
  33. 4
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  34. 19
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  35. 4
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  36. 164
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  37. 208
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  38. 27
      ErsatzTV.Core/Metadata/Nfo/ArtistNfo.cs
  39. 20
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  40. 4
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  41. 34
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  42. 24
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/ArtistConfiguration.cs
  43. 30
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/ArtistMetadataConfiguration.cs
  44. 150
      ErsatzTV.Infrastructure/Data/Repositories/ArtistRepository.cs
  45. 34
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  46. 13
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  47. 61
      ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs
  48. 3
      ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs
  49. 95
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  50. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  51. 1964
      ErsatzTV.Infrastructure/Migrations/20210407113048_Delete_Orphan_GenreTagStudio.Designer.cs
  52. 38
      ErsatzTV.Infrastructure/Migrations/20210407113048_Delete_Orphan_GenreTagStudio.cs
  53. 2165
      ErsatzTV.Infrastructure/Migrations/20210407143124_Add_Artist.Designer.cs
  54. 284
      ErsatzTV.Infrastructure/Migrations/20210407143124_Add_Artist.cs
  55. 2165
      ErsatzTV.Infrastructure/Migrations/20210407230353_Add_ArtistMetadata.Designer.cs
  56. 42
      ErsatzTV.Infrastructure/Migrations/20210407230353_Add_ArtistMetadata.cs
  57. 2167
      ErsatzTV.Infrastructure/Migrations/20210407233717_Update_ArtistMetadata_FK.Designer.cs
  58. 47
      ErsatzTV.Infrastructure/Migrations/20210407233717_Update_ArtistMetadata_FK.cs
  59. 2164
      ErsatzTV.Infrastructure/Migrations/20210408113508_Delete_MusicVideoMetadata_Artist.Designer.cs
  60. 19
      ErsatzTV.Infrastructure/Migrations/20210408113508_Delete_MusicVideoMetadata_Artist.cs
  61. 226
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  62. 62
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  63. 176
      ErsatzTV/Pages/Artist.razor
  64. 163
      ErsatzTV/Pages/ArtistList.razor
  65. 60
      ErsatzTV/Pages/CollectionItems.razor
  66. 13
      ErsatzTV/Pages/MultiSelectBase.cs
  67. 81
      ErsatzTV/Pages/Search.razor
  68. 2
      ErsatzTV/Shared/FragmentLetterAnchor.razor
  69. 2
      ErsatzTV/Shared/MainLayout.razor
  70. 6
      ErsatzTV/Shared/MediaCard.razor
  71. 3
      ErsatzTV/Startup.cs

14
ErsatzTV.Application/Artists/ArtistViewModel.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Artists
{
public record ArtistViewModel(
string Name,
string Disambiguation,
string Biography,
string Thumbnail,
string FanArt,
List<string> Genres,
List<string> Styles,
List<string> Moods);
}

27
ErsatzTV.Application/Artists/Mapper.cs

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Artists
{
internal static class Mapper
{
internal static ArtistViewModel ProjectToViewModel(Artist artist)
{
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head();
return new ArtistViewModel(
metadata.Title,
metadata.Disambiguation,
metadata.Biography,
Artwork(metadata, ArtworkKind.Thumbnail),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Styles.Map(s => s.Name).ToList(),
metadata.Moods.Map(m => m.Name).ToList());
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
}
}

7
ErsatzTV.Application/Artists/Queries/GetArtistById.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
}

21
ErsatzTV.Application/Artists/Queries/GetArtistByIdHandler.cs

@ -0,0 +1,21 @@ @@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Artists.Mapper;
namespace ErsatzTV.Application.Artists.Queries
{
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>>
{
private readonly IArtistRepository _artistRepository;
public GetArtistByIdHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository;
public Task<Option<ArtistViewModel>> Handle(
GetArtistById request,
CancellationToken cancellationToken) =>
_artistRepository.GetArtist(request.ArtistId).MapT(ProjectToViewModel);
}
}

11
ErsatzTV.Application/MediaCards/ArtistCardResultsViewModel.cs

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

12
ErsatzTV.Application/MediaCards/ArtistCardViewModel.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster)
{
}
}

1
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

@ -8,6 +8,7 @@ namespace ErsatzTV.Application.MediaCards @@ -8,6 +8,7 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }

14
ErsatzTV.Application/MediaCards/Mapper.cs

@ -55,11 +55,20 @@ namespace ErsatzTV.Application.MediaCards @@ -55,11 +55,20 @@ namespace ErsatzTV.Application.MediaCards
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
$"{musicVideoMetadata.Title} ({musicVideoMetadata.Artist})",
musicVideoMetadata.Year?.ToString(),
musicVideoMetadata.Title,
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
@ -73,6 +82,7 @@ namespace ErsatzTV.Application.MediaCards @@ -73,6 +82,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };

2
ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Plot, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,

7
ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCards.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetMusicVideoCards
(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>;
}

33
ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel>
{
private readonly IMusicVideoRepository _musicVideoRepository;
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) =>
_musicVideoRepository = musicVideoRepository;
public async Task<MusicVideoCardResultsViewModel> Handle(
GetMusicVideoCards request,
CancellationToken cancellationToken)
{
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId);
List<MusicVideoCardViewModel> results = await _musicVideoRepository
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(count, results, None);
}
}
}

8
ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollection.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public record AddArtistToCollection
(int CollectionId, int ArtistId) : MediatR.IRequest<Either<BaseError, Unit>>;
}

68
ErsatzTV.Application/MediaCollections/Commands/AddArtistToCollectionHandler.cs

@ -0,0 +1,68 @@ @@ -0,0 +1,68 @@
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IArtistRepository _artistRepository;
public AddArtistToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IArtistRepository artistRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_artistRepository = artistRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddArtistToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddArtistRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddArtistRequest(AddArtistToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ArtistId))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
.PlayoutIdsUsingCollection(request.CollectionId))
{
await _channel.WriteAsync(new BuildPlayout(playoutId, true));
}
}
return Unit.Default;
}
private async Task<Validation<BaseError, Unit>> Validate(AddArtistToCollection request) =>
(await CollectionMustExist(request), await ValidateArtist(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddArtistToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateArtist(AddArtistToCollection request) =>
LoadArtist(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<Artist>> LoadArtist(AddArtistToCollection request) =>
_artistRepository.GetArtist(request.ArtistId);
}
}

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

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

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

@ -39,9 +39,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -39,9 +39,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
if (await _mediaCollectionRepository.AddMediaItems(request.CollectionId, allItems))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository

5
ErsatzTV.Application/Playouts/Mapper.cs

@ -37,7 +37,10 @@ namespace ErsatzTV.Application.Playouts @@ -37,7 +37,10 @@ namespace ErsatzTV.Application.Playouts
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;

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

@ -23,10 +23,12 @@ namespace ErsatzTV.Application.Search.Queries @@ -23,10 +23,12 @@ namespace ErsatzTV.Application.Search.Queries
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> showIds = await _searchIndex.Search($"type:show AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> artistIds = await _searchIndex.Search($"type:artist AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> musicVideoIds = await _searchIndex.Search($"type:music_video AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
return new SearchResultAllItemsViewModel(movieIds, showIds, musicVideoIds);
return new SearchResultAllItemsViewModel(movieIds, showIds, artistIds, musicVideoIds);
}
}
}

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

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

44
ErsatzTV.Application/Search/Queries/QuerySearchIndexArtistsHandler.cs

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
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
QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel
>
{
private readonly IArtistRepository _artistRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexArtistsHandler(ISearchIndex searchIndex, IArtistRepository artistRepository)
{
_searchIndex = searchIndex;
_artistRepository = artistRepository;
}
public async Task<ArtistCardResultsViewModel> Handle(
QuerySearchIndexArtists request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<ArtistCardViewModel> items = await _artistRepository
.GetArtistsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new ArtistCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

2
ErsatzTV.Application/Search/SearchResultAllItemsViewModel.cs

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

10
ErsatzTV.Core/Domain/MediaItem/Artist.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class Artist : MediaItem
{
public List<MusicVideo> MusicVideos { get; set; }
public List<ArtistMetadata> ArtistMetadata { get; set; }
}
}

2
ErsatzTV.Core/Domain/MediaItem/MusicVideo.cs

@ -4,6 +4,8 @@ namespace ErsatzTV.Core.Domain @@ -4,6 +4,8 @@ namespace ErsatzTV.Core.Domain
{
public class MusicVideo : MediaItem
{
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}

15
ErsatzTV.Core/Domain/Metadata/ArtistMetadata.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Domain
{
public class ArtistMetadata : Metadata
{
public string Disambiguation { get; set; }
public string Biography { get; set; }
public string Formed { get; set; }
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<Style> Styles { get; set; }
public List<Mood> Moods { get; set; }
}
}

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

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

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

@ -4,7 +4,6 @@ @@ -4,7 +4,6 @@
{
public string Album { get; set; }
public string Plot { get; set; }
public string Artist { get; set; }
public int MusicVideoId { get; set; }
public MusicVideo MusicVideo { get; set; }
}

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

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

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

@ -1,14 +1,16 @@ @@ -1,14 +1,16 @@
using System;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
ShowMetadata GetFallbackMetadataForShow(string showFolder);
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo);
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo);
string GetSortTitle(string title);
}
}

3
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -6,12 +6,15 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -6,12 +6,15 @@ namespace ErsatzTV.Core.Interfaces.Metadata
public interface ILocalMetadataProvider
{
Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<ArtistMetadata> GetMetadataForArtist(string artistFolder);
Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}

25
ErsatzTV.Core/Interfaces/Repositories/IArtistRepository.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IArtistRepository
{
Task<Option<Artist>> GetArtistByMetadata(int libraryPathId, ArtistMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<Artist>>> AddArtist(
int libraryPathId,
string artistFolder,
ArtistMetadata metadata);
Task<List<int>> DeleteEmptyArtists(LibraryPath libraryPath);
Task<Option<Artist>> GetArtist(int artistId);
Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids);
Task<bool> AddGenre(ArtistMetadata metadata, Genre genre);
Task<bool> AddStyle(ArtistMetadata metadata, Style style);
Task<bool> AddMood(ArtistMetadata metadata, Mood mood);
}
}

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

@ -10,6 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -10,6 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveGenre(Genre genre);
Task<bool> RemoveTag(Tag tag);
Task<bool> RemoveStudio(Studio studio);
Task<bool> RemoveStyle(Style style);
Task<bool> RemoveMood(Mood mood);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);

9
ErsatzTV.Core/Interfaces/Repositories/IMusicVideoRepository.cs

@ -8,7 +8,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -8,7 +8,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMusicVideoRepository
{
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string path);
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre);
@ -16,5 +20,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -16,5 +20,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio);
Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids);
Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId);
Task<IEnumerable<string>> FindOrphanPaths(LibraryPath libraryPath);
Task<int> GetMusicVideoCount(int artistId);
Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize);
}
}

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

@ -9,9 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -9,9 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public Task<List<int>> GetItemIdsToIndex();
public Task<Option<MediaItem>> GetItemToIndex(int id);
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
public Task<List<string>> GetLanguagesForShow(Show show);
public Task<List<string>> GetLanguagesForArtist(Artist artist);
}
}

4
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -221,8 +221,8 @@ namespace ErsatzTV.Core.Iptv @@ -221,8 +221,8 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]"
};
}

19
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -4,6 +4,7 @@ using System.IO; @@ -4,6 +4,7 @@ using System.IO;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
@ -18,6 +19,13 @@ namespace ErsatzTV.Core.Metadata @@ -18,6 +19,13 @@ namespace ErsatzTV.Core.Metadata
return GetTelevisionShowMetadata(fileName, metadata);
}
public ArtistMetadata GetFallbackMetadataForArtist(string artistFolder)
{
string fileName = Path.GetFileName(artistFolder);
return new ArtistMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
}
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
@ -36,7 +44,7 @@ namespace ErsatzTV.Core.Metadata @@ -36,7 +44,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo)
public Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@ -46,7 +54,7 @@ namespace ErsatzTV.Core.Metadata @@ -46,7 +54,7 @@ namespace ErsatzTV.Core.Metadata
Title = fileName ?? path
};
return fileName != null ? GetMusicVideoMetadata(fileName, metadata) : metadata;
return GetMusicVideoMetadata(fileName, metadata);
}
public string GetSortTitle(string title)
@ -125,7 +133,7 @@ namespace ErsatzTV.Core.Metadata @@ -125,7 +133,7 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private MusicVideoMetadata GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
private Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
{
try
{
@ -133,13 +141,16 @@ namespace ErsatzTV.Core.Metadata @@ -133,13 +141,16 @@ namespace ErsatzTV.Core.Metadata
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.Artist = match.Groups[1].Value.Trim();
metadata.Title = match.Groups[2].Value.Trim();
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
}
else
{
return None;
}
}
catch (Exception)
{

4
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -166,5 +166,9 @@ namespace ErsatzTV.Core.Metadata @@ -166,5 +166,9 @@ namespace ErsatzTV.Core.Metadata
return false;
}
protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
}
}

164
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Metadata @@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
@ -33,6 +35,7 @@ namespace ErsatzTV.Core.Metadata @@ -33,6 +35,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
@ -41,6 +44,7 @@ namespace ErsatzTV.Core.Metadata @@ -41,6 +44,7 @@ namespace ErsatzTV.Core.Metadata
_metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
@ -70,6 +74,29 @@ namespace ErsatzTV.Core.Metadata @@ -70,6 +74,29 @@ namespace ErsatzTV.Core.Metadata
});
}
public async Task<ArtistMetadata> GetMetadataForArtist(string artistFolder)
{
string nfoFileName = Path.Combine(artistFolder, "artist.nfo");
Option<ArtistMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadArtistMetadata(nfoFileName);
}
return maybeMetadata.Match(
metadata =>
{
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
},
() =>
{
ArtistMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder);
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
LoadMovieMetadata(movie, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
@ -88,6 +115,12 @@ namespace ErsatzTV.Core.Metadata @@ -88,6 +115,12 @@ namespace ErsatzTV.Core.Metadata
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(artist, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
LoadMusicVideoMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
@ -100,8 +133,13 @@ namespace ErsatzTV.Core.Metadata @@ -100,8 +133,13 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Episode episode) =>
ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode));
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
ApplyMetadataUpdate(musicVideo, _fallbackMetadataProvider.GetFallbackMetadata(musicVideo));
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
@ -118,7 +156,6 @@ namespace ErsatzTV.Core.Metadata @@ -118,7 +156,6 @@ namespace ErsatzTV.Core.Metadata
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Artist = nfo.Artist,
Album = nfo.Album,
Title = nfo.Title,
Plot = nfo.Plot,
@ -269,11 +306,104 @@ namespace ErsatzTV.Core.Metadata @@ -269,11 +306,104 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(Artist artist, ArtistMetadata metadata) =>
Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Title = metadata.Title;
existing.Disambiguation = metadata.Disambiguation;
existing.Biography = metadata.Biography;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
var updated = false;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
updated = true;
}
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
if (await _artistRepository.AddGenre(existing, genre))
{
updated = true;
}
}
foreach (Style style in existing.Styles.Filter(s => metadata.Styles.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Styles.Remove(style);
if (await _metadataRepository.RemoveStyle(style))
{
updated = true;
}
}
foreach (Style style in metadata.Styles.Filter(s => existing.Styles.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Styles.Add(style);
if (await _artistRepository.AddStyle(existing, style))
{
updated = true;
}
}
foreach (Mood mood in existing.Moods.Filter(m => metadata.Moods.All(m2 => m2.Name != m.Name))
.ToList())
{
existing.Moods.Remove(mood);
if (await _metadataRepository.RemoveMood(mood))
{
updated = true;
}
}
foreach (Mood mood in metadata.Moods.Filter(s => existing.Moods.All(m2 => m2.Name != s.Name))
.ToList())
{
existing.Moods.Add(mood);
if (await _artistRepository.AddMood(existing, mood))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.ArtistId = artist.Id;
artist.ArtistMetadata = new List<ArtistMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) =>
Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Artist = metadata.Artist;
existing.Title = metadata.Title;
existing.Year = metadata.Year;
existing.Plot = metadata.Plot;
@ -343,6 +473,34 @@ namespace ErsatzTV.Core.Metadata @@ -343,6 +473,34 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Option<ArtistMetadata>> LoadArtistMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<ArtistNfo> maybeNfo = ArtistSerializer.Deserialize(fileStream) as ArtistNfo;
return maybeNfo.Match<Option<ArtistMetadata>>(
nfo => new ArtistMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Name,
Disambiguation = nfo.Disambiguation,
Biography = nfo.Biography,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Styles = nfo.Styles.Map(s => new Style { Name = s }).ToList(),
Moods = nfo.Moods.Map(m => new Mood { Name = m }).ToList()
},
None);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read artist nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{
try

208
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -19,6 +19,7 @@ namespace ErsatzTV.Core.Metadata @@ -19,6 +19,7 @@ namespace ErsatzTV.Core.Metadata
{
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
{
private readonly IArtistRepository _artistRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MusicVideoFolderScanner> _logger;
@ -35,6 +36,7 @@ namespace ErsatzTV.Core.Metadata @@ -35,6 +36,7 @@ namespace ErsatzTV.Core.Metadata
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IMediator mediator,
ILogger<MusicVideoFolderScanner> logger) : base(
@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata @@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_mediator = mediator;
_logger = logger;
@ -67,23 +70,175 @@ namespace ErsatzTV.Core.Metadata @@ -67,23 +70,175 @@ namespace ErsatzTV.Core.Metadata
return new MediaSourceInaccessible();
}
var foldersCompleted = 0;
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity)
.ToList();
var folderQueue = new Queue<string>();
folderQueue.Enqueue(libraryPath.Path);
while (folderQueue.Count > 0)
foreach (string artistFolder in allArtistFolders)
{
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
decimal percentCompletion = (decimal) allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder)
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt));
await maybeArtist.Match(
async result =>
{
await ScanMusicVideos(
libraryPath,
ffprobePath,
result.Item,
artistFolder,
// force scanning all folders if we're adding a new artist
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
},
error =>
{
_logger.LogWarning(
"Error processing artist in folder {Folder}: {Error}",
artistFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
}
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
{
_logger.LogInformation("Removing improperly named music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
}
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> FindOrCreateArtist(
int libraryPathId,
string artistFolder)
{
ArtistMetadata metadata = await _localMetadataProvider.GetMetadataForArtist(artistFolder);
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPathId, metadata);
return await maybeArtist.Match(
artist => Right<BaseError, MediaItemScanResult<Artist>>(new MediaItemScanResult<Artist>(artist))
.AsTask(),
async () => await _artistRepository.AddArtist(libraryPathId, artistFolder, metadata));
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateMetadataForArtist(
MediaItemScanResult<Artist> result,
string artistFolder)
{
try
{
Artist artist = result.Item;
await LocateNfoFileForArtist(artistFolder).Match(
async nfoFile =>
{
bool shouldUpdate = Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated < _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
if (await _localMetadataProvider.RefreshSidecarMetadata(artist, nfoFile))
{
result.IsUpdated = true;
}
}
},
async () =>
{
if (!Optional(artist.ArtistMetadata).Flatten().Any())
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", artistFolder);
if (await _localMetadataProvider.RefreshFallbackMetadata(artist, artistFolder))
{
result.IsUpdated = true;
}
}
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateArtworkForArtist(
MediaItemScanResult<Artist> result,
string artistFolder,
ArtworkKind artworkKind)
{
try
{
Artist artist = result.Item;
await LocateArtworkForArtist(artistFolder, artworkKind).IfSomeAsync(
async artworkFile =>
{
ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind);
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task ScanMusicVideos(
LibraryPath libraryPath,
string ffprobePath,
Artist artist,
string artistFolder,
DateTimeOffset lastScan)
{
var folderQueue = new Queue<string>();
folderQueue.Enqueue(artistFolder);
while (folderQueue.Count > 0)
{
string musicVideoFolder = folderQueue.Dequeue();
foldersCompleted++;
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => f.Contains(" - "))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)
@ -101,7 +256,7 @@ namespace ErsatzTV.Core.Metadata @@ -101,7 +256,7 @@ namespace ErsatzTV.Core.Metadata
{
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
.GetOrAdd(libraryPath, file)
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
@ -125,19 +280,6 @@ namespace ErsatzTV.Core.Metadata @@ -125,19 +280,6 @@ namespace ErsatzTV.Core.Metadata
});
}
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> ids = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
}
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
@ -167,6 +309,8 @@ namespace ErsatzTV.Core.Metadata @@ -167,6 +309,8 @@ namespace ErsatzTV.Core.Metadata
{
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
{
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
@ -184,6 +328,26 @@ namespace ErsatzTV.Core.Metadata @@ -184,6 +328,26 @@ namespace ErsatzTV.Core.Metadata
}
}
private Option<string> LocateNfoFileForArtist(string artistFolder) =>
Optional(Path.Combine(artistFolder, "artist.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
{
string segment = artworkKind switch
{
ArtworkKind.Thumbnail => "thumb",
ArtworkKind.FanArt => "fanart",
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
return ImageFileExtensions
.Map(ext => $"{segment}.{ext}")
.Map(f => Path.Combine(artistFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocateNfoFile(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;

27
ErsatzTV.Core/Metadata/Nfo/ArtistNfo.cs

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace ErsatzTV.Core.Metadata.Nfo
{
[XmlRoot("artist")]
public class ArtistNfo
{
[XmlElement("name")]
public string Name { get; set; }
[XmlElement("disambiguation")]
public string Disambiguation { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("style")]
public List<string> Styles { get; set; }
[XmlElement("mood")]
public List<string> Moods { get; set; }
[XmlElement("biography")]
public string Biography { get; set; }
}
}

20
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -87,6 +87,14 @@ namespace ErsatzTV.Core.Metadata @@ -87,6 +87,14 @@ namespace ErsatzTV.Core.Metadata
await maybeShow.Match(
async result =>
{
await ScanSeasons(
libraryPath,
ffprobePath,
result.Item,
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -95,14 +103,6 @@ namespace ErsatzTV.Core.Metadata @@ -95,14 +103,6 @@ namespace ErsatzTV.Core.Metadata
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await ScanSeasons(
libraryPath,
ffprobePath,
result.Item,
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
},
error =>
{
@ -401,10 +401,6 @@ namespace ErsatzTV.Core.Metadata @@ -401,10 +401,6 @@ namespace ErsatzTV.Core.Metadata
.HeadOrNone();
}
private bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
private static Option<int> SeasonNumberForFolder(string folder)
{
if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber))

4
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -70,6 +70,8 @@ namespace ErsatzTV.Core.Plex @@ -70,6 +70,8 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -80,8 +82,6 @@ namespace ErsatzTV.Core.Plex @@ -80,8 +82,6 @@ namespace ErsatzTV.Core.Plex
_searchRepository,
new List<MediaItem> { result.Item });
}
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
},
error =>
{

34
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -475,20 +475,28 @@ namespace ErsatzTV.Core.Scheduling @@ -475,20 +475,28 @@ namespace ErsatzTV.Core.Scheduling
}
}
private static string DisplayTitle(MediaItem mediaItem) =>
mediaItem switch
private static string DisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
{
Episode e => e.EpisodeMetadata.Any() && e.Season != null
? $"{e.EpisodeMetadata.Head().Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}"
: "[unknown episode]",
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.Title ?? string.Empty,
() => "[unknown movie]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => $"{mvm.Artist} - {mvm.Title}",
() => "[unknown music video]"),
_ => string.Empty
};
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]");
case MusicVideo mv:
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) =>
item.CollectionType switch

24
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/ArtistConfiguration.cs

@ -0,0 +1,24 @@ @@ -0,0 +1,24 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ArtistConfiguration : IEntityTypeConfiguration<Artist>
{
public void Configure(EntityTypeBuilder<Artist> builder)
{
builder.ToTable("Artist");
builder.HasMany(a => a.MusicVideos)
.WithOne(mv => mv.Artist)
.HasForeignKey(mv => mv.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(a => a.ArtistMetadata)
.WithOne(am => am.Artist)
.HasForeignKey(am => am.ArtistId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

30
ErsatzTV.Infrastructure/Data/Configurations/Metadata/ArtistMetadataConfiguration.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class ArtistMetadataConfiguration : IEntityTypeConfiguration<ArtistMetadata>
{
public void Configure(EntityTypeBuilder<ArtistMetadata> builder)
{
builder.ToTable("ArtistMetadata");
builder.HasMany(sm => sm.Artwork)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Genres)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Styles)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(sm => sm.Moods)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

150
ErsatzTV.Infrastructure/Data/Repositories/ArtistRepository.cs

@ -0,0 +1,150 @@ @@ -0,0 +1,150 @@
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class ArtistRepository : IArtistRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public ArtistRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Option<Artist>> GetArtistByMetadata(int libraryPathId, ArtistMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<int> maybeId = await dbContext.ArtistMetadata
.Where(
s => s.Title == metadata.Title && (metadata.MetadataKind == MetadataKind.Fallback ||
s.Disambiguation == metadata.Disambiguation))
.Where(s => s.Artist.LibraryPathId == libraryPathId)
.SingleOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.ArtistId);
return await maybeId.Match(
id =>
{
return dbContext.Artists
.AsNoTracking()
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Genres)
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Styles)
.Include(s => s.ArtistMetadata)
.ThenInclude(sm => sm.Moods)
.Include(s => s.LibraryPath)
.ThenInclude(lp => lp.Library)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync(s => s.Id == id)
.Map(Optional);
},
() => Option<Artist>.None.AsTask());
}
public async Task<Either<BaseError, MediaItemScanResult<Artist>>> AddArtist(
int libraryPathId,
string artistFolder,
ArtistMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Styles ??= new List<Style>();
metadata.Moods ??= new List<Mood>();
var artist = new Artist
{
LibraryPathId = libraryPathId,
ArtistMetadata = new List<ArtistMetadata> { metadata }
};
await dbContext.Artists.AddAsync(artist);
await dbContext.SaveChangesAsync();
await dbContext.Entry(artist).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(artist.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<Artist>(artist) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<List<int>> DeleteEmptyArtists(LibraryPath libraryPath)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
List<Artist> artists = await dbContext.Artists
.Filter(a => a.LibraryPathId == libraryPath.Id)
.Filter(a => a.MusicVideos.Count == 0)
.ToListAsync();
var ids = artists.Map(a => a.Id).ToList();
dbContext.Artists.RemoveRange(artists);
await dbContext.SaveChangesAsync();
return ids;
}
public async Task<Option<Artist>> GetArtist(int artistId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Artists
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Artwork)
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Genres)
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Styles)
.Include(m => m.ArtistMetadata)
.ThenInclude(m => m.Moods)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == artistId)
.Map(Optional);
}
public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.ArtistMetadata
.AsNoTracking()
.Filter(am => ids.Contains(am.ArtistId))
.Include(am => am.Artwork)
.OrderBy(am => am.SortTitle)
.ToListAsync();
}
public Task<bool> AddGenre(ArtistMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStyle(ArtistMetadata metadata, Style style) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Style (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { style.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddMood(ArtistMetadata metadata, Mood mood) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Mood (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)",
new { mood.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
}

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

@ -130,9 +130,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -130,9 +130,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
@ -201,6 +207,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -201,6 +207,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItems(collection));
result.AddRange(await GetSeasonItems(collection));
result.AddRange(await GetEpisodeItems(collection));
result.AddRange(await GetArtistItems(collection));
result.AddRange(await GetMusicVideoItems(collection));
return result.Distinct().ToList();
@ -220,6 +227,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -220,6 +227,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Filter(m => ids.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetArtistItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MusicVideo.Id FROM CollectionItem ci
INNER JOIN Artist on Artist.Id = ci.MediaItemId
INNER JOIN MusicVideo on Artist.Id = MusicVideo.ArtistId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetMusicVideoItems(Collection collection)
{
@ -230,6 +256,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -230,6 +256,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
@ -250,6 +278,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -250,6 +278,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@ -267,6 +297,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -267,6 +297,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@ -283,6 +315,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -283,6 +315,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}

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

@ -156,6 +156,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -156,6 +156,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ArtistMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path)
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
MusicVideoMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
@ -197,5 +202,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -197,5 +202,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<bool> RemoveStudio(Studio studio) =>
_dbConnection.ExecuteAsync("DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id })
.Map(result => result > 0);
public Task<bool> RemoveStyle(Style style) =>
_dbConnection.ExecuteAsync("DELETE FROM Style WHERE Id = @StyleId", new { StyleId = style.Id })
.Map(result => result > 0);
public Task<bool> RemoveMood(Mood mood) =>
_dbConnection.ExecuteAsync("DELETE FROM Mood WHERE Id = @MoodId", new { MoodId = mood.Id })
.Map(result => result > 0);
}
}

61
ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs

@ -26,6 +26,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -26,6 +26,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string path)
{
@ -50,10 +51,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -50,10 +51,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
mediaItem =>
Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddMusicVideo(dbContext, libraryPath.Id, path));
async mediaItem =>
{
if (mediaItem.ArtistId != artist.Id)
{
await _dbConnection.ExecuteAsync(
@"UPDATE MusicVideo SET ArtistId = @ArtistId WHERE Id = @Id",
new { mediaItem.Id, ArtistId = artist.Id });
mediaItem.ArtistId = artist.Id;
mediaItem.Artist = artist;
}
return Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false });
},
async () => await AddMusicVideo(dbContext, artist, libraryPath.Id, path));
}
public Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath) =>
@ -110,6 +123,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -110,6 +123,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mvm => mvm.Artwork)
.OrderBy(mvm => mvm.SortTitle)
.ToListAsync();
@ -132,8 +148,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -132,8 +148,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public Task<IEnumerable<string>> FindOrphanPaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"SELECT MF.Path
FROM MediaFile MF
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id
INNER JOIN MusicVideo M on MV.MusicVideoId = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
WHERE MI.LibraryPathId = @LibraryPathId
AND NOT EXISTS (SELECT * FROM MusicVideoMetadata WHERE MusicVideoId = M.Id)",
new { LibraryPathId = libraryPath.Id });
public Task<int> GetMusicVideoCount(int artistId) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MusicVideo WHERE ArtistId = @ArtistId",
new { ArtistId = artistId });
public async Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Include(m => m.Artwork)
.Include(m => m.Genres)
.Include(m => m.Tags)
.Include(m => m.Studios)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
private static async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> AddMusicVideo(
TvContext dbContext,
Artist artist,
int libraryPathId,
string path)
{
@ -141,6 +193,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -141,6 +193,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
var musicVideo = new MusicVideo
{
ArtistId = artist.Id,
LibraryPathId = libraryPathId,
MediaVersions = new List<MediaVersion>
{

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

@ -101,6 +101,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -101,6 +101,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.MediaItem)

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

@ -55,90 +55,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -55,90 +55,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional);
}
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
WHERE MM.Title LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
WHERE SM.Title LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = $"%{query}%" })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByGenre(string genre)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Genre G on MM.Id = G.MovieMetadataId
WHERE G.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Genre G2 on SM.Id = G2.ShowMetadataId
WHERE G2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = genre })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByTag(string tag)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Tag T on MM.Id = T.MovieMetadataId
WHERE T.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Tag T2 on SM.Id = T2.ShowMetadataId
WHERE T2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = tag })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public Task<List<string>> GetLanguagesForShow(Show show) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
@ -148,5 +75,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -148,5 +75,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
INNER JOIN Season S ON E.SeasonId = S.Id
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList());
public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion V ON MediaStream.MediaVersionId = V.Id
INNER JOIN MusicVideo MV ON V.MusicVideoId = MV.Id
INNER JOIN Artist A on MV.ArtistId = A.Id
WHERE MediaStreamKind = 2 AND A.Id = @ArtistId",
new { ArtistId = artist.Id }).Map(result => result.ToList());
}
}

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -27,6 +27,8 @@ namespace ErsatzTV.Infrastructure.Data @@ -27,6 +27,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artist> Artists { get; set; }
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public DbSet<Show> Shows { get; set; }

1964
ErsatzTV.Infrastructure/Migrations/20210407113048_Delete_Orphan_GenreTagStudio.Designer.cs generated

File diff suppressed because it is too large Load Diff

38
ErsatzTV.Infrastructure/Migrations/20210407113048_Delete_Orphan_GenreTagStudio.cs

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_Orphan_GenreTagStudio : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"DELETE FROM Genre
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
migrationBuilder.Sql(
@"DELETE FROM Tag
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
migrationBuilder.Sql(
@"DELETE FROM Studio
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata)
OR ShowMetadataId NOT IN (SELECT Id FROM Show)
OR SeasonMetadataId NOT IN (SELECT Id FROM Season)
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode)
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

2165
ErsatzTV.Infrastructure/Migrations/20210407143124_Add_Artist.Designer.cs generated

File diff suppressed because it is too large Load Diff

284
ErsatzTV.Infrastructure/Migrations/20210407143124_Add_Artist.cs

@ -0,0 +1,284 @@ @@ -0,0 +1,284 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_Artist : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Tag",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Studio",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistId",
"MusicVideo",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Genre",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"ArtistMetadataId",
"Artwork",
"INTEGER",
nullable: true);
migrationBuilder.CreateTable(
"Artist",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_Artist", x => x.Id);
table.ForeignKey(
"FK_Artist_MediaItem_Id",
x => x.Id,
"MediaItem",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"ArtistMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Disambiguation = table.Column<string>("TEXT", nullable: true),
Biography = table.Column<string>("TEXT", nullable: true),
Formed = table.Column<string>("TEXT", nullable: true),
ArtistId = table.Column<int>("INTEGER", nullable: true),
MetadataKind = table.Column<int>("INTEGER", nullable: false),
Title = table.Column<string>("TEXT", nullable: true),
OriginalTitle = table.Column<string>("TEXT", nullable: true),
SortTitle = table.Column<string>("TEXT", nullable: true),
Year = table.Column<int>("INTEGER", nullable: true),
ReleaseDate = table.Column<DateTime>("TEXT", nullable: true),
DateAdded = table.Column<DateTime>("TEXT", nullable: false),
DateUpdated = table.Column<DateTime>("TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ArtistMetadata", x => x.Id);
table.ForeignKey(
"FK_ArtistMetadata_Artist_ArtistId",
x => x.ArtistId,
"Artist",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"Mood",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Mood", x => x.Id);
table.ForeignKey(
"FK_Mood_ArtistMetadata_ArtistMetadataId",
x => x.ArtistMetadataId,
"ArtistMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"Style",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>("TEXT", nullable: true),
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Style", x => x.Id);
table.ForeignKey(
"FK_Style_ArtistMetadata_ArtistMetadataId",
x => x.ArtistMetadataId,
"ArtistMetadata",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Tag_ArtistMetadataId",
"Tag",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_ArtistMetadataId",
"Studio",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_MusicVideo_ArtistId",
"MusicVideo",
"ArtistId");
migrationBuilder.CreateIndex(
"IX_Genre_ArtistMetadataId",
"Genre",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Artwork_ArtistMetadataId",
"Artwork",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_ArtistMetadata_ArtistId",
"ArtistMetadata",
"ArtistId");
migrationBuilder.CreateIndex(
"IX_Mood_ArtistMetadataId",
"Mood",
"ArtistMetadataId");
migrationBuilder.CreateIndex(
"IX_Style_ArtistMetadataId",
"Style",
"ArtistMetadataId");
migrationBuilder.AddForeignKey(
"FK_Artwork_ArtistMetadata_ArtistMetadataId",
"Artwork",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Genre_ArtistMetadata_ArtistMetadataId",
"Genre",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_MusicVideo_Artist_ArtistId",
"MusicVideo",
"ArtistId",
"Artist",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Studio_ArtistMetadata_ArtistMetadataId",
"Studio",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
migrationBuilder.AddForeignKey(
"FK_Tag_ArtistMetadata_ArtistMetadataId",
"Tag",
"ArtistMetadataId",
"ArtistMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
"FK_Artwork_ArtistMetadata_ArtistMetadataId",
"Artwork");
migrationBuilder.DropForeignKey(
"FK_Genre_ArtistMetadata_ArtistMetadataId",
"Genre");
migrationBuilder.DropForeignKey(
"FK_MusicVideo_Artist_ArtistId",
"MusicVideo");
migrationBuilder.DropForeignKey(
"FK_Studio_ArtistMetadata_ArtistMetadataId",
"Studio");
migrationBuilder.DropForeignKey(
"FK_Tag_ArtistMetadata_ArtistMetadataId",
"Tag");
migrationBuilder.DropTable(
"Mood");
migrationBuilder.DropTable(
"Style");
migrationBuilder.DropTable(
"ArtistMetadata");
migrationBuilder.DropTable(
"Artist");
migrationBuilder.DropIndex(
"IX_Tag_ArtistMetadataId",
"Tag");
migrationBuilder.DropIndex(
"IX_Studio_ArtistMetadataId",
"Studio");
migrationBuilder.DropIndex(
"IX_MusicVideo_ArtistId",
"MusicVideo");
migrationBuilder.DropIndex(
"IX_Genre_ArtistMetadataId",
"Genre");
migrationBuilder.DropIndex(
"IX_Artwork_ArtistMetadataId",
"Artwork");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Tag");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Studio");
migrationBuilder.DropColumn(
"ArtistId",
"MusicVideo");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Genre");
migrationBuilder.DropColumn(
"ArtistMetadataId",
"Artwork");
}
}
}

2165
ErsatzTV.Infrastructure/Migrations/20210407230353_Add_ArtistMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

42
ErsatzTV.Infrastructure/Migrations/20210407230353_Add_ArtistMetadata.cs

@ -0,0 +1,42 @@ @@ -0,0 +1,42 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ArtistMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF");
migrationBuilder.Sql(
@"INSERT INTO MediaItem (LibraryPathId)
SELECT LibraryPath.Id FROM LibraryPath INNER JOIN Library L on LibraryPath.LibraryId = L.Id
WHERE MediaKind = 3");
migrationBuilder.Sql(
@"INSERT INTO Artist (Id)
SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE MediaKind = 3 AND NOT EXISTS (SELECT Id FROM MusicVideo WHERE Id = MediaItem.Id)");
migrationBuilder.Sql(
$@"INSERT INTO ArtistMetadata (ArtistId, Title, DateAdded, DateUpdated, MetadataKind)
SELECT Id, '[FAKE ARTIST]', '{now}', '{now}', 0 FROM Artist");
migrationBuilder.Sql(
@"UPDATE MusicVideo SET ArtistId =
(SELECT Artist.Id FROM Artist
INNER JOIN MediaItem MIA on Artist.Id = MIA.Id
INNER JOIN MediaItem MIMV on MusicVideo.Id = MIMV.Id
INNER JOIN LibraryPath LPA on MIA.LibraryPathId = LPA.Id
INNER JOIN LibraryPath LPMV on MIMV.LibraryPathId = LPMV.Id
WHERE LPA.LibraryId = LPMV.LibraryId)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

2167
ErsatzTV.Infrastructure/Migrations/20210407233717_Update_ArtistMetadata_FK.Designer.cs generated

File diff suppressed because it is too large Load Diff

47
ErsatzTV.Infrastructure/Migrations/20210407233717_Update_ArtistMetadata_FK.cs

@ -0,0 +1,47 @@ @@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_ArtistMetadata_FK : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
"ArtistId",
"MusicVideo",
"INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AlterColumn<int>(
"ArtistId",
"ArtistMetadata",
"INTEGER",
nullable: false,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
"ArtistId",
"MusicVideo",
"INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AlterColumn<int>(
"ArtistId",
"ArtistMetadata",
"INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
}
}
}

2164
ErsatzTV.Infrastructure/Migrations/20210408113508_Delete_MusicVideoMetadata_Artist.Designer.cs generated

File diff suppressed because it is too large Load Diff

19
ErsatzTV.Infrastructure/Migrations/20210408113508_Delete_MusicVideoMetadata_Artist.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Delete_MusicVideoMetadata_Artist : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"Artist",
"MusicVideoMetadata");
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.AddColumn<string>(
"Artist",
"MusicVideoMetadata",
"TEXT",
nullable: true);
}
}

226
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -16,6 +16,57 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -16,6 +16,57 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.4");
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.Property<string>("Biography")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<string>("Disambiguation")
.HasColumnType("TEXT");
b.Property<string>("Formed")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistId");
b.ToTable("ArtistMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
@ -24,6 +75,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -24,6 +75,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("ArtworkKind")
.HasColumnType("INTEGER");
@ -56,6 +110,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -56,6 +110,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("ChannelId");
b.HasIndex("EpisodeMetadataId");
@ -293,6 +349,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -293,6 +349,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@ -313,6 +372,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -313,6 +372,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@ -532,6 +593,27 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -532,6 +593,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaVersion");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Mood",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.ToTable("Mood");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
@ -594,9 +676,6 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -594,9 +676,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Album")
.HasColumnType("TEXT");
b.Property<string>("Artist")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
@ -967,6 +1046,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -967,6 +1046,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@ -987,6 +1069,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -987,6 +1069,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@ -1000,6 +1084,27 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1000,6 +1084,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Studio");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Style",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.ToTable("Style");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
@ -1008,6 +1113,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1008,6 +1113,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@ -1028,6 +1136,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1028,6 +1136,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@ -1080,6 +1190,15 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1080,6 +1190,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexMediaFile");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Artist");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
@ -1112,6 +1231,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1112,6 +1231,11 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.HasIndex("ArtistId");
b.ToTable("MusicVideo");
});
@ -1267,10 +1391,28 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1267,10 +1391,28 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexShow");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist")
.WithMany("ArtistMetadata")
.HasForeignKey("ArtistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Artist");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Artwork")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Channel", null)
.WithMany("Artwork")
.HasForeignKey("ChannelId")
@ -1366,6 +1508,11 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1366,6 +1508,11 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.Genre",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Genres")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Genres")
.HasForeignKey("EpisodeMetadataId");
@ -1475,6 +1622,16 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1475,6 +1622,16 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Mood",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Moods")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
@ -1725,6 +1882,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1725,6 +1882,10 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Studios")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Studios")
.HasForeignKey("EpisodeMetadataId");
@ -1749,10 +1910,24 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1749,10 +1910,24 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Style",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Styles")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Tags")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Tags")
.HasForeignKey("EpisodeMetadataId");
@ -1810,6 +1985,17 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1810,6 +1985,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Artist", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
@ -1844,11 +2030,19 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1844,11 +2030,19 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist")
.WithMany("MusicVideos")
.HasForeignKey("ArtistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Artist");
});
modelBuilder.Entity(
@ -1991,6 +2185,23 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1991,6 +2185,23 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Moods");
b.Navigation("Studios");
b.Navigation("Styles");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
@ -2102,6 +2313,15 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2102,6 +2313,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.Navigation("ArtistMetadata");
b.Navigation("MusicVideos");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>

62
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -29,7 +29,6 @@ namespace ErsatzTV.Infrastructure.Search @@ -29,7 +29,6 @@ namespace ErsatzTV.Infrastructure.Search
private const string IdField = "id";
private const string TypeField = "type";
private const string ArtistField = "artist";
private const string TitleField = "title";
private const string SortTitleField = "sort_title";
private const string GenreField = "genre";
@ -41,9 +40,12 @@ namespace ErsatzTV.Infrastructure.Search @@ -41,9 +40,12 @@ namespace ErsatzTV.Infrastructure.Search
private const string ReleaseDateField = "release_date";
private const string StudioField = "studio";
private const string LanguageField = "language";
private const string StyleField = "style";
private const string MoodField = "mood";
private const string MovieType = "movie";
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private readonly ILogger<SearchIndex> _logger;
@ -84,6 +86,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -84,6 +86,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
@ -110,6 +115,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -110,6 +115,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
@ -362,6 +370,57 @@ namespace ErsatzTV.Infrastructure.Search @@ -362,6 +370,57 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist)
{
Option<ArtistMetadata> maybeMetadata = artist.ArtistMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
ArtistMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, artist.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ArtistType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, artist.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Style style in metadata.Styles)
{
doc.Add(new TextField(StyleField, style.Name, Field.Store.NO));
}
foreach (Mood mood in metadata.Moods)
{
doc.Add(new TextField(MoodField, mood.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, artist.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.Artist = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
}
}
}
private void UpdateMusicVideo(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
@ -375,7 +434,6 @@ namespace ErsatzTV.Infrastructure.Search @@ -375,7 +434,6 @@ namespace ErsatzTV.Infrastructure.Search
{
new StringField(IdField, musicVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MusicVideoType, Field.Store.NO),
new TextField(ArtistField, metadata.Artist, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),

176
ErsatzTV/Pages/Artist.razor

@ -0,0 +1,176 @@ @@ -0,0 +1,176 @@
@page "/media/music/artists/{ArtistId:int}"
@using ErsatzTV.Application.Artists
@using ErsatzTV.Application.Artists.Queries
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ILogger<Artist> Logger
@inject ISnackbar Snackbar
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_artist.FanArt))
{
<img src="@($"/artwork/fanart/{_artist.FanArt}")" alt="fan art"/>
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px">
<div style="display: flex; flex-direction: row;" class="mb-6">
@if (!string.IsNullOrWhiteSpace(_artist.Thumbnail))
{
<img class="mud-elevation-2 mr-6"
style="border-radius: 4px; flex-shrink: 0; height: 220px; width: 220px"
src="@($"/artwork/thumbnails/{_artist.Thumbnail}")" alt="artist thumnail"/>
}
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h2" Class="media-item-title">@_artist.Name</MudText>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_artist.Disambiguation</MudText>
@if (!string.IsNullOrWhiteSpace(_artist.Biography))
{
<MudCard Elevation="2" Class="mb-6">
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
<MudText Style="flex-grow: 1">
@if (_artist.Biography.Length > 400)
{
@(_artist.Biography.Substring(0, 400) + "...")
}
else
{
@_artist.Biography
}
</MudText>
</MudCardContent>
</MudCard>
}
<div>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddToCollection">
Add To Collection
</MudButton>
</div>
</div>
</div>
@if (_artist.Genres.Any())
{
<MudText GutterBottom="true">Genres</MudText>
<div class="mb-2">
@foreach (string genre in _artist.Genres.OrderBy(g => g))
{
<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>
}
@if (_artist.Styles.Any())
{
<MudText GutterBottom="true">Styles</MudText>
<div class="mb-2">
@foreach (string style in _artist.Styles.OrderBy(g => g))
{
<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>
}
@if (_artist.Moods.Any())
{
<MudText GutterBottom="true">Moods</MudText>
<div class="mb-2">
@foreach (string mood in _artist.Moods.OrderBy(g => g))
{
<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>
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards)
{
<MudCard Class="mb-6">
<div id="@($"music-video-{musicVideo.MusicVideoId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px">
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster))
{
<MudPaper style="display: flex; flex-direction: column">
<MudCardMedia Image="@($"/artwork/thumbnails/{musicVideo.Poster}")" Style="flex-grow: 1; height: 220px; width: 293px;"/>
</MudPaper>
}
else
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" Height="220px" Width="293px" />
}
<MudCardContent Class="ml-3">
<div style="display: flex; flex-direction: column; height: 100%">
<MudText Typo="Typo.h4">@musicVideo.Title</MudText>
<MudText Style="flex-grow: 1">@musicVideo.Plot</MudText>
<div class="mt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@(_ => AddMusicVideoToCollection(musicVideo))">
Add To Collection
</MudButton>
</div>
</div>
</MudCardContent>
</div>
</MudCard>
}
</MudContainer>
@code {
[Parameter]
public int ArtistId { get; set; }
private ArtistViewModel _artist;
private MusicVideoCardResultsViewModel _musicVideos;
protected override Task OnParametersSetAsync() => RefreshData();
private async Task RefreshData()
{
await Mediator.Send(new GetArtistById(ArtistId)).IfSomeAsync(vm => _artist = vm);
_musicVideos = await Mediator.Send(new GetMusicVideoCards(ArtistId, 1, 100));
}
private async Task AddToCollection()
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } };
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)
{
await Mediator.Send(new AddArtistToCollection(collection.Id, ArtistId));
NavigationManager.NavigateTo($"/media/collections/{collection.Id}");
}
}
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo)
{
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.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 AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}");
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success));
}
}
}

163
ErsatzTV/Pages/ArtistList.razor

@ -0,0 +1,163 @@ @@ -0,0 +1,163 @@
@page "/media/music/artists"
@page "/media/music/artists/page/{PageNumber:int}"
@using LanguageExt.UnsafeValueAccess
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search.Queries
@using Unit = LanguageExt.Unit
@inherits MultiSelectBase<MusicVideoList>
@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="ArtistCardViewModel" Cards="@_data.Cards">
<MediaCard Data="@context"
Link="@($"/media/music/artists/{context.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
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/music/artists"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private ArtistCardResultsViewModel _data;
private string _query;
protected override Task OnParametersSetAsync()
{
if (PageNumber == 0)
{
PageNumber = 1;
}
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
}
else
{
_query = null;
}
return RefreshData();
}
protected override async Task RefreshData()
{
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:artist" : $"type:artist AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexArtists(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/music/artists/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/music/artists/page/{PageNumber + 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems() => _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>();
SelectClicked(GetSortedItems, card, e);
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is ArtistCardViewModel artist)
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.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 AddArtistToCollection(collection.Id, artist.ArtistId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}");
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

60
ErsatzTV/Pages/CollectionItems.razor

@ -52,6 +52,10 @@ @@ -52,6 +52,10 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
@if (_data.ArtistCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")">@_data.ArtistCards.Count Artists</MudLink>
}
@if (_data.MusicVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
@ -170,6 +174,30 @@ @@ -170,6 +174,30 @@
</MudContainer>
}
@if (_data.ArtistCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _data.ArtistCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveArtistFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.MusicVideoCards.Any())
{
<MudText GutterBottom="true"
@ -243,15 +271,13 @@ @@ -243,15 +271,13 @@
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
}
List<MediaCardViewModel> GetSortedItems() => _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
SelectClicked(GetSortedItems, card, e);
}
@ -269,6 +295,22 @@ @@ -269,6 +295,22 @@
}
}
private async Task RemoveArtistFromCollection(MediaCardViewModel vm)
{
if (vm is ArtistCardViewModel artist)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { artist.ArtistId }
};
await RemoveItemsWithConfirmation(
"artist",
string.IsNullOrWhiteSpace(artist.Subtitle) ? artist.Title : $"{artist.Title} ({artist.Subtitle})",
request);
}
}
private async Task RemoveMusicVideoFromCollection(MediaCardViewModel vm)
{
if (vm is MusicVideoCardViewModel musicVideo)

13
ErsatzTV/Pages/MultiSelectBase.cs

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

81
ErsatzTV/Pages/Search.razor

@ -39,28 +39,21 @@ @@ -39,28 +39,21 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
}
else
{
<MudText Class="ml-4" Style="margin-bottom: auto; margin-top: auto">0 Movies</MudText>
}
if (_shows.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
else
if (_artists.Count > 0)
{
<MudText Class="ml-4" Style="margin-bottom: auto; margin-top: auto">0 Shows</MudText>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
}
if (_musicVideos.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
else
{
<MudText Class="ml-4" Style="margin-bottom: auto; margin-top: auto">0 Music Videos</MudText>
}
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
@ -127,6 +120,34 @@ @@ -127,6 +120,34 @@
</MudContainer>
}
@if (_artists.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", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_musicVideos.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
@ -135,7 +156,7 @@ @@ -135,7 +156,7 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_shows.Count > 50)
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
@ -161,6 +182,7 @@ @@ -161,6 +182,7 @@
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private MusicVideoCardResultsViewModel _musicVideos;
private ArtistCardResultsViewModel _artists;
protected override async Task OnInitializedAsync()
{
@ -173,6 +195,7 @@ @@ -173,6 +195,7 @@
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_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));
}
}
@ -182,7 +205,8 @@ @@ -182,7 +205,8 @@
{
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_musicVideos.Cards.OrderBy(s => s.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.ToList();
}
@ -232,6 +256,27 @@ @@ -232,6 +256,27 @@
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is ArtistCardViewModel artist)
{
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.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 AddArtistToCollection(collection.Id, artist.ArtistId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}");
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is MusicVideoCardViewModel musicVideo)
{
@ -274,6 +319,16 @@ @@ -274,6 +319,16 @@
}
return uri;
}
private string GetArtistsLink()
{
var uri = "/media/music/artists/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
return uri;
}
private string GetMusicVideosLink()
{
@ -288,7 +343,7 @@ @@ -288,7 +343,7 @@
private async Task AddAllToCollection(MouseEventArgs _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query));
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.MusicVideoIds, "search results");
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.ArtistIds, results.MusicVideoIds, "search results");
}
}

2
ErsatzTV/Shared/FragmentLetterAnchor.razor

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
if (!letters.Contains(letter))
{
letters.Add(letter);
<div id="@($"letter-{letter}")" style="scroll-margin-top: 128px">
<div id="@($"letter-{letter}")" style="scroll-margin-top: 140px">
@ChildContent(card)
</div>
}

2
ErsatzTV/Shared/MainLayout.razor

@ -50,7 +50,7 @@ @@ -50,7 +50,7 @@
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/videos">Music Videos</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
<MudNavLink Href="/media/collections">Collections</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>

6
ErsatzTV/Shared/MediaCard.razor

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
@using ErsatzTV.Application.MediaCards
@using static LanguageExt.Prelude
@using ErsatzTV.Application.MediaCards
@using Unit = LanguageExt.Unit
@using static LanguageExt.Prelude
@inject IMediator Mediator
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")">
@ -132,7 +132,7 @@ @@ -132,7 +132,7 @@
private string ArtworkForItem() => string.IsNullOrWhiteSpace(Data.Poster)
? "position: relative"
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover";
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover; background-position: center";
private string PathForArtwork() => ArtworkKind switch
{

3
ErsatzTV/Startup.cs

@ -120,7 +120,7 @@ namespace ErsatzTV @@ -120,7 +120,7 @@ namespace ErsatzTV
// string xmltvPath = Path.Combine(appDataFolder, "xmltv.xml");
// Log.Logger.Information("XMLTV is at {XmltvPath}", xmltvPath);
var connectionString = $"Data Source={FileSystemLayout.DatabasePath}";
var connectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;";
services.AddDbContext<TvContext>(
options => options.UseSqlite(
@ -202,6 +202,7 @@ namespace ErsatzTV @@ -202,6 +202,7 @@ namespace ErsatzTV
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IArtistRepository, ArtistRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();

Loading…
Cancel
Save