Browse Source

add music videos library (#125)

* add music videos library

* add music video tables

* first pass at music video library scan

* support music videos in playouts

* display music videos in search results and collections

* fix music video thumbnails

* remove some obsolete fields
pull/128/head v0.0.27-prealpha
Jason Dove 5 years ago committed by GitHub
parent
commit
633586ddba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs
  2. 3
      ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs
  3. 10
      ErsatzTV.Application/MediaCards/Mapper.cs
  4. 11
      ErsatzTV.Application/MediaCards/MusicVideoCardResultsViewModel.cs
  5. 13
      ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs
  6. 6
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollection.cs
  7. 2
      ErsatzTV.Application/MediaCollections/Commands/AddItemsToCollectionHandler.cs
  8. 8
      ErsatzTV.Application/MediaCollections/Commands/AddMusicVideoToCollection.cs
  9. 68
      ErsatzTV.Application/MediaCollections/Commands/AddMusicVideoToCollectionHandler.cs
  10. 56
      ErsatzTV.Application/MediaItems/Mapper.cs
  11. 9
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibrary.cs
  12. 26
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  13. 4
      ErsatzTV.Application/Playouts/Mapper.cs
  14. 8
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideos.cs
  15. 44
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs
  16. 2
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  17. 3
      ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs
  18. 10
      ErsatzTV.Core/Domain/MediaItem/MediaVersion.cs
  19. 10
      ErsatzTV.Core/Domain/MediaItem/MusicVideo.cs
  20. 11
      ErsatzTV.Core/Domain/Metadata/MusicVideoMetadata.cs
  21. 11
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  22. 2
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  23. 12
      ErsatzTV.Core/Interfaces/Metadata/IMusicVideoFolderScanner.cs
  24. 26
      ErsatzTV.Core/Interfaces/Repositories/IMusicVideoRepository.cs
  25. 4
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  26. 1
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  27. 199
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  28. 2
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  29. 225
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  30. 6
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  31. 8
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  32. 23
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/MusicVideoConfiguration.cs
  33. 30
      ErsatzTV.Infrastructure/Data/Configurations/Metadata/MusicVideoMetadataConfiguration.cs
  34. 5
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  35. 21
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  36. 5
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  37. 181
      ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs
  38. 11
      ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs
  39. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  40. 1826
      ErsatzTV.Infrastructure/Migrations/20210401105350_Add_LocalLibrary_MusicVideos.Designer.cs
  41. 24
      ErsatzTV.Infrastructure/Migrations/20210401105350_Add_LocalLibrary_MusicVideos.cs
  42. 1970
      ErsatzTV.Infrastructure/Migrations/20210401112533_Add_MusicVideo_MusicVideoMetadata.Designer.cs
  43. 228
      ErsatzTV.Infrastructure/Migrations/20210401112533_Add_MusicVideo_MusicVideoMetadata.cs
  44. 1961
      ErsatzTV.Infrastructure/Migrations/20210402232635_Remove_MediaVersion_Codecs.Designer.cs
  45. 43
      ErsatzTV.Infrastructure/Migrations/20210402232635_Remove_MediaVersion_Codecs.cs
  46. 165
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  47. 3
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  48. 68
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  49. 54
      ErsatzTV/Pages/CollectionItems.razor
  50. 4
      ErsatzTV/Pages/LocalLibraryPathEditor.razor
  51. 4
      ErsatzTV/Pages/MultiSelectBase.cs
  52. 164
      ErsatzTV/Pages/MusicVideoList.razor
  53. 79
      ErsatzTV/Pages/Search.razor
  54. 1
      ErsatzTV/Shared/MainLayout.razor
  55. 4
      ErsatzTV/Shared/MediaCard.razor
  56. 2
      ErsatzTV/Startup.cs

6
ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs

@ -18,7 +18,11 @@ namespace ErsatzTV.Application.Libraries.Queries
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) => public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAll() _libraryRepository.GetAll()
.Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList()); .Map(
list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) => private static bool ShouldIncludeLibrary(Library library) =>
library switch library switch

3
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

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

10
ErsatzTV.Application/MediaCards/Mapper.cs

@ -52,6 +52,14 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.SortTitle, movieMetadata.SortTitle,
GetPoster(movieMetadata)); GetPoster(movieMetadata));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
$"{musicVideoMetadata.Title} ({musicVideoMetadata.Artist})",
musicVideoMetadata.Year?.ToString(),
musicVideoMetadata.SortTitle,
GetThumbnail(musicVideoMetadata));
internal static CollectionCardResultsViewModel internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) => ProjectToViewModel(Collection collection) =>
new( new(
@ -64,6 +72,8 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(), collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(), collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head())) collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder }; .ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
private static int GetCustomIndex(Collection collection, int mediaItemId) => private static int GetCustomIndex(Collection collection, int mediaItemId) =>

11
ErsatzTV.Application/MediaCards/MusicVideoCardResultsViewModel.cs

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

13
ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs

@ -0,0 +1,13 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
SortTitle,
Poster)
{
public int CustomIndex { get; set; }
}
}

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

@ -5,5 +5,9 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands namespace ErsatzTV.Application.MediaCollections.Commands
{ {
public record AddItemsToCollection public record AddItemsToCollection
(int CollectionId, List<int> MovieIds, List<int> ShowIds) : MediatR.IRequest<Either<BaseError, Unit>>; (
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
} }

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

@ -41,7 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{ {
if (await _mediaCollectionRepository.AddMediaItems( if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId, request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList())) request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
{ {
// rebuild all playouts that use this collection // rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository foreach (int playoutId in await _mediaCollectionRepository

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

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

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

@ -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
AddMusicVideoToCollectionHandler : MediatR.IRequestHandler<AddMusicVideoToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
public AddMusicVideoToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMusicVideoRepository musicVideoRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_musicVideoRepository = musicVideoRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
AddMusicVideoToCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(_ => ApplyAddMusicVideoRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddMusicVideoRequest(AddMusicVideoToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MusicVideoId))
{
// 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(AddMusicVideoToCollection request) =>
(await CollectionMustExist(request), await ValidateMusicVideo(request))
.Apply((_, _) => Unit.Default);
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddMusicVideoToCollection request) =>
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Collection does not exist."));
private Task<Validation<BaseError, Unit>> ValidateMusicVideo(AddMusicVideoToCollection request) =>
LoadMusicVideo(request)
.MapT(_ => Unit.Default)
.Map(v => v.ToValidation<BaseError>("Music video does not exist"));
private Task<Option<MusicVideo>> LoadMusicVideo(AddMusicVideoToCollection request) =>
_musicVideoRepository.GetMusicVideo(request.MusicVideoId);
}
}

56
ErsatzTV.Application/MediaItems/Mapper.cs

@ -1,5 +1,4 @@
using System; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems namespace ErsatzTV.Application.MediaItems
{ {
@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) => internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
new(mediaItem.Id, mediaItem.LibraryPathId); new(mediaItem.Id, mediaItem.LibraryPathId);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ProjectToSearchViewModel(e),
Movie m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException()
};
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Episode mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"TV Show",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(Movie mediaItem) =>
new(
mediaItem.Id,
GetLibraryName(mediaItem),
"Movie",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{em.Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}")
.IfNone("[unknown episode]"),
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem)
{
MediaVersion version = mediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
return string.Format(
version.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
version.Duration);
}
// TODO: fix this when search is reimplemented
private static string GetLibraryName(MediaItem item) =>
"Library Name";
public static NamedMediaItemViewModel ProjectToViewModel(Show show) => public static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???")); new(show.Id, show.ShowMetadata.HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));

9
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibrary.cs

@ -8,15 +8,24 @@ namespace ErsatzTV.Application.MediaSources.Commands
{ {
int LibraryId { get; } int LibraryId { get; }
bool ForceScan { get; } bool ForceScan { get; }
bool Rescan { get; }
} }
public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary
{ {
public bool ForceScan => false; public bool ForceScan => false;
public bool Rescan => false;
} }
public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary
{ {
public bool ForceScan => true; public bool ForceScan => true;
public bool Rescan => false;
}
public record ForceRescanLocalLibrary(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => true;
public bool Rescan => true;
} }
} }

26
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -16,13 +16,15 @@ using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands namespace ErsatzTV.Application.MediaSources.Commands
{ {
public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>, public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Either<BaseError, string>>,
IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>> IRequestHandler<ScanLocalLibraryIfNeeded, Either<BaseError, string>>,
IRequestHandler<ForceRescanLocalLibrary, Either<BaseError, string>>
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker; private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository; private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger; private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMovieFolderScanner _movieFolderScanner; private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner; private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler( public ScanLocalLibraryHandler(
@ -30,6 +32,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner, IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner, ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IEntityLocker entityLocker, IEntityLocker entityLocker,
ILogger<ScanLocalLibraryHandler> logger) ILogger<ScanLocalLibraryHandler> logger)
{ {
@ -37,10 +40,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
_configElementRepository = configElementRepository; _configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner; _movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner; _televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_entityLocker = entityLocker; _entityLocker = entityLocker;
_logger = logger; _logger = logger;
} }
public Task<Either<BaseError, string>> Handle(
ForceRescanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
public Task<Either<BaseError, string>> Handle( public Task<Either<BaseError, string>> Handle(
ForceScanLocalLibrary request, ForceScanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request); CancellationToken cancellationToken) => Handle(request);
@ -57,7 +65,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
private async Task<Unit> PerformScan(RequestParameters parameters) private async Task<Unit> PerformScan(RequestParameters parameters)
{ {
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters; (LocalLibrary localLibrary, string ffprobePath, bool forceScan, bool rescan) = parameters;
var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero); var lastScan = new DateTimeOffset(localLibrary.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6)) if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
@ -65,15 +73,20 @@ namespace ErsatzTV.Application.MediaSources.Commands
var sw = new Stopwatch(); var sw = new Stopwatch();
sw.Start(); sw.Start();
DateTimeOffset effectiveLastScan = rescan ? DateTimeOffset.MinValue : lastScan;
foreach (LibraryPath libraryPath in localLibrary.Paths) foreach (LibraryPath libraryPath in localLibrary.Paths)
{ {
switch (localLibrary.MediaKind) switch (localLibrary.MediaKind)
{ {
case LibraryMediaKind.Movies: case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan); await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break; break;
case LibraryMediaKind.Shows: case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan); await _televisionFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break; break;
} }
} }
@ -104,7 +117,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
(library, ffprobePath) => new RequestParameters( (library, ffprobePath) => new RequestParameters(
library, library,
ffprobePath, ffprobePath,
request.ForceScan)); request.ForceScan,
request.Rescan));
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist( private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) => IScanLocalLibrary request) =>
@ -119,6 +133,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
ffprobePath => ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system")); ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan); private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan, bool Rescan);
} }
} }

4
ErsatzTV.Application/Playouts/Mapper.cs

@ -36,6 +36,9 @@ namespace ErsatzTV.Application.Playouts
.IfNone("[unknown episode]"); .IfNone("[unknown episode]");
case Movie m: case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]"); return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]");
default: default:
return string.Empty; return string.Empty;
} }
@ -47,6 +50,7 @@ namespace ErsatzTV.Application.Playouts
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };

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

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

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

@ -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
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel
>
{
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMusicVideosHandler(ISearchIndex searchIndex, IMusicVideoRepository musicVideoRepository)
{
_searchIndex = searchIndex;
_musicVideoRepository = musicVideoRepository;
}
public async Task<MusicVideoCardResultsViewModel> Handle(
QuerySearchIndexMusicVideos request,
CancellationToken cancellationToken)
{
SearchResult searchResult = await _searchIndex.Search(
request.Query,
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<MusicVideoCardViewModel> items = await _musicVideoRepository
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
return new MusicVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
}
}

2
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -56,6 +56,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath)) _ => throw new ArgumentOutOfRangeException(nameof(playoutItemWithPath))
}; };
@ -153,6 +154,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem)) _ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
}; };

3
ErsatzTV.Core/Domain/Library/LibraryMediaKind.cs

@ -3,6 +3,7 @@
public enum LibraryMediaKind public enum LibraryMediaKind
{ {
Movies = 1, Movies = 1,
Shows = 2 Shows = 2,
MusicVideos = 3
} }
} }

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

@ -13,16 +13,6 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; } public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; } public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; } public string DisplayAspectRatio { get; set; }
[Obsolete("Use MediaSource instead")]
public string VideoCodec { get; set; }
[Obsolete("Use MediaSource instead")]
public string VideoProfile { get; set; }
[Obsolete("Use MediaSource instead")]
public string AudioCodec { get; set; }
public VideoScanKind VideoScanKind { get; set; } public VideoScanKind VideoScanKind { get; set; }
public DateTime DateAdded { get; set; } public DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; } public DateTime DateUpdated { get; set; }

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

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

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

@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class MusicVideoMetadata : Metadata
{
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; }
}
}

11
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -332,11 +332,12 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale) public FFmpegProcessBuilder WithVideoTrackTimeScale(Option<int> videoTrackTimeScale)
{ {
videoTrackTimeScale.IfSome(timeScale => videoTrackTimeScale.IfSome(
{ timeScale =>
_arguments.Add("-video_track_timescale"); {
_arguments.Add($"{timeScale}"); _arguments.Add("-video_track_timescale");
}); _arguments.Add($"{timeScale}");
});
return this; return this;
} }

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

@ -1,11 +1,13 @@
using System.Threading.Tasks; using System.Threading.Tasks;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata namespace ErsatzTV.Core.Interfaces.Metadata
{ {
public interface ILocalMetadataProvider public interface ILocalMetadataProvider
{ {
Task<ShowMetadata> GetMetadataForShow(string showFolder); Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath);
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path); Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder); Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem); Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);

12
ErsatzTV.Core/Interfaces/Metadata/IMusicVideoFolderScanner.cs

@ -0,0 +1,12 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMusicVideoFolderScanner
{
Task<Either<BaseError, Unit>> ScanFolder(LibraryPath libraryPath, string ffprobePath, DateTimeOffset lastScan);
}
}

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

@ -0,0 +1,26 @@
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 IMusicVideoRepository
{
Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata);
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
LibraryPath libraryPath,
string filePath,
MusicVideoMetadata metadata);
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre);
Task<bool> AddTag(MusicVideoMetadata metadata, Tag tag);
Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio);
Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids);
Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId);
}
}

4
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -221,6 +221,8 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown movie]"), .IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty) Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"), .IfNone("[unknown show]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]"),
_ => "[unknown]" _ => "[unknown]"
}; };
} }
@ -253,6 +255,8 @@ namespace ErsatzTV.Core.Iptv
Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty), Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty),
Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty) Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty)
.IfNone(string.Empty), .IfNone(string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty _ => string.Empty
}; };
} }

1
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -72,6 +72,7 @@ namespace ErsatzTV.Core.Metadata
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };

199
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -18,18 +18,21 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo)); private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo)); private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo)); private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem; private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger; private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository; private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ITelevisionRepository _televisionRepository; private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider( public LocalMetadataProvider(
IMetadataRepository metadataRepository, IMetadataRepository metadataRepository,
IMovieRepository movieRepository, IMovieRepository movieRepository,
ITelevisionRepository televisionRepository, ITelevisionRepository televisionRepository,
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider, IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger) ILogger<LocalMetadataProvider> logger)
@ -37,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_televisionRepository = televisionRepository; _televisionRepository = televisionRepository;
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider; _fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem; _localFileSystem = localFileSystem;
_logger = logger; _logger = logger;
@ -65,6 +69,23 @@ namespace ErsatzTV.Core.Metadata
}); });
} }
public async Task<Option<MusicVideoMetadata>> GetMetadataForMusicVideo(string filePath)
{
string nfoFileName = Path.ChangeExtension(filePath, "nfo");
Option<MusicVideoMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadMusicVideoMetadata(nfoFileName);
}
return maybeMetadata.Map(
metadata =>
{
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) => public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
mediaItem switch mediaItem switch
{ {
@ -78,6 +99,11 @@ namespace ErsatzTV.Core.Metadata
maybeMetadata => maybeMetadata.Match( maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(m, metadata), metadata => ApplyMetadataUpdate(m, metadata),
() => Task.FromResult(false))), () => Task.FromResult(false))),
MusicVideo mv => LoadMetadata(mv, path)
.Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(mv, metadata),
() => Task.FromResult(false))),
_ => Task.FromResult(false) _ => Task.FromResult(false)
}; };
@ -98,6 +124,37 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) => public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder)); ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
private async Task<Option<MusicVideoMetadata>> LoadMusicVideoMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<MusicVideoNfo> maybeNfo = MusicVideoSerializer.Deserialize(fileStream) as MusicVideoNfo;
return maybeNfo.Match<Option<MusicVideoMetadata>>(
nfo => new MusicVideoMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Artist = nfo.Artist,
Album = nfo.Album,
Title = nfo.Title,
Plot = nfo.Plot,
Year = GetYear(nfo.Year, nfo.Premiered),
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
},
None);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read music video nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber) private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
{ {
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber; (EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
@ -344,6 +401,106 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata); return await _metadataRepository.Add(metadata);
}); });
private Task<bool> ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) =>
Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
var updated = false;
existing.Artist = metadata.Artist;
existing.Title = metadata.Title;
existing.Year = metadata.Year;
existing.Plot = metadata.Plot;
existing.Album = metadata.Album;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
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 _musicVideoRepository.AddGenre(existing, genre))
{
updated = true;
}
}
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{
updated = true;
}
}
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
.ToList())
{
existing.Tags.Add(tag);
if (await _musicVideoRepository.AddTag(existing, tag))
{
updated = true;
}
}
foreach (Studio studio in existing.Studios
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
updated = true;
}
}
foreach (Studio studio in metadata.Studios
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Studios.Add(studio);
if (await _musicVideoRepository.AddStudio(existing, studio))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.MusicVideoId = musicVideo.Id;
musicVideo.MusicVideoMetadata = new List<MusicVideoMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName) private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
{ {
if (nfoFileName == null || !File.Exists(nfoFileName)) if (nfoFileName == null || !File.Exists(nfoFileName))
@ -377,6 +534,17 @@ namespace ErsatzTV.Core.Metadata
return await LoadTelevisionShowMetadata(nfoFileName); return await LoadTelevisionShowMetadata(nfoFileName);
} }
private async Task<Option<MusicVideoMetadata>> LoadMetadata(MusicVideo musicVideo, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
return None;
}
return await LoadMusicVideoMetadata(nfoFileName);
}
private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName) private async Task<Option<ShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{ {
try try
@ -589,5 +757,36 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("plot")] [XmlElement("plot")]
public string Plot { get; set; } public string Plot { get; set; }
} }
[XmlRoot("musicvideo")]
public class MusicVideoNfo
{
[XmlElement("artist")]
public string Artist { get; set; }
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("album")]
public string Album { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("premiered")]
public string Premiered { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("genre")]
public List<string> Genres { get; set; }
[XmlElement("tag")]
public List<string> Tags { get; set; }
[XmlElement("studio")]
public List<string> Studios { get; set; }
}
} }
} }

2
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -37,6 +37,7 @@ namespace ErsatzTV.Core.Metadata
{ {
Movie m => m.MediaVersions.Head().MediaFiles.Head().Path, Movie m => m.MediaVersions.Head().MediaFiles.Head().Path,
Episode e => e.MediaVersions.Head().MediaFiles.Head().Path, Episode e => e.MediaVersions.Head().MediaFiles.Head().Path,
MusicVideo mv => mv.MediaVersions.Head().MediaFiles.Head().Path,
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };
@ -63,6 +64,7 @@ namespace ErsatzTV.Core.Metadata
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };

225
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MusicVideoFolderScanner> _logger;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ISearchIndex _searchIndex;
public MusicVideoFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IMetadataRepository metadataRepository,
IImageCache imageCache,
ISearchIndex searchIndex,
IMusicVideoRepository musicVideoRepository,
ILogger<MusicVideoFolderScanner> logger) : base(
localFileSystem,
localStatisticsProvider,
metadataRepository,
imageCache,
logger)
{
_localFileSystem = localFileSystem;
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_musicVideoRepository = musicVideoRepository;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanFolder(
LibraryPath libraryPath,
string ffprobePath,
DateTimeOffset lastScan)
{
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath))
{
return new MediaSourceInaccessible();
}
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path).OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{
string movieFolder = folderQueue.Dequeue();
var allFiles = _localFileSystem.ListFiles(movieFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(
f => !ExtraFiles.Any(
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToList();
if (allFiles.Count == 0)
{
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder).OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
continue;
}
if (_localFileSystem.GetLastWriteTime(movieFolder) < lastScan)
{
continue;
}
foreach (string file in allFiles.OrderBy(identity))
{
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo =
await FindOrCreateMusicVideo(libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
await maybeMusicVideo.Match(
async result =>
{
if (result.IsAdded)
{
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
}
},
error =>
{
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
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);
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> FindOrCreateMusicVideo(
LibraryPath libraryPath,
string filePath)
{
Option<MusicVideoMetadata> maybeMetadata = await _localMetadataProvider.GetMetadataForMusicVideo(filePath);
return await maybeMetadata.Match(
async metadata =>
{
Option<MusicVideo> maybeMusicVideo =
await _musicVideoRepository.GetByMetadata(libraryPath, metadata);
return await maybeMusicVideo.Match(
musicVideo =>
Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(musicVideo))
.AsTask(),
async () => await _musicVideoRepository.Add(libraryPath, filePath, metadata));
},
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
BaseError.New("Unable to locate metadata for music video")).AsTask());
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
MediaItemScanResult<MusicVideo> result)
{
try
{
MusicVideo musicVideo = result.Item;
return await LocateNfoFile(musicVideo).Match<Task<Either<BaseError, MediaItemScanResult<MusicVideo>>>>(
async nfoFile =>
{
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).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(musicVideo, nfoFile))
{
result.IsUpdated = true;
}
}
return result;
},
() => Left<BaseError, MediaItemScanResult<MusicVideo>>(
BaseError.New("Unable to locate metadata for music video")).AsTask());
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateNfoFile(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateThumbnail(
MediaItemScanResult<MusicVideo> result)
{
try
{
MusicVideo musicVideo = result.Item;
await LocateThumbnail(musicVideo).IfSomeAsync(
async thumbnailFile =>
{
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail);
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private Option<string> LocateThumbnail(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
return ImageFileExtensions
.Map(ext => Path.ChangeExtension(path, ext))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}
}
}

6
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -48,6 +48,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match( Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue, mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue), () => DateTime.MaxValue),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue _ => DateTime.MaxValue
}; };
@ -59,6 +62,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match( Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue, mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue), () => DateTime.MaxValue),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue _ => DateTime.MaxValue
}; };

8
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -99,6 +99,8 @@ namespace ErsatzTV.Core.Scheduling
TimeSpan.Zero, TimeSpan.Zero,
Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) == Episode e => e.MediaVersions.HeadOrNone().Map(mv => mv.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero, TimeSpan.Zero,
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration).IfNone(TimeSpan.Zero) ==
TimeSpan.Zero,
_ => true _ => true
})).Map(c => c.Key); })).Map(c => c.Key);
if (zeroDurationCollection.IsSome) if (zeroDurationCollection.IsSome)
@ -180,6 +182,7 @@ namespace ErsatzTV.Core.Scheduling
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
}; };
@ -240,6 +243,7 @@ namespace ErsatzTV.Core.Scheduling
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
}; };
@ -274,6 +278,7 @@ namespace ErsatzTV.Core.Scheduling
{ {
Movie m => m.MediaVersions.Head(), Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(), Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem)) _ => throw new ArgumentOutOfRangeException(nameof(peekMediaItem))
}; };
@ -479,6 +484,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match( Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.Title ?? string.Empty, mm => mm.Title ?? string.Empty,
() => "[unknown movie]"), () => "[unknown movie]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => $"{mvm.Artist} - {mvm.Title}",
() => "[unknown music video]"),
_ => string.Empty _ => string.Empty
}; };

23
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/MusicVideoConfiguration.cs

@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MusicVideoConfiguration : IEntityTypeConfiguration<MusicVideo>
{
public void Configure(EntityTypeBuilder<MusicVideo> builder)
{
builder.ToTable("MusicVideo");
builder.HasMany(m => m.MusicVideoMetadata)
.WithOne(m => m.MusicVideo)
.HasForeignKey(m => m.MusicVideoId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(m => m.MediaVersions)
.WithOne()
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

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

5
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -52,7 +52,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Library>> GetAll() public Task<List<Library>> GetAll()
{ {
using TvContext context = _dbContextFactory.CreateDbContext(); using TvContext context = _dbContextFactory.CreateDbContext();
return context.Libraries.ToListAsync(); return context.Libraries
.AsNoTracking()
.Include(l => l.MediaSource)
.ToListAsync();
} }
public Task<Unit> UpdateLastScan(Library library) => _dbConnection.ExecuteAsync( public Task<Unit> UpdateLastScan(Library library) => _dbConnection.ExecuteAsync(

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

@ -104,6 +104,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as Movie).MovieMetadata) .ThenInclude(i => (i as Movie).MovieMetadata)
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata) .ThenInclude(i => (i as Show).ShowMetadata)
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as Season).Show) .ThenInclude(i => (i as Season).Show)
@ -128,6 +130,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Movie).MovieMetadata) .ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork) .ThenInclude(mm => mm.Artwork)
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata) .ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork) .ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems) .Include(c => c.MediaItems)
@ -196,6 +201,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItems(collection)); result.AddRange(await GetShowItems(collection));
result.AddRange(await GetSeasonItems(collection)); result.AddRange(await GetSeasonItems(collection));
result.AddRange(await GetEpisodeItems(collection)); result.AddRange(await GetEpisodeItems(collection));
result.AddRange(await GetMusicVideoItems(collection));
return result.Distinct().ToList(); return result.Distinct().ToList();
} }
@ -215,6 +221,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync(); .ToListAsync();
} }
private async Task<List<MusicVideo>> GetMusicVideoItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM CollectionItem ci
INNER JOIN MusicVideo m ON m.Id = ci.MediaItemId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.ToListAsync();
}
private async Task<List<Episode>> GetShowItems(Collection collection) private async Task<List<Episode>> GetShowItems(Collection collection)
{ {
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>( IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(

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

@ -156,6 +156,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)", VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters) parameters)
.ToUnit(), .ToUnit(),
MusicVideoMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
_ => Task.FromResult(Unit.Default) _ => Task.FromResult(Unit.Default)
}; };
} }

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

@ -0,0 +1,181 @@
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 MusicVideoRepository : IMusicVideoRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MusicVideoRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<Option<MusicVideo>> GetByMetadata(LibraryPath libraryPath, MusicVideoMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<int> maybeId = await dbContext.MusicVideoMetadata
.Where(s => s.Artist == metadata.Artist && s.Title == metadata.Title && s.Year == metadata.Year)
.Where(s => s.MusicVideo.LibraryPathId == libraryPath.Id)
.SingleOrDefaultAsync()
.Map(Optional)
.MapT(sm => sm.MusicVideoId);
return await maybeId.Match(
id =>
{
return dbContext.MusicVideos
.AsNoTracking()
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Genres)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Tags)
.Include(mv => mv.MusicVideoMetadata)
.ThenInclude(mvm => mvm.Studios)
.Include(mv => mv.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(mv => mv.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(mv => mv.Id)
.SingleOrDefaultAsync(mv => mv.Id == id)
.Map(Optional);
},
() => Option<MusicVideo>.None.AsTask());
}
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> Add(
LibraryPath libraryPath,
string filePath,
MusicVideoMetadata metadata)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
try
{
metadata.DateAdded = DateTime.UtcNow;
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
var musicVideo = new MusicVideo
{
LibraryPathId = libraryPath.Id,
MusicVideoMetadata = new List<MusicVideoMetadata> { metadata },
MediaVersions = new List<MediaVersion>
{
new()
{
MediaFiles = new List<MediaFile>
{
new() { Path = filePath }
},
Streams = new List<MediaStream>()
}
}
};
await dbContext.MusicVideos.AddAsync(musicVideo);
await dbContext.SaveChangesAsync();
await dbContext.Entry(musicVideo).Reference(s => s.LibraryPath).LoadAsync();
await dbContext.Entry(musicVideo.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<MusicVideo>(musicVideo) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public Task<IEnumerable<string>> FindMusicVideoPaths(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",
new { LibraryPathId = libraryPath.Id });
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id
FROM MusicVideo M
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN MediaVersion MV on M.Id = MV.EpisodeId
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
new { LibraryPathId = libraryPath.Id, Path = path }).Map(result => result.ToList());
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (int musicVideoId in ids)
{
MusicVideo musicVideo = await dbContext.MusicVideos.FindAsync(musicVideoId);
dbContext.MusicVideos.Remove(musicVideo);
}
await dbContext.SaveChangesAsync();
return ids;
}
public Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Genre (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddTag(MusicVideoMetadata metadata, Tag tag) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Tag (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio) =>
_dbConnection.ExecuteAsync(
"INSERT INTO Studio (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
public async Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.Artwork)
.OrderBy(mvm => mvm.SortTitle)
.ToListAsync();
}
public async Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideos
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Artwork)
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Genres)
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Tags)
.Include(m => m.MusicVideoMetadata)
.ThenInclude(m => m.Studios)
.OrderBy(m => m.Id)
.SingleOrDefaultAsync(m => m.Id == musicVideoId)
.Map(Optional);
}
}
}

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

@ -65,6 +65,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions) .ThenInclude(mi => (mi as Movie).MediaVersions)
.ThenInclude(mv => mv.Streams) .ThenInclude(mv => mv.Streams)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mv => mv.Streams)
.AsNoTracking() .AsNoTracking()
.SingleOrDefaultAsync() .SingleOrDefaultAsync()
.Map(Optional); .Map(Optional);
@ -89,6 +95,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions) .ThenInclude(mi => (mi as Movie).MediaVersions)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata) .ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork) .ThenInclude(em => em.Artwork)
.Include(i => i.MediaItem) .Include(i => i.MediaItem)

2
ErsatzTV.Infrastructure/Data/TvContext.cs

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

