Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 24f2b4b727 force music video library scan (#159) 2021-04-09 07:42:13 -05:00
Jason DoveandGitHub 667887f387 fix television show and season playouts (#158) 2021-04-09 07:24:12 -05:00
Jason Dove 98eb72fcfe skip docker with [no docker] 2021-04-09 06:42:16 -05:00
Jason Dove c2f92fd054 add github release links to docs [no ci] 2021-04-09 06:39:22 -05:00
Jason DoveandGitHub f04ddd3a40 save collection page size (#157) 2021-04-09 06:30:46 -05:00
Jason DoveandGitHub aa0942384d fix removing deleted music videos (#156) 2021-04-09 05:34:28 -05:00
Jason DoveandGitHub cd100be3a2 relax music video naming requirements (#155) 2021-04-09 05:22:02 -05:00
Jason DoveandGitHub 2b26a5411c add artists as owners of music videos (#154)
* 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 placeholder
2021-04-09 05:10:58 -05:00
Jason DoveandGitHub baf81f31cd fix plex server and connection sync (#153) 2021-04-08 18:48:12 -05:00
Jason DoveandGitHub bfa290790b trim parsed music video titles (#152) 2021-04-07 16:36:20 -05:00
Jason DoveandGitHub b975922a77 only index video stream languages for movies and music videos (#151) 2021-04-07 16:12:39 -05:00
Jason Dove 1a39978a77 try to fix windows builds 2021-04-07 05:34:02 -05:00
Jason DoveandGitHub 436c9119fa add all search results to collection (#150) 2021-04-06 20:43:44 -05:00
Jason Dove 33642a13ce allow manual ci trigger 2021-04-06 09:21:54 -05:00
Jason Dove 09b349d1cb only index audio language [no ci] 2021-04-06 09:11:39 -05:00
93 changed files with 17393 additions and 293 deletions
+2 -1
View File
@@ -1,5 +1,6 @@
name: Build
on:
workflow_dispatch:
pull_request:
push:
branches:
@@ -35,7 +36,7 @@ jobs:
name: Build & Publish to Docker Hub
needs: build_and_test
runs-on: ubuntu-latest
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no ci]')
if: github.event_name == 'push' && !contains(github.event.head_commit.message, '[no docker]')
steps:
- name: Checkout
uses: actions/checkout@v2
@@ -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);
}
+27
View File
@@ -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 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Artists.Queries
{
public record GetArtistById(int ArtistId) : IRequest<Option<ArtistViewModel>>;
}
@@ -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 @@
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 @@
namespace ErsatzTV.Application.MediaCards
{
public record ArtistCardViewModel
(int ArtistId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
ArtistId,
Title,
Subtitle,
SortTitle,
Poster)
{
}
}
@@ -8,6 +8,7 @@ namespace ErsatzTV.Application.MediaCards
List<TelevisionShowCardViewModel> ShowCards,
List<TelevisionSeasonCardViewModel> SeasonCards,
List<TelevisionEpisodeCardViewModel> EpisodeCards,
List<ArtistCardViewModel> ArtistCards,
List<MusicVideoCardViewModel> MusicVideoCards)
{
public bool UseCustomPlaybackOrder { get; set; }
+12 -2
View File
@@ -55,11 +55,20 @@ namespace ErsatzTV.Application.MediaCards
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
musicVideoMetadata.MusicVideoId,
$"{musicVideoMetadata.Title} ({musicVideoMetadata.Artist})",
musicVideoMetadata.Year?.ToString(),
musicVideoMetadata.Title,
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
artistMetadata.ArtistId,
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
new(
@@ -73,6 +82,7 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
@@ -1,7 +1,7 @@
namespace ErsatzTV.Application.MediaCards
{
public record MusicVideoCardViewModel
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Poster) : MediaCardViewModel(
(int MusicVideoId, string Title, string Subtitle, string SortTitle, string Plot, string Poster) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
@@ -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 @@
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 @@
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 @@
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);
}
}
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.MediaCollections.Commands
int CollectionId,
List<int> MovieIds,
List<int> ShowIds,
List<int> ArtistIds,
List<int> MusicVideoIds) : MediatR.IRequest<Either<BaseError, Unit>>;
}
@@ -39,9 +39,13 @@ namespace ErsatzTV.Application.MediaCollections.Commands
private async Task<Unit> ApplyAddItemsRequest(AddItemsToCollection request)
{
if (await _mediaCollectionRepository.AddMediaItems(
request.CollectionId,
request.MovieIds.Append(request.ShowIds).Append(request.MusicVideoIds).ToList()))
var allItems = request.MovieIds
.Append(request.ShowIds)
.Append(request.ArtistIds)
.Append(request.MusicVideoIds)
.ToList();
if (await _mediaCollectionRepository.AddMediaItems(request.CollectionId, allItems))
{
// rebuild all playouts that use this collection
foreach (int playoutId in await _mediaCollectionRepository
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCollections
{
public record PagedMediaCollectionsViewModel(int TotalCount, List<MediaCollectionViewModel> Page);
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public record GetPagedCollections(int PageNum, int PageSize) : IRequest<PagedMediaCollectionsViewModel>;
}
@@ -0,0 +1,32 @@
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.MediaCollections.Mapper;
namespace ErsatzTV.Application.MediaCollections.Queries
{
public class GetPagedCollectionsHandler : IRequestHandler<GetPagedCollections, PagedMediaCollectionsViewModel>
{
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public GetPagedCollectionsHandler(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<PagedMediaCollectionsViewModel> Handle(
GetPagedCollections request,
CancellationToken cancellationToken)
{
int count = await _mediaCollectionRepository.CountAllCollections();
List<MediaCollectionViewModel> page = await _mediaCollectionRepository
.GetPagedCollections(request.PageNum, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
return new PagedMediaCollectionsViewModel(count, page);
}
}
}
+4 -1
View File
@@ -37,7 +37,10 @@ namespace ErsatzTV.Application.Playouts
case Movie m:
return m.MovieMetadata.HeadOrNone().Map(mm => mm.Title).IfNone("[unknown movie]");
case MusicVideo mv:
return mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Plex.Commands
{
@@ -19,6 +20,7 @@ namespace ErsatzTV.Application.Plex.Commands
{
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
private readonly IEntityLocker _entityLocker;
private readonly ILogger<SynchronizePlexMediaSourcesHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexTvApiClient _plexTvApiClient;
@@ -26,12 +28,14 @@ namespace ErsatzTV.Application.Plex.Commands
IMediaSourceRepository mediaSourceRepository,
IPlexTvApiClient plexTvApiClient,
ChannelWriter<IPlexBackgroundServiceRequest> channel,
IEntityLocker entityLocker)
IEntityLocker entityLocker,
ILogger<SynchronizePlexMediaSourcesHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
_plexTvApiClient = plexTvApiClient;
_channel = channel;
_entityLocker = entityLocker;
_logger = logger;
}
public Task<Either<BaseError, List<PlexMediaSource>>> Handle(
@@ -47,6 +51,14 @@ namespace ErsatzTV.Application.Plex.Commands
await SynchronizeServer(allExisting, server);
}
// delete removed servers
foreach (PlexMediaSource removed in allExisting.Filter(
s => servers.All(pms => pms.ClientIdentifier != s.ClientIdentifier)))
{
_logger.LogWarning("Deleting removed Plex server {ServerName}!", removed.Id.ToString());
await _mediaSourceRepository.DeletePlex(removed);
}
foreach (PlexMediaSource mediaSource in await _mediaSourceRepository.GetAllPlex())
{
await _channel.WriteAsync(new SynchronizePlexLibraries(mediaSource.Id));
@@ -72,7 +84,7 @@ namespace ErsatzTV.Application.Plex.Commands
.Filter(connection => existing.Connections.All(c => c.Uri != connection.Uri)).ToList();
var toRemove = existing.Connections
.Filter(connection => server.Connections.All(c => c.Uri != connection.Uri)).ToList();
return _mediaSourceRepository.Update(existing, toAdd, toRemove);
return _mediaSourceRepository.Update(existing, server.Connections, toAdd, toRemove);
},
async () =>
{
@@ -1,7 +0,0 @@
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndex(string Query) : IRequest<SearchResult>;
}
@@ -0,0 +1,6 @@
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public record QuerySearchIndexAllItems(string Query) : IRequest<SearchResultAllItemsViewModel>;
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class
QuerySearchIndexAllItemsHandler : IRequestHandler<QuerySearchIndexAllItems, SearchResultAllItemsViewModel>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexAllItemsHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public async Task<SearchResultAllItemsViewModel> Handle(
QuerySearchIndexAllItems request,
CancellationToken cancellationToken)
{
List<int> movieIds = await _searchIndex.Search($"type:movie AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> showIds = await _searchIndex.Search($"type:show AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> artistIds = await _searchIndex.Search($"type:artist AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
List<int> musicVideoIds = await _searchIndex.Search($"type:music_video AND ({request.Query})", 0, 0)
.Map(result => result.Items.Map(i => i.Id).ToList());
return new SearchResultAllItemsViewModel(movieIds, showIds, artistIds, musicVideoIds);
}
}
}
@@ -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 @@
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);
}
}
}
@@ -1,18 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
using MediatR;
namespace ErsatzTV.Application.Search.Queries
{
public class QuerySearchIndexHandler : IRequestHandler<QuerySearchIndex, SearchResult>
{
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexHandler(ISearchIndex searchIndex) => _searchIndex = searchIndex;
public Task<SearchResult> Handle(QuerySearchIndex request, CancellationToken cancellationToken) =>
_searchIndex.Search(request.Query, 0, 100);
}
}
@@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.Search
{
public record SearchResultAllItemsViewModel(List<int> MovieIds, List<int> ShowIds, List<int> ArtistIds, List<int> MusicVideoIds);
}
@@ -26,6 +26,11 @@ namespace ErsatzTV.Core.Tests.Fakes
throw new NotSupportedException();
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
public Task<int> CountAllCollections() => throw new NotSupportedException();
public Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize) =>
throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
public Task Delete(int collectionId) => throw new NotSupportedException();
+10
View File
@@ -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; }
}
}
@@ -4,6 +4,8 @@ namespace ErsatzTV.Core.Domain
{
public class MusicVideo : MediaItem
{
public int ArtistId { get; set; }
public Artist Artist { get; set; }
public List<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public List<MediaVersion> MediaVersions { get; set; }
}
@@ -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; }
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class Mood
{
public int Id { get; set; }
public string Name { get; set; }
}
}
@@ -4,7 +4,6 @@
{
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; }
}
+8
View File
@@ -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 @@
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);
}
}
@@ -6,12 +6,15 @@ namespace ErsatzTV.Core.Interfaces.Metadata
public interface ILocalMetadataProvider
{
Task<ShowMetadata> GetMetadataForShow(string showFolder);
Task<ArtistMetadata> GetMetadataForArtist(string artistFolder);
Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Show televisionShow, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Episode episode, string nfoFileName);
Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName);
Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName);
Task<bool> RefreshFallbackMetadata(Movie movie);
Task<bool> RefreshFallbackMetadata(Episode episode);
Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder);
Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo);
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
}
@@ -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);
}
}
@@ -15,6 +15,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
Task<Option<Collection>> GetCollectionWithCollectionItemsUntracked(int id);
Task<List<Collection>> GetAll();
Task<int> CountAllCollections();
Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize);
Task<Option<List<MediaItem>>> GetItems(int id);
Task<bool> Update(Collection collection);
Task Delete(int collectionId);
@@ -20,7 +20,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource, List<PlexConnection> toAdd, List<PlexConnection> toDelete);
Task Update(
PlexMediaSource plexMediaSource,
List<PlexConnection> prioritizedConnections,
List<PlexConnection> toAdd,
List<PlexConnection> toDelete);
Task<Unit> UpdateLibraries(
int plexMediaSourceId,
@@ -36,6 +41,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int mediaSourceId);
Task<List<int>> DeleteAllPlex();
Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource);
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}
@@ -10,6 +10,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> RemoveGenre(Genre genre);
Task<bool> RemoveTag(Tag tag);
Task<bool> RemoveStudio(Studio studio);
Task<bool> RemoveStyle(Style style);
Task<bool> RemoveMood(Mood mood);
Task<bool> Update(Domain.Metadata metadata);
Task<bool> Add(Domain.Metadata metadata);
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
@@ -8,7 +8,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMusicVideoRepository
{
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string path);
Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
Task<bool> AddGenre(MusicVideoMetadata metadata, Genre genre);
@@ -16,5 +20,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio);
Task<List<MusicVideoMetadata>> GetMusicVideosForCards(List<int> ids);
Task<Option<MusicVideo>> GetMusicVideo(int musicVideoId);
Task<IEnumerable<string>> FindOrphanPaths(LibraryPath libraryPath);
Task<int> GetMusicVideoCount(int artistId);
Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize);
}
}
@@ -9,9 +9,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
{
public Task<List<int>> GetItemIdsToIndex();
public Task<Option<MediaItem>> GetItemToIndex(int id);
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
public Task<List<string>> GetLanguagesForShow(Show show);
public Task<List<string>> GetLanguagesForArtist(Artist artist);
}
}
+2 -2
View File
@@ -221,8 +221,8 @@ namespace ErsatzTV.Core.Iptv
.IfNone("[unknown movie]"),
Episode e => e.Season.Show.ShowMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty)
.IfNone("[unknown show]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Map(mvm => $"{mvm.Artist} - {mvm.Title}")
.IfNone("[unknown music video]"),
MusicVideo mv => mv.Artist.ArtistMetadata.HeadOrNone().Map(am => am.Title ?? string.Empty)
.IfNone("[unknown artist]"),
_ => "[unknown]"
};
}
@@ -4,6 +4,7 @@ using System.IO;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
@@ -18,6 +19,13 @@ namespace ErsatzTV.Core.Metadata
return GetTelevisionShowMetadata(fileName, metadata);
}
public ArtistMetadata GetFallbackMetadataForArtist(string artistFolder)
{
string fileName = Path.GetFileName(artistFolder);
return new ArtistMetadata
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? artistFolder };
}
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
@@ -36,7 +44,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public MusicVideoMetadata GetFallbackMetadata(MusicVideo musicVideo)
public Option<MusicVideoMetadata> GetFallbackMetadata(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@@ -46,7 +54,7 @@ namespace ErsatzTV.Core.Metadata
Title = fileName ?? path
};
return fileName != null ? GetMusicVideoMetadata(fileName, metadata) : metadata;
return GetMusicVideoMetadata(fileName, metadata);
}
public string GetSortTitle(string title)
@@ -125,28 +133,26 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private MusicVideoMetadata GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
private Option<MusicVideoMetadata> GetMusicVideoMetadata(string fileName, MusicVideoMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?) - (.*?).\w+$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.Artist = match.Groups[1].Value;
metadata.Title = match.Groups[2].Value;
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
}
metadata.Title = match.Success
? match.Groups[2].Value.Trim()
: Path.GetFileNameWithoutExtension(fileName);
metadata.Genres = new List<Genre>();
metadata.Tags = new List<Tag>();
metadata.Studios = new List<Studio>();
metadata.DateUpdated = DateTime.UtcNow;
return metadata;
}
catch (Exception)
{
// ignored
return None;
}
return metadata;
}
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
@@ -166,5 +166,9 @@ namespace ErsatzTV.Core.Metadata
return false;
}
protected bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
}
}
+161 -3
View File
@@ -19,7 +19,9 @@ namespace ErsatzTV.Core.Metadata
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private static readonly XmlSerializer ArtistSerializer = new(typeof(ArtistNfo));
private static readonly XmlSerializer MusicVideoSerializer = new(typeof(MusicVideoNfo));
private readonly IArtistRepository _artistRepository;
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILogger<LocalMetadataProvider> _logger;
@@ -33,6 +35,7 @@ namespace ErsatzTV.Core.Metadata
IMetadataRepository metadataRepository,
IMovieRepository movieRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IFallbackMetadataProvider fallbackMetadataProvider,
ILocalFileSystem localFileSystem,
@@ -41,6 +44,7 @@ namespace ErsatzTV.Core.Metadata
_metadataRepository = metadataRepository;
_movieRepository = movieRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_fallbackMetadataProvider = fallbackMetadataProvider;
_localFileSystem = localFileSystem;
@@ -70,6 +74,29 @@ namespace ErsatzTV.Core.Metadata
});
}
public async Task<ArtistMetadata> GetMetadataForArtist(string artistFolder)
{
string nfoFileName = Path.Combine(artistFolder, "artist.nfo");
Option<ArtistMetadata> maybeMetadata = None;
if (_localFileSystem.FileExists(nfoFileName))
{
maybeMetadata = await LoadArtistMetadata(nfoFileName);
}
return maybeMetadata.Match(
metadata =>
{
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
},
() =>
{
ArtistMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder);
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
public Task<bool> RefreshSidecarMetadata(Movie movie, string nfoFileName) =>
LoadMovieMetadata(movie, nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
@@ -88,6 +115,12 @@ namespace ErsatzTV.Core.Metadata
metadata => ApplyMetadataUpdate(episode, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(Artist artist, string nfoFileName) =>
LoadArtistMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
metadata => ApplyMetadataUpdate(artist, metadata),
() => Task.FromResult(false)));
public Task<bool> RefreshSidecarMetadata(MusicVideo musicVideo, string nfoFileName) =>
LoadMusicVideoMetadata(nfoFileName).Bind(
maybeMetadata => maybeMetadata.Match(
@@ -100,8 +133,13 @@ namespace ErsatzTV.Core.Metadata
public Task<bool> RefreshFallbackMetadata(Episode episode) =>
ApplyMetadataUpdate(episode, _fallbackMetadataProvider.GetFallbackMetadata(episode));
public Task<bool> RefreshFallbackMetadata(Artist artist, string artistFolder) =>
ApplyMetadataUpdate(artist, _fallbackMetadataProvider.GetFallbackMetadataForArtist(artistFolder));
public Task<bool> RefreshFallbackMetadata(MusicVideo musicVideo) =>
ApplyMetadataUpdate(musicVideo, _fallbackMetadataProvider.GetFallbackMetadata(musicVideo));
_fallbackMetadataProvider.GetFallbackMetadata(musicVideo).Match(
metadata => ApplyMetadataUpdate(musicVideo, metadata),
() => Task.FromResult(false));
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
@@ -118,7 +156,6 @@ namespace ErsatzTV.Core.Metadata
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Artist = nfo.Artist,
Album = nfo.Album,
Title = nfo.Title,
Plot = nfo.Plot,
@@ -269,11 +306,104 @@ namespace ErsatzTV.Core.Metadata
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(Artist artist, ArtistMetadata metadata) =>
Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Title = metadata.Title;
existing.Disambiguation = metadata.Disambiguation;
existing.Biography = metadata.Biography;
if (existing.DateAdded == DateTime.MinValue)
{
existing.DateAdded = metadata.DateAdded;
}
existing.DateUpdated = metadata.DateUpdated;
existing.MetadataKind = metadata.MetadataKind;
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
var updated = false;
foreach (Genre genre in existing.Genres.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
updated = true;
}
}
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existing.Genres.Add(genre);
if (await _artistRepository.AddGenre(existing, genre))
{
updated = true;
}
}
foreach (Style style in existing.Styles.Filter(s => metadata.Styles.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Styles.Remove(style);
if (await _metadataRepository.RemoveStyle(style))
{
updated = true;
}
}
foreach (Style style in metadata.Styles.Filter(s => existing.Styles.All(s2 => s2.Name != s.Name))
.ToList())
{
existing.Styles.Add(style);
if (await _artistRepository.AddStyle(existing, style))
{
updated = true;
}
}
foreach (Mood mood in existing.Moods.Filter(m => metadata.Moods.All(m2 => m2.Name != m.Name))
.ToList())
{
existing.Moods.Remove(mood);
if (await _metadataRepository.RemoveMood(mood))
{
updated = true;
}
}
foreach (Mood mood in metadata.Moods.Filter(s => existing.Moods.All(m2 => m2.Name != s.Name))
.ToList())
{
existing.Moods.Add(mood);
if (await _artistRepository.AddMood(existing, mood))
{
updated = true;
}
}
return await _metadataRepository.Update(existing) || updated;
},
async () =>
{
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
: metadata.SortTitle;
metadata.ArtistId = artist.Id;
artist.ArtistMetadata = new List<ArtistMetadata> { metadata };
return await _metadataRepository.Add(metadata);
});
private Task<bool> ApplyMetadataUpdate(MusicVideo musicVideo, MusicVideoMetadata metadata) =>
Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
async existing =>
{
existing.Artist = metadata.Artist;
existing.Title = metadata.Title;
existing.Year = metadata.Year;
existing.Plot = metadata.Plot;
@@ -343,6 +473,34 @@ namespace ErsatzTV.Core.Metadata
}
}
private async Task<Option<ArtistMetadata>> LoadArtistMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<ArtistNfo> maybeNfo = ArtistSerializer.Deserialize(fileStream) as ArtistNfo;
return maybeNfo.Match<Option<ArtistMetadata>>(
nfo => new ArtistMetadata
{
MetadataKind = MetadataKind.Sidecar,
DateAdded = DateTime.UtcNow,
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Name,
Disambiguation = nfo.Disambiguation,
Biography = nfo.Biography,
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
Styles = nfo.Styles.Map(s => new Style { Name = s }).ToList(),
Moods = nfo.Moods.Map(m => new Mood { Name = m }).ToList()
},
None);
}
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read artist nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<Tuple<EpisodeMetadata, int>>> LoadEpisodeMetadata(Episode episode, string nfoFileName)
{
try
+186 -22
View File
@@ -19,6 +19,7 @@ namespace ErsatzTV.Core.Metadata
{
public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScanner
{
private readonly IArtistRepository _artistRepository;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<MusicVideoFolderScanner> _logger;
@@ -35,6 +36,7 @@ namespace ErsatzTV.Core.Metadata
IImageCache imageCache,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
IArtistRepository artistRepository,
IMusicVideoRepository musicVideoRepository,
IMediator mediator,
ILogger<MusicVideoFolderScanner> logger) : base(
@@ -48,6 +50,7 @@ namespace ErsatzTV.Core.Metadata
_localMetadataProvider = localMetadataProvider;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_artistRepository = artistRepository;
_musicVideoRepository = musicVideoRepository;
_mediator = mediator;
_logger = logger;
@@ -67,23 +70,175 @@ namespace ErsatzTV.Core.Metadata
return new MediaSourceInaccessible();
}
var foldersCompleted = 0;
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity)
.ToList();
var folderQueue = new Queue<string>();
folderQueue.Enqueue(libraryPath.Path);
while (folderQueue.Count > 0)
foreach (string artistFolder in allArtistFolders)
{
decimal percentCompletion = (decimal) foldersCompleted / (foldersCompleted + folderQueue.Count);
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
decimal percentCompletion = (decimal) allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder)
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.Thumbnail))
.BindT(artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt));
await maybeArtist.Match(
async result =>
{
await ScanMusicVideos(
libraryPath,
ffprobePath,
result.Item,
artistFolder,
// force scanning all folders if we're adding a new artist
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
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 });
}
},
error =>
{
_logger.LogWarning(
"Error processing artist in folder {Folder}: {Error}",
artistFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
}
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
{
_logger.LogInformation("Removing improperly named music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Removing missing music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
}
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> FindOrCreateArtist(
int libraryPathId,
string artistFolder)
{
ArtistMetadata metadata = await _localMetadataProvider.GetMetadataForArtist(artistFolder);
Option<Artist> maybeArtist = await _artistRepository.GetArtistByMetadata(libraryPathId, metadata);
return await maybeArtist.Match(
artist => Right<BaseError, MediaItemScanResult<Artist>>(new MediaItemScanResult<Artist>(artist))
.AsTask(),
async () => await _artistRepository.AddArtist(libraryPathId, artistFolder, metadata));
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateMetadataForArtist(
MediaItemScanResult<Artist> result,
string artistFolder)
{
try
{
Artist artist = result.Item;
await LocateNfoFileForArtist(artistFolder).Match(
async nfoFile =>
{
bool shouldUpdate = Optional(artist.ArtistMetadata).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(artist, nfoFile))
{
result.IsUpdated = true;
}
}
},
async () =>
{
if (!Optional(artist.ArtistMetadata).Flatten().Any())
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", artistFolder);
if (await _localMetadataProvider.RefreshFallbackMetadata(artist, artistFolder))
{
result.IsUpdated = true;
}
}
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> UpdateArtworkForArtist(
MediaItemScanResult<Artist> result,
string artistFolder,
ArtworkKind artworkKind)
{
try
{
Artist artist = result.Item;
await LocateArtworkForArtist(artistFolder, artworkKind).IfSomeAsync(
async artworkFile =>
{
ArtistMetadata metadata = artist.ArtistMetadata.Head();
await RefreshArtwork(artworkFile, metadata, artworkKind);
});
return result;
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
public async Task ScanMusicVideos(
LibraryPath libraryPath,
string ffprobePath,
Artist artist,
string artistFolder,
DateTimeOffset lastScan)
{
var folderQueue = new Queue<string>();
folderQueue.Enqueue(artistFolder);
while (folderQueue.Count > 0)
{
string musicVideoFolder = folderQueue.Dequeue();
foldersCompleted++;
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
var allFiles = _localFileSystem.ListFiles(musicVideoFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => f.Contains(" - "))
.ToList();
foreach (string subdirectory in _localFileSystem.ListSubdirectories(musicVideoFolder)
@@ -101,7 +256,7 @@ namespace ErsatzTV.Core.Metadata
{
// TODO: figure out how to rebuild playouts
Either<BaseError, MediaItemScanResult<MusicVideo>> maybeMusicVideo = await _musicVideoRepository
.GetOrAdd(libraryPath, file)
.GetOrAdd(artist, libraryPath, file)
.BindT(musicVideo => UpdateStatistics(musicVideo, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
@@ -125,19 +280,6 @@ namespace ErsatzTV.Core.Metadata
});
}
}
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);
}
}
_searchIndex.Commit();
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
@@ -167,6 +309,8 @@ namespace ErsatzTV.Core.Metadata
{
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
{
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
@@ -184,6 +328,26 @@ namespace ErsatzTV.Core.Metadata
}
}
private Option<string> LocateNfoFileForArtist(string artistFolder) =>
Optional(Path.Combine(artistFolder, "artist.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
{
string segment = artworkKind switch
{
ArtworkKind.Thumbnail => "thumb",
ArtworkKind.FanArt => "fanart",
_ => throw new ArgumentOutOfRangeException(nameof(artworkKind))
};
return ImageFileExtensions
.Map(ext => $"{segment}.{ext}")
.Map(f => Path.Combine(artistFolder, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocateNfoFile(MusicVideo musicVideo)
{
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
+27
View File
@@ -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; }
}
}
@@ -87,6 +87,14 @@ namespace ErsatzTV.Core.Metadata
await maybeShow.Match(
async result =>
{
await ScanSeasons(
libraryPath,
ffprobePath,
result.Item,
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@@ -95,14 +103,6 @@ namespace ErsatzTV.Core.Metadata
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await ScanSeasons(
libraryPath,
ffprobePath,
result.Item,
showFolder,
// force scanning all folders if we're adding a new show
result.IsAdded ? DateTimeOffset.MinValue : lastScan);
},
error =>
{
@@ -401,10 +401,6 @@ namespace ErsatzTV.Core.Metadata
.HeadOrNone();
}
private bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
private static Option<int> SeasonNumberForFolder(string folder)
{
if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber))
@@ -70,6 +70,8 @@ namespace ErsatzTV.Core.Plex
await maybeShow.Match(
async result =>
{
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@@ -80,8 +82,6 @@ namespace ErsatzTV.Core.Plex
_searchRepository,
new List<MediaItem> { result.Item });
}
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
},
error =>
{
+21 -13
View File
@@ -475,20 +475,28 @@ namespace ErsatzTV.Core.Scheduling
}
}
private static string DisplayTitle(MediaItem mediaItem) =>
mediaItem switch
private static string DisplayTitle(MediaItem mediaItem)
{
switch (mediaItem)
{
Episode e => e.EpisodeMetadata.Any() && e.Season != null
? $"{e.EpisodeMetadata.Head().Title} - s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00}"
: "[unknown episode]",
Movie m => m.MovieMetadata.HeadOrNone().Match(
mm => mm.Title ?? string.Empty,
() => "[unknown movie]"),
MusicVideo mv => mv.MusicVideoMetadata.HeadOrNone().Match(
mvm => $"{mvm.Artist} - {mvm.Title}",
() => "[unknown music video]"),
_ => string.Empty
};
case Episode e:
string showTitle = e.Season.Show.ShowMetadata.HeadOrNone()
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
return e.EpisodeMetadata.HeadOrNone()
.Map(em => $"{showTitle}s{e.Season.SeasonNumber:00}e{e.EpisodeNumber:00} - {em.Title}")
.IfNone("[unknown episode]");
case Movie m:
return m.MovieMetadata.HeadOrNone().Match(mm => mm.Title ?? string.Empty, () => "[unknown movie]");
case MusicVideo mv:
string artistName = mv.Artist.ArtistMetadata.HeadOrNone()
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
return mv.MusicVideoMetadata.HeadOrNone()
.Map(mvm => $"{artistName}{mvm.Title}")
.IfNone("[unknown music video]");
default:
return string.Empty;
}
}
private static CollectionKey CollectionKeyForItem(ProgramScheduleItem item) =>
item.CollectionType switch
@@ -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 @@
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 @@
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);
}
}
@@ -15,10 +15,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
private readonly IDbConnection _dbConnection;
private readonly TvContext _dbContext;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public MediaCollectionRepository(TvContext dbContext, IDbConnection dbConnection)
public MediaCollectionRepository(
TvContext dbContext,
IDbContextFactory<TvContext> dbContextFactory,
IDbConnection dbConnection)
{
_dbContext = dbContext;
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
@@ -29,7 +34,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return collection;
}
public async Task<bool> AddMediaItem(int collectionId, int mediaItemId)
{
var modified = false;
@@ -130,9 +134,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(i => (i as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Artist).ArtistMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).MusicVideoMetadata)
.ThenInclude(mvm => mvm.Artwork)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(c => c.MediaItems)
.ThenInclude(i => (i as Show).ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(c => c.MediaItems)
@@ -162,6 +172,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<Collection>> GetAll() =>
_dbContext.Collections.ToListAsync();
public Task<int> CountAllCollections() =>
_dbConnection.QuerySingleAsync<int>(@"SELECT COUNT (*) FROM Collection");
public async Task<List<Collection>> GetPagedCollections(int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.Collections.FromSqlRaw(
@"SELECT * FROM Collection
ORDER BY Name
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.ToListAsync();
}
public Task<Option<List<MediaItem>>> GetItems(int id) =>
Get(id).MapT(GetItemsForCollection).Bind(x => x.Sequence());
@@ -201,6 +227,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
result.AddRange(await GetShowItems(collection));
result.AddRange(await GetSeasonItems(collection));
result.AddRange(await GetEpisodeItems(collection));
result.AddRange(await GetArtistItems(collection));
result.AddRange(await GetMusicVideoItems(collection));
return result.Distinct().ToList();
@@ -221,6 +248,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
private async Task<List<MusicVideo>> GetArtistItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT MusicVideo.Id FROM CollectionItem ci
INNER JOIN Artist on Artist.Id = ci.MediaItemId
INNER JOIN MusicVideo on Artist.Id = MusicVideo.ArtistId
WHERE ci.CollectionId = @CollectionId",
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
.ToListAsync();
}
private async Task<List<MusicVideo>> GetMusicVideoItems(Collection collection)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@@ -230,6 +276,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
new { CollectionId = collection.Id });
return await _dbContext.MusicVideos
.Include(m => m.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(m => m.MusicVideoMetadata)
.Include(m => m.MediaVersions)
.Filter(m => ids.Contains(m.Id))
@@ -250,6 +298,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -267,6 +317,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -283,6 +335,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -155,6 +155,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task Update(
PlexMediaSource plexMediaSource,
List<PlexConnection> sortedConnections,
List<PlexConnection> toAdd,
List<PlexConnection> toDelete)
{
@@ -174,27 +175,34 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
plexMediaSource.Id
});
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (PlexConnection add in toAdd)
{
add.PlexMediaSourceId = plexMediaSource.Id;
dbContext.Entry(add).State = EntityState.Added;
await _dbConnection.ExecuteAsync(
@"INSERT INTO PlexConnection (IsActive, Uri, PlexMediaSourceId)
VALUES (0, @Uri, @PlexMediaSourceId)",
new { add.Uri, PlexMediaSourceId = plexMediaSource.Id });
}
foreach (PlexConnection delete in toDelete)
{
dbContext.Entry(delete).State = EntityState.Deleted;
await _dbConnection.ExecuteAsync(
@"DELETE FROM PlexConnection WHERE Id = @Id",
new { delete.Id });
}
await dbContext.SaveChangesAsync();
PlexMediaSource pms = await dbContext.PlexMediaSources.FindAsync(plexMediaSource.Id);
await dbContext.Entry(pms).Collection(x => x.Connections).LoadAsync();
if (plexMediaSource.Connections.Any() && plexMediaSource.Connections.All(c => !c.IsActive))
int activeCount = await _dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM PlexConnection WHERE IsActive = 1 AND PlexMediaSourceId = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id });
if (activeCount == 0)
{
plexMediaSource.Connections.Head().IsActive = true;
await dbContext.SaveChangesAsync();
Option<PlexConnection> toActivate =
sortedConnections.FirstOrDefault(c => toDelete.All(d => d.Id != c.Id));
// update on uri because connections from Plex API don't have our local ids
await toActivate.IfSomeAsync(
async c => await _dbConnection.ExecuteAsync(
@"UPDATE PlexConnection SET IsActive = 1 WHERE Uri = @Uri",
new { c.Uri }));
}
}
@@ -292,6 +300,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return movieIds.Append(showIds).ToList();
}
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
{
List<int> mediaItemIds = await _dbConnection.QueryAsync<int>(
@"SELECT MediaItem.Id FROM MediaItem
INNER JOIN LibraryPath LP on MediaItem.LibraryPathId = LP.Id
INNER JOIN Library L on LP.LibraryId = L.Id
WHERE L.MediaSourceId = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id })
.Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaSource WHERE Id = @PlexMediaSourceId",
new { PlexMediaSourceId = plexMediaSource.Id });
return mediaItemIds;
}
public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
{
await _dbConnection.ExecuteAsync(
@@ -156,6 +156,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
ArtistMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, ArtistMetadataId, DateAdded, DateUpdated, Path)
Values (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
parameters)
.ToUnit(),
MusicVideoMetadata => _dbConnection.ExecuteAsync(
@"INSERT INTO Artwork (ArtworkKind, MusicVideoMetadataId, DateAdded, DateUpdated, Path)
VALUES (@ArtworkKind, @Id, @DateAdded, @DateUpdated, @Path)",
@@ -197,5 +202,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<bool> RemoveStudio(Studio studio) =>
_dbConnection.ExecuteAsync("DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id })
.Map(result => result > 0);
public Task<bool> RemoveStyle(Style style) =>
_dbConnection.ExecuteAsync("DELETE FROM Style WHERE Id = @StyleId", new { StyleId = style.Id })
.Map(result => result > 0);
public Task<bool> RemoveMood(Mood mood) =>
_dbConnection.ExecuteAsync("DELETE FROM Mood WHERE Id = @MoodId", new { MoodId = mood.Id })
.Map(result => result > 0);
}
}
@@ -26,6 +26,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> GetOrAdd(
Artist artist,
LibraryPath libraryPath,
string path)
{
@@ -50,10 +51,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
return await maybeExisting.Match(
mediaItem =>
Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false }).AsTask(),
async () => await AddMusicVideo(dbContext, libraryPath.Id, path));
async mediaItem =>
{
if (mediaItem.ArtistId != artist.Id)
{
await _dbConnection.ExecuteAsync(
@"UPDATE MusicVideo SET ArtistId = @ArtistId WHERE Id = @Id",
new { mediaItem.Id, ArtistId = artist.Id });
mediaItem.ArtistId = artist.Id;
mediaItem.Artist = artist;
}
return Right<BaseError, MediaItemScanResult<MusicVideo>>(
new MediaItemScanResult<MusicVideo>(mediaItem) { IsAdded = false });
},
async () => await AddMusicVideo(dbContext, artist, libraryPath.Id, path));
}
public Task<IEnumerable<string>> FindMusicVideoPaths(LibraryPath libraryPath) =>
@@ -72,7 +85,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"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 MediaVersion MV on M.Id = MV.MusicVideoId
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());
@@ -110,6 +123,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Filter(mvm => ids.Contains(mvm.MusicVideoId))
.Include(mvm => mvm.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(mvm => mvm.Artwork)
.OrderBy(mvm => mvm.SortTitle)
.ToListAsync();
@@ -132,8 +148,44 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public Task<IEnumerable<string>> FindOrphanPaths(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
AND NOT EXISTS (SELECT * FROM MusicVideoMetadata WHERE MusicVideoId = M.Id)",
new { LibraryPathId = libraryPath.Id });
public Task<int> GetMusicVideoCount(int artistId) =>
_dbConnection.QuerySingleAsync<int>(
@"SELECT COUNT(*) FROM MusicVideo WHERE ArtistId = @ArtistId",
new { ArtistId = artistId });
public async Task<List<MusicVideoMetadata>> GetPagedMusicVideos(int artistId, int pageNumber, int pageSize)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
return await dbContext.MusicVideoMetadata
.AsNoTracking()
.Include(m => m.Artwork)
.Include(m => m.Genres)
.Include(m => m.Tags)
.Include(m => m.Studios)
.Include(m => m.MusicVideo)
.ThenInclude(mv => mv.Artist)
.ThenInclude(a => a.ArtistMetadata)
.Filter(m => m.MusicVideo.ArtistId == artistId)
.OrderBy(m => m.SortTitle)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
private static async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> AddMusicVideo(
TvContext dbContext,
Artist artist,
int libraryPathId,
string path)
{
@@ -141,6 +193,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
var musicVideo = new MusicVideo
{
ArtistId = artist.Id,
LibraryPathId = libraryPathId,
MediaVersions = new List<MediaVersion>
{
@@ -101,6 +101,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MediaVersions)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(mm => mm.ArtistMetadata)
.Include(i => i.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.ThenInclude(em => em.Artwork)
.Include(i => i.MediaItem)
@@ -55,98 +55,35 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ThenInclude(mm => mm.Studios)
.Include(mi => (mi as MusicVideo).MediaVersions)
.ThenInclude(mm => mm.Streams)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Genres)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Styles)
.Include(mi => (mi as Artist).ArtistMetadata)
.ThenInclude(mm => mm.Moods)
.OrderBy(mi => mi.Id)
.SingleOrDefaultAsync(mi => mi.Id == id)
.Map(Optional);
}
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
WHERE MM.Title LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
WHERE SM.Title LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = $"%{query}%" })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByGenre(string genre)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Genre G on MM.Id = G.MovieMetadataId
WHERE G.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Genre G2 on SM.Id = G2.ShowMetadataId
WHERE G2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = genre })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public async Task<List<MediaItem>> SearchMediaItemsByTag(string tag)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
INNER JOIN Tag T on MM.Id = T.MovieMetadataId
WHERE T.Name LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
INNER JOIN Tag T2 on SM.Id = T2.ShowMetadataId
WHERE T2.Name LIKE @Query
GROUP BY SM.Title, SM.Year",
new { Query = tag })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
public Task<List<string>> GetLanguagesForShow(Show show) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion MV on MediaStream.MediaVersionId = MV.Id
INNER JOIN Episode E on MV.EpisodeId = E.Id
INNER JOIN Season S on E.SeasonId = S.Id
WHERE S.ShowId = @ShowId",
INNER JOIN MediaVersion MV ON MediaStream.MediaVersionId = MV.Id
INNER JOIN Episode E ON MV.EpisodeId = E.Id
INNER JOIN Season S ON E.SeasonId = S.Id
WHERE MediaStreamKind = 2 AND S.ShowId = @ShowId",
new { ShowId = show.Id }).Map(result => result.ToList());
public Task<List<string>> GetLanguagesForArtist(Artist artist) =>
_dbConnection.QueryAsync<string>(
@"SELECT DISTINCT Language
FROM MediaStream
INNER JOIN MediaVersion V ON MediaStream.MediaVersionId = V.Id
INNER JOIN MusicVideo MV ON V.MusicVideoId = MV.Id
INNER JOIN Artist A on MV.ArtistId = A.Id
WHERE MediaStreamKind = 2 AND A.Id = @ArtistId",
new { ArtistId = artist.Id }).Map(result => result.ToList());
}
}
@@ -472,6 +472,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => ids.Contains(e.Id))
.ToListAsync();
}
@@ -484,6 +486,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Include(e => e.EpisodeMetadata)
.Include(e => e.MediaVersions)
.Include(e => e.Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Filter(e => e.SeasonId == seasonId)
.ToListAsync();
}
@@ -27,6 +27,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaFile> MediaFiles { get; set; }
public DbSet<Movie> Movies { get; set; }
public DbSet<MovieMetadata> MovieMetadata { get; set; }
public DbSet<Artist> Artists { get; set; }
public DbSet<ArtistMetadata> ArtistMetadata { get; set; }
public DbSet<MusicVideo> MusicVideos { get; set; }
public DbSet<MusicVideoMetadata> MusicVideoMetadata { get; set; }
public DbSet<Show> Shows { get; set; }
File diff suppressed because it is too large Load Diff
@@ -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 @@
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,21 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Update_MusicVideoMetadata_Title : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(
@"UPDATE MusicVideoMetadata SET DateUpdated = '0001-01-01 00:00:00' WHERE MetadataKind = 0");
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE LibraryId IN
(SELECT Id FROM Library WHERE MediaKind = 3)");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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 @@
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 @@
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,16 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Reset_LibraryPathLastScan_MusicVideos : Migration
{
protected override void Up(MigrationBuilder migrationBuilder) =>
migrationBuilder.Sql(
@"UPDATE LibraryPath SET LastScan = '0001-01-01 00:00:00' WHERE Id IN
(SELECT LP.Id FROM LibraryPath LP INNER JOIN Library L on L.Id = LP.LibraryId WHERE MediaKind = 3)");
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -16,6 +16,57 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.4");
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.Property<string>("Biography")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime>("DateUpdated")
.HasColumnType("TEXT");
b.Property<string>("Disambiguation")
.HasColumnType("TEXT");
b.Property<string>("Formed")
.HasColumnType("TEXT");
b.Property<int>("MetadataKind")
.HasColumnType("INTEGER");
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleaseDate")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ArtistId");
b.ToTable("ArtistMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
@@ -24,6 +75,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("ArtworkKind")
.HasColumnType("INTEGER");
@@ -56,6 +110,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("ChannelId");
b.HasIndex("EpisodeMetadataId");
@@ -293,6 +349,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@@ -313,6 +372,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@@ -532,6 +593,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaVersion");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Mood",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.ToTable("Mood");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
@@ -594,9 +676,6 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Album")
.HasColumnType("TEXT");
b.Property<string>("Artist")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
@@ -967,6 +1046,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@@ -987,6 +1069,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@@ -1000,6 +1084,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Studio");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Style",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.ToTable("Style");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
@@ -1008,6 +1113,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("ArtistMetadataId")
.HasColumnType("INTEGER");
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
@@ -1028,6 +1136,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.HasKey("Id");
b.HasIndex("ArtistMetadataId");
b.HasIndex("EpisodeMetadataId");
b.HasIndex("MovieMetadataId");
@@ -1080,6 +1190,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexMediaFile");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Artist");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
@@ -1112,6 +1231,11 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("ArtistId")
.HasColumnType("INTEGER");
b.HasIndex("ArtistId");
b.ToTable("MusicVideo");
});
@@ -1267,10 +1391,28 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexShow");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist")
.WithMany("ArtistMetadata")
.HasForeignKey("ArtistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Artist");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Artwork")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.Channel", null)
.WithMany("Artwork")
.HasForeignKey("ChannelId")
@@ -1366,6 +1508,11 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.Genre",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Genres")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Genres")
.HasForeignKey("EpisodeMetadataId");
@@ -1475,6 +1622,16 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Mood",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Moods")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
@@ -1725,6 +1882,10 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.Studio",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Studios")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Studios")
.HasForeignKey("EpisodeMetadataId");
@@ -1749,10 +1910,24 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Style",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Styles")
.HasForeignKey("ArtistMetadataId")
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Tag",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ArtistMetadata", null)
.WithMany("Tags")
.HasForeignKey("ArtistMetadataId");
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
.WithMany("Tags")
.HasForeignKey("EpisodeMetadataId");
@@ -1810,6 +1985,17 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.Artist", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
@@ -1844,11 +2030,19 @@ namespace ErsatzTV.Infrastructure.Migrations
"ErsatzTV.Core.Domain.MusicVideo",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Artist", "Artist")
.WithMany("MusicVideos")
.HasForeignKey("ArtistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.MusicVideo", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Artist");
});
modelBuilder.Entity(
@@ -1991,6 +2185,23 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ArtistMetadata",
b =>
{
b.Navigation("Artwork");
b.Navigation("Genres");
b.Navigation("Moods");
b.Navigation("Studios");
b.Navigation("Styles");
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
@@ -2102,6 +2313,15 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Tags");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artist",
b =>
{
b.Navigation("ArtistMetadata");
b.Navigation("MusicVideos");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
+74 -7
View File
@@ -29,7 +29,6 @@ namespace ErsatzTV.Infrastructure.Search
private const string IdField = "id";
private const string TypeField = "type";
private const string ArtistField = "artist";
private const string TitleField = "title";
private const string SortTitleField = "sort_title";
private const string GenreField = "genre";
@@ -41,9 +40,12 @@ namespace ErsatzTV.Infrastructure.Search
private const string ReleaseDateField = "release_date";
private const string StudioField = "studio";
private const string LanguageField = "language";
private const string StyleField = "style";
private const string MoodField = "mood";
private const string MovieType = "movie";
private const string ShowType = "show";
private const string ArtistType = "artist";
private const string MusicVideoType = "music_video";
private readonly ILogger<SearchIndex> _logger;
@@ -53,7 +55,7 @@ namespace ErsatzTV.Infrastructure.Search
public SearchIndex(ILogger<SearchIndex> logger) => _logger = logger;
public int Version => 4;
public int Version => 6;
public Task<bool> Initialize(ILocalFileSystem localFileSystem)
{
@@ -84,6 +86,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
@@ -110,6 +115,9 @@ namespace ErsatzTV.Infrastructure.Search
case Show show:
await UpdateShow(searchRepository, show);
break;
case Artist artist:
await UpdateArtist(searchRepository, artist);
break;
case MusicVideo musicVideo:
UpdateMusicVideo(musicVideo);
break;
@@ -138,7 +146,7 @@ namespace ErsatzTV.Infrastructure.Search
using DirectoryReader reader = _writer.GetReader(true);
var searcher = new IndexSearcher(reader);
int hitsLimit = skip + limit;
int hitsLimit = limit == 0 ? searcher.IndexReader.MaxDoc : skip + limit;
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
? new QueryParser(AppLuceneVersion, searchField, analyzer)
@@ -148,13 +156,21 @@ namespace ErsatzTV.Infrastructure.Search
var filter = new DuplicateFilter(TitleAndYearField);
var sort = new Sort(new SortField(SortTitleField, SortFieldType.STRING));
TopFieldDocs topDocs = searcher.Search(query, filter, hitsLimit, sort, true, true);
IEnumerable<ScoreDoc> selectedHits = topDocs.ScoreDocs.Skip(skip).Take(limit);
IEnumerable<ScoreDoc> selectedHits = topDocs.ScoreDocs.Skip(skip);
if (limit > 0)
{
selectedHits = selectedHits.Take(limit);
}
var searchResult = new SearchResult(
selectedHits.Map(d => ProjectToSearchItem(searcher.Doc(d.Doc))).ToList(),
topDocs.TotalHits);
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit);
if (limit > 0)
{
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit);
}
return searchResult.AsTask();
}
@@ -280,7 +296,8 @@ namespace ErsatzTV.Infrastructure.Search
if (maybeVersion.IsSome)
{
MediaVersion version = maybeVersion.ValueUnsafe();
foreach (string lang in version.Streams.Map(ms => ms.Language).Distinct()
foreach (string lang in version.Streams.Filter(ms => ms.MediaStreamKind == MediaStreamKind.Video)
.Map(ms => ms.Language).Distinct()
.Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
@@ -353,6 +370,57 @@ namespace ErsatzTV.Infrastructure.Search
}
}
private async Task UpdateArtist(ISearchRepository searchRepository, Artist artist)
{
Option<ArtistMetadata> maybeMetadata = artist.ArtistMetadata.HeadOrNone();
if (maybeMetadata.IsSome)
{
ArtistMetadata metadata = maybeMetadata.ValueUnsafe();
try
{
var doc = new Document
{
new StringField(IdField, artist.Id.ToString(), Field.Store.YES),
new StringField(TypeField, ArtistType, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, artist.LibraryPath.Library.Name, Field.Store.NO),
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
};
List<string> languages = await searchRepository.GetLanguagesForArtist(artist);
foreach (string lang in languages.Distinct().Filter(s => !string.IsNullOrWhiteSpace(s)))
{
doc.Add(new StringField(LanguageField, lang, Field.Store.NO));
}
foreach (Genre genre in metadata.Genres)
{
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
}
foreach (Style style in metadata.Styles)
{
doc.Add(new TextField(StyleField, style.Name, Field.Store.NO));
}
foreach (Mood mood in metadata.Moods)
{
doc.Add(new TextField(MoodField, mood.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, artist.Id.ToString()), doc);
}
catch (Exception ex)
{
metadata.Artist = null;
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
}
}
}
private void UpdateMusicVideo(MusicVideo musicVideo)
{
Option<MusicVideoMetadata> maybeMetadata = musicVideo.MusicVideoMetadata.HeadOrNone();
@@ -366,7 +434,6 @@ namespace ErsatzTV.Infrastructure.Search
{
new StringField(IdField, musicVideo.Id.ToString(), Field.Store.YES),
new StringField(TypeField, MusicVideoType, Field.Store.NO),
new TextField(ArtistField, metadata.Artist, Field.Store.NO),
new TextField(TitleField, metadata.Title, Field.Store.NO),
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
new TextField(LibraryNameField, musicVideo.LibraryPath.Library.Name, Field.Store.NO),
+1
View File
@@ -16,6 +16,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Blazored.LocalStorage" Version="3.0.0" />
<PackageReference Include="FluentValidation" Version="9.5.3" />
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.3" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
+176
View File
@@ -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));
}
}
}
+163
View File
@@ -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));
}
}
}
}
+51 -9
View File
@@ -52,6 +52,10 @@
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#episodes")">@_data.EpisodeCards.Count Episodes</MudLink>
}
@if (_data.ArtistCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")">@_data.ArtistCards.Count Artists</MudLink>
}
@if (_data.MusicVideoCards.Any())
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_data.MusicVideoCards.Count Music Videos</MudLink>
@@ -170,6 +174,30 @@
</MudContainer>
}
@if (_data.ArtistCards.Any())
{
<MudText GutterBottom="true"
Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _data.ArtistCards.OrderBy(e => e.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
DeleteClicked="@RemoveArtistFromCollection"
SelectColor="@Color.Error"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_data.MusicVideoCards.Any())
{
<MudText GutterBottom="true"
@@ -243,15 +271,13 @@
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
{
List<MediaCardViewModel> GetSortedItems()
{
return _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
}
List<MediaCardViewModel> GetSortedItems() => _data.MovieCards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
.Append(_data.SeasonCards.OrderBy(s => s.SortTitle))
.Append(_data.EpisodeCards.OrderBy(ep => ep.Aired))
.Append(_data.ArtistCards.OrderBy(a => a.SortTitle))
.Append(_data.MusicVideoCards.OrderBy(mv => mv.SortTitle))
.ToList();
SelectClicked(GetSortedItems, card, e);
}
@@ -269,6 +295,22 @@
}
}
private async Task RemoveArtistFromCollection(MediaCardViewModel vm)
{
if (vm is ArtistCardViewModel artist)
{
var request = new RemoveItemsFromCollection(Id)
{
MediaItemIds = new List<int> { artist.ArtistId }
};
await RemoveItemsWithConfirmation(
"artist",
string.IsNullOrWhiteSpace(artist.Subtitle) ? artist.Title : $"{artist.Title} ({artist.Subtitle})",
request);
}
}
private async Task RemoveMusicVideoFromCollection(MediaCardViewModel vm)
{
if (vm is MusicVideoCardViewModel musicVideo)
+26 -12
View File
@@ -1,13 +1,20 @@
@page "/media/collections"
@using Microsoft.Extensions.Caching.Memory
@using Blazored.LocalStorage
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.MediaCollections.Queries
@using ErsatzTV.Application.MediaCards
@inject IDialogService Dialog
@inject IMediator Mediator
@inject IMemoryCache MemoryCache
@inject ILocalStorageService LocalStorage
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudTable Hover="true" Items="_data" Dense="true">
<MudTable Hover="true"
ServerData="@(new Func<TableState, Task<TableData<MediaCollectionViewModel>>>(ServerReload))"
Dense="true"
@ref=" _table">
<ToolBarContent>
<MudText Typo="Typo.h6">Collections</MudText>
</ToolBarContent>
@@ -16,11 +23,7 @@
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>
<MudTableSortLabel SortBy="new Func<MediaCollectionViewModel, object>(x => x.Name)">
Name
</MudTableSortLabel>
</MudTh>
<MudTh>Name</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
@@ -50,12 +53,14 @@
</MudContainer>
@code {
private List<MediaCollectionViewModel> _data;
private MudTable<MediaCollectionViewModel> _table;
protected override Task OnParametersSetAsync() => RefreshData();
private async Task RefreshData() =>
_data = await Mediator.Send(new GetAllCollections()).Map(list => list.OrderBy(vm => vm.Name).ToList());
protected override async Task OnAfterRenderAsync(bool firstRender)
{
int rowsPerPage = await LocalStorage.GetItemAsync<int?>("pages.collections.rows_per_page") ?? 10;
_table.RowsPerPage = rowsPerPage;
await _table.ReloadServerData();
}
private async Task DeleteMediaCollection(MediaCardViewModel vm)
{
@@ -69,9 +74,18 @@
if (!result.Cancelled)
{
await Mediator.Send(new DeleteCollection(collection.Id));
await RefreshData();
await _table.ReloadServerData();
}
}
}
private async Task<TableData<MediaCollectionViewModel>> ServerReload(TableState state)
{
await LocalStorage.SetItemAsync<int?>("pages.collections.rows_per_page", state.PageSize);
PagedMediaCollectionsViewModel data = await Mediator.Send(new GetPagedCollections(state.Page, state.PageSize));
return new TableData<MediaCollectionViewModel> { TotalItems = data.TotalCount, Items = data.Page };
}
}
+18 -14
View File
@@ -94,21 +94,30 @@ namespace ErsatzTV.Pages
}
}
protected async Task AddSelectionToCollection()
protected Task AddSelectionToCollection() => AddItemsToCollection(
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
_selectedItems.OfType<ArtistCardViewModel>().Map(a => a.ArtistId).ToList(),
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
protected async Task AddItemsToCollection(
List<int> movieIds,
List<int> showIds,
List<int> artistIds,
List<int> musicVideoIds,
string entityName = "selected items")
{
int count = movieIds.Count + showIds.Count + artistIds.Count + musicVideoIds.Count;
var parameters = new DialogParameters
{ { "EntityType", _selectedItems.Count.ToString() }, { "EntityName", "selected items" } };
{ { "EntityType", count.ToString() }, { "EntityName", entityName } };
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 AddItemsToCollection(
collection.Id,
_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId).ToList(),
_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId).ToList(),
_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId).ToList());
var request = new AddItemsToCollection(collection.Id, movieIds, showIds, artistIds, musicVideoIds);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
@@ -120,7 +129,7 @@ namespace ErsatzTV.Pages
Right: _ =>
{
Snackbar.Add(
$"Added {_selectedItems.Count} items to collection {collection.Name}",
$"Added {count} items to collection {collection.Name}",
Severity.Success);
ClearSelection();
});
@@ -140,12 +149,7 @@ namespace ErsatzTV.Pages
DialogResult result = await dialog.Result;
if (!result.Cancelled)
{
var itemIds = new List<int>();
itemIds.AddRange(_selectedItems.OfType<MovieCardViewModel>().Map(m => m.MovieId));
itemIds.AddRange(_selectedItems.OfType<TelevisionShowCardViewModel>().Map(s => s.TelevisionShowId));
itemIds.AddRange(_selectedItems.OfType<TelevisionSeasonCardViewModel>().Map(s => s.TelevisionSeasonId));
itemIds.AddRange(_selectedItems.OfType<TelevisionEpisodeCardViewModel>().Map(e => e.EpisodeId));
itemIds.AddRange(_selectedItems.OfType<MusicVideoCardViewModel>().Map(mv => mv.MusicVideoId));
var itemIds = _selectedItems.Map(vm => vm.MediaItemId).ToList();
await Mediator.Send(
new RemoveItemsFromCollection(collectionId)
+86 -16
View File
@@ -2,6 +2,7 @@
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.Search
@using ErsatzTV.Application.Search.Queries
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@@ -33,33 +34,34 @@
}
else
{
<MudText>@_query</MudText>
<MudText Style="margin-bottom: auto; margin-top: auto">@_query</MudText>
if (_movies.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_movies.Count Movies</MudLink>
}
else
{
<MudText Class="ml-4">0 Movies</MudText>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")" Style="margin-bottom: auto; margin-top: auto">@_movies.Count Movies</MudLink>
}
if (_shows.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_shows.Count Shows</MudLink>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")" Style="margin-bottom: auto; margin-top: auto">@_shows.Count Shows</MudLink>
}
else
if (_artists.Count > 0)
{
<MudText Class="ml-4">0 Shows</MudText>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#artists")" Style="margin-bottom: auto; margin-top: auto">@_artists.Count Artists</MudLink>
}
if (_musicVideos.Count > 0)
{
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")">@_musicVideos.Count Music Videos</MudLink>
}
else
{
<MudText Class="ml-4">0 Music Videos</MudText>
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#music_videos")" Style="margin-bottom: auto; margin-top: auto">@_musicVideos.Count Music Videos</MudLink>
}
<div style="margin-left: auto">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="@AddAllToCollection">
Add All To Collection
</MudButton>
</div>
}
</div>
</MudPaper>
@@ -118,6 +120,34 @@
</MudContainer>
}
@if (_artists.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
<MudText Typo="Typo.h4"
Style="scroll-margin-top: 160px"
UserAttributes="@(new Dictionary<string, object> { { "id", "artists" } })">
Artists
</MudText>
@if (_artists.Count > 50)
{
<MudLink Href="@GetArtistsLink()" Class="ml-4">See All >></MudLink>
}
</div>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (ArtistCardViewModel card in _artists.Cards.OrderBy(s => s.SortTitle))
{
<MediaCard Data="@card"
Link="@($"/media/music/artists/{card.ArtistId}")"
ArtworkKind="ArtworkKind.Thumbnail"
AddToCollectionClicked="@AddToCollection"
SelectClicked="@(e => SelectClicked(card, e))"
IsSelected="@IsSelected(card)"
IsSelectMode="@IsSelectMode()"/>
}
</MudContainer>
}
@if (_musicVideos.Count > 0)
{
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
@@ -126,7 +156,7 @@
UserAttributes="@(new Dictionary<string, object> { { "id", "music_videos" } })">
Music Videos
</MudText>
@if (_shows.Count > 50)
@if (_musicVideos.Count > 50)
{
<MudLink Href="@GetMusicVideosLink()" Class="ml-4">See All >></MudLink>
}
@@ -152,6 +182,7 @@
private MovieCardResultsViewModel _movies;
private TelevisionShowCardResultsViewModel _shows;
private MusicVideoCardResultsViewModel _musicVideos;
private ArtistCardResultsViewModel _artists;
protected override async Task OnInitializedAsync()
{
@@ -164,6 +195,7 @@
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
_musicVideos = await Mediator.Send(new QuerySearchIndexMusicVideos($"type:music_video AND ({_query})", 1, 50));
_artists = await Mediator.Send(new QuerySearchIndexArtists($"type:artist AND ({_query})", 1, 50));
}
}
@@ -173,7 +205,8 @@
{
return _movies.Cards.OrderBy(m => m.SortTitle)
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
.Append(_musicVideos.Cards.OrderBy(s => s.SortTitle))
.Append(_artists.Cards.OrderBy(a => a.SortTitle))
.Append(_musicVideos.Cards.OrderBy(mv => mv.SortTitle))
.ToList();
}
@@ -223,6 +256,27 @@
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
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));
}
}
if (card is MusicVideoCardViewModel musicVideo)
{
@@ -265,6 +319,16 @@
}
return uri;
}
private string GetArtistsLink()
{
var uri = "/media/music/artists/page/1";
if (!string.IsNullOrWhiteSpace(_query))
{
uri = QueryHelpers.AddQueryString(uri, "query", _query);
}
return uri;
}
private string GetMusicVideosLink()
{
@@ -276,4 +340,10 @@
return uri;
}
private async Task AddAllToCollection(MouseEventArgs _)
{
SearchResultAllItemsViewModel results = await Mediator.Send(new QuerySearchIndexAllItems(_query));
await AddItemsToCollection(results.MovieIds, results.ShowIds, results.ArtistIds, results.MusicVideoIds, "search results");
}
}
+1 -1
View File
@@ -70,7 +70,7 @@
if (MemoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id))
{
_selectedCollection = _collections.SingleOrDefault(c => c.Id == id);
_selectedCollection = _collections.SingleOrDefault(c => c.Id == id) ?? _newCollection;
}
else
{
+13 -9
View File
@@ -4,21 +4,25 @@
@{ var letters = new System.Collections.Generic.HashSet<char>(); }
@foreach (TCard card in Cards.Filter(c => !string.IsNullOrWhiteSpace(c.Title)).OrderBy(c => c.SortTitle))
{
@if (!letters.Contains(card.SortTitle.Head()))
Option<char> maybeLetter = card.SortTitle.ToLowerInvariant().HeadOrNone();
if (maybeLetter.IsSome)
{
Option<char> maybeLetter = card.SortTitle.ToLowerInvariant().HeadOrNone();
if (maybeLetter.IsSome)
char letter = maybeLetter.ValueUnsafe();
if (char.IsDigit(letter) || !char.IsLetter(letter))
{
letter = '#';
}
if (!letters.Contains(letter))
{
char letter = maybeLetter.ValueUnsafe();
if (letter >= '0' && letter <= '9')
{
letter = '#';
}
letters.Add(letter);
<div id="@($"letter-{letter}")" style="scroll-margin-top: 128px">
<div id="@($"letter-{letter}")" style="scroll-margin-top: 140px">
@ChildContent(card)
</div>
}
else
{
@ChildContent(card)
}
}
else
{
+1 -1
View File
@@ -50,7 +50,7 @@
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies">Movies</MudNavLink>
<MudNavLink Href="/media/music/videos">Music Videos</MudNavLink>
<MudNavLink Href="/media/music/artists">Music</MudNavLink>
<MudNavLink Href="/media/collections">Collections</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>
+5 -4
View File
@@ -1,4 +1,5 @@
@using ErsatzTV.Application.MediaCards
@using static LanguageExt.Prelude
@using ErsatzTV.Application.MediaCards
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@@ -125,13 +126,13 @@
return Placeholder;
}
string first = sortTitle?.Substring(0, 1).ToUpperInvariant() ?? string.Empty;
return int.TryParse(first, out _) ? "#" : first;
char first = Optional(sortTitle).Map(t => t.ToUpperInvariant().HeadOrNone()).Flatten().Match(head => head, () => ' ');
return char.IsDigit(first) || !char.IsLetter(first) ? "#" : first.ToString();
}
private string ArtworkForItem() => string.IsNullOrWhiteSpace(Data.Poster)
? "position: relative"
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover";
: $"position: relative; background-image: url(/artwork/{PathForArtwork()}/{Data.Poster}); background-size: cover; background-position: center";
private string PathForArtwork() => ArtworkKind switch
{
+4 -1
View File
@@ -3,6 +3,7 @@ using System.Data;
using System.IO;
using System.Reflection;
using System.Threading.Channels;
using Blazored.LocalStorage;
using Dapper;
using ErsatzTV.Application;
using ErsatzTV.Application.Channels.Queries;
@@ -90,6 +91,7 @@ namespace ErsatzTV
services.AddMudServices();
services.AddCourier(Assembly.GetAssembly(typeof(LibraryScanProgress)));
services.AddBlazoredLocalStorage();
Log.Logger.Information(
"ErsatzTV version {Version}",
@@ -120,7 +122,7 @@ namespace ErsatzTV
// string xmltvPath = Path.Combine(appDataFolder, "xmltv.xml");
// Log.Logger.Information("XMLTV is at {XmltvPath}", xmltvPath);
var connectionString = $"Data Source={FileSystemLayout.DatabasePath}";
var connectionString = $"Data Source={FileSystemLayout.DatabasePath};foreign keys=true;";
services.AddDbContext<TvContext>(
options => options.UseSqlite(
@@ -202,6 +204,7 @@ namespace ErsatzTV
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IArtistRepository, ArtistRepository>();
services.AddScoped<IMusicVideoRepository, MusicVideoRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IMetadataRepository, MetadataRepository>();
+3 -3
View File
@@ -78,7 +78,7 @@ https://raw.githubusercontent.com/Squidly271/community.applications/master/plugi
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the latest version to the `ersatztv` folder.
2. Download and extract the [latest release from GitHub](https://github.com/jasongdove/ErsatzTV/releases) to the `ersatztv` folder.
3. Run `ErsatzTV.exe`
4. Open your browser to `http://[server-ip]:8409`
@@ -87,7 +87,7 @@ https://raw.githubusercontent.com/Squidly271/community.applications/master/plugi
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the latest version to the `ersatztv` folder.
2. Download and extract the [latest release from GitHub](https://github.com/jasongdove/ErsatzTV/releases) to the `ersatztv` folder.
3. Run `ErsatzTV`
4. Open your browser to `http://[server-ip]:8409`
@@ -96,6 +96,6 @@ https://raw.githubusercontent.com/Squidly271/community.applications/master/plugi
### Manual Installation
1. Create a folder `ersatztv` at your preferred install location.
2. Download and extract the latest version to the `ersatztv` folder.
2. Download and extract the [latest release from GitHub](https://github.com/jasongdove/ErsatzTV/releases) to the `ersatztv` folder.
3. Run `ErsatzTV`
4. Open your browser to `http://[server-ip]:8409`
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<!--To inherit the global NuGet package sources remove the <clear/> line below -->
<clear />
<add key="nuget" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>