mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* clean up genre, tag, studio orphans * enforce foreign keys at connection level * wip * fix fragment scroll offset * fix see all link for music videos * add fake artist metadata * not null artist id * add artist scanning * remove improperly named music videos * code cleanup * add artists to search results and collections * clean up music video metadata / artist * add artist view * show music videos on artist page * add music video artwork placeholderpull/155/head
71 changed files with 12938 additions and 197 deletions
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Application.Artists |
||||
{ |
||||
public record ArtistViewModel( |
||||
string Name, |
||||
string Disambiguation, |
||||
string Biography, |
||||
string Thumbnail, |
||||
string FanArt, |
||||
List<string> Genres, |
||||
List<string> Styles, |
||||
List<string> Moods); |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
using System.Linq; |
||||
using ErsatzTV.Core.Domain; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.Artists |
||||
{ |
||||
internal static class Mapper |
||||
{ |
||||
internal static ArtistViewModel ProjectToViewModel(Artist artist) |
||||
{ |
||||
ArtistMetadata metadata = Optional(artist.ArtistMetadata).Flatten().Head(); |
||||
return new ArtistViewModel( |
||||
metadata.Title, |
||||
metadata.Disambiguation, |
||||
metadata.Biography, |
||||
Artwork(metadata, ArtworkKind.Thumbnail), |
||||
Artwork(metadata, ArtworkKind.FanArt), |
||||
metadata.Genres.Map(g => g.Name).ToList(), |
||||
metadata.Styles.Map(s => s.Name).ToList(), |
||||
metadata.Moods.Map(m => m.Name).ToList()); |
||||
} |
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) => |
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) |
||||
.Match(a => a.Path, string.Empty); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Artists.Queries |
||||
{ |
||||
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>; |
||||
} |
||||
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.Artists.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Artists.Queries |
||||
{ |
||||
public class GetArtistByIdHandler : IRequestHandler<GetArtistById, Option<ArtistViewModel>> |
||||
{ |
||||
private readonly IArtistRepository _artistRepository; |
||||
|
||||
public GetArtistByIdHandler(IArtistRepository artistRepository) => _artistRepository = artistRepository; |
||||
|
||||
public Task<Option<ArtistViewModel>> Handle( |
||||
GetArtistById request, |
||||
CancellationToken cancellationToken) => |
||||
_artistRepository.GetArtist(request.ArtistId).MapT(ProjectToViewModel); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards |
||||
{ |
||||
public record ArtistCardResultsViewModel( |
||||
int Count, |
||||
List<ArtistCardViewModel> Cards, |
||||
Option<SearchPageMap> PageMap); |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Application.MediaCards |
||||
{ |
||||
public record ArtistCardViewModel |
||||
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel( |
||||
ArtistId, |
||||
Title, |
||||
Subtitle, |
||||
SortTitle, |
||||
Poster) |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries |
||||
{ |
||||
public record GetMusicVideoCards |
||||
(int ArtistId, int PageNumber, int PageSize) : IRequest<MusicVideoCardResultsViewModel>; |
||||
} |
||||
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.MediaCards.Mapper; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries |
||||
{ |
||||
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel> |
||||
{ |
||||
private readonly IMusicVideoRepository _musicVideoRepository; |
||||
|
||||
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) => |
||||
_musicVideoRepository = musicVideoRepository; |
||||
|
||||
public async Task<MusicVideoCardResultsViewModel> Handle( |
||||
GetMusicVideoCards request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId); |
||||
|
||||
List<MusicVideoCardViewModel> results = await _musicVideoRepository |
||||
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new MusicVideoCardResultsViewModel(count, results, None); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public record AddArtistToCollection |
||||
(int CollectionId, int ArtistId) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
} |
||||
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
using System.Threading; |
||||
using System.Threading.Channels; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Application.Playouts.Commands; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.MediaCollections.Commands |
||||
{ |
||||
public class |
||||
AddArtistToCollectionHandler : MediatR.IRequestHandler<AddArtistToCollection, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly ChannelWriter<IBackgroundServiceRequest> _channel; |
||||
private readonly IMediaCollectionRepository _mediaCollectionRepository; |
||||
private readonly IArtistRepository _artistRepository; |
||||
|
||||
public AddArtistToCollectionHandler( |
||||
IMediaCollectionRepository mediaCollectionRepository, |
||||
IArtistRepository artistRepository, |
||||
ChannelWriter<IBackgroundServiceRequest> channel) |
||||
{ |
||||
_mediaCollectionRepository = mediaCollectionRepository; |
||||
_artistRepository = artistRepository; |
||||
_channel = channel; |
||||
} |
||||
|
||||
public Task<Either<BaseError, Unit>> Handle( |
||||
AddArtistToCollection request, |
||||
CancellationToken cancellationToken) => |
||||
Validate(request) |
||||
.MapT(_ => ApplyAddArtistRequest(request)) |
||||
.Bind(v => v.ToEitherAsync()); |
||||
|
||||
private async Task<Unit> ApplyAddArtistRequest(AddArtistToCollection request) |
||||
{ |
||||
if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ArtistId)) |
||||
{ |
||||
// rebuild all playouts that use this collection
|
||||
foreach (int playoutId in await _mediaCollectionRepository |
||||
.PlayoutIdsUsingCollection(request.CollectionId)) |
||||
{ |
||||
await _channel.WriteAsync(new BuildPlayout(playoutId, true)); |
||||
} |
||||
} |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, Unit>> Validate(AddArtistToCollection request) => |
||||
(await CollectionMustExist(request), await ValidateArtist(request)) |
||||
.Apply((_, _) => Unit.Default); |
||||
|
||||
private Task<Validation<BaseError, Unit>> CollectionMustExist(AddArtistToCollection request) => |
||||
_mediaCollectionRepository.GetCollectionWithItems(request.CollectionId) |
||||
.MapT(_ => Unit.Default) |
||||
.Map(v => v.ToValidation<BaseError>("Collection does not exist.")); |
||||
|
||||
private Task<Validation<BaseError, Unit>> ValidateArtist(AddArtistToCollection request) => |
||||
LoadArtist(request) |
||||
.MapT(_ => Unit.Default) |
||||
.Map(v => v.ToValidation<BaseError>("Music video does not exist")); |
||||
|
||||
private Task<Option<Artist>> LoadArtist(AddArtistToCollection request) => |
||||
_artistRepository.GetArtist(request.ArtistId); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public record QuerySearchIndexArtists |
||||
(string Query, int PageNumber, int PageSize) : IRequest<ArtistCardResultsViewModel>; |
||||
} |
||||
@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Application.MediaCards; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Search; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.MediaCards.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Search.Queries |
||||
{ |
||||
public class |
||||
QuerySearchIndexArtistsHandler : IRequestHandler<QuerySearchIndexArtists, ArtistCardResultsViewModel |
||||
> |
||||
{ |
||||
private readonly IArtistRepository _artistRepository; |
||||
private readonly ISearchIndex _searchIndex; |
||||
|
||||
public QuerySearchIndexArtistsHandler(ISearchIndex searchIndex, IArtistRepository artistRepository) |
||||
{ |
||||
_searchIndex = searchIndex; |
||||
_artistRepository = artistRepository; |
||||
} |
||||
|
||||
public async Task<ArtistCardResultsViewModel> Handle( |
||||
QuerySearchIndexArtists request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
SearchResult searchResult = await _searchIndex.Search( |
||||
request.Query, |
||||
(request.PageNumber - 1) * request.PageSize, |
||||
request.PageSize); |
||||
|
||||
List<ArtistCardViewModel> items = await _artistRepository |
||||
.GetArtistsForCards(searchResult.Items.Map(i => i.Id).ToList()) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
|
||||
return new ArtistCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class Artist : MediaItem |
||||
{ |
||||
public List<MusicVideo> MusicVideos { get; set; } |
||||
public List<ArtistMetadata> ArtistMetadata { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class ArtistMetadata : Metadata |
||||
{ |
||||
public string Disambiguation { get; set; } |
||||
public string Biography { get; set; } |
||||
public string Formed { get; set; } |
||||
public int ArtistId { get; set; } |
||||
public Artist Artist { get; set; } |
||||
public List<Style> Styles { get; set; } |
||||
public List<Mood> Moods { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class Mood |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Name { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class Style |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Name { get; set; } |
||||
} |
||||
} |
||||
@ -1,14 +1,16 @@
@@ -1,14 +1,16 @@
|
||||
using System; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata |
||||
{ |
||||
public interface IFallbackMetadataProvider |
||||
{ |
||||
ShowMetadata GetFallbackMetadataForShow(string showFolder); |
||||
ArtistMetadata GetFallbackMetadataForArtist(string artistFolder); |
||||
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode); |
||||
MovieMetadata GetFallbackMetadata(Movie movie); |
||||
MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo); |
||||
Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo); |
||||
string GetSortTitle(string title); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Metadata; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories |
||||
{ |
||||
public interface IArtistRepository |
||||
{ |
||||
Task<Option<Artist>> GetArtistByMetadata(int libraryPathId, ArtistMetadata metadata); |
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Artist>>> AddArtist( |
||||
int libraryPathId, |
||||
string artistFolder, |
||||
ArtistMetadata metadata); |
||||
|
||||
Task<List<int>> DeleteEmptyArtists(LibraryPath libraryPath); |
||||
Task<Option<Artist>> GetArtist(int artistId); |
||||
Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids); |
||||
Task<bool> AddGenre(ArtistMetadata metadata, Genre genre); |
||||
Task<bool> AddStyle(ArtistMetadata metadata, Style style); |
||||
Task<bool> AddMood(ArtistMetadata metadata, Mood mood); |
||||
} |
||||
} |
||||
@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic; |
||||
using System.Xml.Serialization; |
||||
|
||||
namespace ErsatzTV.Core.Metadata.Nfo |
||||
{ |
||||
[XmlRoot("artist")] |
||||
public class ArtistNfo |
||||
{ |
||||
[XmlElement("name")] |
||||
public string Name { get; set; } |
||||
|
||||
[XmlElement("disambiguation")] |
||||
public string Disambiguation { get; set; } |
||||
|
||||
[XmlElement("genre")] |
||||
public List<string> Genres { get; set; } |
||||
|
||||
[XmlElement("style")] |
||||
public List<string> Styles { get; set; } |
||||
|
||||
[XmlElement("mood")] |
||||
public List<string> Moods { get; set; } |
||||
|
||||
[XmlElement("biography")] |
||||
public string Biography { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class ArtistConfiguration : IEntityTypeConfiguration<Artist> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<Artist> builder) |
||||
{ |
||||
builder.ToTable("Artist"); |
||||
|
||||
builder.HasMany(a => a.MusicVideos) |
||||
.WithOne(mv => mv.Artist) |
||||
.HasForeignKey(mv => mv.ArtistId) |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
|
||||
builder.HasMany(a => a.ArtistMetadata) |
||||
.WithOne(am => am.Artist) |
||||
.HasForeignKey(am => am.ArtistId) |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,30 @@
@@ -0,0 +1,30 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class ArtistMetadataConfiguration : IEntityTypeConfiguration<ArtistMetadata> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<ArtistMetadata> builder) |
||||
{ |
||||
builder.ToTable("ArtistMetadata"); |
||||
|
||||
builder.HasMany(sm => sm.Artwork) |
||||
.WithOne() |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
|
||||
builder.HasMany(sm => sm.Genres) |
||||
.WithOne() |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
|
||||
builder.HasMany(sm => sm.Styles) |
||||
.WithOne() |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
|
||||
builder.HasMany(sm => sm.Moods) |
||||
.WithOne() |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Data; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using Dapper; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Metadata; |
||||
using LanguageExt; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories |
||||
{ |
||||
public class ArtistRepository : IArtistRepository |
||||
{ |
||||
private readonly IDbConnection _dbConnection; |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public ArtistRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection) |
||||
{ |
||||
_dbContextFactory = dbContextFactory; |
||||
_dbConnection = dbConnection; |
||||
} |
||||
|
||||
public async Task<Option<Artist>> GetArtistByMetadata(int libraryPathId, ArtistMetadata metadata) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
Option<int> maybeId = await dbContext.ArtistMetadata |
||||
.Where( |
||||
s => s.Title == metadata.Title && (metadata.MetadataKind == MetadataKind.Fallback || |
||||
s.Disambiguation == metadata.Disambiguation)) |
||||
.Where(s => s.Artist.LibraryPathId == libraryPathId) |
||||
.SingleOrDefaultAsync() |
||||
.Map(Optional) |
||||
.MapT(sm => sm.ArtistId); |
||||
|
||||
return await maybeId.Match( |
||||
id => |
||||
{ |
||||
return dbContext.Artists |
||||
.AsNoTracking() |
||||
.Include(s => s.ArtistMetadata) |
||||
.ThenInclude(sm => sm.Artwork) |
||||
.Include(s => s.ArtistMetadata) |
||||
.ThenInclude(sm => sm.Genres) |
||||
.Include(s => s.ArtistMetadata) |
||||
.ThenInclude(sm => sm.Styles) |
||||
.Include(s => s.ArtistMetadata) |
||||
.ThenInclude(sm => sm.Moods) |
||||
.Include(s => s.LibraryPath) |
||||
.ThenInclude(lp => lp.Library) |
||||
.OrderBy(s => s.Id) |
||||
.SingleOrDefaultAsync(s => s.Id == id) |
||||
.Map(Optional); |
||||
}, |
||||
() => Option<Artist>.None.AsTask()); |
||||
} |
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<Artist>>> AddArtist( |
||||
int libraryPathId, |
||||
string artistFolder, |
||||
ArtistMetadata metadata) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
|
||||
try |
||||
{ |
||||
metadata.DateAdded = DateTime.UtcNow; |
||||
metadata.Genres ??= new List<Genre>(); |
||||
metadata.Styles ??= new List<Style>(); |
||||
metadata.Moods ??= new List<Mood>(); |
||||
var artist = new Artist |
||||
{ |
||||
LibraryPathId = libraryPathId, |
||||
ArtistMetadata = new List<ArtistMetadata> { metadata } |
||||
}; |
||||
|
||||
await dbContext.Artists.AddAsync(artist); |
||||
await dbContext.SaveChangesAsync(); |
||||
await dbContext.Entry(artist).Reference(s => s.LibraryPath).LoadAsync(); |
||||
await dbContext.Entry(artist.LibraryPath).Reference(lp => lp.Library).LoadAsync(); |
||||
|
||||
return new MediaItemScanResult<Artist>(artist) { IsAdded = true }; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
public async Task<List<int>> DeleteEmptyArtists(LibraryPath libraryPath) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
List<Artist> artists = await dbContext.Artists |
||||
.Filter(a => a.LibraryPathId == libraryPath.Id) |
||||
.Filter(a => a.MusicVideos.Count == 0) |
||||
.ToListAsync(); |
||||
var ids = artists.Map(a => a.Id).ToList(); |
||||
dbContext.Artists.RemoveRange(artists); |
||||
await dbContext.SaveChangesAsync(); |
||||
return ids; |
||||
} |
||||
|
||||
public async Task<Option<Artist>> GetArtist(int artistId) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
return await dbContext.Artists |
||||
.Include(m => m.ArtistMetadata) |
||||
.ThenInclude(m => m.Artwork) |
||||
.Include(m => m.ArtistMetadata) |
||||
.ThenInclude(m => m.Genres) |
||||
.Include(m => m.ArtistMetadata) |
||||
.ThenInclude(m => m.Styles) |
||||
.Include(m => m.ArtistMetadata) |
||||
.ThenInclude(m => m.Moods) |
||||
.OrderBy(m => m.Id) |
||||
.SingleOrDefaultAsync(m => m.Id == artistId) |
||||
.Map(Optional); |
||||
} |
||||
|
||||
public async Task<List<ArtistMetadata>> GetArtistsForCards(List<int> ids) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
return await dbContext.ArtistMetadata |
||||
.AsNoTracking() |
||||
.Filter(am => ids.Contains(am.ArtistId)) |
||||
.Include(am => am.Artwork) |
||||
.OrderBy(am => am.SortTitle) |
||||
.ToListAsync(); |
||||
} |
||||
|
||||
public Task<bool> AddGenre(ArtistMetadata metadata, Genre genre) => |
||||
_dbConnection.ExecuteAsync( |
||||
"INSERT INTO Genre (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)", |
||||
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0); |
||||
|
||||
public Task<bool> AddStyle(ArtistMetadata metadata, Style style) => |
||||
_dbConnection.ExecuteAsync( |
||||
"INSERT INTO Style (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)", |
||||
new { style.Name, MetadataId = metadata.Id }).Map(result => result > 0); |
||||
|
||||
public Task<bool> AddMood(ArtistMetadata metadata, Mood mood) => |
||||
_dbConnection.ExecuteAsync( |
||||
"INSERT INTO Mood (Name, ArtistMetadataId) VALUES (@Name, @MetadataId)", |
||||
new { mood.Name, MetadataId = metadata.Id }).Map(result => result > 0); |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Delete_Orphan_GenreTagStudio : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.Sql( |
||||
@"DELETE FROM Genre
|
||||
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata) |
||||
OR ShowMetadataId NOT IN (SELECT Id FROM Show) |
||||
OR SeasonMetadataId NOT IN (SELECT Id FROM Season) |
||||
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode) |
||||
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
|
||||
|
||||
migrationBuilder.Sql( |
||||
@"DELETE FROM Tag
|
||||
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata) |
||||
OR ShowMetadataId NOT IN (SELECT Id FROM Show) |
||||
OR SeasonMetadataId NOT IN (SELECT Id FROM Season) |
||||
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode) |
||||
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
|
||||
|
||||
migrationBuilder.Sql( |
||||
@"DELETE FROM Studio
|
||||
WHERE MovieMetadataId NOT IN (SELECT Id FROM MovieMetadata) |
||||
OR ShowMetadataId NOT IN (SELECT Id FROM Show) |
||||
OR SeasonMetadataId NOT IN (SELECT Id FROM Season) |
||||
OR EpisodeMetadataId NOT IN (SELECT Id FROM Episode) |
||||
OR MusicVideoMetadataId NOT IN (SELECT Id FROM MusicVideoMetadata)");
|
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,284 @@
@@ -0,0 +1,284 @@
|
||||
using System; |
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Add_Artist : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
"ArtistMetadataId", |
||||
"Tag", |
||||
"INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.AddColumn<int>( |
||||
"ArtistMetadataId", |
||||
"Studio", |
||||
"INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.AddColumn<int>( |
||||
"ArtistId", |
||||
"MusicVideo", |
||||
"INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.AddColumn<int>( |
||||
"ArtistMetadataId", |
||||
"Genre", |
||||
"INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.AddColumn<int>( |
||||
"ArtistMetadataId", |
||||
"Artwork", |
||||
"INTEGER", |
||||
nullable: true); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
"Artist", |
||||
table => new |
||||
{ |
||||
Id = table.Column<int>("INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_Artist", x => x.Id); |
||||
table.ForeignKey( |
||||
"FK_Artist_MediaItem_Id", |
||||
x => x.Id, |
||||
"MediaItem", |
||||
"Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
"ArtistMetadata", |
||||
table => new |
||||
{ |
||||
Id = table.Column<int>("INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true), |
||||
Disambiguation = table.Column<string>("TEXT", nullable: true), |
||||
Biography = table.Column<string>("TEXT", nullable: true), |
||||
Formed = table.Column<string>("TEXT", nullable: true), |
||||
ArtistId = table.Column<int>("INTEGER", nullable: true), |
||||
MetadataKind = table.Column<int>("INTEGER", nullable: false), |
||||
Title = table.Column<string>("TEXT", nullable: true), |
||||
OriginalTitle = table.Column<string>("TEXT", nullable: true), |
||||
SortTitle = table.Column<string>("TEXT", nullable: true), |
||||
Year = table.Column<int>("INTEGER", nullable: true), |
||||
ReleaseDate = table.Column<DateTime>("TEXT", nullable: true), |
||||
DateAdded = table.Column<DateTime>("TEXT", nullable: false), |
||||
DateUpdated = table.Column<DateTime>("TEXT", nullable: false) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_ArtistMetadata", x => x.Id); |
||||
table.ForeignKey( |
||||
"FK_ArtistMetadata_Artist_ArtistId", |
||||
x => x.ArtistId, |
||||
"Artist", |
||||
"Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
"Mood", |
||||
table => new |
||||
{ |
||||
Id = table.Column<int>("INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true), |
||||
Name = table.Column<string>("TEXT", nullable: true), |
||||
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_Mood", x => x.Id); |
||||
table.ForeignKey( |
||||
"FK_Mood_ArtistMetadata_ArtistMetadataId", |
||||
x => x.ArtistMetadataId, |
||||
"ArtistMetadata", |
||||
"Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateTable( |
||||
"Style", |
||||
table => new |
||||
{ |
||||
Id = table.Column<int>("INTEGER", nullable: false) |
||||
.Annotation("Sqlite:Autoincrement", true), |
||||
Name = table.Column<string>("TEXT", nullable: true), |
||||
ArtistMetadataId = table.Column<int>("INTEGER", nullable: true) |
||||
}, |
||||
constraints: table => |
||||
{ |
||||
table.PrimaryKey("PK_Style", x => x.Id); |
||||
table.ForeignKey( |
||||
"FK_Style_ArtistMetadata_ArtistMetadataId", |
||||
x => x.ArtistMetadataId, |
||||
"ArtistMetadata", |
||||
"Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
}); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_Tag_ArtistMetadataId", |
||||
"Tag", |
||||
"ArtistMetadataId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_Studio_ArtistMetadataId", |
||||
"Studio", |
||||
"ArtistMetadataId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_MusicVideo_ArtistId", |
||||
"MusicVideo", |
||||
"ArtistId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_Genre_ArtistMetadataId", |
||||
"Genre", |
||||
"ArtistMetadataId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_Artwork_ArtistMetadataId", |
||||
"Artwork", |
||||
"ArtistMetadataId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_ArtistMetadata_ArtistId", |
||||
"ArtistMetadata", |
||||
"ArtistId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_Mood_ArtistMetadataId", |
||||
"Mood", |
||||
"ArtistMetadataId"); |
||||
|
||||
migrationBuilder.CreateIndex( |
||||
"IX_Style_ArtistMetadataId", |
||||
"Style", |
||||
"ArtistMetadataId"); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
"FK_Artwork_ArtistMetadata_ArtistMetadataId", |
||||
"Artwork", |
||||
"ArtistMetadataId", |
||||
"ArtistMetadata", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
"FK_Genre_ArtistMetadata_ArtistMetadataId", |
||||
"Genre", |
||||
"ArtistMetadataId", |
||||
"ArtistMetadata", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
"FK_MusicVideo_Artist_ArtistId", |
||||
"MusicVideo", |
||||
"ArtistId", |
||||
"Artist", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Cascade); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
"FK_Studio_ArtistMetadata_ArtistMetadataId", |
||||
"Studio", |
||||
"ArtistMetadataId", |
||||
"ArtistMetadata", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Restrict); |
||||
|
||||
migrationBuilder.AddForeignKey( |
||||
"FK_Tag_ArtistMetadata_ArtistMetadataId", |
||||
"Tag", |
||||
"ArtistMetadataId", |
||||
"ArtistMetadata", |
||||
principalColumn: "Id", |
||||
onDelete: ReferentialAction.Restrict); |
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropForeignKey( |
||||
"FK_Artwork_ArtistMetadata_ArtistMetadataId", |
||||
"Artwork"); |
||||
|
||||
migrationBuilder.DropForeignKey( |
||||
"FK_Genre_ArtistMetadata_ArtistMetadataId", |
||||
"Genre"); |
||||
|
||||
migrationBuilder.DropForeignKey( |
||||
"FK_MusicVideo_Artist_ArtistId", |
||||
"MusicVideo"); |
||||
|
||||
migrationBuilder.DropForeignKey( |
||||
"FK_Studio_ArtistMetadata_ArtistMetadataId", |
||||
"Studio"); |
||||
|
||||
migrationBuilder.DropForeignKey( |
||||
"FK_Tag_ArtistMetadata_ArtistMetadataId", |
||||
"Tag"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
"Mood"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
"Style"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
"ArtistMetadata"); |
||||
|
||||
migrationBuilder.DropTable( |
||||
"Artist"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
"IX_Tag_ArtistMetadataId", |
||||
"Tag"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
"IX_Studio_ArtistMetadataId", |
||||
"Studio"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
"IX_MusicVideo_ArtistId", |
||||
"MusicVideo"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
"IX_Genre_ArtistMetadataId", |
||||
"Genre"); |
||||
|
||||
migrationBuilder.DropIndex( |
||||
"IX_Artwork_ArtistMetadataId", |
||||
"Artwork"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
"ArtistMetadataId", |
||||
"Tag"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
"ArtistMetadataId", |
||||
"Studio"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
"ArtistId", |
||||
"MusicVideo"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
"ArtistMetadataId", |
||||
"Genre"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
"ArtistMetadataId", |
||||
"Artwork"); |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
|
||||
using System; |
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Add_ArtistMetadata : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
var now = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.FFFFFFF"); |
||||
|
||||
migrationBuilder.Sql( |
||||
@"INSERT INTO MediaItem (LibraryPathId)
|
||||
SELECT LibraryPath.Id FROM LibraryPath INNER JOIN Library L on LibraryPath.LibraryId = L.Id |
||||
WHERE MediaKind = 3");
|
||||
|
||||
migrationBuilder.Sql( |
||||
@"INSERT INTO Artist (Id)
|
||||
SELECT MediaItem.Id FROM MediaItem |
||||
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id |
||||
INNER JOIN Library L on LP.LibraryId = L.Id |
||||
WHERE MediaKind = 3 AND NOT EXISTS (SELECT Id FROM MusicVideo WHERE Id = MediaItem.Id)");
|
||||
|
||||
migrationBuilder.Sql( |
||||
$@"INSERT INTO ArtistMetadata (ArtistId, Title, DateAdded, DateUpdated, MetadataKind)
|
||||
SELECT Id, '[FAKE ARTIST]', '{now}', '{now}', 0 FROM Artist");
|
||||
|
||||
migrationBuilder.Sql( |
||||
@"UPDATE MusicVideo SET ArtistId =
|
||||
(SELECT Artist.Id FROM Artist |
||||
INNER JOIN MediaItem MIA on Artist.Id = MIA.Id |
||||
INNER JOIN MediaItem MIMV on MusicVideo.Id = MIMV.Id |
||||
INNER JOIN LibraryPath LPA on MIA.LibraryPathId = LPA.Id |
||||
INNER JOIN LibraryPath LPMV on MIMV.LibraryPathId = LPMV.Id |
||||
WHERE LPA.LibraryId = LPMV.LibraryId)");
|
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,47 @@
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Update_ArtistMetadata_FK : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AlterColumn<int>( |
||||
"ArtistId", |
||||
"MusicVideo", |
||||
"INTEGER", |
||||
nullable: false, |
||||
oldClrType: typeof(int), |
||||
oldType: "INTEGER", |
||||
oldNullable: true); |
||||
|
||||
migrationBuilder.AlterColumn<int>( |
||||
"ArtistId", |
||||
"ArtistMetadata", |
||||
"INTEGER", |
||||
nullable: false, |
||||
oldClrType: typeof(int), |
||||
oldType: "INTEGER", |
||||
oldNullable: true); |
||||
} |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AlterColumn<int>( |
||||
"ArtistId", |
||||
"MusicVideo", |
||||
"INTEGER", |
||||
nullable: true, |
||||
oldClrType: typeof(int), |
||||
oldType: "INTEGER"); |
||||
|
||||
migrationBuilder.AlterColumn<int>( |
||||
"ArtistId", |
||||
"ArtistMetadata", |
||||
"INTEGER", |
||||
nullable: true, |
||||
oldClrType: typeof(int), |
||||
oldType: "INTEGER"); |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations |
||||
{ |
||||
public partial class Delete_MusicVideoMetadata_Artist : Migration |
||||
{ |
||||
protected override void Up(MigrationBuilder migrationBuilder) => |
||||
migrationBuilder.DropColumn( |
||||
"Artist", |
||||
"MusicVideoMetadata"); |
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) => |
||||
migrationBuilder.AddColumn<string>( |
||||
"Artist", |
||||
"MusicVideoMetadata", |
||||
"TEXT", |
||||
nullable: true); |
||||
} |
||||
} |
||||
@ -0,0 +1,176 @@
@@ -0,0 +1,176 @@
|
||||
@page "/media/music/artists/{ArtistId:int}" |
||||
@using ErsatzTV.Application.Artists |
||||
@using ErsatzTV.Application.Artists.Queries |
||||
@using ErsatzTV.Application.MediaCards |
||||
@using ErsatzTV.Application.MediaCards.Queries |
||||
@using ErsatzTV.Application.MediaCollections |
||||
@using ErsatzTV.Application.MediaCollections.Commands |
||||
@using Unit = LanguageExt.Unit |
||||
@inject IMediator Mediator |
||||
@inject IDialogService Dialog |
||||
@inject NavigationManager NavigationManager |
||||
@inject ILogger<Artist> Logger |
||||
@inject ISnackbar Snackbar |
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container"> |
||||
<div class="fanart-tint"></div> |
||||
@if (!string.IsNullOrWhiteSpace(_artist.FanArt)) |
||||
{ |
||||
<img src="@($"/artwork/fanart/{_artist.FanArt}")" alt="fan art"/> |
||||
} |
||||
</MudContainer> |
||||
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 200px"> |
||||
<div style="display: flex; flex-direction: row;" class="mb-6"> |
||||
@if (!string.IsNullOrWhiteSpace(_artist.Thumbnail)) |
||||
{ |
||||
<img class="mud-elevation-2 mr-6" |
||||
style="border-radius: 4px; flex-shrink: 0; height: 220px; width: 220px" |
||||
src="@($"/artwork/thumbnails/{_artist.Thumbnail}")" alt="artist thumnail"/> |
||||
} |
||||
<div style="display: flex; flex-direction: column; height: 100%"> |
||||
<MudText Typo="Typo.h2" Class="media-item-title">@_artist.Name</MudText> |
||||
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_artist.Disambiguation</MudText> |
||||
@if (!string.IsNullOrWhiteSpace(_artist.Biography)) |
||||
{ |
||||
<MudCard Elevation="2" Class="mb-6"> |
||||
<MudCardContent Class="mx-3 my-3" Style="height: 100%"> |
||||
<MudText Style="flex-grow: 1"> |
||||
@if (_artist.Biography.Length > 400) |
||||
{ |
||||
@(_artist.Biography.Substring(0, 400) + "...") |
||||
} |
||||
else |
||||
{ |
||||
@_artist.Biography |
||||
} |
||||
</MudText> |
||||
</MudCardContent> |
||||
</MudCard> |
||||
} |
||||
<div> |
||||
<MudButton Variant="Variant.Filled" |
||||
Color="Color.Primary" |
||||
StartIcon="@Icons.Material.Filled.Add" |
||||
OnClick="@AddToCollection"> |
||||
Add To Collection |
||||
</MudButton> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
@if (_artist.Genres.Any()) |
||||
{ |
||||
<MudText GutterBottom="true">Genres</MudText> |
||||
<div class="mb-2"> |
||||
@foreach (string genre in _artist.Genres.OrderBy(g => g)) |
||||
{ |
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a%22{Uri.EscapeDataString(genre.ToLowerInvariant())}%22")"/> |
||||
} |
||||
</div> |
||||
} |
||||
@if (_artist.Styles.Any()) |
||||
{ |
||||
<MudText GutterBottom="true">Styles</MudText> |
||||
<div class="mb-2"> |
||||
@foreach (string style in _artist.Styles.OrderBy(g => g)) |
||||
{ |
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@style" Class="mr-2 mb-2" Link="@($"/search?query=style%3a%22{Uri.EscapeDataString(style.ToLowerInvariant())}%22")"/> |
||||
} |
||||
</div> |
||||
} |
||||
@if (_artist.Moods.Any()) |
||||
{ |
||||
<MudText GutterBottom="true">Moods</MudText> |
||||
<div class="mb-2"> |
||||
@foreach (string mood in _artist.Moods.OrderBy(g => g)) |
||||
{ |
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@mood" Class="mr-2 mb-2" Link="@($"/search?query=mood%3a%22{Uri.EscapeDataString(mood.ToLowerInvariant())}%22")"/> |
||||
} |
||||
</div> |
||||
} |
||||
</MudContainer> |
||||
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8"> |
||||
@foreach (MusicVideoCardViewModel musicVideo in _musicVideos.Cards) |
||||
{ |
||||
<MudCard Class="mb-6"> |
||||
<div id="@($"music-video-{musicVideo.MusicVideoId}")" style="display: flex; flex-direction: row; scroll-margin-top: 85px"> |
||||
@if (!string.IsNullOrWhiteSpace(musicVideo.Poster)) |
||||
{ |
||||
<MudPaper style="display: flex; flex-direction: column"> |
||||
<MudCardMedia Image="@($"/artwork/thumbnails/{musicVideo.Poster}")" Style="flex-grow: 1; height: 220px; width: 293px;"/> |
||||
</MudPaper> |
||||
} |
||||
else |
||||
{ |
||||
<MudSkeleton SkeletonType="SkeletonType.Rectangle" Animation="Animation.False" Height="220px" Width="293px" /> |
||||
} |
||||
<MudCardContent Class="ml-3"> |
||||
<div style="display: flex; flex-direction: column; height: 100%"> |
||||
<MudText Typo="Typo.h4">@musicVideo.Title</MudText> |
||||
<MudText Style="flex-grow: 1">@musicVideo.Plot</MudText> |
||||
<div class="mt-6"> |
||||
<MudButton Variant="Variant.Filled" |
||||
Color="Color.Primary" |
||||
StartIcon="@Icons.Material.Filled.Add" |
||||
OnClick="@(_ => AddMusicVideoToCollection(musicVideo))"> |
||||
Add To Collection |
||||
</MudButton> |
||||
</div> |
||||
</div> |
||||
</MudCardContent> |
||||
</div> |
||||
</MudCard> |
||||
} |
||||
</MudContainer> |
||||
|
||||
@code { |
||||
|
||||
[Parameter] |
||||
public int ArtistId { get; set; } |
||||
|
||||
private ArtistViewModel _artist; |
||||
private MusicVideoCardResultsViewModel _musicVideos; |
||||
|
||||
protected override Task OnParametersSetAsync() => RefreshData(); |
||||
|
||||
private async Task RefreshData() |
||||
{ |
||||
await Mediator.Send(new GetArtistById(ArtistId)).IfSomeAsync(vm => _artist = vm); |
||||
_musicVideos = await Mediator.Send(new GetMusicVideoCards(ArtistId, 1, 100)); |
||||
} |
||||
|
||||
private async Task AddToCollection() |
||||
{ |
||||
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", _artist.Name } }; |
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; |
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options); |
||||
DialogResult result = await dialog.Result; |
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) |
||||
{ |
||||
await Mediator.Send(new AddArtistToCollection(collection.Id, ArtistId)); |
||||
NavigationManager.NavigateTo($"/media/collections/{collection.Id}"); |
||||
} |
||||
} |
||||
|
||||
private async Task AddMusicVideoToCollection(MusicVideoCardViewModel musicVideo) |
||||
{ |
||||
var parameters = new DialogParameters { { "EntityType", "music video" }, { "EntityName", musicVideo.Title } }; |
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; |
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options); |
||||
DialogResult result = await dialog.Result; |
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) |
||||
{ |
||||
var request = new AddMusicVideoToCollection(collection.Id, musicVideo.MusicVideoId); |
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request); |
||||
addResult.Match( |
||||
Left: error => |
||||
{ |
||||
Snackbar.Add($"Unexpected error adding music video to collection: {error.Value}"); |
||||
Logger.LogError("Unexpected error adding music video to collection: {Error}", error.Value); |
||||
}, |
||||
Right: _ => Snackbar.Add($"Added {musicVideo.Title} to collection {collection.Name}", Severity.Success)); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,163 @@
@@ -0,0 +1,163 @@
|
||||
@page "/media/music/artists" |
||||
@page "/media/music/artists/page/{PageNumber:int}" |
||||
@using LanguageExt.UnsafeValueAccess |
||||
@using Microsoft.AspNetCore.WebUtilities |
||||
@using Microsoft.Extensions.Primitives |
||||
@using ErsatzTV.Application.MediaCards |
||||
@using ErsatzTV.Application.MediaCollections |
||||
@using ErsatzTV.Application.MediaCollections.Commands |
||||
@using ErsatzTV.Application.Search.Queries |
||||
@using Unit = LanguageExt.Unit |
||||
@inherits MultiSelectBase<MusicVideoList> |
||||
@inject NavigationManager NavigationManager |
||||
@inject ChannelWriter<IBackgroundServiceRequest> Channel |
||||
|
||||
<MudPaper Square="true" Style="display: flex; height: 64px; left: 240px; padding: 0; position: fixed; right: 0; z-index: 100;"> |
||||
<div style="display: flex; flex-direction: row; margin-bottom: auto; margin-top: auto; width: 100%" class="ml-6 mr-6"> |
||||
@if (IsSelectMode()) |
||||
{ |
||||
<MudText Typo="Typo.h6" Color="Color.Primary">@SelectionLabel()</MudText> |
||||
<div style="margin-left: auto"> |
||||
<MudButton Variant="Variant.Filled" |
||||
Color="Color.Primary" |
||||
StartIcon="@Icons.Material.Filled.Add" |
||||
OnClick="@(_ => AddSelectionToCollection())"> |
||||
Add To Collection |
||||
</MudButton> |
||||
<MudButton Class="ml-3" |
||||
Variant="Variant.Filled" |
||||
Color="Color.Secondary" |
||||
StartIcon="@Icons.Material.Filled.Check" |
||||
OnClick="@(_ => ClearSelection())"> |
||||
Clear Selection |
||||
</MudButton> |
||||
</div> |
||||
} |
||||
else |
||||
{ |
||||
<MudText Style="margin-bottom: auto; margin-top: auto; width: 33%">@_query</MudText> |
||||
<div style="max-width: 300px; width: 33%;"> |
||||
<MudPaper Style="align-items: center; display: flex; justify-content: center;"> |
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft" |
||||
OnClick="@PrevPage" |
||||
Disabled="@(PageNumber <= 1)"> |
||||
</MudIconButton> |
||||
<MudText Style="flex-grow: 1" |
||||
Align="Align.Center"> |
||||
@Math.Min((PageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, PageNumber * PageSize) of @_data.Count |
||||
</MudText> |
||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight" |
||||
OnClick="@NextPage" Disabled="@(PageNumber * PageSize >= _data.Count)"> |
||||
</MudIconButton> |
||||
</MudPaper> |
||||
</div> |
||||
} |
||||
</div> |
||||
</MudPaper> |
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8" Style="margin-top: 64px"> |
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid"> |
||||
<FragmentLetterAnchor TCard="ArtistCardViewModel" Cards="@_data.Cards"> |
||||
<MediaCard Data="@context" |
||||
Link="@($"/media/music/artists/{context.ArtistId}")" |
||||
ArtworkKind="ArtworkKind.Thumbnail" |
||||
AddToCollectionClicked="@AddToCollection" |
||||
SelectClicked="@(e => SelectClicked(context, e))" |
||||
IsSelected="@IsSelected(context)" |
||||
IsSelectMode="@IsSelectMode()"/> |
||||
</FragmentLetterAnchor> |
||||
</MudContainer> |
||||
</MudContainer> |
||||
@if (_data.PageMap.IsSome) |
||||
{ |
||||
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()" |
||||
BaseUri="/media/music/artists" |
||||
Query="@_query"/> |
||||
} |
||||
|
||||
@code { |
||||
private static int PageSize => 100; |
||||
|
||||
[Parameter] |
||||
public int PageNumber { get; set; } |
||||
|
||||
private ArtistCardResultsViewModel _data; |
||||
private string _query; |
||||
|
||||
protected override Task OnParametersSetAsync() |
||||
{ |
||||
if (PageNumber == 0) |
||||
{ |
||||
PageNumber = 1; |
||||
} |
||||
|
||||
string query = new Uri(NavigationManager.Uri).Query; |
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value)) |
||||
{ |
||||
_query = value; |
||||
} |
||||
else |
||||
{ |
||||
_query = null; |
||||
} |
||||
|
||||
return RefreshData(); |
||||
} |
||||
|
||||
protected override async Task RefreshData() |
||||
{ |
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:artist" : $"type:artist AND ({_query})"; |
||||
_data = await Mediator.Send(new QuerySearchIndexArtists(searchQuery, PageNumber, PageSize)); |
||||
} |
||||
|
||||
private void PrevPage() |
||||
{ |
||||
var uri = $"/media/music/artists/page/{PageNumber - 1}"; |
||||
if (!string.IsNullOrWhiteSpace(_query)) |
||||
{ |
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query); |
||||
} |
||||
NavigationManager.NavigateTo(uri); |
||||
} |
||||
|
||||
private void NextPage() |
||||
{ |
||||
var uri = $"/media/music/artists/page/{PageNumber + 1}"; |
||||
if (!string.IsNullOrWhiteSpace(_query)) |
||||
{ |
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query); |
||||
} |
||||
NavigationManager.NavigateTo(uri); |
||||
} |
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e) |
||||
{ |
||||
List<MediaCardViewModel> GetSortedItems() => _data.Cards.OrderBy(m => m.SortTitle).ToList<MediaCardViewModel>(); |
||||
|
||||
SelectClicked(GetSortedItems, card, e); |
||||
} |
||||
|
||||
private async Task AddToCollection(MediaCardViewModel card) |
||||
{ |
||||
if (card is ArtistCardViewModel artist) |
||||
{ |
||||
var parameters = new DialogParameters { { "EntityType", "artist" }, { "EntityName", artist.Title } }; |
||||
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; |
||||
|
||||
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options); |
||||
DialogResult result = await dialog.Result; |
||||
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection) |
||||
{ |
||||
var request = new AddArtistToCollection(collection.Id, artist.ArtistId); |
||||
Either<BaseError, Unit> addResult = await Mediator.Send(request); |
||||
addResult.Match( |
||||
Left: error => |
||||
{ |
||||
Snackbar.Add($"Unexpected error adding artist to collection: {error.Value}"); |
||||
Logger.LogError("Unexpected error adding artist to collection: {Error}", error.Value); |
||||
}, |
||||
Right: _ => Snackbar.Add($"Added {artist.Title} to collection {collection.Name}", Severity.Success)); |
||||
} |
||||
} |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue