mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* 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 fieldspull/128/head v0.0.27-prealpha
56 changed files with 7572 additions and 112 deletions
@ -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); |
||||||
|
} |
||||||
@ -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; } |
||||||
|
} |
||||||
|
} |
||||||
@ -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>>; |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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>; |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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; } |
||||||
|
} |
||||||
|
} |
||||||
@ -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; } |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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) |
||||||
|
{ |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue