Compare commits
15 Commits
v0.0.20-pr
...
v0.0.23-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c70d61d48 | ||
|
|
00fdc272e9 | ||
|
|
f04c18c810 | ||
|
|
eca58dbe7f | ||
|
|
cf9479d2a9 | ||
|
|
b6331331b0 | ||
|
|
ed365cfa43 | ||
|
|
b3a1e71570 | ||
|
|
454343d14f | ||
|
|
c0a6677861 | ||
|
|
2efcbca2da | ||
|
|
f96efa9b2f | ||
|
|
f46041305c | ||
|
|
493a496b91 | ||
|
|
739d074bc6 |
@@ -1,8 +1,10 @@
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries.Commands
|
||||
@@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
|
||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
|
||||
{
|
||||
_libraryRepository = libraryRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
DeleteLocalLibraryPath request,
|
||||
@@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands
|
||||
.MapT(DoDeletion)
|
||||
.Bind(t => t.ToEitherAsync());
|
||||
|
||||
private Task<Unit> DoDeletion(LibraryPath libraryPath) =>
|
||||
_libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit();
|
||||
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
|
||||
{
|
||||
List<int> ids = await _libraryRepository.GetMediaIdsByLocalPath(libraryPath.Id);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
await _libraryRepository.DeleteLocalPath(libraryPath.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
|
||||
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards);
|
||||
public record MovieCardResultsViewModel(int Count, List<MovieCardViewModel> Cards, Option<SearchPageMap> PageMap);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public record GetMovieCards(int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public class
|
||||
GetMovieCardsHandler : IRequestHandler<GetMovieCards, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
|
||||
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository;
|
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _movieRepository.GetMovieCount();
|
||||
|
||||
List<MovieCardViewModel> results = await _movieRepository
|
||||
.GetPagedMovies(request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new MovieCardResultsViewModel(count, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
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 GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
|
||||
{
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
|
||||
|
||||
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
|
||||
GetSearchCards request,
|
||||
CancellationToken cancellationToken) =>
|
||||
request.Query.Split(":").Head() switch
|
||||
{
|
||||
"genre" => GenreSearch(request.Query.Replace("genre:", string.Empty)),
|
||||
"tag" => TagSearch(request.Query.Replace("tag:", string.Empty)),
|
||||
_ => TitleSearch(request.Query)
|
||||
};
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> TitleSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByTitle(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> GenreSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByGenre(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
|
||||
private Task<Either<BaseError, SearchCardResultsViewModel>> TagSearch(string query) =>
|
||||
Try(_searchRepository.SearchMediaItemsByTag(query)).Sequence()
|
||||
.Map(ProjectToSearchResults)
|
||||
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public record GetTelevisionShowCards(int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public class
|
||||
GetTelevisionShowCardsHandler : IRequestHandler<GetTelevisionShowCards, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionShowCardsHandler(ITelevisionRepository televisionRepository) =>
|
||||
_televisionRepository = televisionRepository;
|
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle(
|
||||
GetTelevisionShowCards request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int count = await _televisionRepository.GetShowCount();
|
||||
|
||||
List<TelevisionShowCardViewModel> results = await _televisionRepository
|
||||
.GetPagedShows(request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new TelevisionShowCardResultsViewModel(count, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards);
|
||||
public record TelevisionShowCardResultsViewModel(
|
||||
int Count,
|
||||
List<TelevisionShowCardViewModel> Cards,
|
||||
Option<SearchPageMap> PageMap);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Movies
|
||||
Artwork(metadata, ArtworkKind.Poster),
|
||||
Artwork(metadata, ArtworkKind.FanArt),
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Tags.Map(t => t.Name).ToList());
|
||||
metadata.Tags.Map(t => t.Name).ToList(),
|
||||
metadata.Studios.Map(s => s.Name).ToList());
|
||||
}
|
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace ErsatzTV.Application.Movies
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
List<string> Tags,
|
||||
List<string> Studios);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
@@ -13,20 +15,24 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexSecretStore _plexSecretStore;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public SignOutOfPlexHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IEntityLocker entityLocker)
|
||||
IEntityLocker entityLocker,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_entityLocker = entityLocker;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
|
||||
{
|
||||
await _mediaSourceRepository.DeleteAllPlex();
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
|
||||
|
||||
@@ -78,10 +78,10 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
|
||||
connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd);
|
||||
toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c));
|
||||
|
||||
return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource);
|
||||
return _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.PlexMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
|
||||
@@ -66,23 +66,20 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
existing.ProductVersion = server.ProductVersion;
|
||||
existing.ServerName = server.ServerName;
|
||||
MergeConnections(existing.Connections, server.Connections);
|
||||
if (existing.Connections.Any() && existing.Connections.All(c => !c.IsActive))
|
||||
{
|
||||
existing.Connections.Head().IsActive = true;
|
||||
}
|
||||
|
||||
return _mediaSourceRepository.Update(existing);
|
||||
var toAdd = server.Connections
|
||||
.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);
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
await _mediaSourceRepository.Add(server);
|
||||
if (server.Connections.Any())
|
||||
{
|
||||
server.Connections.Head().IsActive = true;
|
||||
}
|
||||
|
||||
await _mediaSourceRepository.Update(server);
|
||||
await _mediaSourceRepository.Add(server);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
@@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
public UpdatePlexLibraryPreferencesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdatePlexLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
|
||||
List<int> ids = await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
|
||||
|
||||
@@ -6,7 +6,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Plex.Commands
|
||||
{
|
||||
@@ -35,20 +34,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
plexMediaSource.PathReplacements.AddRange(toAdd);
|
||||
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr));
|
||||
foreach (PlexPathReplacement pathReplacement in toUpdate)
|
||||
{
|
||||
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id))
|
||||
.IfSome(
|
||||
pr =>
|
||||
{
|
||||
pr.PlexPath = pathReplacement.PlexPath;
|
||||
pr.LocalPath = pathReplacement.LocalPath;
|
||||
});
|
||||
}
|
||||
|
||||
return _mediaSourceRepository.Update(plexMediaSource).ToUnit();
|
||||
return _mediaSourceRepository.UpdatePathReplacements(plexMediaSource.Id, toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Commands
|
||||
{
|
||||
public record RebuildSearchIndex : MediatR.IRequest<Unit>, IBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Commands
|
||||
{
|
||||
public class RebuildSearchIndexHandler : MediatR.IRequestHandler<RebuildSearchIndex, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly ILogger<RebuildSearchIndexHandler> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public RebuildSearchIndexHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<RebuildSearchIndexHandler> logger)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
_searchRepository = searchRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
}
|
||||
|
||||
public async Task<Unit> Handle(RebuildSearchIndex request, CancellationToken cancellationToken)
|
||||
{
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
_searchIndex.Version)
|
||||
{
|
||||
_logger.LogDebug("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
|
||||
await _searchIndex.Rebuild(itemIds);
|
||||
|
||||
Option<ConfigElement> maybeVersion =
|
||||
await _configElementRepository.Get(ConfigElementKey.SearchIndexVersion);
|
||||
await maybeVersion.Match(
|
||||
version =>
|
||||
{
|
||||
version.Value = _searchIndex.Version.ToString();
|
||||
return _configElementRepository.Update(version);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var configElement = new ConfigElement
|
||||
{
|
||||
Key = ConfigElementKey.SearchIndexVersion.Key,
|
||||
Value = _searchIndex.Version.ToString()
|
||||
};
|
||||
return _configElementRepository.Add(configElement);
|
||||
});
|
||||
|
||||
_logger.LogDebug("Done migrating search index");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Search index is already version {Version}", _searchIndex.Version);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs
Normal file
7
ErsatzTV.Application/Search/Queries/QuerySearchIndex.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Search;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndex(string Query) : IRequest<SearchResult>;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexMovies
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<MovieCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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 QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_movieRepository = movieRepository;
|
||||
}
|
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(
|
||||
QuerySearchIndexMovies request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<MovieCardViewModel> items = await _movieRepository
|
||||
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public record QuerySearchIndexShows
|
||||
(string Query, int PageNumber, int PageSize) : IRequest<TelevisionShowCardResultsViewModel>;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle(
|
||||
QuerySearchIndexShows request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SearchResult searchResult = await _searchIndex.Search(
|
||||
request.Query,
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<TelevisionShowCardViewModel> items = await _televisionRepository
|
||||
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
|
||||
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
ErsatzTV.Application/Search/SearchResultViewModel.cs
Normal file
10
ErsatzTV.Application/Search/SearchResultViewModel.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Application.Search
|
||||
{
|
||||
public class SearchResultViewModel<T>
|
||||
{
|
||||
public int TotalCount { get; set; }
|
||||
public List<T> Items { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,11 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
public class
|
||||
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
|
||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||
|
||||
@@ -16,7 +16,8 @@ namespace ErsatzTV.Application.Television
|
||||
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()));
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
|
||||
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList()).IfNone(new List<string>()));
|
||||
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
|
||||
new(
|
||||
|
||||
@@ -10,5 +10,6 @@ namespace ErsatzTV.Application.Television
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Tags);
|
||||
List<string> Tags,
|
||||
List<string> Studios);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
|
||||
}
|
||||
|
||||
public Unit EnsureFolderExists(string folder) => Unit.Default;
|
||||
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Optional(_files.SingleOrDefault(f => f.Path == path))
|
||||
.Map(f => f.LastWriteTime)
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes
|
||||
{
|
||||
public class FakeMovieWithPath : Movie
|
||||
public class FakeMovieWithPath : MediaItemScanResult<Movie>
|
||||
{
|
||||
public FakeMovieWithPath(string path)
|
||||
{
|
||||
Path = path;
|
||||
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
: base(
|
||||
new Movie
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new() { Path = path }
|
||||
new()
|
||||
{
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new() { Path = path }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public string Path { get; }
|
||||
}) =>
|
||||
IsAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Fakes
|
||||
@@ -11,12 +12,6 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
{
|
||||
public Task<bool> AllShowsExist(List<int> showIds) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Show show) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Season season) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Show>> GetAllShows() => throw new NotSupportedException();
|
||||
|
||||
public Task<Option<Show>> GetShow(int showId) => throw new NotSupportedException();
|
||||
@@ -26,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<List<ShowMetadata>> GetShowsForCards(List<int> ids) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
|
||||
@@ -49,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, Show>>
|
||||
public Task<Either<BaseError, MediaItemScanResult<Show>>>
|
||||
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -65,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
|
||||
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item) =>
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
||||
@@ -76,9 +75,12 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||
@@ -86,5 +88,13 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
|
||||
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Show show) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Season season) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> Update(Episode episode) => throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Tests.Fakes;
|
||||
using FluentAssertions;
|
||||
@@ -44,20 +45,24 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository = new Mock<IMovieRepository>();
|
||||
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
|
||||
.Returns(
|
||||
(LibraryPath _, string path) => Right<BaseError, Movie>(new FakeMovieWithPath(path)).AsTask());
|
||||
(LibraryPath _, string path) =>
|
||||
Right<BaseError, MediaItemScanResult<Movie>>(new FakeMovieWithPath(path)).AsTask());
|
||||
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
|
||||
.Returns(new List<string>().AsEnumerable().AsTask());
|
||||
|
||||
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
|
||||
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
|
||||
|
||||
_localStatisticsProvider.Setup(x => x.RefreshStatistics(It.IsAny<string>(), It.IsAny<MediaItem>()))
|
||||
.Returns<string, MediaItem>((_, _) => Right<BaseError, bool>(true).AsTask());
|
||||
|
||||
// fallback metadata adds metadata to a movie, so we need to replicate that here
|
||||
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
|
||||
.Returns(
|
||||
(MediaItem mediaItem) =>
|
||||
{
|
||||
((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() };
|
||||
return Unit.Default.AsTask();
|
||||
return Task.FromResult(true);
|
||||
});
|
||||
|
||||
_imageCache = new Mock<IImageCache>();
|
||||
@@ -104,11 +109,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -137,11 +145,15 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath),
|
||||
x => x.RefreshSidecarMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
|
||||
metadataPath),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -170,11 +182,15 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshSidecarMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath), metadataPath),
|
||||
x => x.RefreshSidecarMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath),
|
||||
metadataPath),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -207,11 +223,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_imageCache.Verify(
|
||||
@@ -248,11 +267,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_imageCache.Verify(
|
||||
@@ -288,11 +310,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -324,11 +349,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -354,11 +382,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||
|
||||
_localStatisticsProvider.Verify(
|
||||
x => x.RefreshStatistics(FFprobePath, It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshStatistics(
|
||||
FFprobePath,
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
|
||||
_localMetadataProvider.Verify(
|
||||
x => x.RefreshFallbackMetadata(It.Is<FakeMovieWithPath>(i => i.Path == moviePath)),
|
||||
x => x.RefreshFallbackMetadata(
|
||||
It.Is<Movie>(i => i.MediaVersions.Head().MediaFiles.Head().Path == moviePath)),
|
||||
Times.Once);
|
||||
}
|
||||
|
||||
@@ -417,7 +448,9 @@ namespace ErsatzTV.Core.Tests.Metadata
|
||||
_movieRepository.Object,
|
||||
_localStatisticsProvider.Object,
|
||||
_localMetadataProvider.Object,
|
||||
new Mock<IMetadataRepository>().Object,
|
||||
_imageCache.Object,
|
||||
new Mock<ISearchIndex>().Object,
|
||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
|
||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,5 +17,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public List<Artwork> Artwork { get; set; }
|
||||
public List<Genre> Genres { get; set; }
|
||||
public List<Tag> Tags { get; set; }
|
||||
public List<Studio> Studios { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
8
ErsatzTV.Core/Domain/Metadata/Studio.cs
Normal file
8
ErsatzTV.Core/Domain/Metadata/Studio.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Studio
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core
|
||||
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
|
||||
|
||||
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
|
||||
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
|
||||
|
||||
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalFileSystem
|
||||
{
|
||||
Unit EnsureFolderExists(string folder);
|
||||
DateTime GetLastWriteTime(string path);
|
||||
bool IsLibraryPathAccessible(LibraryPath libraryPath);
|
||||
IEnumerable<string> ListSubdirectories(string folder);
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalMetadataProvider
|
||||
{
|
||||
Task<ShowMetadata> GetMetadataForShow(string showFolder);
|
||||
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
||||
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
||||
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem);
|
||||
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
||||
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
||||
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);
|
||||
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
{
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
|
||||
Task<Option<LibraryPath>> GetPath(int libraryPathId);
|
||||
Task<int> CountMediaItemsByPath(int libraryPathId);
|
||||
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
|
||||
Task DeleteLocalPath(int libraryPathId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,11 +20,23 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
|
||||
Task<int> CountMediaItems(int id);
|
||||
Task Update(LocalMediaSource localMediaSource);
|
||||
Task Update(PlexMediaSource plexMediaSource);
|
||||
Task Update(PlexMediaSource plexMediaSource, List<PlexConnection> toAdd, List<PlexConnection> toDelete);
|
||||
|
||||
Task<Unit> UpdateLibraries(
|
||||
int plexMediaSourceId,
|
||||
List<PlexLibrary> toAdd,
|
||||
List<PlexLibrary> toDelete);
|
||||
|
||||
Task<Unit> UpdatePathReplacements(
|
||||
int plexMediaSourceId,
|
||||
List<PlexPathReplacement> toAdd,
|
||||
List<PlexPathReplacement> toUpdate,
|
||||
List<PlexPathReplacement> toDelete);
|
||||
|
||||
Task Update(PlexLibrary plexMediaSourceLibrary);
|
||||
Task Delete(int mediaSourceId);
|
||||
Task<Unit> DeleteAllPlex();
|
||||
Task DisablePlexLibrarySync(List<int> libraryIds);
|
||||
Task<List<int>> DeleteAllPlex();
|
||||
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
|
||||
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IMetadataRepository
|
||||
{
|
||||
Task<Unit> RemoveGenre(Genre genre);
|
||||
Task<Unit> UpdateStatistics(MediaVersion mediaVersion);
|
||||
Task<bool> RemoveGenre(Genre genre);
|
||||
Task<bool> RemoveTag(Tag tag);
|
||||
Task<bool> RemoveStudio(Studio studio);
|
||||
Task<bool> Update(Domain.Metadata metadata);
|
||||
Task<bool> Add(Domain.Metadata metadata);
|
||||
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
|
||||
Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion);
|
||||
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
||||
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
@@ -9,14 +10,17 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
Task<bool> AllMoviesExist(List<int> movieIds);
|
||||
Task<Option<Movie>> GetMovie(int movieId);
|
||||
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
||||
Task<bool> Update(Movie movie);
|
||||
Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
||||
Task<int> GetMovieCount();
|
||||
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
|
||||
Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
|
||||
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
|
||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<Unit> AddGenre(MovieMetadata metadata, Genre genre);
|
||||
Task<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
||||
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
|
||||
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
||||
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface ISearchRepository
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
@@ -8,13 +9,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
public interface ITelevisionRepository
|
||||
{
|
||||
Task<bool> AllShowsExist(List<int> showIds);
|
||||
Task<bool> Update(Show show);
|
||||
Task<bool> Update(Season season);
|
||||
Task<bool> Update(Episode episode);
|
||||
Task<List<Show>> GetAllShows();
|
||||
Task<Option<Show>> GetShow(int showId);
|
||||
Task<int> GetShowCount();
|
||||
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
|
||||
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
|
||||
Task<List<Episode>> GetShowItems(int showId);
|
||||
Task<List<Season>> GetAllSeasons();
|
||||
Task<Option<Season>> GetSeason(int seasonId);
|
||||
@@ -25,19 +24,27 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<int> GetEpisodeCount(int seasonId);
|
||||
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
|
||||
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
|
||||
Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
|
||||
int libraryPathId,
|
||||
string showFolder,
|
||||
ShowMetadata metadata);
|
||||
|
||||
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
|
||||
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
|
||||
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
|
||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
||||
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
|
||||
Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
|
||||
Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath);
|
||||
Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
|
||||
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
|
||||
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
|
||||
Task<Unit> AddGenre(ShowMetadata metadata, Genre genre);
|
||||
Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
|
||||
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(ShowMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
|
||||
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
|
||||
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
|
||||
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
|
||||
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
|
||||
}
|
||||
}
|
||||
|
||||
19
ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
Normal file
19
ErsatzTV.Core/Interfaces/Search/ISearchIndex.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Search
|
||||
{
|
||||
public interface ISearchIndex
|
||||
{
|
||||
public int Version { get; }
|
||||
Task<bool> Initialize();
|
||||
Task<Unit> Rebuild(List<int> itemIds);
|
||||
Task<Unit> AddItems(List<MediaItem> items);
|
||||
Task<Unit> UpdateItems(List<MediaItem> items);
|
||||
Task<Unit> RemoveItems(List<int> ids);
|
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -22,7 +23,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
string fileName = Path.GetFileName(path);
|
||||
var metadata = new EpisodeMetadata
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path };
|
||||
{ MetadataKind = MetadataKind.Fallback, Title = fileName ?? path, DateAdded = DateTime.UtcNow };
|
||||
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
|
||||
}
|
||||
|
||||
@@ -47,6 +48,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
return title.Substring(4);
|
||||
}
|
||||
|
||||
if (title.StartsWith("a ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return title.Substring(2);
|
||||
}
|
||||
|
||||
if (title.StartsWith("an ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return title.Substring(3);
|
||||
}
|
||||
|
||||
if (title.StartsWith("Æ"))
|
||||
{
|
||||
return title.Replace("Æ", "E");
|
||||
@@ -64,6 +75,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (match.Success)
|
||||
{
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
return Tuple(metadata, int.Parse(match.Groups[3].Value));
|
||||
}
|
||||
}
|
||||
@@ -86,6 +98,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.Genres = new List<Genre>();
|
||||
metadata.Tags = new List<Tag>();
|
||||
metadata.Studios = new List<Studio>();
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
@@ -107,6 +123,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
metadata.Title = match.Groups[1].Value;
|
||||
metadata.Year = int.Parse(match.Groups[2].Value);
|
||||
metadata.ReleaseDate = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
@@ -11,6 +11,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class LocalFileSystem : ILocalFileSystem
|
||||
{
|
||||
public Unit EnsureFolderExists(string folder)
|
||||
{
|
||||
if (!Directory.Exists(folder))
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public DateTime GetLastWriteTime(string path) =>
|
||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
@@ -48,27 +48,32 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
static LocalFolderScanner() => Crypto = new SHA1CryptoServiceProvider();
|
||||
|
||||
protected LocalFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ILogger logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_metadataRepository = metadataRepository;
|
||||
_imageCache = imageCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, T>> UpdateStatistics<T>(T mediaItem, string ffprobePath)
|
||||
protected async Task<Either<BaseError, MediaItemScanResult<T>>> UpdateStatistics<T>(
|
||||
MediaItemScanResult<T> mediaItem,
|
||||
string ffprobePath)
|
||||
where T : MediaItem
|
||||
{
|
||||
try
|
||||
{
|
||||
MediaVersion version = mediaItem switch
|
||||
MediaVersion version = mediaItem.Item switch
|
||||
{
|
||||
Movie m => m.MediaVersions.Head(),
|
||||
Episode e => e.MediaVersions.Head(),
|
||||
@@ -80,9 +85,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||
Either<BaseError, Unit> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
||||
refreshResult.IfLeft(
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
|
||||
refreshResult.Match(
|
||||
result =>
|
||||
{
|
||||
if (result)
|
||||
{
|
||||
mediaItem.IsUpdated = true;
|
||||
}
|
||||
},
|
||||
error =>
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
@@ -99,17 +111,16 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
protected bool RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
protected async Task<bool> RefreshArtwork(string artworkFile, Domain.Metadata metadata, ArtworkKind artworkKind)
|
||||
{
|
||||
DateTime lastWriteTime = _localFileSystem.GetLastWriteTime(artworkFile);
|
||||
|
||||
metadata.Artwork ??= new List<Artwork>();
|
||||
|
||||
Option<Artwork> maybeArtwork =
|
||||
Optional(metadata.Artwork).Flatten().FirstOrDefault(a => a.ArtworkKind == artworkKind);
|
||||
Option<Artwork> maybeArtwork = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
bool shouldRefresh = maybeArtwork.Match(
|
||||
artwork => artwork.DateUpdated < lastWriteTime,
|
||||
artwork => lastWriteTime.Subtract(artwork.DateUpdated) > TimeSpan.FromSeconds(1),
|
||||
true);
|
||||
|
||||
if (shouldRefresh)
|
||||
@@ -117,13 +128,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", artworkKind, artworkFile);
|
||||
string cacheName = _imageCache.CopyArtworkToCache(artworkFile, artworkKind);
|
||||
|
||||
maybeArtwork.Match(
|
||||
artwork =>
|
||||
await maybeArtwork.Match(
|
||||
async artwork =>
|
||||
{
|
||||
artwork.Path = cacheName;
|
||||
artwork.DateUpdated = lastWriteTime;
|
||||
await _metadataRepository.UpdateArtworkPath(artwork);
|
||||
},
|
||||
() =>
|
||||
async () =>
|
||||
{
|
||||
var artwork = new Artwork
|
||||
{
|
||||
@@ -132,8 +144,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
DateUpdated = lastWriteTime,
|
||||
ArtworkKind = artworkKind
|
||||
};
|
||||
|
||||
metadata.Artwork.Add(artwork);
|
||||
await _metadataRepository.AddArtwork(metadata, artwork);
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -22,17 +22,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public LocalMetadataProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
IMovieRepository movieRepository,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<LocalMetadataProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_movieRepository = movieRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
@@ -62,38 +65,48 @@ namespace ErsatzTV.Core.Metadata
|
||||
});
|
||||
}
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Episode e => LoadMetadata(e, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
|
||||
.Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(e, metadata),
|
||||
() => Task.FromResult(false))),
|
||||
Movie m => LoadMetadata(m, path)
|
||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
.Bind(
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(m, metadata),
|
||||
() => Task.FromResult(false))),
|
||||
_ => Task.FromResult(false)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
|
||||
public Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder) =>
|
||||
LoadMetadata(televisionShow, showFolder).Bind(
|
||||
maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata)));
|
||||
maybeMetadata => maybeMetadata.Match(
|
||||
metadata => ApplyMetadataUpdate(televisionShow, metadata),
|
||||
() => Task.FromResult(false)));
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
public Task<bool> RefreshFallbackMetadata(MediaItem mediaItem) =>
|
||||
mediaItem switch
|
||||
{
|
||||
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e))
|
||||
.ToUnit(),
|
||||
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
|
||||
_ => Task.FromResult(Unit.Default)
|
||||
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)),
|
||||
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)),
|
||||
_ => Task.FromResult(false)
|
||||
};
|
||||
|
||||
public Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
|
||||
.ToUnit();
|
||||
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
|
||||
|
||||
private async Task ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
|
||||
private async Task<bool> ApplyMetadataUpdate(Episode episode, Tuple<EpisodeMetadata, int> metadataEpisodeNumber)
|
||||
{
|
||||
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
|
||||
episode.EpisodeNumber = episodeNumber;
|
||||
Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
if (episode.EpisodeNumber != episodeNumber)
|
||||
{
|
||||
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
|
||||
}
|
||||
|
||||
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
@@ -109,22 +122,26 @@ namespace ErsatzTV.Core.Metadata
|
||||
existing.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
|
||||
return _metadataRepository.Update(existing);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.EpisodeId = episode.Id;
|
||||
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
|
||||
|
||||
return _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
await _televisionRepository.Update(episode);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata)
|
||||
{
|
||||
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
|
||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
async existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
@@ -144,41 +161,62 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
await _movieRepository.AddGenre(existing, genre);
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
await _metadataRepository.RemoveTag(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
await _movieRepository.AddTag(existing, tag);
|
||||
}
|
||||
|
||||
foreach (Studio studio in existing.Studios
|
||||
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
await _metadataRepository.RemoveStudio(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
await _movieRepository.AddStudio(existing, studio);
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
},
|
||||
() =>
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.MovieId = movie.Id;
|
||||
movie.MovieMetadata = new List<MovieMetadata> { metadata };
|
||||
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
await _mediaItemRepository.Update(movie);
|
||||
}
|
||||
|
||||
private async Task ApplyMetadataUpdate(Show show, ShowMetadata metadata)
|
||||
{
|
||||
private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
|
||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||
existing =>
|
||||
async existing =>
|
||||
{
|
||||
existing.Outline = metadata.Outline;
|
||||
existing.Plot = metadata.Plot;
|
||||
@@ -198,36 +236,58 @@ namespace ErsatzTV.Core.Metadata
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres.Filter(g => existing.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Genres.Add(genre);
|
||||
await _televisionRepository.AddGenre(existing, genre);
|
||||
}
|
||||
|
||||
foreach (Tag tag in existing.Tags.Filter(t => metadata.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Remove(tag);
|
||||
await _metadataRepository.RemoveTag(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags.Filter(t => existing.Tags.All(t2 => t2.Name != t.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Tags.Add(tag);
|
||||
await _televisionRepository.AddTag(existing, tag);
|
||||
}
|
||||
|
||||
foreach (Studio studio in existing.Studios
|
||||
.Filter(s => metadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Remove(studio);
|
||||
await _metadataRepository.RemoveStudio(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(s => existing.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existing.Studios.Add(studio);
|
||||
await _televisionRepository.AddStudio(existing, studio);
|
||||
}
|
||||
|
||||
return await _metadataRepository.Update(existing);
|
||||
},
|
||||
() =>
|
||||
async () =>
|
||||
{
|
||||
metadata.SortTitle = string.IsNullOrWhiteSpace(metadata.SortTitle)
|
||||
? _fallbackMetadataProvider.GetSortTitle(metadata.Title)
|
||||
: metadata.SortTitle;
|
||||
metadata.ShowId = show.Id;
|
||||
show.ShowMetadata = new List<ShowMetadata> { metadata };
|
||||
});
|
||||
|
||||
await _televisionRepository.Update(show);
|
||||
}
|
||||
return await _metadataRepository.Add(metadata);
|
||||
});
|
||||
|
||||
private async Task<Option<MovieMetadata>> LoadMetadata(Movie mediaItem, string nfoFileName)
|
||||
{
|
||||
@@ -277,10 +337,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
Plot = nfo.Plot,
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Year = nfo.Year,
|
||||
ReleaseDate = GetAired(nfo.Premiered) ?? new DateTime(nfo.Year, 1, 1),
|
||||
Year = GetYear(nfo.Year, nfo.Premiered),
|
||||
ReleaseDate = GetAired(nfo.Year, nfo.Premiered),
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -305,7 +366,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
MetadataKind = MetadataKind.Sidecar,
|
||||
DateUpdated = File.GetLastWriteTimeUtc(nfoFileName),
|
||||
Title = nfo.Title,
|
||||
ReleaseDate = GetAired(nfo.Aired),
|
||||
ReleaseDate = GetAired(0, nfo.Aired),
|
||||
Plot = nfo.Plot
|
||||
};
|
||||
return Tuple(metadata, nfo.Episode);
|
||||
@@ -337,7 +398,8 @@ namespace ErsatzTV.Core.Metadata
|
||||
Outline = nfo.Outline,
|
||||
Tagline = nfo.Tagline,
|
||||
Genres = nfo.Genres.Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList()
|
||||
Tags = nfo.Tags.Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = nfo.Studios.Map(s => new Studio { Name = s }).ToList()
|
||||
},
|
||||
None);
|
||||
}
|
||||
@@ -348,21 +410,38 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(string aired)
|
||||
private static int? GetYear(int year, string premiered)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
if (year > 1000)
|
||||
{
|
||||
return year;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(premiered))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(aired, out DateTime parsed))
|
||||
if (DateTime.TryParse(premiered, out DateTime parsed))
|
||||
{
|
||||
return parsed;
|
||||
return parsed.Year;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static DateTime? GetAired(int year, string aired)
|
||||
{
|
||||
DateTime? fallback = year > 1000 ? new DateTime(year, 1, 1) : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(aired))
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return DateTime.TryParse(aired, out DateTime parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
[XmlRoot("movie")]
|
||||
public class MovieNfo
|
||||
{
|
||||
@@ -392,6 +471,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[XmlElement("studio")]
|
||||
public List<string> Studios { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("tvshow")]
|
||||
@@ -420,6 +502,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
[XmlElement("tag")]
|
||||
public List<string> Tags { get; set; }
|
||||
|
||||
[XmlElement("studio")]
|
||||
public List<string> Studios { get; set; }
|
||||
}
|
||||
|
||||
[XmlRoot("episodedetails")]
|
||||
|
||||
@@ -17,19 +17,19 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<LocalStatisticsProvider> _logger;
|
||||
private readonly IMediaItemRepository _mediaItemRepository;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
|
||||
public LocalStatisticsProvider(
|
||||
IMediaItemRepository mediaItemRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILogger<LocalStatisticsProvider> logger)
|
||||
{
|
||||
_mediaItemRepository = mediaItemRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -45,10 +45,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||
await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
return Right<BaseError, bool>(result);
|
||||
},
|
||||
error => Task.FromResult(Left<BaseError, Unit>(error)));
|
||||
error => Task.FromResult(Left<BaseError, bool>(error)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -79,7 +79,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
mediaItemVersion.VideoProfile = version.VideoProfile;
|
||||
mediaItemVersion.VideoScanKind = version.VideoScanKind;
|
||||
|
||||
return await _mediaItemRepository.Update(mediaItem) && durationChange;
|
||||
return await _metadataRepository.UpdateLocalStatistics(mediaItemVersion) && durationChange;
|
||||
}
|
||||
|
||||
private Task<Either<BaseError, FFprobe>> GetProbeOutput(string ffprobePath, string filePath)
|
||||
|
||||
14
ErsatzTV.Core/Metadata/MediaItemScanResult.cs
Normal file
14
ErsatzTV.Core/Metadata/MediaItemScanResult.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
public class MediaItemScanResult<T> where T : MediaItem
|
||||
{
|
||||
public MediaItemScanResult(T item) => Item = item;
|
||||
|
||||
public T Item { get; }
|
||||
|
||||
public bool IsAdded { get; set; }
|
||||
public bool IsUpdated { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -21,19 +22,23 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<MovieFolderScanner> _logger;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public MovieFolderScanner(
|
||||
ILocalFileSystem localFileSystem,
|
||||
IMovieRepository movieRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger<MovieFolderScanner> logger)
|
||||
: base(localFileSystem, localStatisticsProvider, imageCache, logger)
|
||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_movieRepository = movieRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -71,20 +76,33 @@ namespace ErsatzTV.Core.Metadata
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper?
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, Movie> maybeMovie = await _movieRepository
|
||||
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
||||
.BindT(movie => UpdateStatistics(movie, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
|
||||
|
||||
maybeMovie.IfLeft(
|
||||
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
|
||||
await maybeMovie.Match(
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,17 +111,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Removing missing movie at {Path}", path);
|
||||
await _movieRepository.DeleteByPath(libraryPath, path);
|
||||
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie movie)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(
|
||||
MediaItemScanResult<Movie> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
Movie movie = result.Item;
|
||||
await LocateNfoFile(movie).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
@@ -115,7 +136,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
|
||||
if (await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
@@ -124,37 +148,40 @@ namespace ErsatzTV.Core.Metadata
|
||||
{
|
||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(movie);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(movie))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return movie;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Movie>> UpdateArtwork(Movie movie, ArtworkKind artworkKind)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateArtwork(
|
||||
MediaItemScanResult<Movie> result,
|
||||
ArtworkKind artworkKind)
|
||||
{
|
||||
try
|
||||
{
|
||||
Movie movie = result.Item;
|
||||
await LocateArtwork(movie, artworkKind).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
MovieMetadata metadata = movie.MovieMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, artworkKind))
|
||||
{
|
||||
await _movieRepository.Update(movie);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
});
|
||||
|
||||
return movie;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.Core.Metadata
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public TelevisionFolderScanner(
|
||||
@@ -26,16 +28,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
ITelevisionRepository televisionRepository,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalMetadataProvider localMetadataProvider,
|
||||
IMetadataRepository metadataRepository,
|
||||
IImageCache imageCache,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger<TelevisionFolderScanner> logger) : base(
|
||||
localFileSystem,
|
||||
localStatisticsProvider,
|
||||
metadataRepository,
|
||||
imageCache,
|
||||
logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_televisionRepository = televisionRepository;
|
||||
_localMetadataProvider = localMetadataProvider;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -53,14 +59,26 @@ namespace ErsatzTV.Core.Metadata
|
||||
|
||||
foreach (string showFolder in allShowFolders)
|
||||
{
|
||||
Either<BaseError, Show> maybeShow =
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
|
||||
|
||||
await maybeShow.Match(
|
||||
show => ScanSeasons(libraryPath, ffprobePath, show, showFolder),
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await ScanSeasons(libraryPath, ffprobePath, result.Item, showFolder);
|
||||
},
|
||||
_ => Task.FromResult(Unit.Default));
|
||||
}
|
||||
|
||||
@@ -74,19 +92,20 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(libraryPath);
|
||||
await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Show>> FindOrCreateShow(
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
|
||||
int libraryPathId,
|
||||
string showFolder)
|
||||
{
|
||||
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
||||
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
|
||||
return await maybeShow.Match(
|
||||
show => Right<BaseError, Show>(show).AsTask(),
|
||||
show => Right<BaseError, MediaItemScanResult<Show>>(new MediaItemScanResult<Show>(show)).AsTask(),
|
||||
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
|
||||
}
|
||||
|
||||
@@ -128,7 +147,9 @@ namespace ErsatzTV.Core.Metadata
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
||||
.GetOrAddEpisode(season, libraryPath, file)
|
||||
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
|
||||
.BindT(
|
||||
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
|
||||
.MapT(_ => episode))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateThumbnail);
|
||||
|
||||
@@ -139,12 +160,13 @@ namespace ErsatzTV.Core.Metadata
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Show>> UpdateMetadataForShow(
|
||||
Show show,
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateMetadataForShow(
|
||||
MediaItemScanResult<Show> result,
|
||||
string showFolder)
|
||||
{
|
||||
try
|
||||
{
|
||||
Show show = result.Item;
|
||||
await LocateNfoFileForShow(showFolder).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
@@ -156,7 +178,10 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
|
||||
if (await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
@@ -164,15 +189,18 @@ namespace ErsatzTV.Core.Metadata
|
||||
if (!Optional(show.ShowMetadata).Flatten().Any())
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
|
||||
await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(show, showFolder))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return show;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +225,11 @@ namespace ErsatzTV.Core.Metadata
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
if (!Optional(episode.EpisodeMetadata).Flatten().Any())
|
||||
bool shouldUpdate = Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.DateUpdated == DateTime.MinValue,
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
@@ -209,32 +241,30 @@ namespace ErsatzTV.Core.Metadata
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Show>> UpdateArtworkForShow(
|
||||
Show show,
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
|
||||
MediaItemScanResult<Show> result,
|
||||
string showFolder,
|
||||
ArtworkKind artworkKind)
|
||||
{
|
||||
try
|
||||
{
|
||||
Show show = result.Item;
|
||||
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
ShowMetadata metadata = show.ShowMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, artworkKind))
|
||||
{
|
||||
await _televisionRepository.Update(show);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||
});
|
||||
|
||||
return show;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,25 +275,15 @@ namespace ErsatzTV.Core.Metadata
|
||||
await LocatePoster(season, seasonFolder).IfSomeAsync(
|
||||
async posterFile =>
|
||||
{
|
||||
season.SeasonMetadata ??= new List<SeasonMetadata>();
|
||||
if (!season.SeasonMetadata.Any())
|
||||
{
|
||||
season.SeasonMetadata.Add(new SeasonMetadata { SeasonId = season.Id });
|
||||
}
|
||||
|
||||
SeasonMetadata metadata = season.SeasonMetadata.Head();
|
||||
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Poster))
|
||||
{
|
||||
await _televisionRepository.Update(season);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Poster);
|
||||
});
|
||||
|
||||
return season;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,17 +295,14 @@ namespace ErsatzTV.Core.Metadata
|
||||
async posterFile =>
|
||||
{
|
||||
EpisodeMetadata metadata = episode.EpisodeMetadata.Head();
|
||||
if (RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail))
|
||||
{
|
||||
await _televisionRepository.Update(episode);
|
||||
}
|
||||
await RefreshArtwork(posterFile, metadata, ArtworkKind.Thumbnail);
|
||||
});
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Core.Plex
|
||||
{
|
||||
@@ -16,17 +17,20 @@ namespace ErsatzTV.Core.Plex
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public PlexMovieLibraryScanner(
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
IMovieRepository movieRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger<PlexMovieLibraryScanner> logger)
|
||||
: base(metadataRepository)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_movieRepository = movieRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,21 +50,37 @@ namespace ErsatzTV.Core.Plex
|
||||
foreach (PlexMovie incoming in movieEntries)
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, PlexMovie> maybeMovie = await _movieRepository
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(plexMediaSourceLibrary, incoming)
|
||||
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
|
||||
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
maybeMovie.IfLeft(
|
||||
error => _logger.LogWarning(
|
||||
"Error processing plex movie at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value));
|
||||
await maybeMovie.Match(
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex movie at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
var movieKeys = movieEntries.Map(s => s.Key).ToList();
|
||||
await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
|
||||
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(plexMediaSourceLibrary, movieKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
@@ -75,12 +95,13 @@ namespace ErsatzTV.Core.Plex
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexMovie>> UpdateStatistics(
|
||||
PlexMovie existing,
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
@@ -97,16 +118,19 @@ namespace ErsatzTV.Core.Plex
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = incomingVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdateStatistics(existingVersion);
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
}
|
||||
|
||||
return Right<BaseError, PlexMovie>(existing);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexMovie>> UpdateMetadata(PlexMovie existing, PlexMovie incoming)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||
|
||||
@@ -117,7 +141,10 @@ namespace ErsatzTV.Core.Plex
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Remove(genre);
|
||||
await _metadataRepository.RemoveGenre(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
@@ -125,15 +152,54 @@ namespace ErsatzTV.Core.Plex
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Add(genre);
|
||||
await _movieRepository.AddGenre(existingMetadata, genre);
|
||||
if (await _movieRepository.AddGenre(existingMetadata, genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in existingMetadata.Studios
|
||||
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Remove(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Add(studio);
|
||||
if (await _movieRepository.AddStudio(existingMetadata, studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (incomingMetadata.SortTitle != existingMetadata.SortTitle)
|
||||
{
|
||||
existingMetadata.SortTitle = incomingMetadata.SortTitle;
|
||||
if (await _movieRepository.UpdateSortTitle(existingMetadata))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: update other metadata?
|
||||
}
|
||||
|
||||
return existing;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexMovie>> UpdateArtwork(PlexMovie existing, PlexMovie incoming)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateArtwork(
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||
|
||||
@@ -143,7 +209,7 @@ namespace ErsatzTV.Core.Plex
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
}
|
||||
|
||||
return existing;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -15,18 +17,21 @@ namespace ErsatzTV.Core.Plex
|
||||
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public PlexTelevisionLibraryScanner(
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMetadataRepository metadataRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ILogger<PlexTelevisionLibraryScanner> logger)
|
||||
: base(metadataRepository)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_televisionRepository = televisionRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -46,13 +51,25 @@ namespace ErsatzTV.Core.Plex
|
||||
foreach (PlexShow incoming in showEntries)
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, PlexShow> maybeShow = await _televisionRepository
|
||||
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
|
||||
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
|
||||
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
await maybeShow.Match(
|
||||
async show => await ScanSeasons(plexMediaSourceLibrary, show, connection, token),
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(new List<MediaItem> { result.Item });
|
||||
}
|
||||
|
||||
await ScanSeasons(plexMediaSourceLibrary, result.Item, connection, token);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
@@ -64,7 +81,9 @@ namespace ErsatzTV.Core.Plex
|
||||
}
|
||||
|
||||
var showKeys = showEntries.Map(s => s.Key).ToList();
|
||||
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
|
||||
List<int> ids =
|
||||
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
return Unit.Default;
|
||||
},
|
||||
@@ -79,8 +98,11 @@ namespace ErsatzTV.Core.Plex
|
||||
});
|
||||
}
|
||||
|
||||
private Task<Either<BaseError, PlexShow>> UpdateMetadata(PlexShow existing, PlexShow incoming)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
|
||||
MediaItemScanResult<PlexShow> result,
|
||||
PlexShow incoming)
|
||||
{
|
||||
PlexShow existing = result.Item;
|
||||
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
|
||||
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
||||
|
||||
@@ -93,7 +115,10 @@ namespace ErsatzTV.Core.Plex
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Remove(genre);
|
||||
_metadataRepository.RemoveGenre(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
@@ -101,15 +126,43 @@ namespace ErsatzTV.Core.Plex
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Add(genre);
|
||||
_televisionRepository.AddGenre(existingMetadata, genre);
|
||||
if (await _televisionRepository.AddGenre(existingMetadata, genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in existingMetadata.Studios
|
||||
.Filter(s => incomingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Remove(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Add(studio);
|
||||
if (await _televisionRepository.AddStudio(existingMetadata, studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Right<BaseError, PlexShow>(existing).AsTask();
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexShow>> UpdateArtwork(PlexShow existing, PlexShow incoming)
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateArtwork(
|
||||
MediaItemScanResult<PlexShow> result,
|
||||
PlexShow incoming)
|
||||
{
|
||||
PlexShow existing = result.Item;
|
||||
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
|
||||
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
||||
|
||||
@@ -119,7 +172,7 @@ namespace ErsatzTV.Core.Plex
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
}
|
||||
|
||||
return existing;
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||
@@ -257,7 +310,7 @@ namespace ErsatzTV.Core.Plex
|
||||
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
|
||||
existingVersion.DateUpdated = incomingVersion.DateUpdated;
|
||||
|
||||
await _metadataRepository.UpdateStatistics(existingVersion);
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion);
|
||||
},
|
||||
_ => Task.CompletedTask);
|
||||
}
|
||||
|
||||
4
ErsatzTV.Core/Search/SearchItem.cs
Normal file
4
ErsatzTV.Core/Search/SearchItem.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Search
|
||||
{
|
||||
public record SearchItem(int Id);
|
||||
}
|
||||
6
ErsatzTV.Core/Search/SearchPageMap.cs
Normal file
6
ErsatzTV.Core/Search/SearchPageMap.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Search
|
||||
{
|
||||
public record SearchPageMap(Dictionary<char, int> PageMap);
|
||||
}
|
||||
10
ErsatzTV.Core/Search/SearchResult.cs
Normal file
10
ErsatzTV.Core/Search/SearchResult.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Search
|
||||
{
|
||||
public record SearchResult(List<SearchItem> Items, int TotalCount)
|
||||
{
|
||||
public Option<SearchPageMap> PageMap { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,15 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Genres)
|
||||
builder.HasMany(sm => sm.Genres)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(mm => mm.Tags)
|
||||
builder.HasMany(sm => sm.Tags)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(sm => sm.Studios)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class StudioConfiguration : IEntityTypeConfiguration<Studio>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<Studio> builder) => builder.ToTable("Studio");
|
||||
}
|
||||
}
|
||||
@@ -84,6 +84,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPathId });
|
||||
|
||||
public Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId) =>
|
||||
_dbConnection.QueryAsync<int>(
|
||||
@"SELECT Id FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPathId })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public async Task DeleteLocalPath(int libraryPathId)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -107,14 +107,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public Task<Option<PlexMediaSource>> GetPlexByLibraryId(int plexLibraryId)
|
||||
public async Task<Option<PlexMediaSource>> GetPlexByLibraryId(int plexLibraryId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.PlexMediaSources
|
||||
int? id = await _dbConnection.QuerySingleAsync<int?>(
|
||||
@"SELECT L.MediaSourceId FROM Library L
|
||||
INNER JOIN PlexLibrary PL on L.Id = PL.Id
|
||||
WHERE L.Id = @PlexLibraryId",
|
||||
new { PlexLibraryId = plexLibraryId });
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return await context.PlexMediaSources
|
||||
.Include(p => p.Connections)
|
||||
.Include(p => p.Libraries)
|
||||
.Where(p => p.Libraries.Any(l => l.Id == plexLibraryId))
|
||||
.SingleOrDefaultAsync()
|
||||
.OrderBy(p => p.Id)
|
||||
.SingleOrDefaultAsync(p => p.Id == id)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
@@ -146,11 +152,94 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task Update(PlexMediaSource plexMediaSource)
|
||||
public async Task Update(
|
||||
PlexMediaSource plexMediaSource,
|
||||
List<PlexConnection> toAdd,
|
||||
List<PlexConnection> toDelete)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
context.PlexMediaSources.Update(plexMediaSource);
|
||||
await context.SaveChangesAsync();
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE PlexMediaSource SET ProductVersion = @ProductVersion, ServerName = @ServerName WHERE Id = @Id",
|
||||
new { plexMediaSource.ProductVersion, plexMediaSource.ServerName, plexMediaSource.Id });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
foreach (PlexConnection add in toAdd)
|
||||
{
|
||||
add.PlexMediaSourceId = plexMediaSource.Id;
|
||||
dbContext.Entry(add).State = EntityState.Added;
|
||||
}
|
||||
|
||||
foreach (PlexConnection delete in toDelete)
|
||||
{
|
||||
dbContext.Entry(delete).State = EntityState.Deleted;
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
plexMediaSource.Connections.Head().IsActive = true;
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Unit> UpdateLibraries(
|
||||
int plexMediaSourceId,
|
||||
List<PlexLibrary> toAdd,
|
||||
List<PlexLibrary> toDelete)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
foreach (PlexLibrary add in toAdd)
|
||||
{
|
||||
add.MediaSourceId = plexMediaSourceId;
|
||||
dbContext.Entry(add).State = EntityState.Added;
|
||||
}
|
||||
|
||||
foreach (PlexLibrary delete in toDelete)
|
||||
{
|
||||
dbContext.Entry(delete).State = EntityState.Deleted;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<Unit> UpdatePathReplacements(
|
||||
int plexMediaSourceId,
|
||||
List<PlexPathReplacement> toAdd,
|
||||
List<PlexPathReplacement> toUpdate,
|
||||
List<PlexPathReplacement> toDelete)
|
||||
{
|
||||
foreach (PlexPathReplacement add in toAdd)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO PlexPathReplacement
|
||||
(PlexPath, LocalPath, PlexMediaSourceId)
|
||||
VALUES (@PlexPath, @LocalPath, @PlexMediaSourceId)",
|
||||
new { add.PlexPath, add.LocalPath, PlexMediaSourceId = plexMediaSourceId });
|
||||
}
|
||||
|
||||
foreach (PlexPathReplacement update in toUpdate)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE PlexPathReplacement
|
||||
SET PlexPath = @PlexPath, LocalPath = @LocalPath
|
||||
WHERE Id = @Id",
|
||||
new { update.PlexPath, update.LocalPath, update.Id });
|
||||
}
|
||||
|
||||
foreach (PlexPathReplacement delete in toDelete)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM PlexPathReplacement WHERE Id = @Id",
|
||||
new { delete.Id });
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task Update(PlexLibrary plexMediaSourceLibrary)
|
||||
@@ -168,16 +257,25 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public async Task<Unit> DeleteAllPlex()
|
||||
public async Task<List<int>> DeleteAllPlex()
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
List<PlexMediaSource> allMediaSources = await context.PlexMediaSources.ToListAsync();
|
||||
context.PlexMediaSources.RemoveRange(allMediaSources);
|
||||
|
||||
List<PlexLibrary> allPlexLibraries = await context.PlexLibraries.ToListAsync();
|
||||
context.PlexLibraries.RemoveRange(allPlexLibraries);
|
||||
|
||||
List<int> movieIds = await context.PlexMovies.Map(pm => pm.Id).ToListAsync();
|
||||
List<int> showIds = await context.PlexShows.Map(ps => ps.Id).ToListAsync();
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
|
||||
return movieIds.Append(showIds).ToList();
|
||||
}
|
||||
|
||||
public async Task DisablePlexLibrarySync(List<int> libraryIds)
|
||||
public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
||||
@@ -187,6 +285,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> movieIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexMovie pm ON pm.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
@@ -214,6 +320,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> showIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexShow ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
@@ -222,6 +336,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).ToList();
|
||||
}
|
||||
|
||||
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
|
||||
|
||||
@@ -4,19 +4,53 @@ using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class MetadataRepository : IMetadataRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public MetadataRepository(IDbConnection dbConnection) => _dbConnection = dbConnection;
|
||||
public MetadataRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public Task<Unit> RemoveGenre(Genre genre) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id }).ToUnit();
|
||||
public async Task<bool> Update(Metadata metadata)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
dbContext.Entry(metadata).State = EntityState.Modified;
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public Task<Unit> UpdateStatistics(MediaVersion mediaVersion) =>
|
||||
public async Task<bool> Add(Metadata metadata)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
dbContext.Entry(metadata).State = EntityState.Added;
|
||||
foreach (Genre genre in metadata.Genres)
|
||||
{
|
||||
dbContext.Entry(genre).State = EntityState.Added;
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags)
|
||||
{
|
||||
dbContext.Entry(tag).State = EntityState.Added;
|
||||
}
|
||||
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
dbContext.Entry(mediaVersion).State = EntityState.Modified;
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MediaVersion SET
|
||||
SampleAspectRatio = @SampleAspectRatio,
|
||||
@@ -29,7 +63,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
mediaVersion.VideoScanKind,
|
||||
mediaVersion.DateUpdated,
|
||||
MediaVersionId = mediaVersion.Id
|
||||
}).ToUnit();
|
||||
}).Map(result => result > 0);
|
||||
|
||||
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@@ -74,5 +108,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
|
||||
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
|
||||
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
|
||||
|
||||
public Task<bool> RemoveGenre(Genre genre) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Genre WHERE Id = @GenreId", new { GenreId = genre.Id })
|
||||
.Map(result => result > 0);
|
||||
|
||||
public Task<bool> RemoveTag(Tag tag) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Tag WHERE Id = @TagId", new { TagId = tag.Id })
|
||||
.Map(result => result > 0);
|
||||
|
||||
public Task<bool> RemoveStudio(Studio studio) =>
|
||||
_dbConnection.ExecuteAsync("DELETE FROM Studio WHERE Id = @StudioId", new { StudioId = studio.Id })
|
||||
.Map(result => result > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -16,15 +17,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
public class MovieRepository : IMovieRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly TvContext _dbContext;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public MovieRepository(
|
||||
TvContext dbContext,
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IDbConnection dbConnection)
|
||||
public MovieRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
@@ -45,32 +41,42 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(m => m.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(m => m.Tags)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(m => m.Studios)
|
||||
.OrderBy(m => m.Id)
|
||||
.SingleOrDefaultAsync(m => m.Id == movieId)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path)
|
||||
public async Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path)
|
||||
{
|
||||
Option<Movie> maybeExisting = await _dbContext.Movies
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<Movie> maybeExisting = await dbContext.Movies
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
mediaItem => Right<BaseError, Movie>(mediaItem).AsTask(),
|
||||
async () => await AddMovie(libraryPath.Id, path));
|
||||
mediaItem =>
|
||||
Right<BaseError, MediaItemScanResult<Movie>>(
|
||||
new MediaItemScanResult<Movie>(mediaItem) { IsAdded = false }).AsTask(),
|
||||
async () => await AddMovie(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item)
|
||||
public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(
|
||||
PlexLibrary library,
|
||||
PlexMovie item)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexMovie> maybeExisting = await context.PlexMovies
|
||||
@@ -78,37 +84,54 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
plexMovie => Right<BaseError, PlexMovie>(plexMovie).AsTask(),
|
||||
plexMovie =>
|
||||
Right<BaseError, MediaItemScanResult<PlexMovie>>(
|
||||
new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = true }).AsTask(),
|
||||
async () => await AddPlexMovie(context, library, item));
|
||||
}
|
||||
|
||||
public async Task<bool> Update(Movie movie)
|
||||
{
|
||||
_dbContext.Movies.Update(movie);
|
||||
return await _dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public Task<int> GetMovieCount() =>
|
||||
_dbConnection.QuerySingleAsync<int>(@"SELECT COUNT(DISTINCT MovieId) FROM MovieMetadata");
|
||||
|
||||
public Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize) =>
|
||||
_dbContext.MovieMetadata.FromSqlRaw(
|
||||
public async Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.MovieMetadata.FromSqlRaw(
|
||||
@"SELECT * FROM MovieMetadata WHERE Id IN
|
||||
(SELECT Id FROM MovieMetadata GROUP BY MovieId, MetadataKind HAVING MetadataKind = MAX(MetadataKind))
|
||||
ORDER BY SortTitle
|
||||
LIMIT {0} OFFSET {1}",
|
||||
pageSize,
|
||||
(pageNumber - 1) * pageSize)
|
||||
.AsNoTracking()
|
||||
.Include(mm => mm.Artwork)
|
||||
.OrderBy(mm => mm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.MovieMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(mm => ids.Contains(mm.MovieId))
|
||||
.Include(mm => mm.Artwork)
|
||||
.OrderBy(mm => mm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath) =>
|
||||
_dbConnection.QueryAsync<string>(
|
||||
@@ -120,43 +143,73 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE MI.LibraryPathId = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPath.Id });
|
||||
|
||||
public async Task<Unit> DeleteByPath(LibraryPath libraryPath, string path)
|
||||
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM Movie M
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN MediaVersion MV on M.Id = MV.MovieId
|
||||
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
||||
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||
new { LibraryPathId = libraryPath.Id, Path = path });
|
||||
new { LibraryPathId = libraryPath.Id, Path = path })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
foreach (int movieId in ids)
|
||||
{
|
||||
Movie movie = await _dbContext.Movies.FindAsync(movieId);
|
||||
_dbContext.Movies.Remove(movie);
|
||||
Movie movie = await dbContext.Movies.FindAsync(movieId);
|
||||
dbContext.Movies.Remove(movie);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
bool changed = await dbContext.SaveChangesAsync() > 0;
|
||||
return changed ? ids : new List<int>();
|
||||
}
|
||||
|
||||
public Task<Unit> AddGenre(MovieMetadata metadata, Genre genre) =>
|
||||
public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { genre.Name, MetadataId = metadata.Id }).ToUnit();
|
||||
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys) =>
|
||||
public Task<bool> AddTag(MovieMetadata metadata, Tag tag) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Tag (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddStudio(MovieMetadata metadata, Studio studio) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Studio (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexMovie pm ON pm.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys",
|
||||
new { LibraryId = library.Id, Keys = movieKeys }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexMovie pm ON pm.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys)",
|
||||
new { LibraryId = library.Id, Keys = movieKeys }).ToUnit();
|
||||
new { LibraryId = library.Id, Keys = movieKeys });
|
||||
|
||||
private async Task<Either<BaseError, Movie>> AddMovie(int libraryPathId, string path)
|
||||
return ids;
|
||||
}
|
||||
|
||||
public Task<bool> UpdateSortTitle(MovieMetadata movieMetadata) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MovieMetadata SET SortTitle = @SortTitle WHERE Id = @Id",
|
||||
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -174,10 +227,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
};
|
||||
await _dbContext.Movies.AddAsync(movie);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await _dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
return movie;
|
||||
await dbContext.Movies.AddAsync(movie);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<Movie>(movie) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -185,7 +239,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddPlexMovie(
|
||||
TvContext context,
|
||||
PlexLibrary library,
|
||||
PlexMovie item)
|
||||
@@ -197,7 +251,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
await context.PlexMovies.AddAsync(item);
|
||||
await context.SaveChangesAsync();
|
||||
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
return item;
|
||||
await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
@@ -21,6 +22,34 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
_dbConnection = dbConnection;
|
||||
}
|
||||
|
||||
public Task<List<int>> GetItemIdsToIndex() =>
|
||||
_dbConnection.QueryAsync<int>(@"SELECT Id FROM MediaItem")
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public async Task<Option<MediaItem>> GetItemToIndex(int id)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Include(mi => mi.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.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>(
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -16,15 +17,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
public class TelevisionRepository : ITelevisionRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly TvContext _dbContext;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public TelevisionRepository(
|
||||
TvContext dbContext,
|
||||
IDbConnection dbConnection,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
public TelevisionRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_dbConnection = dbConnection;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -35,33 +31,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
new { ShowIds = showIds })
|
||||
.Map(c => c == showIds.Count);
|
||||
|
||||
public async Task<bool> Update(Show show)
|
||||
public async Task<List<Show>> GetAllShows()
|
||||
{
|
||||
_dbContext.Shows.Update(show);
|
||||
return await _dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> Update(Season season)
|
||||
{
|
||||
_dbContext.Seasons.Update(season);
|
||||
return await _dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> Update(Episode episode)
|
||||
{
|
||||
_dbContext.Episodes.Update(episode);
|
||||
return await _dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
public Task<List<Show>> GetAllShows() =>
|
||||
_dbContext.Shows
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Shows
|
||||
.AsNoTracking()
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Option<Show>> GetShow(int showId) =>
|
||||
_dbContext.Shows
|
||||
public async Task<Option<Show>> GetShow(int showId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Shows
|
||||
.AsNoTracking()
|
||||
.Filter(s => s.Id == showId)
|
||||
.Include(s => s.ShowMetadata)
|
||||
@@ -70,30 +53,53 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync()
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public Task<int> GetShowCount() =>
|
||||
_dbContext.ShowMetadata
|
||||
public async Task<int> GetShowCount()
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.GroupBy(sm => new { sm.Title, sm.Year })
|
||||
.CountAsync();
|
||||
}
|
||||
|
||||
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
|
||||
_dbContext.ShowMetadata.FromSqlRaw(
|
||||
public async Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.ShowMetadata.FromSqlRaw(
|
||||
@"SELECT * FROM ShowMetadata WHERE Id IN
|
||||
(SELECT MIN(Id) FROM ShowMetadata GROUP BY Title, Year, MetadataKind HAVING MetadataKind = MAX(MetadataKind))
|
||||
ORDER BY SortTitle
|
||||
LIMIT {0} OFFSET {1}",
|
||||
pageSize,
|
||||
(pageNumber - 1) * pageSize)
|
||||
.AsNoTracking()
|
||||
.Include(mm => mm.Artwork)
|
||||
.OrderBy(mm => mm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<Season>> GetAllSeasons() =>
|
||||
_dbContext.Seasons
|
||||
public async Task<List<ShowMetadata>> GetShowsForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.ShowMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(sm => ids.Contains(sm.ShowId))
|
||||
.Include(sm => sm.Artwork)
|
||||
.OrderBy(sm => sm.SortTitle)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<List<Season>> GetAllSeasons()
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Seasons
|
||||
.AsNoTracking()
|
||||
.Include(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
@@ -101,9 +107,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Option<Season>> GetSeason(int seasonId) =>
|
||||
_dbContext.Seasons
|
||||
public async Task<Option<Season>> GetSeason(int seasonId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Seasons
|
||||
.AsNoTracking()
|
||||
.Include(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
@@ -113,11 +122,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync(s => s.Id == seasonId)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public Task<int> GetSeasonCount(int showId) =>
|
||||
_dbContext.Seasons
|
||||
public async Task<int> GetSeasonCount(int showId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Seasons
|
||||
.AsNoTracking()
|
||||
.CountAsync(s => s.ShowId == showId);
|
||||
}
|
||||
|
||||
public async Task<List<Season>> GetPagedSeasons(int televisionShowId, int pageNumber, int pageSize)
|
||||
{
|
||||
@@ -129,7 +142,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
new { ShowId = televisionShowId })
|
||||
.Map(results => results.ToList());
|
||||
|
||||
return await _dbContext.Seasons
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Seasons
|
||||
.AsNoTracking()
|
||||
.Where(s => showIds.Contains(s.ShowId))
|
||||
.Include(s => s.SeasonMetadata)
|
||||
@@ -142,8 +156,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Option<Episode>> GetEpisode(int episodeId) =>
|
||||
_dbContext.Episodes
|
||||
public async Task<Option<Episode>> GetEpisode(int episodeId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Include(e => e.Season)
|
||||
.Include(e => e.EpisodeMetadata)
|
||||
@@ -151,14 +167,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync(s => s.Id == episodeId)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public Task<int> GetEpisodeCount(int seasonId) =>
|
||||
_dbContext.Episodes
|
||||
public async Task<int> GetEpisodeCount(int seasonId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.CountAsync(e => e.SeasonId == seasonId);
|
||||
}
|
||||
|
||||
public Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize) =>
|
||||
_dbContext.EpisodeMetadata
|
||||
public async Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.EpisodeMetadata
|
||||
.AsNoTracking()
|
||||
.Filter(em => em.Episode.SeasonId == seasonId)
|
||||
.Include(em => em.Artwork)
|
||||
@@ -170,10 +192,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata)
|
||||
{
|
||||
Option<int> maybeId = await _dbContext.ShowMetadata
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<int> maybeId = await dbContext.ShowMetadata
|
||||
.Where(s => s.Title == metadata.Title && s.Year == metadata.Year)
|
||||
.Where(s => s.Show.LibraryPathId == libraryPathId)
|
||||
.SingleOrDefaultAsync()
|
||||
@@ -183,13 +207,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return await maybeId.Match(
|
||||
id =>
|
||||
{
|
||||
return _dbContext.Shows
|
||||
return dbContext.Shows
|
||||
.AsNoTracking()
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(s => s.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(s => s.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.OrderBy(s => s.Id)
|
||||
.SingleOrDefaultAsync(s => s.Id == id)
|
||||
.Map(Optional);
|
||||
@@ -197,8 +224,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
() => Option<Show>.None.AsTask());
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata)
|
||||
public async Task<Either<BaseError, MediaItemScanResult<Show>>> AddShow(
|
||||
int libraryPathId,
|
||||
string showFolder,
|
||||
ShowMetadata metadata)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
try
|
||||
{
|
||||
metadata.DateAdded = DateTime.UtcNow;
|
||||
@@ -211,10 +243,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
Seasons = new List<Season>()
|
||||
};
|
||||
|
||||
await _dbContext.Shows.AddAsync(show);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await dbContext.Shows.AddAsync(show);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(show).Reference(s => s.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
|
||||
return show;
|
||||
return new MediaItemScanResult<Show>(show) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -224,14 +258,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
public async Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber)
|
||||
{
|
||||
Option<Season> maybeExisting = await _dbContext.Seasons
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<Season> maybeExisting = await dbContext.Seasons
|
||||
.Include(s => s.SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.OrderBy(s => s.ShowId)
|
||||
.ThenBy(s => s.SeasonNumber)
|
||||
.SingleOrDefaultAsync(s => s.ShowId == show.Id && s.SeasonNumber == seasonNumber);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
season => Right<BaseError, Season>(season).AsTask(),
|
||||
() => AddSeason(show, libraryPathId, seasonNumber));
|
||||
() => AddSeason(dbContext, show, libraryPathId, seasonNumber));
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Episode>> GetOrAddEpisode(
|
||||
@@ -239,7 +276,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
LibraryPath libraryPath,
|
||||
string path)
|
||||
{
|
||||
Option<Episode> maybeExisting = await _dbContext.Episodes
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<Episode> maybeExisting = await dbContext.Episodes
|
||||
.Include(i => i.EpisodeMetadata)
|
||||
.ThenInclude(em => em.Artwork)
|
||||
.Include(i => i.MediaVersions)
|
||||
@@ -249,7 +287,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
return await maybeExisting.Match(
|
||||
episode => Right<BaseError, Episode>(episode).AsTask(),
|
||||
() => AddEpisode(season, libraryPath.Id, path));
|
||||
() => AddEpisode(dbContext, season, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) =>
|
||||
@@ -273,64 +311,73 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
||||
new { LibraryPathId = libraryPath.Id, Path = path });
|
||||
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
foreach (int episodeId in ids)
|
||||
{
|
||||
Episode episode = await _dbContext.Episodes.FindAsync(episodeId);
|
||||
_dbContext.Episodes.Remove(episode);
|
||||
Episode episode = await dbContext.Episodes.FindAsync(episodeId);
|
||||
dbContext.Episodes.Remove(episode);
|
||||
}
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) =>
|
||||
_dbContext.Seasons
|
||||
public async Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<Season> seasons = await dbContext.Seasons
|
||||
.Filter(s => s.LibraryPathId == libraryPath.Id)
|
||||
.Filter(s => s.Episodes.Count == 0)
|
||||
.ToListAsync()
|
||||
.Bind(
|
||||
list =>
|
||||
{
|
||||
_dbContext.Seasons.RemoveRange(list);
|
||||
return _dbContext.SaveChangesAsync();
|
||||
})
|
||||
.ToUnit();
|
||||
.ToListAsync();
|
||||
dbContext.Seasons.RemoveRange(seasons);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) =>
|
||||
_dbContext.Shows
|
||||
public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<Show> shows = await dbContext.Shows
|
||||
.Filter(s => s.LibraryPathId == libraryPath.Id)
|
||||
.Filter(s => s.Seasons.Count == 0)
|
||||
.ToListAsync()
|
||||
.Bind(
|
||||
list =>
|
||||
{
|
||||
_dbContext.Shows.RemoveRange(list);
|
||||
return _dbContext.SaveChangesAsync();
|
||||
})
|
||||
.ToUnit();
|
||||
.ToListAsync();
|
||||
var ids = shows.Map(s => s.Id).ToList();
|
||||
dbContext.Shows.RemoveRange(shows);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item)
|
||||
public async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexShow> maybeExisting = await context.PlexShows
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexShow> maybeExisting = await dbContext.PlexShows
|
||||
.AsNoTracking()
|
||||
.Include(i => i.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(i => i.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(i => i.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.Include(i => i.ShowMetadata)
|
||||
.ThenInclude(sm => sm.Artwork)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
plexShow => Right<BaseError, PlexShow>(plexShow).AsTask(),
|
||||
async () => await AddPlexShow(context, library, item));
|
||||
plexShow => Right<BaseError, MediaItemScanResult<PlexShow>>(
|
||||
new MediaItemScanResult<PlexShow>(plexShow) { IsAdded = true }).AsTask(),
|
||||
async () => await AddPlexShow(dbContext, library, item));
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexSeason> maybeExisting = await context.PlexSeasons
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
|
||||
.AsNoTracking()
|
||||
.Include(i => i.SeasonMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
@@ -339,13 +386,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
return await maybeExisting.Match(
|
||||
plexSeason => Right<BaseError, PlexSeason>(plexSeason).AsTask(),
|
||||
async () => await AddPlexSeason(context, library, item));
|
||||
async () => await AddPlexSeason(dbContext, library, item));
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexEpisode> maybeExisting = await context.PlexEpisodes
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes
|
||||
.AsNoTracking()
|
||||
.Include(i => i.EpisodeMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
@@ -356,23 +403,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
return await maybeExisting.Match(
|
||||
plexEpisode => Right<BaseError, PlexEpisode>(plexEpisode).AsTask(),
|
||||
async () => await AddPlexEpisode(context, library, item));
|
||||
async () => await AddPlexEpisode(dbContext, library, item));
|
||||
}
|
||||
|
||||
public Task<Unit> AddGenre(ShowMetadata metadata, Genre genre) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { genre.Name, MetadataId = metadata.Id }).ToUnit();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexShow ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys)",
|
||||
new { LibraryId = library.Id, Keys = showKeys }).ToUnit();
|
||||
|
||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
@@ -382,7 +415,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
INNER JOIN PlexShow P on P.Id = s.ShowId
|
||||
WHERE P.Key = @ShowKey AND ps.Key not in @Keys)",
|
||||
new { ShowKey = showKey, Keys = seasonKeys }).ToUnit();
|
||||
|
||||
|
||||
public Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
@@ -393,6 +426,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys)",
|
||||
new { SeasonKey = seasonKey, Keys = episodeKeys }).ToUnit();
|
||||
|
||||
public async Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber)
|
||||
{
|
||||
episode.EpisodeNumber = episodeNumber;
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
|
||||
new { EpisodeNumber = episodeNumber, episode.Id });
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<List<Episode>> GetShowItems(int showId)
|
||||
{
|
||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@@ -402,7 +444,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
WHERE Show.Id = @ShowId",
|
||||
new { ShowId = showId });
|
||||
|
||||
return await _dbContext.Episodes
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Include(e => e.EpisodeMetadata)
|
||||
.Include(e => e.MediaVersions)
|
||||
.Include(e => e.Season)
|
||||
@@ -410,15 +454,58 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<Episode>> GetSeasonItems(int seasonId) =>
|
||||
_dbContext.Episodes
|
||||
public async Task<List<Episode>> GetSeasonItems(int seasonId)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return await dbContext.Episodes
|
||||
.AsNoTracking()
|
||||
.Include(e => e.EpisodeMetadata)
|
||||
.Include(e => e.MediaVersions)
|
||||
.Include(e => e.Season)
|
||||
.Filter(e => e.SeasonId == seasonId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Season>> AddSeason(Show show, int libraryPathId, int seasonNumber)
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Genre (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"INSERT INTO Studio (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
|
||||
new { studio.Name, MetadataId = metadata.Id }).Map(result => result > 0);
|
||||
|
||||
public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexShow ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys",
|
||||
new { LibraryId = library.Id, Keys = showKeys }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexShow ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys)",
|
||||
new { LibraryId = library.Id, Keys = showKeys });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Season>> AddSeason(
|
||||
TvContext dbContext,
|
||||
Show show,
|
||||
int libraryPathId,
|
||||
int seasonNumber)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -428,10 +515,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
ShowId = show.Id,
|
||||
SeasonNumber = seasonNumber,
|
||||
Episodes = new List<Episode>(),
|
||||
SeasonMetadata = new List<SeasonMetadata>()
|
||||
SeasonMetadata = new List<SeasonMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
DateAdded = DateTime.UtcNow
|
||||
}
|
||||
}
|
||||
};
|
||||
await _dbContext.Seasons.AddAsync(season);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await dbContext.Seasons.AddAsync(season);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return season;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -440,15 +533,32 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Episode>> AddEpisode(Season season, int libraryPathId, string path)
|
||||
private static async Task<Either<BaseError, Episode>> AddEpisode(
|
||||
TvContext dbContext,
|
||||
Season season,
|
||||
int libraryPathId,
|
||||
string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dbContext.MediaFiles.Any(mf => mf.Path == path))
|
||||
{
|
||||
return BaseError.New("Multi-episode files are not yet supported");
|
||||
}
|
||||
|
||||
var episode = new Episode
|
||||
{
|
||||
LibraryPathId = libraryPathId,
|
||||
SeasonId = season.Id,
|
||||
EpisodeMetadata = new List<EpisodeMetadata>(),
|
||||
EpisodeMetadata = new List<EpisodeMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
DateAdded = DateTime.UtcNow,
|
||||
DateUpdated = DateTime.MinValue,
|
||||
MetadataKind = MetadataKind.Fallback
|
||||
}
|
||||
},
|
||||
MediaVersions = new List<MediaVersion>
|
||||
{
|
||||
new()
|
||||
@@ -460,8 +570,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
};
|
||||
await _dbContext.Episodes.AddAsync(episode);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
await dbContext.Episodes.AddAsync(episode);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -470,8 +580,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexShow>> AddPlexShow(
|
||||
TvContext context,
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> AddPlexShow(
|
||||
TvContext dbContext,
|
||||
PlexLibrary library,
|
||||
PlexShow item)
|
||||
{
|
||||
@@ -479,10 +589,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await context.PlexShows.AddAsync(item);
|
||||
await context.SaveChangesAsync();
|
||||
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
return item;
|
||||
await dbContext.PlexShows.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -490,8 +601,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexSeason>> AddPlexSeason(
|
||||
TvContext context,
|
||||
private static async Task<Either<BaseError, PlexSeason>> AddPlexSeason(
|
||||
TvContext dbContext,
|
||||
PlexLibrary library,
|
||||
PlexSeason item)
|
||||
{
|
||||
@@ -499,9 +610,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await context.PlexSeasons.AddAsync(item);
|
||||
await context.SaveChangesAsync();
|
||||
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.PlexSeasons.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
return item;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -510,18 +621,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexEpisode>> AddPlexEpisode(
|
||||
TvContext context,
|
||||
private static async Task<Either<BaseError, PlexEpisode>> AddPlexEpisode(
|
||||
TvContext dbContext,
|
||||
PlexLibrary library,
|
||||
PlexEpisode item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path))
|
||||
{
|
||||
return BaseError.New("Multi-episode files are not yet supported");
|
||||
}
|
||||
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await context.PlexEpisodes.AddAsync(item);
|
||||
await context.SaveChangesAsync();
|
||||
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.PlexEpisodes.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
return item;
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -7,13 +7,16 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dapper" Version="2.0.78" />
|
||||
<PackageReference Include="Lucene.Net" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.Analysis.Common" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00013" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="5.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
|
||||
<PackageReference Include="Refit" Version="6.0.24" />
|
||||
<PackageReference Include="Refit" Version="6.0.38" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
1686
ErsatzTV.Infrastructure/Migrations/20210319010444_Delete_OrphanPlexMediaSources.Designer.cs
generated
Normal file
1686
ErsatzTV.Infrastructure/Migrations/20210319010444_Delete_OrphanPlexMediaSources.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Delete_OrphanPlexMediaSources : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.Sql(
|
||||
@"DELETE FROM MediaSource WHERE Id NOT IN
|
||||
(SELECT Id FROM LocalMediaSource UNION ALL SELECT Id FROM PlexMediaSource)");
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
1749
ErsatzTV.Infrastructure/Migrations/20210321162257_Add_Studio.Designer.cs
generated
Normal file
1749
ErsatzTV.Infrastructure/Migrations/20210321162257_Add_Studio.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Add_Studio : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
"Studio",
|
||||
table => new
|
||||
{
|
||||
Id = table.Column<int>("INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>("TEXT", nullable: true),
|
||||
EpisodeMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
MovieMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
SeasonMetadataId = table.Column<int>("INTEGER", nullable: true),
|
||||
ShowMetadataId = table.Column<int>("INTEGER", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Studio", x => x.Id);
|
||||
table.ForeignKey(
|
||||
"FK_Studio_EpisodeMetadata_EpisodeMetadataId",
|
||||
x => x.EpisodeMetadataId,
|
||||
"EpisodeMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
"FK_Studio_MovieMetadata_MovieMetadataId",
|
||||
x => x.MovieMetadataId,
|
||||
"MovieMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
"FK_Studio_SeasonMetadata_SeasonMetadataId",
|
||||
x => x.SeasonMetadataId,
|
||||
"SeasonMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
"FK_Studio_ShowMetadata_ShowMetadataId",
|
||||
x => x.ShowMetadataId,
|
||||
"ShowMetadata",
|
||||
"Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Studio_EpisodeMetadataId",
|
||||
"Studio",
|
||||
"EpisodeMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Studio_MovieMetadataId",
|
||||
"Studio",
|
||||
"MovieMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Studio_SeasonMetadataId",
|
||||
"Studio",
|
||||
"SeasonMetadataId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
"IX_Studio_ShowMetadataId",
|
||||
"Studio",
|
||||
"ShowMetadataId");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder) =>
|
||||
migrationBuilder.DropTable(
|
||||
"Studio");
|
||||
}
|
||||
}
|
||||
1749
ErsatzTV.Infrastructure/Migrations/20210321182241_Reset_MetadataDateUpdated_Studio.Designer.cs
generated
Normal file
1749
ErsatzTV.Infrastructure/Migrations/20210321182241_Reset_MetadataDateUpdated_Studio.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_MetadataDateUpdated_Studio : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(@"UPDATE MovieMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(@"UPDATE ShowMetadata SET DateUpdated = '0001-01-01 00:00:00'");
|
||||
migrationBuilder.Sql(@"UPDATE Library SET LastScan = '0001-01-01 00:00:00'");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "5.0.3");
|
||||
.HasAnnotation("ProductVersion", "5.0.4");
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Artwork",
|
||||
@@ -845,6 +845,42 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.ToTable("ShowMetadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Studio",
|
||||
b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("EpisodeMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MovieMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("SeasonMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("ShowMetadataId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("EpisodeMetadataId");
|
||||
|
||||
b.HasIndex("MovieMetadataId");
|
||||
|
||||
b.HasIndex("SeasonMetadataId");
|
||||
|
||||
b.HasIndex("ShowMetadataId");
|
||||
|
||||
b.ToTable("Studio");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Tag",
|
||||
b =>
|
||||
@@ -1499,6 +1535,29 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
b.Navigation("Show");
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Studio",
|
||||
b =>
|
||||
{
|
||||
b.HasOne("ErsatzTV.Core.Domain.EpisodeMetadata", null)
|
||||
.WithMany("Studios")
|
||||
.HasForeignKey("EpisodeMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.MovieMetadata", null)
|
||||
.WithMany("Studios")
|
||||
.HasForeignKey("MovieMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.SeasonMetadata", null)
|
||||
.WithMany("Studios")
|
||||
.HasForeignKey("SeasonMetadataId");
|
||||
|
||||
b.HasOne("ErsatzTV.Core.Domain.ShowMetadata", null)
|
||||
.WithMany("Studios")
|
||||
.HasForeignKey("ShowMetadataId")
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity(
|
||||
"ErsatzTV.Core.Domain.Tag",
|
||||
b =>
|
||||
@@ -1744,6 +1803,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Studios");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
@@ -1765,6 +1826,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Studios");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
@@ -1794,6 +1857,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Studios");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
@@ -1805,6 +1870,8 @@ namespace ErsatzTV.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Genres");
|
||||
|
||||
b.Navigation("Studios");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace ErsatzTV.Infrastructure.Plex.Models
|
||||
public int AddedAt { get; set; }
|
||||
public int UpdatedAt { get; set; }
|
||||
public int Index { get; set; }
|
||||
public string Studio { get; set; }
|
||||
public List<PlexMediaResponse> Media { get; set; }
|
||||
public List<PlexGenreResponse> Genre { get; set; }
|
||||
}
|
||||
|
||||
@@ -176,9 +176,16 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Tagline = response.Tagline,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = lastWriteTime,
|
||||
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList()
|
||||
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.Studio))
|
||||
{
|
||||
metadata.Studios.Add(new Studio { Name = response.Studio });
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
@@ -277,9 +284,16 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
Tagline = response.Tagline,
|
||||
DateAdded = dateAdded,
|
||||
DateUpdated = lastWriteTime,
|
||||
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList()
|
||||
Genres = Optional(response.Genre).Flatten().Map(g => new Genre { Name = g.Tag }).ToList(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(response.Studio))
|
||||
{
|
||||
metadata.Studios.Add(new Studio { Name = response.Studio });
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
|
||||
@@ -51,17 +51,17 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
.Append(httpsResources.Filter(resource => resource.HttpsRequired))
|
||||
.ToList();
|
||||
|
||||
IEnumerable<PlexMediaSource> sources = allResources
|
||||
IEnumerable<PlexMediaSource> sources = await allResources
|
||||
.Filter(r => r.Provides.Split(",").Any(p => p == "server"))
|
||||
.Filter(r => r.Owned) // TODO: maybe support non-owned servers in the future
|
||||
.Map(
|
||||
resource =>
|
||||
async resource =>
|
||||
{
|
||||
var serverAuthToken = new PlexServerAuthToken(
|
||||
resource.ClientIdentifier,
|
||||
resource.AccessToken);
|
||||
|
||||
_plexSecretStore.UpsertServerAuthToken(serverAuthToken);
|
||||
await _plexSecretStore.UpsertServerAuthToken(serverAuthToken);
|
||||
List<PlexResourceConnection> sortedConnections = resource.HttpsRequired
|
||||
? resource.Connections
|
||||
: resource.Connections.OrderBy(c => c.Local ? 0 : 1).ToList();
|
||||
@@ -76,7 +76,9 @@ namespace ErsatzTV.Infrastructure.Plex
|
||||
};
|
||||
|
||||
return source;
|
||||
});
|
||||
})
|
||||
.Sequence();
|
||||
|
||||
result.AddRange(sources);
|
||||
}
|
||||
|
||||
|
||||
371
ErsatzTV.Infrastructure/Search/SearchIndex.cs
Normal file
371
ErsatzTV.Infrastructure/Search/SearchIndex.cs
Normal file
@@ -0,0 +1,371 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Lucene.Net.Documents;
|
||||
using Lucene.Net.Index;
|
||||
using Lucene.Net.QueryParsers.Classic;
|
||||
using Lucene.Net.Sandbox.Queries;
|
||||
using Lucene.Net.Search;
|
||||
using Lucene.Net.Store;
|
||||
using Lucene.Net.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Query = Lucene.Net.Search.Query;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Search
|
||||
{
|
||||
public class SearchIndex : ISearchIndex
|
||||
{
|
||||
private const LuceneVersion AppLuceneVersion = LuceneVersion.LUCENE_48;
|
||||
|
||||
private const string IdField = "id";
|
||||
private const string TypeField = "type";
|
||||
private const string TitleField = "title";
|
||||
private const string SortTitleField = "sort_title";
|
||||
private const string GenreField = "genre";
|
||||
private const string TagField = "tag";
|
||||
private const string PlotField = "plot";
|
||||
private const string LibraryNameField = "library_name";
|
||||
private const string TitleAndYearField = "title_and_year";
|
||||
private const string JumpLetterField = "jump_letter";
|
||||
private const string ReleaseDateField = "release_date";
|
||||
private const string StudioField = "studio";
|
||||
|
||||
private const string MovieType = "movie";
|
||||
private const string ShowType = "show";
|
||||
|
||||
private static bool _isRebuilding;
|
||||
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILogger<SearchIndex> _logger;
|
||||
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public SearchIndex(
|
||||
ILocalFileSystem localFileSystem,
|
||||
ISearchRepository searchRepository,
|
||||
ILogger<SearchIndex> logger)
|
||||
{
|
||||
_localFileSystem = localFileSystem;
|
||||
_searchRepository = searchRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public int Version => 2;
|
||||
|
||||
public Task<bool> Initialize()
|
||||
{
|
||||
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public async Task<Unit> Rebuild(List<int> itemIds)
|
||||
{
|
||||
_isRebuilding = true;
|
||||
|
||||
await Initialize();
|
||||
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.CREATE };
|
||||
using var writer = new IndexWriter(dir, indexConfig);
|
||||
|
||||
foreach (int id in itemIds)
|
||||
{
|
||||
Option<MediaItem> maybeMediaItem = await _searchRepository.GetItemToIndex(id);
|
||||
if (maybeMediaItem.IsSome)
|
||||
{
|
||||
MediaItem mediaItem = maybeMediaItem.ValueUnsafe();
|
||||
switch (mediaItem)
|
||||
{
|
||||
case Movie movie:
|
||||
UpdateMovie(movie, writer);
|
||||
break;
|
||||
case Show show:
|
||||
UpdateShow(show, writer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_isRebuilding = false;
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public Task<Unit> AddItems(List<MediaItem> items) => UpdateItems(items);
|
||||
|
||||
public Task<Unit> UpdateItems(List<MediaItem> items)
|
||||
{
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
|
||||
using var writer = new IndexWriter(dir, indexConfig);
|
||||
|
||||
foreach (MediaItem item in items)
|
||||
{
|
||||
switch (item)
|
||||
{
|
||||
case Movie movie:
|
||||
UpdateMovie(movie, writer);
|
||||
break;
|
||||
case Show show:
|
||||
UpdateShow(show, writer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(Unit.Default);
|
||||
}
|
||||
|
||||
public Task<Unit> RemoveItems(List<int> ids)
|
||||
{
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
var indexConfig = new IndexWriterConfig(AppLuceneVersion, analyzer) { OpenMode = OpenMode.APPEND };
|
||||
using var writer = new IndexWriter(dir, indexConfig);
|
||||
|
||||
foreach (int id in ids)
|
||||
{
|
||||
writer.DeleteDocuments(new Term(IdField, id.ToString()));
|
||||
}
|
||||
|
||||
return Task.FromResult(Unit.Default);
|
||||
}
|
||||
|
||||
public Task<SearchResult> Search(string searchQuery, int skip, int limit, string searchField = "")
|
||||
{
|
||||
if (_isRebuilding ||
|
||||
string.IsNullOrWhiteSpace(searchQuery.Replace("*", string.Empty).Replace("?", string.Empty)))
|
||||
{
|
||||
return new SearchResult(new List<SearchItem>(), 0).AsTask();
|
||||
}
|
||||
|
||||
using var dir = FSDirectory.Open(FileSystemLayout.SearchIndexFolder);
|
||||
using var reader = DirectoryReader.Open(dir);
|
||||
var searcher = new IndexSearcher(reader);
|
||||
int hitsLimit = skip + limit;
|
||||
using var analyzer = new StandardAnalyzer(AppLuceneVersion);
|
||||
QueryParser parser = !string.IsNullOrWhiteSpace(searchField)
|
||||
? new QueryParser(AppLuceneVersion, searchField, analyzer)
|
||||
: new MultiFieldQueryParser(AppLuceneVersion, new[] { TitleField }, analyzer);
|
||||
parser.AllowLeadingWildcard = true;
|
||||
Query query = ParseQuery(searchQuery, parser);
|
||||
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);
|
||||
|
||||
var searchResult = new SearchResult(
|
||||
selectedHits.Map(d => ProjectToSearchItem(searcher.Doc(d.Doc))).ToList(),
|
||||
topDocs.TotalHits);
|
||||
|
||||
searchResult.PageMap = GetSearchPageMap(searcher, query, filter, sort, limit);
|
||||
|
||||
return searchResult.AsTask();
|
||||
}
|
||||
|
||||
private static Option<SearchPageMap> GetSearchPageMap(
|
||||
IndexSearcher searcher,
|
||||
Query query,
|
||||
DuplicateFilter filter,
|
||||
Sort sort,
|
||||
int limit)
|
||||
{
|
||||
ScoreDoc[] allDocs = searcher.Search(query, filter, int.MaxValue, sort, true, true).ScoreDocs;
|
||||
var letters = new List<char>
|
||||
{
|
||||
'#', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't',
|
||||
'u', 'v', 'w', 'x', 'y', 'z'
|
||||
};
|
||||
var map = letters.ToDictionary(letter => letter, _ => 0);
|
||||
|
||||
var current = 0;
|
||||
var page = 0;
|
||||
while (current < allDocs.Length)
|
||||
{
|
||||
// walk up by page size (limit)
|
||||
page++;
|
||||
current += limit;
|
||||
if (current > allDocs.Length)
|
||||
{
|
||||
current = allDocs.Length;
|
||||
}
|
||||
|
||||
char jumpLetter = searcher.Doc(allDocs[current - 1].Doc).Get(JumpLetterField).Head();
|
||||
foreach (char letter in letters.Where(l => letters.IndexOf(l) <= letters.IndexOf(jumpLetter)))
|
||||
{
|
||||
if (map[letter] == 0)
|
||||
{
|
||||
map[letter] = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int max = map.Values.Max();
|
||||
foreach (char letter in letters.Where(letter => map[letter] == 0))
|
||||
{
|
||||
map[letter] = max;
|
||||
}
|
||||
|
||||
return new SearchPageMap(map);
|
||||
}
|
||||
|
||||
private void UpdateMovie(Movie movie, IndexWriter writer)
|
||||
{
|
||||
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
{
|
||||
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = new Document
|
||||
{
|
||||
new StringField(IdField, movie.Id.ToString(), Field.Store.YES),
|
||||
new StringField(TypeField, MovieType, Field.Store.NO),
|
||||
new TextField(TitleField, metadata.Title, Field.Store.NO),
|
||||
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
|
||||
new TextField(LibraryNameField, movie.LibraryPath.Library.Name, Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
new StringField(
|
||||
ReleaseDateField,
|
||||
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
|
||||
Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Plot))
|
||||
{
|
||||
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres)
|
||||
{
|
||||
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags)
|
||||
{
|
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios)
|
||||
{
|
||||
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metadata.Movie = null;
|
||||
_logger.LogWarning(ex, "Error indexing movie with metadata {@Metadata}", metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateShow(Show show, IndexWriter writer)
|
||||
{
|
||||
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
|
||||
if (maybeMetadata.IsSome)
|
||||
{
|
||||
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||
|
||||
try
|
||||
{
|
||||
var doc = new Document
|
||||
{
|
||||
new StringField(IdField, show.Id.ToString(), Field.Store.YES),
|
||||
new StringField(TypeField, ShowType, Field.Store.NO),
|
||||
new TextField(TitleField, metadata.Title, Field.Store.NO),
|
||||
new StringField(SortTitleField, metadata.SortTitle.ToLowerInvariant(), Field.Store.NO),
|
||||
new TextField(LibraryNameField, show.LibraryPath.Library.Name, Field.Store.NO),
|
||||
new StringField(TitleAndYearField, GetTitleAndYear(metadata), Field.Store.NO),
|
||||
new StringField(JumpLetterField, GetJumpLetter(metadata), Field.Store.YES)
|
||||
};
|
||||
|
||||
if (metadata.ReleaseDate.HasValue)
|
||||
{
|
||||
doc.Add(
|
||||
new StringField(
|
||||
ReleaseDateField,
|
||||
metadata.ReleaseDate.Value.ToString("yyyyMMdd"),
|
||||
Field.Store.NO));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(metadata.Plot))
|
||||
{
|
||||
doc.Add(new TextField(PlotField, metadata.Plot ?? string.Empty, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Genre genre in metadata.Genres)
|
||||
{
|
||||
doc.Add(new TextField(GenreField, genre.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Tag tag in metadata.Tags)
|
||||
{
|
||||
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
foreach (Studio studio in metadata.Studios)
|
||||
{
|
||||
doc.Add(new TextField(StudioField, studio.Name, Field.Store.NO));
|
||||
}
|
||||
|
||||
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
metadata.Show = null;
|
||||
_logger.LogWarning(ex, "Error indexing show with metadata {@Metadata}", metadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SearchItem ProjectToSearchItem(Document doc) => new(Convert.ToInt32(doc.Get(IdField)));
|
||||
|
||||
private Query ParseQuery(string searchQuery, QueryParser parser)
|
||||
{
|
||||
Query query;
|
||||
try
|
||||
{
|
||||
query = parser.Parse(searchQuery.Trim());
|
||||
}
|
||||
catch (ParseException)
|
||||
{
|
||||
query = parser.Parse(QueryParserBase.Escape(searchQuery.Trim()));
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
private static string GetTitleAndYear(Metadata metadata) =>
|
||||
$"{metadata.Title}_{metadata.Year}";
|
||||
|
||||
private static string GetJumpLetter(Metadata metadata)
|
||||
{
|
||||
char c = metadata.SortTitle.ToLowerInvariant().Head();
|
||||
return c switch
|
||||
{
|
||||
(>= 'a' and <= 'z') => c.ToString(),
|
||||
_ => "#"
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Accelist.FluentValidation.Blazor" Version="4.0.0" />
|
||||
<PackageReference Include="FluentValidation" Version="9.5.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.2" />
|
||||
<PackageReference Include="FluentValidation" Version="9.5.3" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="9.5.3" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
|
||||
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="9.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.4" />
|
||||
@@ -20,14 +20,14 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MudBlazor" Version="5.0.5" />
|
||||
<PackageReference Include="MudBlazor" Version="5.0.6" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="6.0.24" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="4.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.SQLite" Version="5.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.1.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Newtonsoft" Version="6.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@page "/channels/{Id:int?}"
|
||||
@page "/channels/add"
|
||||
@using static LanguageExt.Prelude
|
||||
@using ErsatzTV.Application.FFmpegProfiles
|
||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||
@using ErsatzTV.Application.Images.Commands
|
||||
@using ErsatzTV.Application.Channels
|
||||
@using ErsatzTV.Application.Channels.Queries
|
||||
@using static LanguageExt.Prelude
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject ILogger<ChannelEditor> Logger
|
||||
@inject ISnackbar Snackbar
|
||||
@@ -95,13 +95,15 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
FFmpegSettingsViewModel ffmpegSettings = await Mediator.Send(new GetFFmpegSettings());
|
||||
|
||||
// TODO: command for new channel
|
||||
IEnumerable<int> channelNumbers = await Mediator.Send(new GetAllChannels())
|
||||
.Map(list => list.Map(c => int.TryParse(c.Number.Split(".").Head(), out int result) ? result : 0));
|
||||
int maxNumber = Optional(channelNumbers).Flatten().DefaultIfEmpty(0).Max();
|
||||
_model.Number = (maxNumber + 1).ToString();
|
||||
_model.Name = "New Channel";
|
||||
_model.FFmpegProfileId = _ffmpegProfiles.Head().Id;
|
||||
_model.FFmpegProfileId = ffmpegSettings.DefaultFFmpegProfileId;
|
||||
_model.StreamingMode = StreamingMode.TransportStream;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<MudSwitch T="bool"
|
||||
Label="Save troubleshooting reports to disk"
|
||||
Color="Color.Primary"
|
||||
@bind-Checked="@_ffmpegSettings.SaveReports" />
|
||||
@bind-Checked="@_ffmpegSettings.SaveReports"/>
|
||||
</MudElement>
|
||||
</MudForm>
|
||||
</MudCardContent>
|
||||
|
||||
@@ -43,13 +43,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_movie.Studios.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Studios</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string studio in _movie.Studios.OrderBy(s => s))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_movie.Genres.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Genres</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string genre in _movie.Genres.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
<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>
|
||||
}
|
||||
@@ -59,7 +69,7 @@
|
||||
<div>
|
||||
@foreach (string tag in _movie.Tags.OrderBy(t => t))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
@page "/media/movies"
|
||||
@page "/media/movies/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCards.Queries
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<MovieList>
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -32,7 +35,8 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
|
||||
<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"
|
||||
@@ -63,6 +67,12 @@
|
||||
}
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
|
||||
BaseUri="/media/movies"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
@@ -71,6 +81,7 @@
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private MovieCardResultsViewModel _data;
|
||||
private string _query;
|
||||
|
||||
protected override Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -79,15 +90,40 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(NavigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
protected override async Task RefreshData() =>
|
||||
_data = await Mediator.Send(new GetMovieCards(PageNumber, PageSize));
|
||||
protected override async Task RefreshData()
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:movie" : $"type:movie AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexMovies(searchQuery, PageNumber, PageSize));
|
||||
}
|
||||
|
||||
private void PrevPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber - 1}");
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"/media/movies/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
}
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber + 1}");
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"/media/movies/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
}
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
|
||||
@@ -83,65 +83,86 @@
|
||||
|
||||
@if (_selectedItem is not null)
|
||||
{
|
||||
<div style="max-width: 400px;">
|
||||
<EditForm Model="_selectedItem">
|
||||
<FluentValidator/>
|
||||
<MudCard Class="mt-6">
|
||||
<div style="display: flex; flex-direction: row;" class="mt-6">
|
||||
<div style="flex-grow: 1; max-width: 400px;">
|
||||
<EditForm Model="_selectedItem">
|
||||
<FluentValidator/>
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudSelect Label="Start Type" @bind-Value="_selectedItem.StartType" For="@(() => _selectedItem.StartType)">
|
||||
@foreach (StartType startType in Enum.GetValues<StartType>())
|
||||
{
|
||||
<MudSelectItem Value="@startType">@startType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
|
||||
<MudSelect Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
|
||||
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
|
||||
{
|
||||
<MudSelectItem Value="@collectionType">@collectionType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
|
||||
{
|
||||
<MudAutocomplete Class="mt-3"
|
||||
T="MediaCollectionViewModel"
|
||||
Label="Collection"
|
||||
@bind-value="_selectedItem.Collection"
|
||||
SearchFunc="@SearchMediaCollections"
|
||||
ToStringFunc="@(c => c?.Name)"/>
|
||||
}
|
||||
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
|
||||
{
|
||||
<MudAutocomplete Class="mt-3"
|
||||
T="NamedMediaItemViewModel"
|
||||
Label="Television Show"
|
||||
@bind-value="_selectedItem.MediaItem"
|
||||
SearchFunc="@SearchTelevisionShows"
|
||||
ToStringFunc="@(s => s?.Name)"/>
|
||||
}
|
||||
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
|
||||
{
|
||||
<MudAutocomplete Class="mt-3"
|
||||
T="NamedMediaItemViewModel"
|
||||
Label="Television Season"
|
||||
@bind-value="_selectedItem.MediaItem"
|
||||
SearchFunc="@SearchTelevisionSeasons"
|
||||
ToStringFunc="@(s => s?.Name)"/>
|
||||
}
|
||||
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
|
||||
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
|
||||
{
|
||||
<MudSelectItem Value="@playoutMode">@playoutMode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
|
||||
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
|
||||
</MudElement>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
<div style="max-width: 600px;" class="ml-6">
|
||||
<MudCard>
|
||||
<MudCardContent>
|
||||
<MudSelect Label="Start Type" @bind-Value="_selectedItem.StartType" For="@(() => _selectedItem.StartType)">
|
||||
@foreach (StartType startType in Enum.GetValues<StartType>())
|
||||
{
|
||||
<MudSelectItem Value="@startType">@startType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
|
||||
<MudSelect Label="Collection Type" @bind-Value="_selectedItem.CollectionType" For="@(() => _selectedItem.CollectionType)">
|
||||
@foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues<ProgramScheduleItemCollectionType>())
|
||||
{
|
||||
<MudSelectItem Value="@collectionType">@collectionType</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection)
|
||||
{
|
||||
<MudAutocomplete Class="mt-3"
|
||||
T="MediaCollectionViewModel"
|
||||
Label="Collection"
|
||||
@bind-value="_selectedItem.Collection"
|
||||
SearchFunc="@SearchMediaCollections"
|
||||
ToStringFunc="@(c => c?.Name)"/>
|
||||
}
|
||||
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow)
|
||||
{
|
||||
<MudAutocomplete Class="mt-3"
|
||||
T="NamedMediaItemViewModel"
|
||||
Label="Television Show"
|
||||
@bind-value="_selectedItem.MediaItem"
|
||||
SearchFunc="@SearchTelevisionShows"
|
||||
ToStringFunc="@(s => s?.Name)"/>
|
||||
}
|
||||
@if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason)
|
||||
{
|
||||
<MudAutocomplete Class="mt-3"
|
||||
T="NamedMediaItemViewModel"
|
||||
Label="Television Season"
|
||||
@bind-value="_selectedItem.MediaItem"
|
||||
SearchFunc="@SearchTelevisionSeasons"
|
||||
ToStringFunc="@(s => s?.Name)"/>
|
||||
}
|
||||
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
|
||||
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
|
||||
{
|
||||
<MudSelectItem Value="@playoutMode">@playoutMode</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
|
||||
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
|
||||
<MudElement HtmlTag="div" Class="mt-3">
|
||||
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
|
||||
</MudElement>
|
||||
<MudText Class="mt-3" Typo="Typo.h6">
|
||||
Start Type
|
||||
</MudText>
|
||||
<MudText Class="mt-3">
|
||||
A <b>fixed</b> start type requires a <b>start time</b>, while a <b>dynamic</b> start type means the schedule item will start immediately after the preceding schedule item.
|
||||
</MudText>
|
||||
<MudText Class="mt-3 mb-2" Typo="Typo.h6">Playout Mode</MudText>
|
||||
<ul class="mud-typography-body1">
|
||||
<li><b>One</b> - to play one media item from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Multiple</b> - to play a specified <b>count</b> of media items from the collection before advancing to the next schedule item.</li>
|
||||
<li><b>Duration</b> - to play the maximum number of complete media items that will fit in the specified <b>playout duration</b>, before either going offline for the remainder of the <b>playout duration</b> (an <b>offline tail</b>), or immediately advancing to the next schedule item.</li>
|
||||
<li><b>Flood</b> - to play media items from the collection forever, or until the next schedule item's <b>start time</b> if one exists.</li>
|
||||
</ul>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@page "/search"
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCards.Queries
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using Unit = LanguageExt.Unit
|
||||
@@ -34,23 +34,43 @@
|
||||
else
|
||||
{
|
||||
<MudText>@_query</MudText>
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
|
||||
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>
|
||||
}
|
||||
|
||||
if (_shows.Count > 0)
|
||||
{
|
||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_shows.Count Shows</MudLink>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudText Class="ml-4">0 Shows</MudText>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</MudPaper>
|
||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
|
||||
@if (_data?.MovieCards.Any() == true)
|
||||
@if (_movies.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
||||
Movies
|
||||
</MudText>
|
||||
<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", "movies" } })">
|
||||
Movies
|
||||
</MudText>
|
||||
@if (_movies.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (MovieCardViewModel card in _data.MovieCards.OrderBy(m => m.SortTitle))
|
||||
@foreach (MovieCardViewModel card in _movies.Cards.OrderBy(m => m.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/movies/{card.MovieId}")"
|
||||
@@ -62,17 +82,22 @@
|
||||
</MudContainer>
|
||||
}
|
||||
|
||||
@if (_data?.ShowCards.Any() == true)
|
||||
@if (_shows.Count > 0)
|
||||
{
|
||||
<MudText GutterBottom="true"
|
||||
Typo="Typo.h4"
|
||||
Style="scroll-margin-top: 160px"
|
||||
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
||||
Shows
|
||||
</MudText>
|
||||
<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", "shows" } })">
|
||||
Shows
|
||||
</MudText>
|
||||
@if (_shows.Count > 50)
|
||||
{
|
||||
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
|
||||
}
|
||||
</div>
|
||||
|
||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
||||
@foreach (TelevisionShowCardViewModel card in _data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
@foreach (TelevisionShowCardViewModel card in _shows.Cards.OrderBy(s => s.SortTitle))
|
||||
{
|
||||
<MediaCard Data="@card"
|
||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||
@@ -87,7 +112,8 @@
|
||||
|
||||
@code {
|
||||
private string _query;
|
||||
private SearchCardResultsViewModel _data;
|
||||
private MovieCardResultsViewModel _movies;
|
||||
private TelevisionShowCardResultsViewModel _shows;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
@@ -96,8 +122,9 @@
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
Either<BaseError, SearchCardResultsViewModel> maybeResults = await Mediator.Send(new GetSearchCards(_query));
|
||||
maybeResults.IfRight(results => _data = results);
|
||||
|
||||
_movies = await Mediator.Send(new QuerySearchIndexMovies($"type:movie AND ({_query})", 1, 50));
|
||||
_shows = await Mediator.Send(new QuerySearchIndexShows($"type:show AND ({_query})", 1, 50));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +132,8 @@
|
||||
{
|
||||
List<MediaCardViewModel> GetSortedItems()
|
||||
{
|
||||
return _data.MovieCards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
||||
return _movies.Cards.OrderBy(m => m.SortTitle)
|
||||
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -158,4 +185,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
private string GetMoviesLink()
|
||||
{
|
||||
var uri = "/media/movies";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
private string GetShowsLink()
|
||||
{
|
||||
var uri = "/media/tv/shows";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -58,13 +58,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_show.Studios.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Studios</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string studio in _show.Studios.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@studio" Class="mr-2 mb-2" Link="@($"/search?query=studio%3a%22{Uri.EscapeDataString(studio.ToLowerInvariant())}%22")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_show.Genres.Any())
|
||||
{
|
||||
<MudText GutterBottom="true">Genres</MudText>
|
||||
<div class="mb-2">
|
||||
@foreach (string genre in _show.Genres.OrderBy(g => g))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@genre" Class="mr-2 mb-2" Link="@($"/search?query=genre%3a{genre}")"/>
|
||||
<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>
|
||||
}
|
||||
@@ -74,7 +84,7 @@
|
||||
<div>
|
||||
@foreach (string tag in _show.Tags.OrderBy(t => t))
|
||||
{
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a{tag}")"/>
|
||||
<MudFab Color="Color.Info" Size="Size.Small" Label="@tag" Class="mr-2 mb-2" Link="@($"/search?query=tag%3a%22{Uri.EscapeDataString(tag.ToLowerInvariant())}%22")"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
@page "/media/tv/shows"
|
||||
@page "/media/tv/shows/page/{PageNumber:int}"
|
||||
@using LanguageExt.UnsafeValueAccess
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using ErsatzTV.Application.MediaCards
|
||||
@using ErsatzTV.Application.MediaCards.Queries
|
||||
@using ErsatzTV.Application.MediaCollections
|
||||
@using ErsatzTV.Application.MediaCollections.Commands
|
||||
@using ErsatzTV.Application.Search.Queries
|
||||
@using Unit = LanguageExt.Unit
|
||||
@inherits MultiSelectBase<TelevisionShowList>
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -32,7 +35,8 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="margin-left: auto; margin-right: auto; max-width: 300px;">
|
||||
<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"
|
||||
@@ -63,6 +67,12 @@
|
||||
}
|
||||
</MudContainer>
|
||||
</MudContainer>
|
||||
@if (_data.PageMap.IsSome)
|
||||
{
|
||||
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
|
||||
BaseUri="/media/tv/shows"
|
||||
Query="@_query"/>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static int PageSize => 100;
|
||||
@@ -71,6 +81,7 @@
|
||||
public int PageNumber { get; set; }
|
||||
|
||||
private TelevisionShowCardResultsViewModel _data;
|
||||
private string _query;
|
||||
|
||||
protected override Task OnParametersSetAsync()
|
||||
{
|
||||
@@ -79,15 +90,40 @@
|
||||
PageNumber = 1;
|
||||
}
|
||||
|
||||
string query = new Uri(NavigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
|
||||
return RefreshData();
|
||||
}
|
||||
|
||||
protected override async Task RefreshData() =>
|
||||
_data = await Mediator.Send(new GetTelevisionShowCards(PageNumber, PageSize));
|
||||
protected override async Task RefreshData()
|
||||
{
|
||||
string searchQuery = string.IsNullOrWhiteSpace(_query) ? "type:show" : $"type:show AND ({_query})";
|
||||
_data = await Mediator.Send(new QuerySearchIndexShows(searchQuery, PageNumber, PageSize));
|
||||
}
|
||||
|
||||
private void PrevPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber - 1}");
|
||||
private void PrevPage()
|
||||
{
|
||||
var uri = $"/media/tv/shows/page/{PageNumber - 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
}
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void NextPage() => NavigationManager.NavigateTo($"/media/tv/shows/page/{PageNumber + 1}");
|
||||
private void NextPage()
|
||||
{
|
||||
var uri = $"/media/tv/shows/page/{PageNumber + 1}";
|
||||
if (!string.IsNullOrWhiteSpace(_query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", _query);
|
||||
}
|
||||
NavigationManager.NavigateTo(uri);
|
||||
}
|
||||
|
||||
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,7 @@ using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Application.Plex.Commands;
|
||||
using ErsatzTV.Application.Search.Commands;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -54,6 +55,7 @@ namespace ErsatzTV.Services
|
||||
|
||||
private async Task DoWork(CancellationToken cancellationToken)
|
||||
{
|
||||
await RebuildSearchIndex(cancellationToken);
|
||||
await BuildPlayouts(cancellationToken);
|
||||
await ScanLocalMediaSources(cancellationToken);
|
||||
await ScanPlexMediaSources(cancellationToken);
|
||||
@@ -111,5 +113,8 @@ namespace ErsatzTV.Services
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RebuildSearchIndex(CancellationToken cancellationToken) =>
|
||||
await _channel.WriteAsync(new RebuildSearchIndex(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaSources.Commands;
|
||||
using ErsatzTV.Application.Playouts.Commands;
|
||||
using ErsatzTV.Application.Plex.Commands;
|
||||
using ErsatzTV.Application.Search.Commands;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -82,6 +83,9 @@ namespace ErsatzTV.Services
|
||||
synchronizePlexLibraryById.PlexLibraryId,
|
||||
error.Value));
|
||||
break;
|
||||
case RebuildSearchIndex rebuildSearchIndex:
|
||||
await mediator.Send(rebuildSearchIndex, cancellationToken);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
56
ErsatzTV/Shared/LetterBar.razor
Normal file
56
ErsatzTV/Shared/LetterBar.razor
Normal file
@@ -0,0 +1,56 @@
|
||||
@using ErsatzTV.Core.Search
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
<div class="letter-bar-container">
|
||||
<div class="letter-bar">
|
||||
<MudLink Href="@GetLinkForLetter('#')">#</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('a')">A</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('b')">B</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('c')">C</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('d')">D</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('e')">E</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('f')">F</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('g')">G</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('h')">H</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('i')">I</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('j')">J</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('k')">K</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('l')">L</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('m')">M</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('n')">N</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('o')">O</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('p')">P</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('q')">Q</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('r')">R</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('s')">S</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('t')">T</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('u')">U</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('v')">V</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('w')">W</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('x')">X</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('y')">Y</MudLink>
|
||||
<MudLink Href="@GetLinkForLetter('z')">Z</MudLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
|
||||
[Parameter]
|
||||
public SearchPageMap PageMap { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string BaseUri { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Query { get; set; }
|
||||
|
||||
private string GetLinkForLetter(char letter)
|
||||
{
|
||||
var uri = $"{BaseUri}/page/{PageMap.PageMap[letter]}";
|
||||
if (!string.IsNullOrWhiteSpace(Query))
|
||||
{
|
||||
uri = QueryHelpers.AddQueryString(uri, "query", Query);
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
@using System.Reflection
|
||||
@using Microsoft.AspNetCore.WebUtilities
|
||||
@using Microsoft.Extensions.Primitives
|
||||
@using System.Web
|
||||
@inherits LayoutComponentBase
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -15,7 +17,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<MudTextField T="string"
|
||||
@ref="_textField"
|
||||
@bind-Value="@_query"
|
||||
AdornmentIcon="@Icons.Material.Filled.Search"
|
||||
Adornment="Adornment.Start"
|
||||
Variant="Variant.Outlined"
|
||||
@@ -65,7 +67,7 @@
|
||||
@code {
|
||||
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
|
||||
|
||||
private MudTextField<string> _textField;
|
||||
private string _query;
|
||||
|
||||
private MudTheme _ersatzTvTheme => new()
|
||||
{
|
||||
@@ -81,18 +83,34 @@
|
||||
DrawerText = "rgba(255,255,255, 0.80)",
|
||||
TextPrimary = "rgba(255,255,255, 0.80)",
|
||||
TextSecondary = "rgba(255,255,255, 0.80)",
|
||||
TextDisabled = "rgba(255,255,255, 0.40)",
|
||||
ActionDisabled = "rgba(255,255,255, 0.40)",
|
||||
Info = "#00c0c0",
|
||||
Tertiary = "#00c000",
|
||||
White = Colors.Shades.White
|
||||
}
|
||||
};
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
await base.OnParametersSetAsync();
|
||||
|
||||
string query = new Uri(NavigationManager.Uri).Query;
|
||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||
{
|
||||
_query = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
_query = string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSearchKeyDown(KeyboardEventArgs args)
|
||||
{
|
||||
if (args.Code == "Enter")
|
||||
{
|
||||
string query = HttpUtility.UrlEncode(_textField.Value);
|
||||
_textField.Reset();
|
||||
string query = HttpUtility.UrlEncode(_query);
|
||||
NavigationManager.NavigateTo($"/search?query={query}", true);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
@@ -24,6 +25,7 @@ using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Infrastructure.Images;
|
||||
using ErsatzTV.Infrastructure.Locking;
|
||||
using ErsatzTV.Infrastructure.Plex;
|
||||
using ErsatzTV.Infrastructure.Search;
|
||||
using ErsatzTV.Serialization;
|
||||
using ErsatzTV.Services;
|
||||
using FluentValidation.AspNetCore;
|
||||
@@ -209,6 +211,7 @@ namespace ErsatzTV
|
||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||
services.AddScoped<ISearchIndex, SearchIndex>();
|
||||
|
||||
services.AddHostedService<PlexService>();
|
||||
services.AddHostedService<FFmpegLocatorService>();
|
||||
|
||||
@@ -63,7 +63,7 @@ namespace ErsatzTV.ViewModels
|
||||
Resolution.Id,
|
||||
NormalizeResolution,
|
||||
VideoCodec,
|
||||
NormalizeAudioCodec,
|
||||
NormalizeVideoCodec,
|
||||
VideoBitrate,
|
||||
VideoBufferSize,
|
||||
AudioCodec,
|
||||
@@ -86,7 +86,7 @@ namespace ErsatzTV.ViewModels
|
||||
Resolution.Id,
|
||||
NormalizeResolution,
|
||||
VideoCodec,
|
||||
NormalizeAudioCodec,
|
||||
NormalizeVideoCodec,
|
||||
VideoBitrate,
|
||||
VideoBufferSize,
|
||||
AudioCodec,
|
||||
|
||||
@@ -106,4 +106,18 @@
|
||||
.media-item-subtitle {
|
||||
color: #fff;
|
||||
text-shadow: 1px 1px 5px #000;
|
||||
}
|
||||
|
||||
.letter-bar-container {
|
||||
height: calc(100vh - 128px);
|
||||
position: fixed;
|
||||
right: 1em;
|
||||
top: 128px;
|
||||
}
|
||||
|
||||
.letter-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-around;
|
||||
}
|
||||
Reference in New Issue
Block a user