1826
ErsatzTV.Infrastructure/Migrations/20210401105350_Add_LocalLibrary_MusicVideos.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20210401105350_Add_LocalLibrary_MusicVideos.cs

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_LocalLibrary_MusicVideos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
// create local music videos library
migrationBuilder.Sql(
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
SELECT 'Music Videos', 3, Id FROM
(SELECT LMS.Id FROM LocalMediaSource LMS
INNER JOIN Library L on L.MediaSourceId = LMS.Id
INNER JOIN LocalLibrary LL on L.Id = LL.Id
WHERE L.Name = 'Movies')");
migrationBuilder.Sql("INSERT INTO LocalLibrary (Id) Values (last_insert_rowid())");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

1970
ErsatzTV.Infrastructure/Migrations/20210401112533_Add_MusicVideo_MusicVideoMetadata.Designer.cs generated

File diff suppressed because it is too large Load Diff

228
ErsatzTV.Infrastructure/Migrations/20210401112533_Add_MusicVideo_MusicVideoMetadata.cs

@ -0,0 +1,228 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_MusicVideo_MusicVideoMetadata : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Tag",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Studio",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoId",
"MediaVersion",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Genre",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"MusicVideoMetadataId",
"Artwork",
"INTEGER",
nullable: true);
migrationBuilder.CreateTable(
"MusicVideo",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_MusicVideo", x => x.Id);
table.ForeignKey(
"FK_MusicVideo_MediaItem_Id",
x => x.Id,
"MediaItem",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"MusicVideoMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Album = table.Column<string>("TEXT", nullable: true),
Plot = table.Column<string>("TEXT", nullable: true),
Artist = table.Column<string>("TEXT", nullable: true),
MusicVideoId = table.Column<int>("INTEGER", nullable: false),
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_MusicVideoMetadata", x => x.Id);
table.ForeignKey(
"FK_MusicVideoMetadata_MusicVideo_MusicVideoId",
x => x.MusicVideoId,
"MusicVideo",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_Tag_MusicVideoMetadataId",
"Tag",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_Studio_MusicVideoMetadataId",
"Studio",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_MediaVersion_MusicVideoId",
"MediaVersion",
"MusicVideoId");
migrationBuilder.CreateIndex(
"IX_Genre_MusicVideoMetadataId",
"Genre",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_Artwork_MusicVideoMetadataId",
"Artwork",
"MusicVideoMetadataId");
migrationBuilder.CreateIndex(
"IX_MusicVideoMetadata_MusicVideoId",
"MusicVideoMetadata",
"MusicVideoId");
migrationBuilder.AddForeignKey(
"FK_Artwork_MusicVideoMetadata_MusicVideoMetadataId",
"Artwork",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Genre_MusicVideoMetadata_MusicVideoMetadataId",
"Genre",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_MediaVersion_MusicVideo_MusicVideoId",
"MediaVersion",
"MusicVideoId",
"MusicVideo",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Studio_MusicVideoMetadata_MusicVideoMetadataId",
"Studio",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
"FK_Tag_MusicVideoMetadata_MusicVideoMetadataId",
"Tag",
"MusicVideoMetadataId",
"MusicVideoMetadata",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
"FK_Artwork_MusicVideoMetadata_MusicVideoMetadataId",
"Artwork");
migrationBuilder.DropForeignKey(
"FK_Genre_MusicVideoMetadata_MusicVideoMetadataId",
"Genre");
migrationBuilder.DropForeignKey(
"FK_MediaVersion_MusicVideo_MusicVideoId",
"MediaVersion");
migrationBuilder.DropForeignKey(
"FK_Studio_MusicVideoMetadata_MusicVideoMetadataId",
"Studio");
migrationBuilder.DropForeignKey(
"FK_Tag_MusicVideoMetadata_MusicVideoMetadataId",
"Tag");
migrationBuilder.DropTable(
"MusicVideoMetadata");
migrationBuilder.DropTable(
"MusicVideo");
migrationBuilder.DropIndex(
"IX_Tag_MusicVideoMetadataId",
"Tag");
migrationBuilder.DropIndex(
"IX_Studio_MusicVideoMetadataId",
"Studio");
migrationBuilder.DropIndex(
"IX_MediaVersion_MusicVideoId",
"MediaVersion");
migrationBuilder.DropIndex(
"IX_Genre_MusicVideoMetadataId",
"Genre");
migrationBuilder.DropIndex(
"IX_Artwork_MusicVideoMetadataId",
"Artwork");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Tag");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Studio");
migrationBuilder.DropColumn(
"MusicVideoId",
"MediaVersion");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Genre");
migrationBuilder.DropColumn(
"MusicVideoMetadataId",
"Artwork");
}
}
}

1961
ErsatzTV.Infrastructure/Migrations/20210402232635_Remove_MediaVersion_Codecs.Designer.cs generated

File diff suppressed because it is too large Load Diff

43
ErsatzTV.Infrastructure/Migrations/20210402232635_Remove_MediaVersion_Codecs.cs

@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_MediaVersion_Codecs : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"AudioCodec",
"MediaVersion");
migrationBuilder.DropColumn(
"VideoCodec",
"MediaVersion");
migrationBuilder.DropColumn(
"VideoProfile",
"MediaVersion");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
"AudioCodec",
"MediaVersion",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"VideoCodec",
"MediaVersion",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"VideoProfile",
"MediaVersion",
"TEXT",
nullable: true);
}
}
}

