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 4 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. 3
      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. 46
      ErsatzTV/Pages/CollectionItems.razor
  50. 4
      ErsatzTV/Pages/LocalLibraryPathEditor.razor
  51. 4
      ErsatzTV/Pages/MultiSelectBase.cs
  52. 164
      ErsatzTV/Pages/MusicVideoList.razor
  53. 75
      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 @@ -18,7 +18,11 @@ namespace ErsatzTV.Application.Libraries.Queries
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_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) =>
library switch

3
ErsatzTV.Application/MediaCards/CollectionCardResultsViewModel.cs

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

10
ErsatzTV.Application/MediaCards/Mapper.cs

@ -52,6 +52,14 @@ namespace ErsatzTV.Application.MediaCards @@ -52,6 +52,14 @@ namespace ErsatzTV.Application.MediaCards
movieMetadata.SortTitle,
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
ProjectToViewModel(Collection collection) =>
new(
@ -64,6 +72,8 @@ namespace ErsatzTV.Application.MediaCards @@ -64,6 +72,8 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
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 };
private static int GetCustomIndex(Collection collection, int mediaItemId) =>

11
ErsatzTV.Application/MediaCards/MusicVideoCardResultsViewModel.cs

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

13
ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs

@ -0,0 +1,13 @@ @@ -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; @@ -5,5 +5,9 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
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 @@ -41,7 +41,7 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).ToList()))
request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository

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

@ -0,0 +1,8 @@ @@ -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 @@ @@ -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 @@ @@ -1,5 +1,4 @@
using System;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems @@ -8,59 +7,6 @@ namespace ErsatzTV.Application.MediaItems
internal static MediaItemViewModel ProjectToViewModel(MediaItem mediaItem) =>
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) =>
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 @@ -8,15 +8,24 @@ namespace ErsatzTV.Application.MediaSources.Commands
{
int LibraryId { get; }
bool ForceScan { get; }
bool Rescan { get; }
}
public record ScanLocalLibraryIfNeeded(int LibraryId) : IScanLocalLibrary
{
public bool ForceScan => false;
public bool Rescan => false;
}
public record ForceScanLocalLibrary(int LibraryId) : IScanLocalLibrary
{
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; @@ -16,13 +16,15 @@ using Unit = LanguageExt.Unit;
namespace ErsatzTV.Application.MediaSources.Commands
{
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 IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<ScanLocalLibraryHandler> _logger;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly IMusicVideoFolderScanner _musicVideoFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalLibraryHandler(
@ -30,6 +32,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -30,6 +32,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
IConfigElementRepository configElementRepository,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IMusicVideoFolderScanner musicVideoFolderScanner,
IEntityLocker entityLocker,
ILogger<ScanLocalLibraryHandler> logger)
{
@ -37,10 +40,15 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -37,10 +40,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
_configElementRepository = configElementRepository;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_musicVideoFolderScanner = musicVideoFolderScanner;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, string>> Handle(
ForceRescanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
public Task<Either<BaseError, string>> Handle(
ForceScanLocalLibrary request,
CancellationToken cancellationToken) => Handle(request);
@ -57,7 +65,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -57,7 +65,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
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);
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
@ -65,15 +73,20 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -65,15 +73,20 @@ namespace ErsatzTV.Application.MediaSources.Commands
var sw = new Stopwatch();
sw.Start();
DateTimeOffset effectiveLastScan = rescan ? DateTimeOffset.MinValue : lastScan;
foreach (LibraryPath libraryPath in localLibrary.Paths)
{
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, lastScan);
await _movieFolderScanner.ScanFolder(libraryPath, ffprobePath, effectiveLastScan);
break;
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;
}
}
@ -104,7 +117,8 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -104,7 +117,8 @@ namespace ErsatzTV.Application.MediaSources.Commands
(library, ffprobePath) => new RequestParameters(
library,
ffprobePath,
request.ForceScan));
request.ForceScan,
request.Rescan));
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
IScanLocalLibrary request) =>
@ -119,6 +133,6 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -119,6 +133,6 @@ namespace ErsatzTV.Application.MediaSources.Commands
ffprobePath =>
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 @@ -36,6 +36,9 @@ namespace ErsatzTV.Application.Playouts
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
@ -47,6 +50,7 @@ namespace ErsatzTV.Application.Playouts @@ -47,6 +50,7 @@ namespace ErsatzTV.Application.Playouts
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

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

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

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

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

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

@ -13,16 +13,6 @@ namespace ErsatzTV.Core.Domain @@ -13,16 +13,6 @@ namespace ErsatzTV.Core.Domain
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { 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 DateTime DateAdded { get; set; }
public DateTime DateUpdated { get; set; }

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

@ -0,0 +1,10 @@ @@ -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 @@ @@ -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; }
}
}

3
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

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

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

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

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

@ -0,0 +1,12 @@ @@ -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 @@ @@ -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 @@ -221,6 +221,8 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]"),
_ => "[unknown]"
};
}
@ -253,6 +255,8 @@ namespace ErsatzTV.Core.Iptv @@ -253,6 +255,8 @@ namespace ErsatzTV.Core.Iptv
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)
.IfNone(string.Empty),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => mvm.Plot ?? string.Empty)
.IfNone(string.Empty),
_ => string.Empty
};
}

1
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

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

199
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -18,18 +18,21 @@ namespace ErsatzTV.Core.Metadata @@ -18,18 +18,21 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(
IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
ILogger<LocalMetadataProvider> logger)
@ -37,6 +40,7 @@ namespace ErsatzTV.Core.Metadata @@ -37,6 +40,7 @@ namespace ErsatzTV.Core.Metadata
_metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
_logger = logger;
@ -65,6 +69,23 @@ namespace ErsatzTV.Core.Metadata @@ -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) =>
mediaItem switch
{
@ -78,6 +99,11 @@ namespace ErsatzTV.Core.Metadata @@ -78,6 +99,11 @@ namespace ErsatzTV.Core.Metadata
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(m, metadata),
() => Task.FromResult(false))),
MusicVideo mv => LoadMetadata(mv, path)
.Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(mv, metadata),
() => Task.FromResult(false))),
_ => Task.FromResult(false)
};
@ -98,6 +124,37 @@ namespace ErsatzTV.Core.Metadata @@ -98,6 +124,37 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string 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)
{
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
@ -344,6 +401,106 @@ namespace ErsatzTV.Core.Metadata @@ -344,6 +401,106 @@ namespace ErsatzTV.Core.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)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
@ -377,6 +534,17 @@ namespace ErsatzTV.Core.Metadata @@ -377,6 +534,17 @@ namespace ErsatzTV.Core.Metadata
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)
{
try
@ -589,5 +757,36 @@ namespace ErsatzTV.Core.Metadata @@ -589,5 +757,36 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("plot")]
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 @@ -37,6 +37,7 @@ namespace ErsatzTV.Core.Metadata
{
Movie m => m.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))
};
@ -63,6 +64,7 @@ namespace ErsatzTV.Core.Metadata @@ -63,6 +64,7 @@ namespace ErsatzTV.Core.Metadata
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
MusicVideo mv => mv.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};

225
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -0,0 +1,225 @@ @@ -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 @@ -48,6 +48,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue
};
@ -59,6 +62,9 @@ namespace ErsatzTV.Core.Scheduling @@ -59,6 +62,9 @@ namespace ErsatzTV.Core.Scheduling
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => mvm.ReleaseDate ?? DateTime.MaxValue,
() => DateTime.MaxValue),
_ => DateTime.MaxValue
};

8
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

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

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

@ -0,0 +1,23 @@ @@ -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 @@ @@ -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 @@ -52,7 +52,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Library>> GetAll()
{
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(

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

@ -104,6 +104,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -104,6 +104,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Movie).MovieMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Season).Show)
@ -128,6 +130,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -128,6 +130,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.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(sm => sm.Artwork)
.Include(c => c.MediaItems)
@ -196,6 +201,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -196,6 +201,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItems(collection));
result.AddRange(await GetSeasonItems(collection));
result.AddRange(await GetEpisodeItems(collection));
result.AddRange(await GetMusicVideoItems(collection));
return result.Distinct().ToList();
}
@ -215,6 +221,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -215,6 +221,21 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.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)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(

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

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

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

@ -0,0 +1,181 @@ @@ -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 @@ -65,6 +65,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.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()
.SingleOrDefaultAsync()
.Map(Optional);
@ -89,6 +95,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -89,6 +95,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Movie).MediaVersions)
.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(em => em.Artwork)
.Include(i => i.MediaItem)

2
ErsatzTV.Infrastructure/Data/TvContext.cs

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

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

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

68
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -29,6 +29,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -29,6 +29,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string IdField = "id";
private const string TypeField = "type";
private const string ArtistField = "artist";
private const string TitleField = "title";
private const string SortTitleField = "sort_title";
private const string GenreField = "genre";
@ -42,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search @@ -42,6 +43,7 @@ namespace ErsatzTV.Infrastructure.Search
private const string MovieType = "movie";
private const string ShowType = "show";
private const string MusicVideoType = "music_video";
private static bool _isRebuilding;
@ -93,6 +95,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -93,6 +95,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
UpdateShow(show, writer);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
break;
}
}
}
@ -121,6 +126,9 @@ namespace ErsatzTV.Infrastructure.Search @@ -121,6 +126,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
UpdateShow(show, writer);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo, writer);
break;
}
}
@ -338,6 +346,66 @@ namespace ErsatzTV.Infrastructure.Search @@ -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 Query ParseQuery(string searchQuery, QueryParser parser)

46
ErsatzTV/Pages/CollectionItems.razor

@ -52,6 +52,10 @@ @@ -52,6 +52,10 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
@if (_data.MusicVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
}
@if (SupportsCustomOrdering())
{
<div style="margin-left: auto">
@ -165,6 +169,29 @@ @@ -165,6 +169,29 @@
}
</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>
@code {
@ -215,14 +242,12 @@ @@ -215,14 +242,12 @@
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
List<MediaCardViewModel> GetSortedItems() => _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
@ -240,6 +265,19 @@ @@ -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)
{
if (vm is TelevisionShowCardViewModel show)

4
ErsatzTV/Pages/LocalLibraryPathEditor.razor

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

4
ErsatzTV/Pages/MultiSelectBase.cs

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

164
ErsatzTV/Pages/MusicVideoList.razor

@ -0,0 +1,164 @@ @@ -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));
}
}
}
}

75
ErsatzTV/Pages/Search.razor

@ -51,6 +51,15 @@ @@ -51,6 +51,15 @@
{
<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>
</MudPaper>
@ -108,12 +117,40 @@ @@ -108,12 +117,40 @@
}
</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>
@code {
private string _query;
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private MusicVideoCardResultsViewModel _musicVideos;
protected override async Task OnInitializedAsync()
{
@ -125,17 +162,16 @@ @@ -125,17 +162,16 @@
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
}
}
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _movies.Cards.OrderBy(m => m.SortTitle)
List<MediaCardViewModel> GetSortedItems() => _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_musicVideos.Cards.OrderBy(s => s.SortTitle))
.ToList();
}
SelectClicked(GetSortedItems, card, e);
}
@ -183,6 +219,27 @@ @@ -183,6 +219,27 @@
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()
@ -205,4 +262,14 @@ @@ -205,4 +262,14 @@
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 @@ @@ -47,6 +47,7 @@
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/videos">Music Videos</MudNavLink>
<MudNavLink Href="/media/collections">Collections</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>

4
ErsatzTV/Shared/MediaCard.razor

@ -3,7 +3,7 @@ @@ -3,7 +3,7 @@
@inject IMediator Mediator
<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" : "")"
style="border-radius: 4px; position: relative;">
@ -26,7 +26,7 @@ @@ -26,7 +26,7 @@
<div class="media-card-overlay" style="">
<MudButton Link="@(IsSelectMode ? null : Link)"
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>
@if (SelectClicked.HasDelegate)
{

2
ErsatzTV/Startup.cs

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

Loading…
Cancel
Save