mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* first pass at adding song libraries * start handling optional video * fix song playback * fix song transitions * add songs page to UIpull/491/head
63 changed files with 13234 additions and 186 deletions
@ -0,0 +1,11 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
using ErsatzTV.Core.Search; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCards |
||||||
|
{ |
||||||
|
public record SongCardResultsViewModel( |
||||||
|
int Count, |
||||||
|
List<SongCardViewModel> Cards, |
||||||
|
Option<SearchPageMap> PageMap); |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
namespace ErsatzTV.Application.MediaCards |
||||||
|
{ |
||||||
|
public record SongCardViewModel |
||||||
|
( |
||||||
|
int SongId, |
||||||
|
string Title, |
||||||
|
string Subtitle, |
||||||
|
string SortTitle) : MediaCardViewModel( |
||||||
|
SongId, |
||||||
|
Title, |
||||||
|
Subtitle, |
||||||
|
SortTitle, |
||||||
|
null) |
||||||
|
{ |
||||||
|
public int CustomIndex { get; set; } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
using ErsatzTV.Core; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands |
||||||
|
{ |
||||||
|
public record AddSongToCollection |
||||||
|
(int CollectionId, int SongId) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
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 ErsatzTV.Infrastructure.Data; |
||||||
|
using ErsatzTV.Infrastructure.Extensions; |
||||||
|
using LanguageExt; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.MediaCollections.Commands |
||||||
|
{ |
||||||
|
public class AddSongToCollectionHandler : |
||||||
|
MediatR.IRequestHandler<AddSongToCollection, Either<BaseError, Unit>> |
||||||
|
{ |
||||||
|
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
private readonly IMediaCollectionRepository _mediaCollectionRepository; |
||||||
|
|
||||||
|
public AddSongToCollectionHandler( |
||||||
|
IDbContextFactory<TvContext> dbContextFactory, |
||||||
|
IMediaCollectionRepository mediaCollectionRepository, |
||||||
|
ChannelWriter<IBackgroundServiceRequest> channel) |
||||||
|
{ |
||||||
|
_dbContextFactory = dbContextFactory; |
||||||
|
_mediaCollectionRepository = mediaCollectionRepository; |
||||||
|
_channel = channel; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> Handle( |
||||||
|
AddSongToCollection request, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||||
|
Validation<BaseError, Parameters> validation = await Validate(dbContext, request); |
||||||
|
return await validation.Apply(parameters => ApplyAddSongRequest(dbContext, parameters)); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Unit> ApplyAddSongRequest(TvContext dbContext, Parameters parameters) |
||||||
|
{ |
||||||
|
parameters.Collection.MediaItems.Add(parameters.Song); |
||||||
|
if (await dbContext.SaveChangesAsync() > 0) |
||||||
|
{ |
||||||
|
// rebuild all playouts that use this collection
|
||||||
|
foreach (int playoutId in await _mediaCollectionRepository |
||||||
|
.PlayoutIdsUsingCollection(parameters.Collection.Id)) |
||||||
|
{ |
||||||
|
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task<Validation<BaseError, Parameters>> Validate( |
||||||
|
TvContext dbContext, |
||||||
|
AddSongToCollection request) => |
||||||
|
(await CollectionMustExist(dbContext, request), await ValidateSong(dbContext, request)) |
||||||
|
.Apply((collection, episode) => new Parameters(collection, episode)); |
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Collection>> CollectionMustExist( |
||||||
|
TvContext dbContext, |
||||||
|
AddSongToCollection request) => |
||||||
|
dbContext.Collections |
||||||
|
.Include(c => c.MediaItems) |
||||||
|
.SelectOneAsync(c => c.Id, c => c.Id == request.CollectionId) |
||||||
|
.Map(o => o.ToValidation<BaseError>("Collection does not exist.")); |
||||||
|
|
||||||
|
private static Task<Validation<BaseError, Song>> ValidateSong( |
||||||
|
TvContext dbContext, |
||||||
|
AddSongToCollection request) => |
||||||
|
dbContext.Songs |
||||||
|
.SelectOneAsync(m => m.Id, e => e.Id == request.SongId) |
||||||
|
.Map(o => o.ToValidation<BaseError>("Song does not exist")); |
||||||
|
|
||||||
|
private record Parameters(Collection Collection, Song Song); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
using ErsatzTV.Application.MediaCards; |
||||||
|
using MediatR; |
||||||
|
|
||||||
|
namespace ErsatzTV.Application.Search.Queries |
||||||
|
{ |
||||||
|
public record QuerySearchIndexSongs |
||||||
|
(string Query, int PageNumber, int PageSize) : IRequest<SongCardResultsViewModel>; |
||||||
|
} |
||||||
@ -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 |
||||||
|
QuerySearchIndexSongsHandler : IRequestHandler<QuerySearchIndexSongs, |
||||||
|
SongCardResultsViewModel> |
||||||
|
{ |
||||||
|
private readonly ISongRepository _songRepository; |
||||||
|
private readonly ISearchIndex _searchIndex; |
||||||
|
|
||||||
|
public QuerySearchIndexSongsHandler(ISearchIndex searchIndex, ISongRepository songRepository) |
||||||
|
{ |
||||||
|
_searchIndex = searchIndex; |
||||||
|
_songRepository = songRepository; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<SongCardResultsViewModel> Handle( |
||||||
|
QuerySearchIndexSongs request, |
||||||
|
CancellationToken cancellationToken) |
||||||
|
{ |
||||||
|
SearchResult searchResult = await _searchIndex.Search( |
||||||
|
request.Query, |
||||||
|
(request.PageNumber - 1) * request.PageSize, |
||||||
|
request.PageSize); |
||||||
|
|
||||||
|
List<SongCardViewModel> items = await _songRepository |
||||||
|
.GetSongsForCards(searchResult.Items.Map(i => i.Id).ToList()) |
||||||
|
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||||
|
|
||||||
|
return new SongCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
using System.Collections.Generic; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Domain |
||||||
|
{ |
||||||
|
public class Song : MediaItem |
||||||
|
{ |
||||||
|
public List<SongMetadata> SongMetadata { get; set; } |
||||||
|
public List<MediaVersion> MediaVersions { get; set; } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
namespace ErsatzTV.Core.Domain |
||||||
|
{ |
||||||
|
public class SongMetadata : Metadata |
||||||
|
{ |
||||||
|
public int SongId { get; set; } |
||||||
|
public Song Song { get; set; } |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
using System; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Extensions |
||||||
|
{ |
||||||
|
public static class MediaItemExtensions |
||||||
|
{ |
||||||
|
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) => |
||||||
|
mediaItem switch |
||||||
|
{ |
||||||
|
Movie m => m.MediaVersions.Head(), |
||||||
|
Episode e => e.MediaVersions.Head(), |
||||||
|
MusicVideo mv => mv.MediaVersions.Head(), |
||||||
|
OtherVideo ov => ov.MediaVersions.Head(), |
||||||
|
Song s => s.MediaVersions.Head(), |
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem)) |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.FFmpeg |
||||||
|
{ |
||||||
|
public record WatermarkOptions(Option<ChannelWatermark> Watermark, Option<string> ImagePath, bool IsAnimated); |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
using System.Threading.Tasks; |
||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using LanguageExt; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Interfaces.Metadata |
||||||
|
{ |
||||||
|
public interface ISongFolderScanner |
||||||
|
{ |
||||||
|
Task<Either<BaseError, Unit>> ScanFolder( |
||||||
|
LibraryPath libraryPath, |
||||||
|
string ffprobePath, |
||||||
|
decimal progressMin, |
||||||
|
decimal progressMax); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
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 ISongRepository |
||||||
|
{ |
||||||
|
Task<Either<BaseError, MediaItemScanResult<Song>>> GetOrAdd(LibraryPath libraryPath, string path); |
||||||
|
Task<IEnumerable<string>> FindSongPaths(LibraryPath libraryPath); |
||||||
|
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path); |
||||||
|
Task<bool> AddTag(SongMetadata metadata, Tag tag); |
||||||
|
Task<List<SongMetadata>> GetSongsForCards(List<int> ids); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,195 @@ |
|||||||
|
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 MediatR; |
||||||
|
using Microsoft.Extensions.Logging; |
||||||
|
using static LanguageExt.Prelude; |
||||||
|
using Unit = LanguageExt.Unit; |
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Metadata |
||||||
|
{ |
||||||
|
public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner |
||||||
|
{ |
||||||
|
private readonly ILocalFileSystem _localFileSystem; |
||||||
|
private readonly ILocalMetadataProvider _localMetadataProvider; |
||||||
|
private readonly IMediator _mediator; |
||||||
|
private readonly ISearchIndex _searchIndex; |
||||||
|
private readonly ISearchRepository _searchRepository; |
||||||
|
private readonly ISongRepository _songRepository; |
||||||
|
private readonly ILibraryRepository _libraryRepository; |
||||||
|
private readonly ILogger<SongFolderScanner> _logger; |
||||||
|
|
||||||
|
public SongFolderScanner( |
||||||
|
ILocalFileSystem localFileSystem, |
||||||
|
ILocalStatisticsProvider localStatisticsProvider, |
||||||
|
ILocalMetadataProvider localMetadataProvider, |
||||||
|
IMetadataRepository metadataRepository, |
||||||
|
IImageCache imageCache, |
||||||
|
IMediator mediator, |
||||||
|
ISearchIndex searchIndex, |
||||||
|
ISearchRepository searchRepository, |
||||||
|
ISongRepository songRepository, |
||||||
|
ILibraryRepository libraryRepository, |
||||||
|
ILogger<SongFolderScanner> logger) : base( |
||||||
|
localFileSystem, |
||||||
|
localStatisticsProvider, |
||||||
|
metadataRepository, |
||||||
|
imageCache, |
||||||
|
logger) |
||||||
|
{ |
||||||
|
_localFileSystem = localFileSystem; |
||||||
|
_localMetadataProvider = localMetadataProvider; |
||||||
|
_mediator = mediator; |
||||||
|
_searchIndex = searchIndex; |
||||||
|
_searchRepository = searchRepository; |
||||||
|
_songRepository = songRepository; |
||||||
|
_libraryRepository = libraryRepository; |
||||||
|
_logger = logger; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Either<BaseError, Unit>> ScanFolder( |
||||||
|
LibraryPath libraryPath, |
||||||
|
string ffprobePath, |
||||||
|
decimal progressMin, |
||||||
|
decimal progressMax) |
||||||
|
{ |
||||||
|
decimal progressSpread = progressMax - progressMin; |
||||||
|
|
||||||
|
if (!_localFileSystem.IsLibraryPathAccessible(libraryPath)) |
||||||
|
{ |
||||||
|
return new MediaSourceInaccessible(); |
||||||
|
} |
||||||
|
|
||||||
|
var foldersCompleted = 0; |
||||||
|
|
||||||
|
var folderQueue = new Queue<string>(); |
||||||
|
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path) |
||||||
|
.Filter(ShouldIncludeFolder) |
||||||
|
.OrderBy(identity)) |
||||||
|
{ |
||||||
|
folderQueue.Enqueue(folder); |
||||||
|
} |
||||||
|
|
||||||
|
while (folderQueue.Count > 0) |
||||||
|
{ |
||||||
|
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); |
||||||
|
await _mediator.Publish( |
||||||
|
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread)); |
||||||
|
|
||||||
|
string songFolder = folderQueue.Dequeue(); |
||||||
|
foldersCompleted++; |
||||||
|
|
||||||
|
var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList(); |
||||||
|
|
||||||
|
var allFiles = filesForEtag |
||||||
|
.Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f))) |
||||||
|
.Filter(f => !Path.GetFileName(f).StartsWith("._")) |
||||||
|
.ToList(); |
||||||
|
|
||||||
|
foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder) |
||||||
|
.Filter(ShouldIncludeFolder) |
||||||
|
.OrderBy(identity)) |
||||||
|
{ |
||||||
|
folderQueue.Enqueue(subdirectory); |
||||||
|
} |
||||||
|
|
||||||
|
string etag = FolderEtag.Calculate(songFolder, _localFileSystem); |
||||||
|
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders |
||||||
|
.Filter(f => f.Path == songFolder) |
||||||
|
.HeadOrNone(); |
||||||
|
|
||||||
|
// skip folder if etag matches
|
||||||
|
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag) |
||||||
|
{ |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
_logger.LogDebug( |
||||||
|
"UPDATE: Etag has changed for folder {Folder}", |
||||||
|
songFolder); |
||||||
|
|
||||||
|
foreach (string file in allFiles.OrderBy(identity)) |
||||||
|
{ |
||||||
|
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository |
||||||
|
.GetOrAdd(libraryPath, file) |
||||||
|
.BindT(video => UpdateStatistics(video, ffprobePath)) |
||||||
|
.BindT(UpdateMetadata); |
||||||
|
|
||||||
|
await maybeSong.Match( |
||||||
|
async result => |
||||||
|
{ |
||||||
|
if (result.IsAdded) |
||||||
|
{ |
||||||
|
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item }); |
||||||
|
} |
||||||
|
else if (result.IsUpdated) |
||||||
|
{ |
||||||
|
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item }); |
||||||
|
} |
||||||
|
|
||||||
|
await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag); |
||||||
|
}, |
||||||
|
error => |
||||||
|
{ |
||||||
|
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value); |
||||||
|
return Task.CompletedTask; |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
foreach (string path in await _songRepository.FindSongPaths(libraryPath)) |
||||||
|
{ |
||||||
|
if (!_localFileSystem.FileExists(path)) |
||||||
|
{ |
||||||
|
_logger.LogInformation("Removing missing song at {Path}", path); |
||||||
|
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path); |
||||||
|
await _searchIndex.RemoveItems(songIds); |
||||||
|
} |
||||||
|
else if (Path.GetFileName(path).StartsWith("._")) |
||||||
|
{ |
||||||
|
_logger.LogInformation("Removing dot underscore file at {Path}", path); |
||||||
|
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path); |
||||||
|
await _searchIndex.RemoveItems(songIds); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
_searchIndex.Commit(); |
||||||
|
return Unit.Default; |
||||||
|
} |
||||||
|
|
||||||
|
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata( |
||||||
|
MediaItemScanResult<Song> result) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
Song song = result.Item; |
||||||
|
if (!Optional(song.SongMetadata).Flatten().Any()) |
||||||
|
{ |
||||||
|
song.SongMetadata ??= new List<SongMetadata>(); |
||||||
|
|
||||||
|
string path = song.MediaVersions.Head().MediaFiles.Head().Path; |
||||||
|
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path); |
||||||
|
if (await _localMetadataProvider.RefreshFallbackMetadata(song)) |
||||||
|
{ |
||||||
|
result.IsUpdated = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
return BaseError.New(ex.ToString()); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||||
|
{ |
||||||
|
public class SongConfiguration : IEntityTypeConfiguration<Song> |
||||||
|
{ |
||||||
|
public void Configure(EntityTypeBuilder<Song> builder) |
||||||
|
{ |
||||||
|
builder.ToTable("Song"); |
||||||
|
|
||||||
|
builder.HasMany(m => m.SongMetadata) |
||||||
|
.WithOne(m => m.Song) |
||||||
|
.HasForeignKey(m => m.SongId) |
||||||
|
.OnDelete(DeleteBehavior.Cascade); |
||||||
|
|
||||||
|
builder.HasMany(m => m.MediaVersions) |
||||||
|
.WithOne() |
||||||
|
.OnDelete(DeleteBehavior.Cascade); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
using ErsatzTV.Core.Domain; |
||||||
|
using Microsoft.EntityFrameworkCore; |
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||||
|
{ |
||||||
|
public class SongMetadataConfiguration : IEntityTypeConfiguration<SongMetadata> |
||||||
|
{ |
||||||
|
public void Configure(EntityTypeBuilder<SongMetadata> builder) |
||||||
|
{ |
||||||
|
builder.ToTable("SongMetadata"); |
||||||
|
|
||||||
|
builder.HasMany(mm => mm.Artwork) |
||||||
|
.WithOne() |
||||||
|
.OnDelete(DeleteBehavior.Cascade); |
||||||
|
|
||||||
|
builder.HasMany(mm => mm.Tags) |
||||||
|
.WithOne() |
||||||
|
.OnDelete(DeleteBehavior.Cascade); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,146 @@ |
|||||||
|
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 SongRepository : ISongRepository |
||||||
|
{ |
||||||
|
private readonly IDbConnection _dbConnection; |
||||||
|
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||||
|
|
||||||
|
public SongRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory) |
||||||
|
{ |
||||||
|
_dbConnection = dbConnection; |
||||||
|
_dbContextFactory = dbContextFactory; |
||||||
|
} |
||||||
|
|
||||||
|
public async Task<Either<BaseError, MediaItemScanResult<Song>>> GetOrAdd( |
||||||
|
LibraryPath libraryPath, |
||||||
|
string path) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); |
||||||
|
Option<Song> maybeExisting = await dbContext.Songs |
||||||
|
.AsNoTracking() |
||||||
|
.Include(ov => ov.SongMetadata) |
||||||
|
.ThenInclude(ovm => ovm.Artwork) |
||||||
|
.Include(ov => ov.SongMetadata) |
||||||
|
.ThenInclude(ovm => ovm.Tags) |
||||||
|
.Include(ov => ov.LibraryPath) |
||||||
|
.ThenInclude(lp => lp.Library) |
||||||
|
.Include(ov => ov.MediaVersions) |
||||||
|
.ThenInclude(ov => ov.MediaFiles) |
||||||
|
.Include(ov => ov.MediaVersions) |
||||||
|
.ThenInclude(ov => ov.Streams) |
||||||
|
.Include(ov => ov.TraktListItems) |
||||||
|
.ThenInclude(tli => tli.TraktList) |
||||||
|
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path) |
||||||
|
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path); |
||||||
|
|
||||||
|
return await maybeExisting.Match( |
||||||
|
mediaItem => |
||||||
|
Right<BaseError, MediaItemScanResult<Song>>( |
||||||
|
new MediaItemScanResult<Song>(mediaItem) { IsAdded = false }).AsTask(), |
||||||
|
async () => await AddSong(dbContext, libraryPath.Id, path)); |
||||||
|
} |
||||||
|
|
||||||
|
public Task<IEnumerable<string>> FindSongPaths(LibraryPath libraryPath) => |
||||||
|
_dbConnection.QueryAsync<string>( |
||||||
|
@"SELECT MF.Path
|
||||||
|
FROM MediaFile MF |
||||||
|
INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id |
||||||
|
INNER JOIN Song O on MV.SongId = O.Id |
||||||
|
INNER JOIN MediaItem MI on O.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 O.Id
|
||||||
|
FROM Song O |
||||||
|
INNER JOIN MediaItem MI on O.Id = MI.Id |
||||||
|
INNER JOIN MediaVersion MV on O.Id = MV.SongId |
||||||
|
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 = await _dbContextFactory.CreateDbContextAsync(); |
||||||
|
foreach (int songId in ids) |
||||||
|
{ |
||||||
|
Song song = await dbContext.Songs.FindAsync(songId); |
||||||
|
if (song != null) |
||||||
|
{ |
||||||
|
dbContext.Songs.Remove(song); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await dbContext.SaveChangesAsync(); |
||||||
|
|
||||||
|
return ids; |
||||||
|
} |
||||||
|
|
||||||
|
public Task<bool> AddTag(SongMetadata metadata, Tag tag) => |
||||||
|
_dbConnection.ExecuteAsync( |
||||||
|
"INSERT INTO Tag (Name, SongMetadataId) VALUES (@Name, @MetadataId)", |
||||||
|
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0); |
||||||
|
|
||||||
|
public async Task<List<SongMetadata>> GetSongsForCards(List<int> ids) |
||||||
|
{ |
||||||
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); |
||||||
|
return await dbContext.SongMetadata |
||||||
|
.AsNoTracking() |
||||||
|
.Filter(ovm => ids.Contains(ovm.SongId)) |
||||||
|
.Include(ovm => ovm.Song) |
||||||
|
.Include(ovm => ovm.Artwork) |
||||||
|
.OrderBy(ovm => ovm.SortTitle) |
||||||
|
.ToListAsync(); |
||||||
|
} |
||||||
|
|
||||||
|
private static async Task<Either<BaseError, MediaItemScanResult<Song>>> AddSong( |
||||||
|
TvContext dbContext, |
||||||
|
int libraryPathId, |
||||||
|
string path) |
||||||
|
{ |
||||||
|
try |
||||||
|
{ |
||||||
|
var song = new Song |
||||||
|
{ |
||||||
|
LibraryPathId = libraryPathId, |
||||||
|
MediaVersions = new List<MediaVersion> |
||||||
|
{ |
||||||
|
new() |
||||||
|
{ |
||||||
|
MediaFiles = new List<MediaFile> |
||||||
|
{ |
||||||
|
new() { Path = path } |
||||||
|
}, |
||||||
|
Streams = new List<MediaStream>() |
||||||
|
} |
||||||
|
}, |
||||||
|
TraktListItems = new List<TraktListItem>() |
||||||
|
}; |
||||||
|
|
||||||
|
await dbContext.Songs.AddAsync(song); |
||||||
|
await dbContext.SaveChangesAsync(); |
||||||
|
await dbContext.Entry(song).Reference(m => m.LibraryPath).LoadAsync(); |
||||||
|
await dbContext.Entry(song.LibraryPath).Reference(lp => lp.Library).LoadAsync(); |
||||||
|
return new MediaItemScanResult<Song>(song) { IsAdded = true }; |
||||||
|
} |
||||||
|
catch (Exception ex) |
||||||
|
{ |
||||||
|
return BaseError.New(ex.Message); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,23 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_LocalLibrary_Songs : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
// create local songs library
|
||||||
|
migrationBuilder.Sql( |
||||||
|
@"INSERT INTO Library (Name, MediaKind, MediaSourceId)
|
||||||
|
SELECT 'Songs', 5, Id FROM |
||||||
|
(SELECT LMS.Id FROM LocalMediaSource LMS LIMIT 1)");
|
||||||
|
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,285 @@ |
|||||||
|
using System; |
||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_Songs_SongMetadata : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Tag", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Studio", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "MetadataGuid", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongId", |
||||||
|
table: "MediaVersion", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Genre", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Artwork", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Actor", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: true); |
||||||
|
|
||||||
|
migrationBuilder.CreateTable( |
||||||
|
name: "Song", |
||||||
|
columns: table => new |
||||||
|
{ |
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false) |
||||||
|
.Annotation("Sqlite:Autoincrement", true) |
||||||
|
}, |
||||||
|
constraints: table => |
||||||
|
{ |
||||||
|
table.PrimaryKey("PK_Song", x => x.Id); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_Song_MediaItem_Id", |
||||||
|
column: x => x.Id, |
||||||
|
principalTable: "MediaItem", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
}); |
||||||
|
|
||||||
|
migrationBuilder.CreateTable( |
||||||
|
name: "SongMetadata", |
||||||
|
columns: table => new |
||||||
|
{ |
||||||
|
Id = table.Column<int>(type: "INTEGER", nullable: false) |
||||||
|
.Annotation("Sqlite:Autoincrement", true), |
||||||
|
SongId = table.Column<int>(type: "INTEGER", nullable: false), |
||||||
|
MetadataKind = table.Column<int>(type: "INTEGER", nullable: false), |
||||||
|
Title = table.Column<string>(type: "TEXT", nullable: true), |
||||||
|
OriginalTitle = table.Column<string>(type: "TEXT", nullable: true), |
||||||
|
SortTitle = table.Column<string>(type: "TEXT", nullable: true), |
||||||
|
Year = table.Column<int>(type: "INTEGER", nullable: true), |
||||||
|
ReleaseDate = table.Column<DateTime>(type: "TEXT", nullable: true), |
||||||
|
DateAdded = table.Column<DateTime>(type: "TEXT", nullable: false), |
||||||
|
DateUpdated = table.Column<DateTime>(type: "TEXT", nullable: false) |
||||||
|
}, |
||||||
|
constraints: table => |
||||||
|
{ |
||||||
|
table.PrimaryKey("PK_SongMetadata", x => x.Id); |
||||||
|
table.ForeignKey( |
||||||
|
name: "FK_SongMetadata_Song_SongId", |
||||||
|
column: x => x.SongId, |
||||||
|
principalTable: "Song", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
}); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Tag_SongMetadataId", |
||||||
|
table: "Tag", |
||||||
|
column: "SongMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Studio_SongMetadataId", |
||||||
|
table: "Studio", |
||||||
|
column: "SongMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_MetadataGuid_SongMetadataId", |
||||||
|
table: "MetadataGuid", |
||||||
|
column: "SongMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_MediaVersion_SongId", |
||||||
|
table: "MediaVersion", |
||||||
|
column: "SongId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Genre_SongMetadataId", |
||||||
|
table: "Genre", |
||||||
|
column: "SongMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Artwork_SongMetadataId", |
||||||
|
table: "Artwork", |
||||||
|
column: "SongMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_Actor_SongMetadataId", |
||||||
|
table: "Actor", |
||||||
|
column: "SongMetadataId"); |
||||||
|
|
||||||
|
migrationBuilder.CreateIndex( |
||||||
|
name: "IX_SongMetadata_SongId", |
||||||
|
table: "SongMetadata", |
||||||
|
column: "SongId"); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_Actor_SongMetadata_SongMetadataId", |
||||||
|
table: "Actor", |
||||||
|
column: "SongMetadataId", |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_Artwork_SongMetadata_SongMetadataId", |
||||||
|
table: "Artwork", |
||||||
|
column: "SongMetadataId", |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_Genre_SongMetadata_SongMetadataId", |
||||||
|
table: "Genre", |
||||||
|
column: "SongMetadataId", |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_MediaVersion_Song_SongId", |
||||||
|
table: "MediaVersion", |
||||||
|
column: "SongId", |
||||||
|
principalTable: "Song", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_MetadataGuid_SongMetadata_SongMetadataId", |
||||||
|
table: "MetadataGuid", |
||||||
|
column: "SongMetadataId", |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_Studio_SongMetadata_SongMetadataId", |
||||||
|
table: "Studio", |
||||||
|
column: "SongMetadataId", |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id"); |
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey( |
||||||
|
name: "FK_Tag_SongMetadata_SongMetadataId", |
||||||
|
table: "Tag", |
||||||
|
column: "SongMetadataId", |
||||||
|
principalTable: "SongMetadata", |
||||||
|
principalColumn: "Id", |
||||||
|
onDelete: ReferentialAction.Cascade); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_Actor_SongMetadata_SongMetadataId", |
||||||
|
table: "Actor"); |
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_Artwork_SongMetadata_SongMetadataId", |
||||||
|
table: "Artwork"); |
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_Genre_SongMetadata_SongMetadataId", |
||||||
|
table: "Genre"); |
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_MediaVersion_Song_SongId", |
||||||
|
table: "MediaVersion"); |
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_MetadataGuid_SongMetadata_SongMetadataId", |
||||||
|
table: "MetadataGuid"); |
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_Studio_SongMetadata_SongMetadataId", |
||||||
|
table: "Studio"); |
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey( |
||||||
|
name: "FK_Tag_SongMetadata_SongMetadataId", |
||||||
|
table: "Tag"); |
||||||
|
|
||||||
|
migrationBuilder.DropTable( |
||||||
|
name: "SongMetadata"); |
||||||
|
|
||||||
|
migrationBuilder.DropTable( |
||||||
|
name: "Song"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_Tag_SongMetadataId", |
||||||
|
table: "Tag"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_Studio_SongMetadataId", |
||||||
|
table: "Studio"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_MetadataGuid_SongMetadataId", |
||||||
|
table: "MetadataGuid"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_MediaVersion_SongId", |
||||||
|
table: "MediaVersion"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_Genre_SongMetadataId", |
||||||
|
table: "Genre"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_Artwork_SongMetadataId", |
||||||
|
table: "Artwork"); |
||||||
|
|
||||||
|
migrationBuilder.DropIndex( |
||||||
|
name: "IX_Actor_SongMetadataId", |
||||||
|
table: "Actor"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Tag"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Studio"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "MetadataGuid"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongId", |
||||||
|
table: "MediaVersion"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Genre"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Artwork"); |
||||||
|
|
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "SongMetadataId", |
||||||
|
table: "Actor"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations; |
||||||
|
|
||||||
|
#nullable disable |
||||||
|
|
||||||
|
namespace ErsatzTV.Infrastructure.Migrations |
||||||
|
{ |
||||||
|
public partial class Add_StreamAttachedPic : Migration |
||||||
|
{ |
||||||
|
protected override void Up(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.AddColumn<bool>( |
||||||
|
name: "AttachedPic", |
||||||
|
table: "MediaStream", |
||||||
|
type: "INTEGER", |
||||||
|
nullable: false, |
||||||
|
defaultValue: false); |
||||||
|
} |
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder) |
||||||
|
{ |
||||||
|
migrationBuilder.DropColumn( |
||||||
|
name: "AttachedPic", |
||||||
|
table: "MediaStream"); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,158 @@ |
|||||||
|
@page "/music/songs" |
||||||
|
@page "/music/songs/page/{PageNumber:int}" |
||||||
|
@using LanguageExt.UnsafeValueAccess |
||||||
|
@using ErsatzTV.Application.MediaCards |
||||||
|
@using ErsatzTV.Application.MediaCollections |
||||||
|
@using ErsatzTV.Application.MediaCollections.Commands |
||||||
|
@using ErsatzTV.Application.Search.Queries |
||||||
|
@using ErsatzTV.Extensions |
||||||
|
@using Unit = LanguageExt.Unit |
||||||
|
@inherits MultiSelectBase<SongList> |
||||||
|
@inject NavigationManager _navigationManager |
||||||
|
@inject ChannelWriter<IBackgroundServiceRequest> _channel |
||||||
|
|
||||||
|
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;"> |
||||||
|
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6"> |
||||||
|
@if (IsSelectMode()) |
||||||
|
{ |
||||||
|
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText> |
||||||
|
<div style="margin-left: auto"> |
||||||
|
<MudButton Variant="Variant.Filled" |
||||||
|
Color="Color.Primary" |
||||||
|
StartIcon="@Icons.Material.Filled.Add" |
||||||
|
OnClick="@(_ => AddSelectionToCollection())"> |
||||||
|
Add To Collection |
||||||
|
</MudButton> |
||||||
|
<MudButton Class="ml-3" |
||||||
|
Variant="Variant.Filled" |
||||||
|
Color="Color.Secondary" |
||||||
|
StartIcon="@Icons.Material.Filled.Check" |
||||||
|
OnClick="@(_ => ClearSelection())"> |
||||||
|
Clear Selection |
||||||
|
</MudButton> |
||||||
|
</div> |
||||||
|
} |
||||||
|
else |
||||||
|
{ |
||||||
|
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText> |
||||||
|
<div style="max-width: 300px; width: 33%;"> |
||||||
|
<MudPaper Style="align-items: center; display: flex; justify-content: center;"> |
||||||
|
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft" |
||||||
|
OnClick="@PrevPage" |
||||||
|
Disabled="@(PageNumber <= 1)"> |
||||||
|
</MudIconButton> |
||||||
|
<MudText Style="flex-grow: 1" |
||||||
|
Align="Align.Center"> |
||||||
|
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count |
||||||
|
</MudText> |
||||||
|
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight" |
||||||
|
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)"> |
||||||
|
</MudIconButton> |
||||||
|
</MudPaper> |
||||||
|
</div> |
||||||
|
} |
||||||
|
</div> |
||||||
|
</MudPaper> |
||||||
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px"> |
||||||
|
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> |
||||||
|
<FragmentLetterAnchor TCard="SongCardViewModel" Cards="@_data.Cards"> |
||||||
|
<MediaCard Data="@context" |
||||||
|
Link="" |
||||||
|
ArtworkKind="ArtworkKind.Thumbnail" |
||||||
|
AddToCollectionClicked="@AddToCollection" |
||||||
|
SelectClicked="@(e => SelectClicked(context, e))" |
||||||
|
IsSelected="@IsSelected(context)" |
||||||
|
IsSelectMode="@IsSelectMode()"/> |
||||||
|
</FragmentLetterAnchor> |
||||||
|
</MudContainer> |
||||||
|
</MudContainer> |
||||||
|
@if (_data.PageMap.IsSome) |
||||||
|
{ |
||||||
|
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()" |
||||||
|
BaseUri="/music/songs" |
||||||
|
Query="@_query"/> |
||||||
|
} |
||||||
|
|
||||||
|
@code { |
||||||
|
private static int PageSize => 100; |
||||||
|
|
||||||
|
[Parameter] |
||||||
|
public int PageNumber { get; set; } |
||||||
|
|
||||||
|
private SongCardResultsViewModel _data; |
||||||
|
private string _query; |
||||||
|
|
||||||
|
protected override Task OnParametersSetAsync() |
||||||
|
{ |
||||||
|
if (PageNumber == 0) |
||||||
|
{ |
||||||
|
PageNumber = 1; |
||||||
|
} |
||||||
|
|
||||||
|
_query = _navigationManager.Uri.GetSearchQuery(); |
||||||
|
return RefreshData(); |
||||||
|
} |
||||||
|
|
||||||
|
protected override async Task RefreshData() |
||||||
|
{ |
||||||
|
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:song" : $"type:song AND ({_query})"; |
||||||
|
_data = await Mediator.Send(new QuerySearchIndexSongs(searchQuery, PageNumber, PageSize)); |
||||||
|
} |
||||||
|
|
||||||
|
private void PrevPage() |
||||||
|
{ |
||||||
|
var uri = $"/music/songs/page/{PageNumber - 1}"; |
||||||
|
if (!string.IsNullOrWhiteSpace(_query)) |
||||||
|
{ |
||||||
|
(string key, string value) = _query.EncodeQuery(); |
||||||
|
uri = $"{uri}?{key}={value}"; |
||||||
|
} |
||||||
|
_navigationManager.NavigateTo(uri); |
||||||
|
} |
||||||
|
|
||||||
|
private void NextPage() |
||||||
|
{ |
||||||
|
var uri = $"/music/songs/page/{PageNumber + 1}"; |
||||||
|
if (!string.IsNullOrWhiteSpace(_query)) |
||||||
|
{ |
||||||
|
(string key, string value) = _query.EncodeQuery(); |
||||||
|
uri = $"{uri}?{key}={value}"; |
||||||
|
} |
||||||
|
_navigationManager.NavigateTo(uri); |
||||||
|
} |
||||||
|
|
||||||
|
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) |
||||||
|
{ |
||||||
|
List<MediaCardViewModel> GetSortedItems() |
||||||
|
{ |
||||||
|
return _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>(); |
||||||
|
} |
||||||
|
|
||||||
|
SelectClicked(GetSortedItems, card, e); |
||||||
|
} |
||||||
|
|
||||||
|
private async Task AddToCollection(MediaCardViewModel card) |
||||||
|
{ |
||||||
|
if (card is SongCardViewModel song) |
||||||
|
{ |
||||||
|
var parameters = new DialogParameters { { "EntityType", "song" }, { "EntityName", song.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 AddSongToCollection(collection.Id, song.SongId); |
||||||
|
Either<BaseError, Unit> addResult = await Mediator.Send(request); |
||||||
|
addResult.Match( |
||||||
|
Left: error => |
||||||
|
{ |
||||||
|
Snackbar.Add($"Unexpected error adding song to collection: {error.Value}"); |
||||||
|
Logger.LogError("Unexpected error adding song to collection: {Error}", error.Value); |
||||||
|
}, |
||||||
|
Right: _ => Snackbar.Add($"Added {song.Title} to collection {collection.Name}", Severity.Success)); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
Loading…
Reference in new issue