165
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -42,6 +42,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId") b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Path") b.Property<string>("Path")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -59,6 +62,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId"); b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -294,6 +299,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId") b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -309,6 +317,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId"); b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -472,9 +482,6 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded") b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -496,18 +503,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieId") b.Property<int?>("MovieId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MusicVideoId")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("SampleAspectRatio") b.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.Property<string>("VideoProfile")
.HasColumnType("TEXT");
b.Property<int>("VideoScanKind") b.Property<int>("VideoScanKind")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@ -520,6 +524,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieId"); b.HasIndex("MovieId");
b.HasIndex("MusicVideoId");
b.ToTable("MediaVersion"); b.ToTable("MediaVersion");
}); });
@ -574,6 +580,57 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MovieMetadata"); b.ToTable("MovieMetadata");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Album")
.HasColumnType("TEXT");
b.Property<string>("Artist")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<int>("MusicVideoId")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.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("MusicVideoId");
b.ToTable("MusicVideoMetadata");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout", "ErsatzTV.Core.Domain.Playout",
b => b =>
@ -913,6 +970,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId") b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -928,6 +988,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId"); b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -949,6 +1011,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("MovieMetadataId") b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int?>("MusicVideoMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@ -964,6 +1029,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasIndex("MovieMetadataId"); b.HasIndex("MovieMetadataId");
b.HasIndex("MusicVideoMetadataId");
b.HasIndex("SeasonMetadataId"); b.HasIndex("SeasonMetadataId");
b.HasIndex("ShowMetadataId"); b.HasIndex("ShowMetadataId");
@ -1036,6 +1103,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Movie"); b.ToTable("Movie");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("MusicVideo");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season", "ErsatzTV.Core.Domain.Season",
b => b =>
@ -1207,6 +1283,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId") .HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Artwork")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Artwork") .WithMany("Artwork")
.HasForeignKey("SeasonMetadataId") .HasForeignKey("SeasonMetadataId")
@ -1291,6 +1372,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId") .HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Genres")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Genres") .WithMany("Genres")
.HasForeignKey("SeasonMetadataId"); .HasForeignKey("SeasonMetadataId");
@ -1379,6 +1465,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.WithMany("MediaVersions") .WithMany("MediaVersions")
.HasForeignKey("MovieId") .HasForeignKey("MovieId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", null)
.WithMany("MediaVersions")
.HasForeignKey("MusicVideoId")
.OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity( modelBuilder.Entity(
@ -1394,6 +1485,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Movie"); b.Navigation("Movie");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MusicVideo", "MusicVideo")
.WithMany("MusicVideoMetadata")
.HasForeignKey("MusicVideoId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MusicVideo");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout", "ErsatzTV.Core.Domain.Playout",
b => b =>
@ -1627,6 +1731,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId") .HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Studios")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Studios") .WithMany("Studios")
.HasForeignKey("SeasonMetadataId"); .HasForeignKey("SeasonMetadataId");
@ -1650,6 +1759,11 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MovieMetadataId") .HasForeignKey("MovieMetadataId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.MusicVideoMetadata", null)
.WithMany("Tags")
.HasForeignKey("MusicVideoMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null) b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
.WithMany("Tags") .WithMany("Tags")
.HasForeignKey("SeasonMetadataId"); .HasForeignKey("SeasonMetadataId");
@ -1723,6 +1837,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season", "ErsatzTV.Core.Domain.Season",
b => b =>
@ -1917,6 +2042,19 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags"); b.Navigation("Tags");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideoMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Studios");
b.Navigation("Tags");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout", "ErsatzTV.Core.Domain.Playout",
b => b =>
@ -1979,6 +2117,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MovieMetadata"); b.Navigation("MovieMetadata");
}); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.Navigation("MediaVersions");
b.Navigation("MusicVideoMetadata");
});
modelBuilder.Entity( modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season", "ErsatzTV.Core.Domain.Season",
b => b =>

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

@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
namespace ErsatzTV.Infrastructure.Plex.Models namespace ErsatzTV.Infrastructure.Plex.Models
{ {

68
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -29,6 +29,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string IdField = "id"; private const string IdField = "id";
private const string TypeField = "type"; private const string TypeField = "type";
private const string ArtistField = "artist";
private const string TitleField = "title"; private const string TitleField = "title";
private const string SortTitleField = "sort_title"; private const string SortTitleField = "sort_title";
private const string GenreField = "genre"; private const string GenreField = "genre";
@ -42,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string MovieType = "movie"; private const string MovieType = "movie";
private const string ShowType = "show"; private const string ShowType = "show";
private const string MusicVideoType = "music_video";
private static bool _isRebuilding; private static bool _isRebuilding;
@ -93,6 +95,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show: case Show show:
UpdateShow(show, writer); UpdateShow(show, writer);
break; break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
break;
} }
} }
} }
@ -121,6 +126,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show: case Show show:
UpdateShow(show, writer); UpdateShow(show, writer);
break; break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
break;
} }
} }
@ -338,6 +346,66 @@ namespace ErsatzTV.Infrastructure.Search
} }
} }
private void UpdateMusicVideo(MusicVideo musicVideo, IndexWriter writer)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
MusicVideoMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
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),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
if (metadata.ReleaseDate.HasValue)
{
doc.Add(
new StringField(
ReleaseDateField,
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
Field.Store.NO));
}
if (!string.IsNullOrWhiteSpace(metadata.Plot))
{
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
foreach (Studio studio in metadata.Studios)
{
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
}
writer.UpdateDocument(new Term(IdField, musicVideo.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.MusicVideo = null;
_logger.LogWarning(ex, "Error indexing music video with metadata {@Metadata}", metadata);
}
}
}
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField))); private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
private Query ParseQuery(string searchQuery, QueryParser parser) private Query ParseQuery(string searchQuery, QueryParser parser)

54
ErsatzTV/Pages/CollectionItems.razor

@ -52,6 +52,10 @@
{ {
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink> <MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
} }
@if (_data.MusicVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
}
@if (SupportsCustomOrdering()) @if (SupportsCustomOrdering())
{ {
<div style="margin-left: auto"> <div style="margin-left: auto">
@ -165,6 +169,29 @@
} }
</MudContainer> </MudContainer>
} }
@if (_data.MusicVideoCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _data.MusicVideoCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link=""
DeleteClicked="@RemoveMusicVideoFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer> </MudContainer>
@code { @code {
@ -215,14 +242,12 @@
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{ {
List<MediaCardViewModel> GetSortedItems() List<MediaCardViewModel> GetSortedItems() => _data.MovieCards.OrderBy(m => m.SortTitle)
{ .Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
return _data.MovieCards.OrderBy(m => m.SortTitle) .Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle)) .Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle)) .Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired)) .ToList();
.ToList();
}
SelectClicked(GetSortedItems, card, e); SelectClicked(GetSortedItems, card, e);
} }
@ -240,6 +265,19 @@
} }
} }
private async Task RemoveMusicVideoFromCollection(MediaCardViewModel vm)
{
if (vm is MusicVideoCardViewModel musicVideo)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { musicVideo.MusicVideoId }
};
await RemoveItemsWithConfirmation("music video", $"{musicVideo.Title} ({musicVideo.Subtitle})", request);
}
}
private async Task RemoveShowFromCollection(MediaCardViewModel vm) private async Task RemoveShowFromCollection(MediaCardViewModel vm)
{ {
if (vm is TelevisionShowCardViewModel show) if (vm is TelevisionShowCardViewModel show)

4
ErsatzTV/Pages/LocalLibraryPathEditor.razor

@ -70,11 +70,11 @@
Logger.LogError("Unexpected error saving local library path: {Error}", error.Value); Logger.LogError("Unexpected error saving local library path: {Error}", error.Value);
return Task.CompletedTask; return Task.CompletedTask;
}, },
Right: async vm => Right: async _ =>
{ {
if (Locker.LockLibrary(_library.Id)) if (Locker.LockLibrary(_library.Id))
{ {
await Channel.WriteAsync(new ForceScanLocalLibrary(_library.Id)); await Channel.WriteAsync(new ForceRescanLocalLibrary(_library.Id));
NavigationManager.NavigateTo("/media/libraries"); NavigationManager.NavigateTo("/media/libraries");
} }
}); });

4
ErsatzTV/Pages/MultiSelectBase.cs

@ -107,7 +107,8 @@ namespace ErsatzTV.Pages
var request = new AddItemsToCollection( var request = new AddItemsToCollection(
collection.Id, collection.Id,
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(), _selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList()); _selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
Either<BaseError, Unit> addResult = await Mediator.Send(request); Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match( addResult.Match(
@ -144,6 +145,7 @@ namespace ErsatzTV.Pages
itemIds.AddRange(_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId)); itemIds.AddRange(_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId));
itemIds.AddRange(_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId)); itemIds.AddRange(_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId));
itemIds.AddRange(_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId)); itemIds.AddRange(_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId));
itemIds.AddRange(_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId));
await Mediator.Send( await Mediator.Send(
new RemoveItemsFromCollection(collectionId) new RemoveItemsFromCollection(collectionId)

164
ErsatzTV/Pages/MusicVideoList.razor

@ -0,0 +1,164 @@
@page "/media/music/videos"
@page "/media/music/videos/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">
@foreach (MusicVideoCardViewModel card in _data.Cards.Where(m => !string.IsNullOrWhiteSpace(m.Title)).OrderBy(m => m.SortTitle))
{
<MediaCard Data="@card"
Link=""
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
</MudContainer>
@if (_data.PageMap.IsSome)
{
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
BaseUri="/media/music/videos"
Query="@_query"/>
}
@code {
private static int PageSize => 100;
[Parameter]
public int PageNumber { get; set; }
private MusicVideoCardResultsViewModel _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:music_video" : $"type:music_video AND ({_query})";
_data = await Mediator.Send(new QuerySearchIndexMusicVideos(searchQuery, PageNumber, PageSize));
}
private void PrevPage()
{
var uri = $"/media/music/videos/page/{PageNumber - 1}";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
NavigationManager.NavigateTo(uri);
}
private void NextPage()
{
var uri = $"/media/music/videos/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 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));
}
}
}
}

79
ErsatzTV/Pages/Search.razor

@ -51,6 +51,15 @@
{ {
<MudText Class="ml-4">0 Shows</MudText> <MudText Class="ml-4">0 Shows</MudText>
} }
if (_musicVideos.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_musicVideos.Count Music Videos</MudLink>
}
else
{
<MudText Class="ml-4">0 Music Videos</MudText>
}
} }
</div> </div>
</MudPaper> </MudPaper>
@ -108,12 +117,40 @@
} }
</MudContainer> </MudContainer>
} }
@if (_musicVideos.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", "music_videos" } })">
Music Videos
</MudText>
@if (_shows.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MusicVideoCardViewModel card in _musicVideos.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link=""
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
</MudContainer> </MudContainer>
@code { @code {
private string _query; private string _query;
private MovieCardResultsViewModel _movies; private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows; private TelevisionShowCardResultsViewModel _shows;
private MusicVideoCardResultsViewModel _musicVideos;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@ -125,17 +162,16 @@
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50)); _movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50)); _shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
} }
} }
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{ {
List<MediaCardViewModel> GetSortedItems() List<MediaCardViewModel> GetSortedItems() => _movies.Cards.OrderBy(m => m.SortTitle)
{ .Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
return _movies.Cards.OrderBy(m => m.SortTitle) .Append(_musicVideos.Cards.OrderBy(s => s.SortTitle))
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle)) .ToList();
.ToList();
}
SelectClicked(GetSortedItems, card, e); SelectClicked(GetSortedItems, card, e);
} }
@ -183,6 +219,27 @@
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success)); Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
} }
} }
if (card is 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));
}
}
} }
private string GetMoviesLink() private string GetMoviesLink()
@ -205,4 +262,14 @@
return uri; return uri;
} }
private string GetMusicVideosLink()
{
var uri = "/media/music/videos/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
return uri;
}
} }

1
ErsatzTV/Shared/MainLayout.razor

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

4
ErsatzTV/Shared/MediaCard.razor

@ -3,7 +3,7 @@
@inject IMediator Mediator @inject IMediator Mediator
<div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")"> <div class="@((ContainerClass ?? "media-card-container mr-6") + " pb-3")" id="@($"item_{Data.MediaItemId}")">
@if (!string.IsNullOrWhiteSpace(Link)) @if (SelectClicked.HasDelegate || !string.IsNullOrWhiteSpace(Link))
{ {
<div class="@(IsSelected ? DeleteClicked.HasDelegate ? "media-card-selected-delete" : "media-card-selected" : "")" <div class="@(IsSelected ? DeleteClicked.HasDelegate ? "media-card-selected-delete" : "media-card-selected" : "")"
style="border-radius: 4px; position: relative;"> style="border-radius: 4px; position: relative;">
@ -26,7 +26,7 @@
<div class="media-card-overlay" style=""> <div class="media-card-overlay" style="">
<MudButton Link="@(IsSelectMode ? null : Link)" <MudButton Link="@(IsSelectMode ? null : Link)"
Style="height: 100%; width: 100%" Style="height: 100%; width: 100%"
OnClick="@(e => IsSelectMode ? SelectClicked.InvokeAsync(e) : Task.CompletedTask)"> OnClick="@(e => IsSelectMode || string.IsNullOrWhiteSpace(Link) ? SelectClicked.InvokeAsync(e) : Task.CompletedTask)">
</MudButton> </MudButton>
@if (SelectClicked.HasDelegate) @if (SelectClicked.HasDelegate)
{ {

2
ErsatzTV/Startup.cs

@ -198,6 +198,7 @@ namespace ErsatzTV
services.AddScoped<ITelevisionRepository, TelevisionRepository>(); services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<ISearchRepository, SearchRepository>(); services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>(); services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>(); services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>(); services.AddScoped<IMetadataRepository, MetadataRepository>();
services.AddScoped<IFFmpegLocator, FFmpegLocator>(); services.AddScoped<IFFmpegLocator, FFmpegLocator>();
@ -209,6 +210,7 @@ namespace ErsatzTV
services.AddScoped<ILocalFileSystem, LocalFileSystem>(); services.AddScoped<ILocalFileSystem, LocalFileSystem>();
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>(); services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>(); services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IMusicVideoFolderScanner, MusicVideoFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>(); services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>(); services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>(); services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();

Loading…
Cancel
Save