search overhaul (#87)
* add letter bar with no links * use lucene for search, add paged search results * add search index version * index library_name; rebuild index when folder is missing * maintain index as local movies change * fix tests * maintain index as local shows change * maintain index as plex movies change * maintain index as plex shows change * code cleanup * add duplicate filter to search * add links to letter bar * code cleanup
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
using System.Threading;
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Libraries.Commands
|
namespace ErsatzTV.Application.Libraries.Commands
|
||||||
@@ -11,9 +13,13 @@ namespace ErsatzTV.Application.Libraries.Commands
|
|||||||
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
|
DeleteLocalLibraryPathHandler : MediatR.IRequestHandler<DeleteLocalLibraryPath, Either<BaseError, Unit>>
|
||||||
{
|
{
|
||||||
private readonly ILibraryRepository _libraryRepository;
|
private readonly ILibraryRepository _libraryRepository;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
|
||||||
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository) =>
|
public DeleteLocalLibraryPathHandler(ILibraryRepository libraryRepository, ISearchIndex searchIndex)
|
||||||
|
{
|
||||||
_libraryRepository = libraryRepository;
|
_libraryRepository = libraryRepository;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
|
}
|
||||||
|
|
||||||
public Task<Either<BaseError, Unit>> Handle(
|
public Task<Either<BaseError, Unit>> Handle(
|
||||||
DeleteLocalLibraryPath request,
|
DeleteLocalLibraryPath request,
|
||||||
@@ -22,8 +28,13 @@ namespace ErsatzTV.Application.Libraries.Commands
|
|||||||
.MapT(DoDeletion)
|
.MapT(DoDeletion)
|
||||||
.Bind(t => t.ToEitherAsync());
|
.Bind(t => t.ToEitherAsync());
|
||||||
|
|
||||||
private Task<Unit> DoDeletion(LibraryPath libraryPath) =>
|
private async Task<Unit> DoDeletion(LibraryPath libraryPath)
|
||||||
_libraryRepository.DeleteLocalPath(libraryPath.Id).ToUnit();
|
{
|
||||||
|
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) =>
|
private async Task<Validation<BaseError, LibraryPath>> MediaSourceMustExist(DeleteLocalLibraryPath request) =>
|
||||||
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
|
(await _libraryRepository.GetPath(request.LocalLibraryPathId))
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using ErsatzTV.Core.Search;
|
||||||
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards
|
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,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 System.Collections.Generic;
|
||||||
|
using ErsatzTV.Core.Search;
|
||||||
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.MediaCards
|
namespace ErsatzTV.Application.MediaCards
|
||||||
{
|
{
|
||||||
public record TelevisionShowCardResultsViewModel(int Count, List<TelevisionShowCardViewModel> Cards);
|
public record TelevisionShowCardResultsViewModel(
|
||||||
|
int Count,
|
||||||
|
List<TelevisionShowCardViewModel> Cards,
|
||||||
|
Option<SearchPageMap> PageMap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Application.Plex.Commands
|
namespace ErsatzTV.Application.Plex.Commands
|
||||||
@@ -13,16 +14,23 @@ namespace ErsatzTV.Application.Plex.Commands
|
|||||||
Either<BaseError, Unit>>
|
Either<BaseError, Unit>>
|
||||||
{
|
{
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
|
||||||
public UpdatePlexLibraryPreferencesHandler(IMediaSourceRepository mediaSourceRepository) =>
|
public UpdatePlexLibraryPreferencesHandler(
|
||||||
|
IMediaSourceRepository mediaSourceRepository,
|
||||||
|
ISearchIndex searchIndex)
|
||||||
|
{
|
||||||
_mediaSourceRepository = mediaSourceRepository;
|
_mediaSourceRepository = mediaSourceRepository;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Either<BaseError, Unit>> Handle(
|
public async Task<Either<BaseError, Unit>> Handle(
|
||||||
UpdatePlexLibraryPreferences request,
|
UpdatePlexLibraryPreferences request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
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);
|
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||||
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
|
await _mediaSourceRepository.EnablePlexLibrarySync(toEnable);
|
||||||
|
|||||||
@@ -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<MediaItem> items = await _searchRepository.GetItemsToIndex();
|
||||||
|
await _searchIndex.Rebuild(items);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -19,11 +19,11 @@ namespace ErsatzTV.Application.Streaming.Queries
|
|||||||
public class
|
public class
|
||||||
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
|
||||||
{
|
{
|
||||||
|
private readonly IConfigElementRepository _configElementRepository;
|
||||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||||
private readonly ILocalFileSystem _localFileSystem;
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
|
||||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||||
private readonly IConfigElementRepository _configElementRepository;
|
|
||||||
private readonly IPlayoutRepository _playoutRepository;
|
private readonly IPlayoutRepository _playoutRepository;
|
||||||
|
|
||||||
public GetPlayoutItemProcessByChannelNumberHandler(
|
public GetPlayoutItemProcessByChannelNumberHandler(
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
|
_folders = allFolders.Distinct().Map(f => new FakeFolderEntry(f)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Unit EnsureFolderExists(string folder) => Unit.Default;
|
||||||
|
|
||||||
public DateTime GetLastWriteTime(string path) =>
|
public DateTime GetLastWriteTime(string path) =>
|
||||||
Optional(_files.SingleOrDefault(f => f.Path == path))
|
Optional(_files.SingleOrDefault(f => f.Path == path))
|
||||||
.Map(f => f.LastWriteTime)
|
.Map(f => f.LastWriteTime)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Tests.Fakes
|
namespace ErsatzTV.Core.Tests.Fakes
|
||||||
{
|
{
|
||||||
public class FakeMovieWithPath : Movie
|
public class FakeMovieWithPath : MediaItemScanResult<Movie>
|
||||||
{
|
{
|
||||||
public FakeMovieWithPath(string path)
|
public FakeMovieWithPath(string path)
|
||||||
|
: base(
|
||||||
|
new Movie
|
||||||
{
|
{
|
||||||
Path = path;
|
|
||||||
|
|
||||||
MediaVersions = new List<MediaVersion>
|
MediaVersions = new List<MediaVersion>
|
||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
@@ -18,9 +19,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
new() { Path = path }
|
new() { Path = path }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
}) =>
|
||||||
public string Path { get; }
|
IsAdded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Tests.Fakes
|
namespace ErsatzTV.Core.Tests.Fakes
|
||||||
@@ -20,6 +21,8 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
|
public Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize) =>
|
||||||
throw new NotSupportedException();
|
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<Episode>> GetShowItems(int showId) => throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
|
public Task<List<Season>> GetAllSeasons() => throw new NotSupportedException();
|
||||||
@@ -43,7 +46,7 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
public Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<Either<BaseError, Show>>
|
public Task<Either<BaseError, MediaItemScanResult<Show>>>
|
||||||
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
AddShow(int libraryPathId, string showFolder, ShowMetadata metadata) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
@@ -59,9 +62,11 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
|
|
||||||
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
|
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();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
public Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item) =>
|
||||||
@@ -70,9 +75,9 @@ namespace ErsatzTV.Core.Tests.Fakes
|
|||||||
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
|
public Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item) =>
|
||||||
throw new NotSupportedException();
|
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<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||||
throw new NotSupportedException();
|
throw new NotSupportedException();
|
||||||
|
|
||||||
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using ErsatzTV.Core.Errors;
|
|||||||
using ErsatzTV.Core.Interfaces.Images;
|
using ErsatzTV.Core.Interfaces.Images;
|
||||||
using ErsatzTV.Core.Interfaces.Metadata;
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Core.Metadata;
|
using ErsatzTV.Core.Metadata;
|
||||||
using ErsatzTV.Core.Tests.Fakes;
|
using ErsatzTV.Core.Tests.Fakes;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
@@ -44,20 +45,24 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository = new Mock<IMovieRepository>();
|
_movieRepository = new Mock<IMovieRepository>();
|
||||||
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
|
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
|
||||||
.Returns(
|
.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>()))
|
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
|
||||||
.Returns(new List<string>().AsEnumerable().AsTask());
|
.Returns(new List<string>().AsEnumerable().AsTask());
|
||||||
|
|
||||||
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
|
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
|
||||||
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
|
_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
|
// fallback metadata adds metadata to a movie, so we need to replicate that here
|
||||||
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
|
_localMetadataProvider.Setup(x => x.RefreshFallbackMetadata(It.IsAny<MediaItem>()))
|
||||||
.Returns(
|
.Returns(
|
||||||
(MediaItem mediaItem) =>
|
(MediaItem mediaItem) =>
|
||||||
{
|
{
|
||||||
((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() };
|
((Movie) mediaItem).MovieMetadata = new List<MovieMetadata> { new() };
|
||||||
return Unit.Default.AsTask();
|
return Task.FromResult(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
_imageCache = new Mock<IImageCache>();
|
_imageCache = new Mock<IImageCache>();
|
||||||
@@ -104,11 +109,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +145,15 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,11 +182,15 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,11 +223,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_imageCache.Verify(
|
_imageCache.Verify(
|
||||||
@@ -248,11 +267,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_imageCache.Verify(
|
_imageCache.Verify(
|
||||||
@@ -288,11 +310,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,11 +349,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,11 +382,14 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
_movieRepository.Verify(x => x.GetOrAdd(libraryPath, moviePath), Times.Once);
|
||||||
|
|
||||||
_localStatisticsProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
|
|
||||||
_localMetadataProvider.Verify(
|
_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);
|
Times.Once);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,6 +450,7 @@ namespace ErsatzTV.Core.Tests.Metadata
|
|||||||
_localMetadataProvider.Object,
|
_localMetadataProvider.Object,
|
||||||
new Mock<IMetadataRepository>().Object,
|
new Mock<IMetadataRepository>().Object,
|
||||||
_imageCache.Object,
|
_imageCache.Object,
|
||||||
|
new Mock<ISearchIndex>().Object,
|
||||||
new Mock<ILogger<MovieFolderScanner>>().Object
|
new Mock<ILogger<MovieFolderScanner>>().Object
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,6 @@
|
|||||||
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
public static ConfigElementKey FFmpegDefaultProfileId => new("ffmpeg.default_profile_id");
|
||||||
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
|
public static ConfigElementKey FFmpegDefaultResolutionId => new("ffmpeg.default_resolution_id");
|
||||||
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
public static ConfigElementKey FFmpegSaveReports => new("ffmpeg.save_reports");
|
||||||
|
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace ErsatzTV.Core
|
|||||||
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
|
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 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");
|
public static readonly string ArtworkCacheFolder = Path.Combine(AppDataFolder, "cache", "artwork");
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
|||||||
{
|
{
|
||||||
public interface ILocalFileSystem
|
public interface ILocalFileSystem
|
||||||
{
|
{
|
||||||
|
Unit EnsureFolderExists(string folder);
|
||||||
DateTime GetLastWriteTime(string path);
|
DateTime GetLastWriteTime(string path);
|
||||||
bool IsLibraryPathAccessible(LibraryPath libraryPath);
|
bool IsLibraryPathAccessible(LibraryPath libraryPath);
|
||||||
IEnumerable<string> ListSubdirectories(string folder);
|
IEnumerable<string> ListSubdirectories(string folder);
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using LanguageExt;
|
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Interfaces.Metadata
|
namespace ErsatzTV.Core.Interfaces.Metadata
|
||||||
{
|
{
|
||||||
public interface ILocalMetadataProvider
|
public interface ILocalMetadataProvider
|
||||||
{
|
{
|
||||||
Task<ShowMetadata> GetMetadataForShow(string showFolder);
|
Task<ShowMetadata> GetMetadataForShow(string showFolder);
|
||||||
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path);
|
||||||
Task<Unit> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
Task<bool> RefreshSidecarMetadata(Show televisionShow, string showFolder);
|
||||||
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem);
|
Task<bool> RefreshFallbackMetadata(MediaItem mediaItem);
|
||||||
Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
|||||||
{
|
{
|
||||||
public interface ILocalStatisticsProvider
|
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<List<LibraryPath>> GetLocalPaths(int libraryId);
|
||||||
Task<Option<LibraryPath>> GetPath(int libraryPathId);
|
Task<Option<LibraryPath>> GetPath(int libraryPathId);
|
||||||
Task<int> CountMediaItemsByPath(int libraryPathId);
|
Task<int> CountMediaItemsByPath(int libraryPathId);
|
||||||
|
Task<List<int>> GetMediaIdsByLocalPath(int libraryPathId);
|
||||||
Task DeleteLocalPath(int libraryPathId);
|
Task DeleteLocalPath(int libraryPathId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
Task Update(PlexLibrary plexMediaSourceLibrary);
|
Task Update(PlexLibrary plexMediaSourceLibrary);
|
||||||
Task Delete(int mediaSourceId);
|
Task Delete(int mediaSourceId);
|
||||||
Task<Unit> DeleteAllPlex();
|
Task<Unit> DeleteAllPlex();
|
||||||
Task DisablePlexLibrarySync(List<int> libraryIds);
|
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
|
||||||
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
{
|
{
|
||||||
public interface IMetadataRepository
|
public interface IMetadataRepository
|
||||||
{
|
{
|
||||||
Task<Unit> RemoveGenre(Genre genre);
|
Task<bool> RemoveGenre(Genre genre);
|
||||||
Task<bool> Update(Domain.Metadata metadata);
|
Task<bool> Update(Domain.Metadata metadata);
|
||||||
Task<bool> Add(Domain.Metadata metadata);
|
Task<bool> Add(Domain.Metadata metadata);
|
||||||
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
|
Task<bool> UpdateLocalStatistics(MediaVersion mediaVersion);
|
||||||
Task<Unit> UpdatePlexStatistics(MediaVersion mediaVersion);
|
Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion);
|
||||||
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
Task<Unit> UpdateArtworkPath(Artwork artwork);
|
||||||
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
Task<Unit> AddArtwork(Domain.Metadata metadata, Artwork artwork);
|
||||||
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
Task<Unit> RemoveArtwork(Domain.Metadata metadata, ArtworkKind artworkKind);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||||
@@ -9,13 +10,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
{
|
{
|
||||||
Task<bool> AllMoviesExist(List<int> movieIds);
|
Task<bool> AllMoviesExist(List<int> movieIds);
|
||||||
Task<Option<Movie>> GetMovie(int movieId);
|
Task<Option<Movie>> GetMovie(int movieId);
|
||||||
Task<Either<BaseError, Movie>> GetOrAdd(LibraryPath libraryPath, string path);
|
Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||||
Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
||||||
Task<int> GetMovieCount();
|
Task<int> GetMovieCount();
|
||||||
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
|
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
|
||||||
|
Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
|
||||||
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
|
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
|
||||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||||
Task<Unit> AddGenre(MovieMetadata metadata, Genre genre);
|
Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
|
||||||
Task<Unit> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
{
|
{
|
||||||
public interface ISearchRepository
|
public interface ISearchRepository
|
||||||
{
|
{
|
||||||
|
public Task<List<MediaItem>> GetItemsToIndex();
|
||||||
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
|
public Task<List<MediaItem>> SearchMediaItemsByTitle(string query);
|
||||||
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
|
public Task<List<MediaItem>> SearchMediaItemsByGenre(string genre);
|
||||||
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
|
public Task<List<MediaItem>> SearchMediaItemsByTag(string tag);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||||
@@ -12,6 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
Task<Option<Show>> GetShow(int showId);
|
Task<Option<Show>> GetShow(int showId);
|
||||||
Task<int> GetShowCount();
|
Task<int> GetShowCount();
|
||||||
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
|
Task<List<ShowMetadata>> GetPagedShows(int pageNumber, int pageSize);
|
||||||
|
Task<List<ShowMetadata>> GetShowsForCards(List<int> ids);
|
||||||
Task<List<Episode>> GetShowItems(int showId);
|
Task<List<Episode>> GetShowItems(int showId);
|
||||||
Task<List<Season>> GetAllSeasons();
|
Task<List<Season>> GetAllSeasons();
|
||||||
Task<Option<Season>> GetSeason(int seasonId);
|
Task<Option<Season>> GetSeason(int seasonId);
|
||||||
@@ -22,18 +24,23 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
|||||||
Task<int> GetEpisodeCount(int seasonId);
|
Task<int> GetEpisodeCount(int seasonId);
|
||||||
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
|
Task<List<EpisodeMetadata>> GetPagedEpisodes(int seasonId, int pageNumber, int pageSize);
|
||||||
Task<Option<Show>> GetShowByMetadata(int libraryPathId, ShowMetadata metadata);
|
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, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
|
||||||
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
|
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
|
||||||
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
|
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
|
||||||
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
|
||||||
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
|
||||||
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
|
Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath);
|
||||||
Task<Either<BaseError, PlexShow>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
|
Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
|
||||||
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
|
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
|
||||||
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
|
Task<Either<BaseError, PlexEpisode>> GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
|
||||||
Task<Unit> AddGenre(ShowMetadata metadata, Genre genre);
|
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
|
||||||
Task<Unit> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
|
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
|
||||||
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
|
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
|
||||||
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
|
Task<Unit> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
|
||||||
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
|
Task<Unit> SetEpisodeNumber(Episode episode, int episodeNumber);
|
||||||
|
|||||||
@@ -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<MediaItem> items);
|
||||||
|
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 = "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,16 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
{
|
{
|
||||||
public class LocalFileSystem : ILocalFileSystem
|
public class LocalFileSystem : ILocalFileSystem
|
||||||
{
|
{
|
||||||
|
public Unit EnsureFolderExists(string folder)
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(folder))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Unit.Default;
|
||||||
|
}
|
||||||
|
|
||||||
public DateTime GetLastWriteTime(string path) =>
|
public DateTime GetLastWriteTime(string path) =>
|
||||||
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
Try(File.GetLastWriteTimeUtc(path)).IfFail(() => DateTime.MinValue);
|
||||||
|
|
||||||
|
|||||||
@@ -66,12 +66,14 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
_logger = logger;
|
_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
|
where T : MediaItem
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
MediaVersion version = mediaItem switch
|
MediaVersion version = mediaItem.Item switch
|
||||||
{
|
{
|
||||||
Movie m => m.MediaVersions.Head(),
|
Movie m => m.MediaVersions.Head(),
|
||||||
Episode e => e.MediaVersions.Head(),
|
Episode e => e.MediaVersions.Head(),
|
||||||
@@ -83,9 +85,16 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
if (version.DateUpdated < _localFileSystem.GetLastWriteTime(path))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", path);
|
||||||
Either<BaseError, Unit> refreshResult =
|
Either<BaseError, bool> refreshResult =
|
||||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
|
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem.Item);
|
||||||
refreshResult.IfLeft(
|
refreshResult.Match(
|
||||||
|
result =>
|
||||||
|
{
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
mediaItem.IsUpdated = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
error =>
|
error =>
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||||
|
|||||||
@@ -22,19 +22,16 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
private readonly ILocalFileSystem _localFileSystem;
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly ILogger<LocalMetadataProvider> _logger;
|
private readonly ILogger<LocalMetadataProvider> _logger;
|
||||||
|
|
||||||
private readonly IMediaItemRepository _mediaItemRepository;
|
|
||||||
private readonly IMetadataRepository _metadataRepository;
|
private readonly IMetadataRepository _metadataRepository;
|
||||||
private readonly ITelevisionRepository _televisionRepository;
|
private readonly ITelevisionRepository _televisionRepository;
|
||||||
|
|
||||||
public LocalMetadataProvider(
|
public LocalMetadataProvider(
|
||||||
IMediaItemRepository mediaItemRepository,
|
|
||||||
IMetadataRepository metadataRepository,
|
IMetadataRepository metadataRepository,
|
||||||
ITelevisionRepository televisionRepository,
|
ITelevisionRepository televisionRepository,
|
||||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||||
ILocalFileSystem localFileSystem,
|
ILocalFileSystem localFileSystem,
|
||||||
ILogger<LocalMetadataProvider> logger)
|
ILogger<LocalMetadataProvider> logger)
|
||||||
{
|
{
|
||||||
_mediaItemRepository = mediaItemRepository;
|
|
||||||
_metadataRepository = metadataRepository;
|
_metadataRepository = metadataRepository;
|
||||||
_televisionRepository = televisionRepository;
|
_televisionRepository = televisionRepository;
|
||||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||||
@@ -65,40 +62,47 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
public Task<bool> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
|
||||||
mediaItem switch
|
mediaItem switch
|
||||||
{
|
{
|
||||||
Episode e => LoadMetadata(e, path)
|
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)
|
Movie m => LoadMetadata(m, path)
|
||||||
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
|
.Bind(
|
||||||
_ => Task.FromResult(Unit.Default)
|
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(
|
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
|
mediaItem switch
|
||||||
{
|
{
|
||||||
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e))
|
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e)),
|
||||||
.ToUnit(),
|
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)),
|
||||||
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
|
_ => Task.FromResult(false)
|
||||||
_ => Task.FromResult(Unit.Default)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
public Task<Unit> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
public Task<bool> RefreshFallbackMetadata(Show televisionShow, string showFolder) =>
|
||||||
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder))
|
ApplyMetadataUpdate(televisionShow, _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder));
|
||||||
.ToUnit();
|
|
||||||
|
|
||||||
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;
|
(EpisodeMetadata metadata, int episodeNumber) = metadataEpisodeNumber;
|
||||||
if (episode.EpisodeNumber != episodeNumber)
|
if (episode.EpisodeNumber != episodeNumber)
|
||||||
{
|
{
|
||||||
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
|
await _televisionRepository.SetEpisodeNumber(episode, episodeNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
await Optional(episode.EpisodeMetadata).Flatten().HeadOrNone().Match(
|
||||||
existing =>
|
existing =>
|
||||||
{
|
{
|
||||||
@@ -128,9 +132,11 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
|
|
||||||
return _metadataRepository.Add(metadata);
|
return _metadataRepository.Add(metadata);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
|
private Task<bool> ApplyMetadataUpdate(Movie movie, MovieMetadata metadata) =>
|
||||||
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
Optional(movie.MovieMetadata).Flatten().HeadOrNone().Match(
|
||||||
existing =>
|
existing =>
|
||||||
{
|
{
|
||||||
@@ -185,7 +191,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
return _metadataRepository.Add(metadata);
|
return _metadataRepository.Add(metadata);
|
||||||
});
|
});
|
||||||
|
|
||||||
private Task ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
|
private Task<bool> ApplyMetadataUpdate(Show show, ShowMetadata metadata) =>
|
||||||
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
Optional(show.ShowMetadata).Flatten().HeadOrNone().Match(
|
||||||
existing =>
|
existing =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,22 +17,19 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
{
|
{
|
||||||
private readonly ILocalFileSystem _localFileSystem;
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly ILogger<LocalStatisticsProvider> _logger;
|
private readonly ILogger<LocalStatisticsProvider> _logger;
|
||||||
private readonly IMediaItemRepository _mediaItemRepository;
|
|
||||||
private readonly IMetadataRepository _metadataRepository;
|
private readonly IMetadataRepository _metadataRepository;
|
||||||
|
|
||||||
public LocalStatisticsProvider(
|
public LocalStatisticsProvider(
|
||||||
IMediaItemRepository mediaItemRepository,
|
|
||||||
IMetadataRepository metadataRepository,
|
IMetadataRepository metadataRepository,
|
||||||
ILocalFileSystem localFileSystem,
|
ILocalFileSystem localFileSystem,
|
||||||
ILogger<LocalStatisticsProvider> logger)
|
ILogger<LocalStatisticsProvider> logger)
|
||||||
{
|
{
|
||||||
_mediaItemRepository = mediaItemRepository;
|
|
||||||
_metadataRepository = metadataRepository;
|
_metadataRepository = metadataRepository;
|
||||||
_localFileSystem = localFileSystem;
|
_localFileSystem = localFileSystem;
|
||||||
_logger = logger;
|
_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
|
try
|
||||||
{
|
{
|
||||||
@@ -48,10 +45,10 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
async ffprobe =>
|
async ffprobe =>
|
||||||
{
|
{
|
||||||
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
MediaVersion version = ProjectToMediaVersion(ffprobe);
|
||||||
await ApplyVersionUpdate(mediaItem, version, filePath);
|
bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||||
return Right<BaseError, Unit>(Unit.Default);
|
return Right<BaseError, bool>(result);
|
||||||
},
|
},
|
||||||
error => Task.FromResult(Left<BaseError, Unit>(error)));
|
error => Task.FromResult(Left<BaseError, bool>(error)));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.Images;
|
||||||
using ErsatzTV.Core.Interfaces.Metadata;
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using static LanguageExt.Prelude;
|
using static LanguageExt.Prelude;
|
||||||
@@ -21,6 +22,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||||
private readonly ILogger<MovieFolderScanner> _logger;
|
private readonly ILogger<MovieFolderScanner> _logger;
|
||||||
private readonly IMovieRepository _movieRepository;
|
private readonly IMovieRepository _movieRepository;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
|
||||||
public MovieFolderScanner(
|
public MovieFolderScanner(
|
||||||
ILocalFileSystem localFileSystem,
|
ILocalFileSystem localFileSystem,
|
||||||
@@ -29,12 +31,14 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
ILocalMetadataProvider localMetadataProvider,
|
ILocalMetadataProvider localMetadataProvider,
|
||||||
IMetadataRepository metadataRepository,
|
IMetadataRepository metadataRepository,
|
||||||
IImageCache imageCache,
|
IImageCache imageCache,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
ILogger<MovieFolderScanner> logger)
|
ILogger<MovieFolderScanner> logger)
|
||||||
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
: base(localFileSystem, localStatisticsProvider, metadataRepository, imageCache, logger)
|
||||||
{
|
{
|
||||||
_localFileSystem = localFileSystem;
|
_localFileSystem = localFileSystem;
|
||||||
_movieRepository = movieRepository;
|
_movieRepository = movieRepository;
|
||||||
_localMetadataProvider = localMetadataProvider;
|
_localMetadataProvider = localMetadataProvider;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,19 +76,33 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
foreach (string file in allFiles.OrderBy(identity))
|
foreach (string file in allFiles.OrderBy(identity))
|
||||||
{
|
{
|
||||||
// TODO: figure out how to rebuild playlists
|
// TODO: figure out how to rebuild playlists
|
||||||
Either<BaseError, Movie> maybeMovie = await _movieRepository
|
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||||
.GetOrAdd(libraryPath, file)
|
.GetOrAdd(libraryPath, file)
|
||||||
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
|
.BindT(movie => UpdateStatistics(movie, ffprobePath))
|
||||||
.BindT(UpdateMetadata)
|
.BindT(UpdateMetadata)
|
||||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
|
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster))
|
||||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
|
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt));
|
||||||
|
|
||||||
maybeMovie.IfLeft(
|
await maybeMovie.Match(
|
||||||
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
|
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))
|
if (!_localFileSystem.FileExists(path))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Removing missing movie at {Path}", 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;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, Movie>> UpdateMetadata(Movie movie)
|
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(
|
||||||
|
MediaItemScanResult<Movie> result)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Movie movie = result.Item;
|
||||||
await LocateNfoFile(movie).Match(
|
await LocateNfoFile(movie).Match(
|
||||||
async nfoFile =>
|
async nfoFile =>
|
||||||
{
|
{
|
||||||
@@ -115,7 +136,10 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
if (shouldUpdate)
|
if (shouldUpdate)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||||
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
|
if (await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile))
|
||||||
|
{
|
||||||
|
result.IsUpdated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async () =>
|
async () =>
|
||||||
@@ -124,11 +148,14 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
{
|
{
|
||||||
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
|
||||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", 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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -136,10 +163,13 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
|
Movie movie = result.Item;
|
||||||
await LocateArtwork(movie, artworkKind).IfSomeAsync(
|
await LocateArtwork(movie, artworkKind).IfSomeAsync(
|
||||||
async posterFile =>
|
async posterFile =>
|
||||||
{
|
{
|
||||||
@@ -147,7 +177,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||||
});
|
});
|
||||||
|
|
||||||
return movie;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ErsatzTV.Core.Errors;
|
|||||||
using ErsatzTV.Core.Interfaces.Images;
|
using ErsatzTV.Core.Interfaces.Images;
|
||||||
using ErsatzTV.Core.Interfaces.Metadata;
|
using ErsatzTV.Core.Interfaces.Metadata;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using static LanguageExt.Prelude;
|
using static LanguageExt.Prelude;
|
||||||
@@ -19,6 +20,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
private readonly ILocalFileSystem _localFileSystem;
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
private readonly ILocalMetadataProvider _localMetadataProvider;
|
private readonly ILocalMetadataProvider _localMetadataProvider;
|
||||||
private readonly ILogger<TelevisionFolderScanner> _logger;
|
private readonly ILogger<TelevisionFolderScanner> _logger;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
private readonly ITelevisionRepository _televisionRepository;
|
private readonly ITelevisionRepository _televisionRepository;
|
||||||
|
|
||||||
public TelevisionFolderScanner(
|
public TelevisionFolderScanner(
|
||||||
@@ -28,6 +30,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
ILocalMetadataProvider localMetadataProvider,
|
ILocalMetadataProvider localMetadataProvider,
|
||||||
IMetadataRepository metadataRepository,
|
IMetadataRepository metadataRepository,
|
||||||
IImageCache imageCache,
|
IImageCache imageCache,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
ILogger<TelevisionFolderScanner> logger) : base(
|
ILogger<TelevisionFolderScanner> logger) : base(
|
||||||
localFileSystem,
|
localFileSystem,
|
||||||
localStatisticsProvider,
|
localStatisticsProvider,
|
||||||
@@ -38,6 +41,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
_localFileSystem = localFileSystem;
|
_localFileSystem = localFileSystem;
|
||||||
_televisionRepository = televisionRepository;
|
_televisionRepository = televisionRepository;
|
||||||
_localMetadataProvider = localMetadataProvider;
|
_localMetadataProvider = localMetadataProvider;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,14 +59,26 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
|
|
||||||
foreach (string showFolder in allShowFolders)
|
foreach (string showFolder in allShowFolders)
|
||||||
{
|
{
|
||||||
Either<BaseError, Show> maybeShow =
|
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster))
|
||||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
|
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt));
|
||||||
|
|
||||||
await maybeShow.Match(
|
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));
|
_ => Task.FromResult(Unit.Default));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,19 +92,20 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _televisionRepository.DeleteEmptySeasons(libraryPath);
|
await _televisionRepository.DeleteEmptySeasons(libraryPath);
|
||||||
await _televisionRepository.DeleteEmptyShows(libraryPath);
|
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||||
|
await _searchIndex.RemoveItems(ids);
|
||||||
|
|
||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, Show>> FindOrCreateShow(
|
private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
|
||||||
int libraryPathId,
|
int libraryPathId,
|
||||||
string showFolder)
|
string showFolder)
|
||||||
{
|
{
|
||||||
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
||||||
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
|
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
|
||||||
return await maybeShow.Match(
|
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));
|
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +147,9 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
// TODO: figure out how to rebuild playlists
|
// TODO: figure out how to rebuild playlists
|
||||||
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
Either<BaseError, Episode> maybeEpisode = await _televisionRepository
|
||||||
.GetOrAddEpisode(season, libraryPath, file)
|
.GetOrAddEpisode(season, libraryPath, file)
|
||||||
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
|
.BindT(
|
||||||
|
episode => UpdateStatistics(new MediaItemScanResult<Episode>(episode), ffprobePath)
|
||||||
|
.MapT(_ => episode))
|
||||||
.BindT(UpdateMetadata)
|
.BindT(UpdateMetadata)
|
||||||
.BindT(UpdateThumbnail);
|
.BindT(UpdateThumbnail);
|
||||||
|
|
||||||
@@ -141,12 +160,13 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, Show>> UpdateMetadataForShow(
|
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateMetadataForShow(
|
||||||
Show show,
|
MediaItemScanResult<Show> result,
|
||||||
string showFolder)
|
string showFolder)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Show show = result.Item;
|
||||||
await LocateNfoFileForShow(showFolder).Match(
|
await LocateNfoFileForShow(showFolder).Match(
|
||||||
async nfoFile =>
|
async nfoFile =>
|
||||||
{
|
{
|
||||||
@@ -158,7 +178,10 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
if (shouldUpdate)
|
if (shouldUpdate)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||||
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
|
if (await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile))
|
||||||
|
{
|
||||||
|
result.IsUpdated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async () =>
|
async () =>
|
||||||
@@ -166,11 +189,14 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
if (!Optional(show.ShowMetadata).Flatten().Any())
|
if (!Optional(show.ShowMetadata).Flatten().Any())
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", showFolder);
|
_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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -215,13 +241,14 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, Show>> UpdateArtworkForShow(
|
private async Task<Either<BaseError, MediaItemScanResult<Show>>> UpdateArtworkForShow(
|
||||||
Show show,
|
MediaItemScanResult<Show> result,
|
||||||
string showFolder,
|
string showFolder,
|
||||||
ArtworkKind artworkKind)
|
ArtworkKind artworkKind)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Show show = result.Item;
|
||||||
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
|
await LocateArtworkForShow(showFolder, artworkKind).IfSomeAsync(
|
||||||
async posterFile =>
|
async posterFile =>
|
||||||
{
|
{
|
||||||
@@ -229,7 +256,7 @@ namespace ErsatzTV.Core.Metadata
|
|||||||
await RefreshArtwork(posterFile, metadata, artworkKind);
|
await RefreshArtwork(posterFile, metadata, artworkKind);
|
||||||
});
|
});
|
||||||
|
|
||||||
return show;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ using System.Threading.Tasks;
|
|||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Plex;
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using static LanguageExt.Prelude;
|
|
||||||
|
|
||||||
namespace ErsatzTV.Core.Plex
|
namespace ErsatzTV.Core.Plex
|
||||||
{
|
{
|
||||||
@@ -16,17 +17,20 @@ namespace ErsatzTV.Core.Plex
|
|||||||
private readonly IMetadataRepository _metadataRepository;
|
private readonly IMetadataRepository _metadataRepository;
|
||||||
private readonly IMovieRepository _movieRepository;
|
private readonly IMovieRepository _movieRepository;
|
||||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
|
|
||||||
public PlexMovieLibraryScanner(
|
public PlexMovieLibraryScanner(
|
||||||
IPlexServerApiClient plexServerApiClient,
|
IPlexServerApiClient plexServerApiClient,
|
||||||
IMovieRepository movieRepository,
|
IMovieRepository movieRepository,
|
||||||
IMetadataRepository metadataRepository,
|
IMetadataRepository metadataRepository,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
ILogger<PlexMovieLibraryScanner> logger)
|
ILogger<PlexMovieLibraryScanner> logger)
|
||||||
: base(metadataRepository)
|
: base(metadataRepository)
|
||||||
{
|
{
|
||||||
_plexServerApiClient = plexServerApiClient;
|
_plexServerApiClient = plexServerApiClient;
|
||||||
_movieRepository = movieRepository;
|
_movieRepository = movieRepository;
|
||||||
_metadataRepository = metadataRepository;
|
_metadataRepository = metadataRepository;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,21 +50,37 @@ namespace ErsatzTV.Core.Plex
|
|||||||
foreach (PlexMovie incoming in movieEntries)
|
foreach (PlexMovie incoming in movieEntries)
|
||||||
{
|
{
|
||||||
// TODO: figure out how to rebuild playlists
|
// TODO: figure out how to rebuild playlists
|
||||||
Either<BaseError, PlexMovie> maybeMovie = await _movieRepository
|
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||||
.GetOrAdd(plexMediaSourceLibrary, incoming)
|
.GetOrAdd(plexMediaSourceLibrary, incoming)
|
||||||
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
|
.BindT(existing => UpdateStatistics(existing, incoming, connection, token))
|
||||||
.BindT(existing => UpdateMetadata(existing, incoming))
|
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||||
|
|
||||||
maybeMovie.IfLeft(
|
await maybeMovie.Match(
|
||||||
error => _logger.LogWarning(
|
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}",
|
"Error processing plex movie at {Key}: {Error}",
|
||||||
incoming.Key,
|
incoming.Key,
|
||||||
error.Value));
|
error.Value);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var movieKeys = movieEntries.Map(s => s.Key).ToList();
|
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 =>
|
error =>
|
||||||
{
|
{
|
||||||
@@ -75,12 +95,13 @@ namespace ErsatzTV.Core.Plex
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, PlexMovie>> UpdateStatistics(
|
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
|
||||||
PlexMovie existing,
|
MediaItemScanResult<PlexMovie> result,
|
||||||
PlexMovie incoming,
|
PlexMovie incoming,
|
||||||
PlexConnection connection,
|
PlexConnection connection,
|
||||||
PlexServerAuthToken token)
|
PlexServerAuthToken token)
|
||||||
{
|
{
|
||||||
|
PlexMovie existing = result.Item;
|
||||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||||
|
|
||||||
@@ -102,11 +123,14 @@ namespace ErsatzTV.Core.Plex
|
|||||||
_ => Task.CompletedTask);
|
_ => 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 existingMetadata = existing.MovieMetadata.Head();
|
||||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||||
|
|
||||||
@@ -117,7 +141,10 @@ namespace ErsatzTV.Core.Plex
|
|||||||
.ToList())
|
.ToList())
|
||||||
{
|
{
|
||||||
existingMetadata.Genres.Remove(genre);
|
existingMetadata.Genres.Remove(genre);
|
||||||
await _metadataRepository.RemoveGenre(genre);
|
if (await _metadataRepository.RemoveGenre(genre))
|
||||||
|
{
|
||||||
|
result.IsUpdated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Genre genre in incomingMetadata.Genres
|
foreach (Genre genre in incomingMetadata.Genres
|
||||||
@@ -125,17 +152,23 @@ namespace ErsatzTV.Core.Plex
|
|||||||
.ToList())
|
.ToList())
|
||||||
{
|
{
|
||||||
existingMetadata.Genres.Add(genre);
|
existingMetadata.Genres.Add(genre);
|
||||||
await _movieRepository.AddGenre(existingMetadata, genre);
|
if (await _movieRepository.AddGenre(existingMetadata, genre))
|
||||||
|
{
|
||||||
|
result.IsUpdated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: update other metadata?
|
// 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 existingMetadata = existing.MovieMetadata.Head();
|
||||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||||
|
|
||||||
@@ -145,7 +178,7 @@ namespace ErsatzTV.Core.Plex
|
|||||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
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.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Plex;
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using static LanguageExt.Prelude;
|
using static LanguageExt.Prelude;
|
||||||
@@ -15,18 +17,21 @@ namespace ErsatzTV.Core.Plex
|
|||||||
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
|
||||||
private readonly IMetadataRepository _metadataRepository;
|
private readonly IMetadataRepository _metadataRepository;
|
||||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||||
|
private readonly ISearchIndex _searchIndex;
|
||||||
private readonly ITelevisionRepository _televisionRepository;
|
private readonly ITelevisionRepository _televisionRepository;
|
||||||
|
|
||||||
public PlexTelevisionLibraryScanner(
|
public PlexTelevisionLibraryScanner(
|
||||||
IPlexServerApiClient plexServerApiClient,
|
IPlexServerApiClient plexServerApiClient,
|
||||||
ITelevisionRepository televisionRepository,
|
ITelevisionRepository televisionRepository,
|
||||||
IMetadataRepository metadataRepository,
|
IMetadataRepository metadataRepository,
|
||||||
|
ISearchIndex searchIndex,
|
||||||
ILogger<PlexTelevisionLibraryScanner> logger)
|
ILogger<PlexTelevisionLibraryScanner> logger)
|
||||||
: base(metadataRepository)
|
: base(metadataRepository)
|
||||||
{
|
{
|
||||||
_plexServerApiClient = plexServerApiClient;
|
_plexServerApiClient = plexServerApiClient;
|
||||||
_televisionRepository = televisionRepository;
|
_televisionRepository = televisionRepository;
|
||||||
_metadataRepository = metadataRepository;
|
_metadataRepository = metadataRepository;
|
||||||
|
_searchIndex = searchIndex;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +51,25 @@ namespace ErsatzTV.Core.Plex
|
|||||||
foreach (PlexShow incoming in showEntries)
|
foreach (PlexShow incoming in showEntries)
|
||||||
{
|
{
|
||||||
// TODO: figure out how to rebuild playlists
|
// TODO: figure out how to rebuild playlists
|
||||||
Either<BaseError, PlexShow> maybeShow = await _televisionRepository
|
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
|
||||||
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
|
.GetOrAddPlexShow(plexMediaSourceLibrary, incoming)
|
||||||
.BindT(existing => UpdateMetadata(existing, incoming))
|
.BindT(existing => UpdateMetadata(existing, incoming))
|
||||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||||
|
|
||||||
await maybeShow.Match(
|
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 =>
|
error =>
|
||||||
{
|
{
|
||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
@@ -64,7 +81,9 @@ namespace ErsatzTV.Core.Plex
|
|||||||
}
|
}
|
||||||
|
|
||||||
var showKeys = showEntries.Map(s => s.Key).ToList();
|
var showKeys = showEntries.Map(s => s.Key).ToList();
|
||||||
|
List<int> ids =
|
||||||
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
|
await _televisionRepository.RemoveMissingPlexShows(plexMediaSourceLibrary, showKeys);
|
||||||
|
await _searchIndex.RemoveItems(ids);
|
||||||
|
|
||||||
return Unit.Default;
|
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 existingMetadata = existing.ShowMetadata.Head();
|
||||||
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
||||||
|
|
||||||
@@ -93,7 +115,10 @@ namespace ErsatzTV.Core.Plex
|
|||||||
.ToList())
|
.ToList())
|
||||||
{
|
{
|
||||||
existingMetadata.Genres.Remove(genre);
|
existingMetadata.Genres.Remove(genre);
|
||||||
_metadataRepository.RemoveGenre(genre);
|
if (await _metadataRepository.RemoveGenre(genre))
|
||||||
|
{
|
||||||
|
result.IsUpdated = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (Genre genre in incomingMetadata.Genres
|
foreach (Genre genre in incomingMetadata.Genres
|
||||||
@@ -101,15 +126,21 @@ namespace ErsatzTV.Core.Plex
|
|||||||
.ToList())
|
.ToList())
|
||||||
{
|
{
|
||||||
existingMetadata.Genres.Add(genre);
|
existingMetadata.Genres.Add(genre);
|
||||||
_televisionRepository.AddGenre(existingMetadata, genre);
|
if (await _televisionRepository.AddGenre(existingMetadata, genre))
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Right<BaseError, PlexShow>(existing).AsTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Either<BaseError, PlexShow>> UpdateArtwork(PlexShow existing, PlexShow incoming)
|
|
||||||
{
|
{
|
||||||
|
result.IsUpdated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateArtwork(
|
||||||
|
MediaItemScanResult<PlexShow> result,
|
||||||
|
PlexShow incoming)
|
||||||
|
{
|
||||||
|
PlexShow existing = result.Item;
|
||||||
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
|
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
|
||||||
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
||||||
|
|
||||||
@@ -119,7 +150,7 @@ namespace ErsatzTV.Core.Plex
|
|||||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||||
}
|
}
|
||||||
|
|
||||||
return existing;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace ErsatzTV.Core.Search
|
||||||
|
{
|
||||||
|
public record SearchItem(int Id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Search
|
||||||
|
{
|
||||||
|
public record SearchPageMap(Dictionary<char, int> PageMap);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,6 +84,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
@"SELECT COUNT(*) FROM MediaItem WHERE LibraryPathId = @LibraryPathId",
|
||||||
new { 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)
|
public async Task DeleteLocalPath(int libraryPathId)
|
||||||
{
|
{
|
||||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
int? id = await _dbConnection.QuerySingleAsync<int?>(
|
int? id = await _dbConnection.QuerySingleAsync<int?>(
|
||||||
@"SELECT L.MediaSourceId FROM Library L
|
@"SELECT L.MediaSourceId FROM Library L
|
||||||
INNER JOIN PlexLibrary PL on L.Id = PL.Id
|
INNER JOIN PlexLibrary PL on L.Id = PL.Id
|
||||||
WHERE L.Id = @PlexLibraryId", new { PlexLibraryId = plexLibraryId });
|
WHERE L.Id = @PlexLibraryId",
|
||||||
|
new { PlexLibraryId = plexLibraryId });
|
||||||
|
|
||||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||||
return await context.PlexMediaSources
|
return await context.PlexMediaSources
|
||||||
@@ -187,7 +188,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task DisablePlexLibrarySync(List<int> libraryIds)
|
public async Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds)
|
||||||
{
|
{
|
||||||
await _dbConnection.ExecuteAsync(
|
await _dbConnection.ExecuteAsync(
|
||||||
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
||||||
@@ -197,6 +198,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
||||||
new { ids = libraryIds });
|
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(
|
await _dbConnection.ExecuteAsync(
|
||||||
@"DELETE FROM MediaItem WHERE Id IN
|
@"DELETE FROM MediaItem WHERE Id IN
|
||||||
(SELECT m.Id FROM MediaItem m
|
(SELECT m.Id FROM MediaItem m
|
||||||
@@ -224,6 +233,14 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
WHERE l.Id IN @ids)",
|
WHERE l.Id IN @ids)",
|
||||||
new { ids = libraryIds });
|
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(
|
await _dbConnection.ExecuteAsync(
|
||||||
@"DELETE FROM MediaItem WHERE Id IN
|
@"DELETE FROM MediaItem WHERE Id IN
|
||||||
(SELECT m.Id FROM MediaItem m
|
(SELECT m.Id FROM MediaItem m
|
||||||
@@ -232,6 +249,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||||
WHERE l.Id IN @ids)",
|
WHERE l.Id IN @ids)",
|
||||||
new { ids = libraryIds });
|
new { ids = libraryIds });
|
||||||
|
|
||||||
|
return movieIds.Append(showIds).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
|
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
_dbConnection = dbConnection;
|
_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)
|
public async Task<bool> Update(Metadata metadata)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
@@ -53,7 +50,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
return await dbContext.SaveChangesAsync() > 0;
|
return await dbContext.SaveChangesAsync() > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Unit> UpdatePlexStatistics(MediaVersion mediaVersion) =>
|
public Task<bool> UpdatePlexStatistics(MediaVersion mediaVersion) =>
|
||||||
_dbConnection.ExecuteAsync(
|
_dbConnection.ExecuteAsync(
|
||||||
@"UPDATE MediaVersion SET
|
@"UPDATE MediaVersion SET
|
||||||
SampleAspectRatio = @SampleAspectRatio,
|
SampleAspectRatio = @SampleAspectRatio,
|
||||||
@@ -66,7 +63,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
mediaVersion.VideoScanKind,
|
mediaVersion.VideoScanKind,
|
||||||
mediaVersion.DateUpdated,
|
mediaVersion.DateUpdated,
|
||||||
MediaVersionId = mediaVersion.Id
|
MediaVersionId = mediaVersion.Id
|
||||||
}).ToUnit();
|
}).Map(result => result > 0);
|
||||||
|
|
||||||
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
public Task<Unit> UpdateArtworkPath(Artwork artwork) =>
|
||||||
_dbConnection.ExecuteAsync(
|
_dbConnection.ExecuteAsync(
|
||||||
@@ -111,5 +108,9 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
|
@"DELETE FROM Artwork WHERE ArtworkKind = @ArtworkKind AND (MovieMetadataId = @Id
|
||||||
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
|
OR ShowMetadataId = @Id OR SeasonMetadataId = @Id OR EpisodeMetadataId = @Id)",
|
||||||
new { ArtworkKind = artworkKind, metadata.Id }).ToUnit();
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Dapper;
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using static LanguageExt.Prelude;
|
using static LanguageExt.Prelude;
|
||||||
@@ -45,7 +46,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.Map(Optional);
|
.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)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
Option<Movie> maybeExisting = await dbContext.Movies
|
Option<Movie> maybeExisting = await dbContext.Movies
|
||||||
@@ -56,17 +57,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.Include(i => i.MovieMetadata)
|
.Include(i => i.MovieMetadata)
|
||||||
.ThenInclude(mm => mm.Tags)
|
.ThenInclude(mm => mm.Tags)
|
||||||
.Include(i => i.LibraryPath)
|
.Include(i => i.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
.Include(i => i.MediaVersions)
|
.Include(i => i.MediaVersions)
|
||||||
.ThenInclude(mv => mv.MediaFiles)
|
.ThenInclude(mv => mv.MediaFiles)
|
||||||
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
.OrderBy(i => i.MediaVersions.First().MediaFiles.First().Path)
|
||||||
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
.SingleOrDefaultAsync(i => i.MediaVersions.First().MediaFiles.First().Path == path);
|
||||||
|
|
||||||
return await maybeExisting.Match(
|
return await maybeExisting.Match(
|
||||||
mediaItem => Right<BaseError, Movie>(mediaItem).AsTask(),
|
mediaItem =>
|
||||||
|
Right<BaseError, MediaItemScanResult<Movie>>(
|
||||||
|
new MediaItemScanResult<Movie>(mediaItem) { IsAdded = false }).AsTask(),
|
||||||
async () => await AddMovie(dbContext, libraryPath.Id, path));
|
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();
|
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||||
Option<PlexMovie> maybeExisting = await context.PlexMovies
|
Option<PlexMovie> maybeExisting = await context.PlexMovies
|
||||||
@@ -74,14 +80,20 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.Include(i => i.MovieMetadata)
|
.Include(i => i.MovieMetadata)
|
||||||
.ThenInclude(mm => mm.Genres)
|
.ThenInclude(mm => mm.Genres)
|
||||||
.Include(i => i.MovieMetadata)
|
.Include(i => i.MovieMetadata)
|
||||||
|
.ThenInclude(mm => mm.Tags)
|
||||||
|
.Include(i => i.MovieMetadata)
|
||||||
.ThenInclude(mm => mm.Artwork)
|
.ThenInclude(mm => mm.Artwork)
|
||||||
.Include(i => i.MediaVersions)
|
.Include(i => i.MediaVersions)
|
||||||
.ThenInclude(mv => mv.MediaFiles)
|
.ThenInclude(mv => mv.MediaFiles)
|
||||||
|
.Include(i => i.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
.OrderBy(i => i.Key)
|
.OrderBy(i => i.Key)
|
||||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||||
|
|
||||||
return await maybeExisting.Match(
|
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));
|
async () => await AddPlexMovie(context, library, item));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +116,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.ToListAsync();
|
.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) =>
|
public Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath) =>
|
||||||
_dbConnection.QueryAsync<string>(
|
_dbConnection.QueryAsync<string>(
|
||||||
@"SELECT MF.Path
|
@"SELECT MF.Path
|
||||||
@@ -114,17 +137,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
WHERE MI.LibraryPathId = @LibraryPathId",
|
WHERE MI.LibraryPathId = @LibraryPathId",
|
||||||
new { LibraryPathId = libraryPath.Id });
|
new { LibraryPathId = libraryPath.Id });
|
||||||
|
|
||||||
public async Task<Unit> DeleteByPath(LibraryPath libraryPath, string path)
|
public async Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
|
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||||
@"SELECT M.Id
|
@"SELECT M.Id
|
||||||
FROM Movie M
|
FROM Movie M
|
||||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||||
INNER JOIN MediaVersion MV on M.Id = MV.MovieId
|
INNER JOIN MediaVersion MV on M.Id = MV.MovieId
|
||||||
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId
|
||||||
WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path",
|
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)
|
foreach (int movieId in ids)
|
||||||
{
|
{
|
||||||
@@ -132,26 +156,36 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
dbContext.Movies.Remove(movie);
|
dbContext.Movies.Remove(movie);
|
||||||
}
|
}
|
||||||
|
|
||||||
await dbContext.SaveChangesAsync();
|
bool changed = await dbContext.SaveChangesAsync() > 0;
|
||||||
|
return changed ? ids : new List<int>();
|
||||||
return Unit.Default;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<Unit> AddGenre(MovieMetadata metadata, Genre genre) =>
|
public Task<bool> AddGenre(MovieMetadata metadata, Genre genre) =>
|
||||||
_dbConnection.ExecuteAsync(
|
_dbConnection.ExecuteAsync(
|
||||||
"INSERT INTO Genre (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
|
"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 async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
|
||||||
_dbConnection.ExecuteAsync(
|
{
|
||||||
|
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
|
@"DELETE FROM MediaItem WHERE Id IN
|
||||||
(SELECT m.Id FROM MediaItem m
|
(SELECT m.Id FROM MediaItem m
|
||||||
INNER JOIN PlexMovie pm ON pm.Id = m.Id
|
INNER JOIN PlexMovie pm ON pm.Id = m.Id
|
||||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||||
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys)",
|
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys)",
|
||||||
new { LibraryId = library.Id, Keys = movieKeys }).ToUnit();
|
new { LibraryId = library.Id, Keys = movieKeys });
|
||||||
|
|
||||||
private static async Task<Either<BaseError, Movie>> AddMovie(
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
int libraryPathId,
|
int libraryPathId,
|
||||||
string path)
|
string path)
|
||||||
@@ -175,7 +209,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
await dbContext.Movies.AddAsync(movie);
|
await dbContext.Movies.AddAsync(movie);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||||
return movie;
|
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||||
|
return new MediaItemScanResult<Movie>(movie) { IsAdded = true };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -183,7 +218,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(
|
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddPlexMovie(
|
||||||
TvContext context,
|
TvContext context,
|
||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexMovie item)
|
PlexMovie item)
|
||||||
@@ -195,7 +230,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
await context.PlexMovies.AddAsync(item);
|
await context.PlexMovies.AddAsync(item);
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,6 +21,24 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
_dbConnection = dbConnection;
|
_dbConnection = dbConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<MediaItem>> GetItemsToIndex()
|
||||||
|
{
|
||||||
|
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 Show).ShowMetadata)
|
||||||
|
.ThenInclude(mm => mm.Genres)
|
||||||
|
.Include(mi => (mi as Show).ShowMetadata)
|
||||||
|
.ThenInclude(mm => mm.Tags)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
|
public async Task<List<MediaItem>> SearchMediaItemsByTitle(string query)
|
||||||
{
|
{
|
||||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using Dapper;
|
|||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
|
using ErsatzTV.Core.Metadata;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using static LanguageExt.Prelude;
|
using static LanguageExt.Prelude;
|
||||||
@@ -82,6 +83,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
public async Task<List<Season>> GetAllSeasons()
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
@@ -201,6 +213,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.ThenInclude(sm => sm.Genres)
|
.ThenInclude(sm => sm.Genres)
|
||||||
.Include(s => s.ShowMetadata)
|
.Include(s => s.ShowMetadata)
|
||||||
.ThenInclude(sm => sm.Tags)
|
.ThenInclude(sm => sm.Tags)
|
||||||
|
.Include(s => s.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
.OrderBy(s => s.Id)
|
.OrderBy(s => s.Id)
|
||||||
.SingleOrDefaultAsync(s => s.Id == id)
|
.SingleOrDefaultAsync(s => s.Id == id)
|
||||||
.Map(Optional);
|
.Map(Optional);
|
||||||
@@ -208,7 +222,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
() => Option<Show>.None.AsTask());
|
() => 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();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
|
|
||||||
@@ -226,8 +243,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
|
|
||||||
await dbContext.Shows.AddAsync(show);
|
await dbContext.Shows.AddAsync(show);
|
||||||
await dbContext.SaveChangesAsync();
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -314,19 +333,22 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Unit> DeleteEmptyShows(LibraryPath libraryPath)
|
public async Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath)
|
||||||
{
|
{
|
||||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
List<Show> shows = await dbContext.Shows
|
List<Show> shows = await dbContext.Shows
|
||||||
.Filter(s => s.LibraryPathId == libraryPath.Id)
|
.Filter(s => s.LibraryPathId == libraryPath.Id)
|
||||||
.Filter(s => s.Seasons.Count == 0)
|
.Filter(s => s.Seasons.Count == 0)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
var ids = shows.Map(s => s.Id).ToList();
|
||||||
dbContext.Shows.RemoveRange(shows);
|
dbContext.Shows.RemoveRange(shows);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
return Unit.Default;
|
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 dbContext = _dbContextFactory.CreateDbContext();
|
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||||
Option<PlexShow> maybeExisting = await dbContext.PlexShows
|
Option<PlexShow> maybeExisting = await dbContext.PlexShows
|
||||||
@@ -334,12 +356,17 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.Include(i => i.ShowMetadata)
|
.Include(i => i.ShowMetadata)
|
||||||
.ThenInclude(mm => mm.Genres)
|
.ThenInclude(mm => mm.Genres)
|
||||||
.Include(i => i.ShowMetadata)
|
.Include(i => i.ShowMetadata)
|
||||||
|
.ThenInclude(mm => mm.Tags)
|
||||||
|
.Include(i => i.ShowMetadata)
|
||||||
.ThenInclude(mm => mm.Artwork)
|
.ThenInclude(mm => mm.Artwork)
|
||||||
|
.Include(i => i.LibraryPath)
|
||||||
|
.ThenInclude(lp => lp.Library)
|
||||||
.OrderBy(i => i.Key)
|
.OrderBy(i => i.Key)
|
||||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||||
|
|
||||||
return await maybeExisting.Match(
|
return await maybeExisting.Match(
|
||||||
plexShow => Right<BaseError, PlexShow>(plexShow).AsTask(),
|
plexShow => Right<BaseError, MediaItemScanResult<PlexShow>>(
|
||||||
|
new MediaItemScanResult<PlexShow>(plexShow) { IsAdded = true }).AsTask(),
|
||||||
async () => await AddPlexShow(dbContext, library, item));
|
async () => await AddPlexShow(dbContext, library, item));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,20 +402,6 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
async () => await AddPlexEpisode(dbContext, 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) =>
|
public Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys) =>
|
||||||
_dbConnection.ExecuteAsync(
|
_dbConnection.ExecuteAsync(
|
||||||
@"DELETE FROM MediaItem WHERE Id IN
|
@"DELETE FROM MediaItem WHERE Id IN
|
||||||
@@ -414,7 +427,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
episode.EpisodeNumber = episodeNumber;
|
episode.EpisodeNumber = episodeNumber;
|
||||||
await _dbConnection.ExecuteAsync(
|
await _dbConnection.ExecuteAsync(
|
||||||
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
|
@"UPDATE Episode SET EpisodeNumber = @EpisodeNumber WHERE Id = @Id",
|
||||||
new { EpisodeNumber = episodeNumber, Id = episode.Id });
|
new { EpisodeNumber = episodeNumber, episode.Id });
|
||||||
return Unit.Default;
|
return Unit.Default;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -449,6 +462,31 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) =>
|
||||||
|
_dbConnection.ExecuteAsync(
|
||||||
|
"INSERT INTO Genre (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
|
||||||
|
new { genre.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(
|
private static async Task<Either<BaseError, Season>> AddSeason(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
Show show,
|
Show show,
|
||||||
@@ -519,7 +557,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Either<BaseError, PlexShow>> AddPlexShow(
|
private static async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> AddPlexShow(
|
||||||
TvContext dbContext,
|
TvContext dbContext,
|
||||||
PlexLibrary library,
|
PlexLibrary library,
|
||||||
PlexShow item)
|
PlexShow item)
|
||||||
@@ -531,7 +569,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
|||||||
await dbContext.PlexShows.AddAsync(item);
|
await dbContext.PlexShows.AddAsync(item);
|
||||||
await dbContext.SaveChangesAsync();
|
await dbContext.SaveChangesAsync();
|
||||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||||
return item;
|
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||||
|
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Dapper" Version="2.0.78" />
|
<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" Version="5.0.4" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
@@ -176,7 +176,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
Tagline = response.Tagline,
|
Tagline = response.Tagline,
|
||||||
DateAdded = dateAdded,
|
DateAdded = dateAdded,
|
||||||
DateUpdated = lastWriteTime,
|
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>()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
|
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
|
||||||
@@ -277,7 +278,8 @@ namespace ErsatzTV.Infrastructure.Plex
|
|||||||
Tagline = response.Tagline,
|
Tagline = response.Tagline,
|
||||||
DateAdded = dateAdded,
|
DateAdded = dateAdded,
|
||||||
DateUpdated = lastWriteTime,
|
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>()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
|
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
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.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 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 MovieType = "movie";
|
||||||
|
private const string ShowType = "show";
|
||||||
|
|
||||||
|
private readonly ILocalFileSystem _localFileSystem;
|
||||||
|
|
||||||
|
private readonly string[] _searchFields = { TitleField, GenreField, TagField };
|
||||||
|
|
||||||
|
public SearchIndex(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
|
||||||
|
|
||||||
|
public int Version => 1;
|
||||||
|
|
||||||
|
public Task<bool> Initialize()
|
||||||
|
{
|
||||||
|
_localFileSystem.EnsureFolderExists(FileSystemLayout.SearchIndexFolder);
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Unit> Rebuild(List<MediaItem> items)
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
|
||||||
|
UpdateMovies(items.OfType<Movie>(), writer);
|
||||||
|
UpdateShows(items.OfType<Show>(), writer);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
UpdateMovies(items.OfType<Movie>(), writer);
|
||||||
|
UpdateShows(items.OfType<Show>(), writer);
|
||||||
|
|
||||||
|
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 (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, _searchFields, analyzer);
|
||||||
|
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 static void UpdateMovies(IEnumerable<Movie> movies, IndexWriter writer)
|
||||||
|
{
|
||||||
|
foreach (Movie movie in movies)
|
||||||
|
{
|
||||||
|
Option<MovieMetadata> maybeMetadata = movie.MovieMetadata.HeadOrNone();
|
||||||
|
if (maybeMetadata.IsSome)
|
||||||
|
{
|
||||||
|
MovieMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||||
|
|
||||||
|
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 (!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));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.UpdateDocument(new Term(IdField, movie.Id.ToString()), doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateShows(IEnumerable<Show> shows, IndexWriter writer)
|
||||||
|
{
|
||||||
|
foreach (Show show in shows)
|
||||||
|
{
|
||||||
|
Option<ShowMetadata> maybeMetadata = show.ShowMetadata.HeadOrNone();
|
||||||
|
if (maybeMetadata.IsSome)
|
||||||
|
{
|
||||||
|
ShowMetadata metadata = maybeMetadata.ValueUnsafe();
|
||||||
|
|
||||||
|
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 (!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));
|
||||||
|
}
|
||||||
|
|
||||||
|
writer.UpdateDocument(new Term(IdField, show.Id.ToString()), doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
_ => "#"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
@page "/channels/{Id:int?}"
|
@page "/channels/{Id:int?}"
|
||||||
@page "/channels/add"
|
@page "/channels/add"
|
||||||
|
@using static LanguageExt.Prelude
|
||||||
@using ErsatzTV.Application.FFmpegProfiles
|
@using ErsatzTV.Application.FFmpegProfiles
|
||||||
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
@using ErsatzTV.Application.FFmpegProfiles.Queries
|
||||||
@using ErsatzTV.Application.Images.Commands
|
@using ErsatzTV.Application.Images.Commands
|
||||||
@using ErsatzTV.Application.Channels
|
@using ErsatzTV.Application.Channels
|
||||||
@using ErsatzTV.Application.Channels.Queries
|
@using ErsatzTV.Application.Channels.Queries
|
||||||
@using static LanguageExt.Prelude
|
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject ILogger<ChannelEditor> Logger
|
@inject ILogger<ChannelEditor> Logger
|
||||||
@inject ISnackbar Snackbar
|
@inject ISnackbar Snackbar
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
<MudSwitch T="bool"
|
<MudSwitch T="bool"
|
||||||
Label="Save troubleshooting reports to disk"
|
Label="Save troubleshooting reports to disk"
|
||||||
Color="Color.Primary"
|
Color="Color.Primary"
|
||||||
@bind-Checked="@_ffmpegSettings.SaveReports" />
|
@bind-Checked="@_ffmpegSettings.SaveReports"/>
|
||||||
</MudElement>
|
</MudElement>
|
||||||
</MudForm>
|
</MudForm>
|
||||||
</MudCardContent>
|
</MudCardContent>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@foreach (string genre in _movie.Genres.OrderBy(g => g))
|
@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{genre.ToLowerInvariant()}%22")"/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<div>
|
<div>
|
||||||
@foreach (string tag in _movie.Tags.OrderBy(t => t))
|
@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{tag.ToLowerInvariant()}%22")"/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
@page "/media/movies"
|
@page "/media/movies"
|
||||||
@page "/media/movies/page/{PageNumber:int}"
|
@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
|
||||||
@using ErsatzTV.Application.MediaCards.Queries
|
|
||||||
@using ErsatzTV.Application.MediaCollections
|
@using ErsatzTV.Application.MediaCollections
|
||||||
@using ErsatzTV.Application.MediaCollections.Commands
|
@using ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
@using ErsatzTV.Application.Search.Queries
|
||||||
@using Unit = LanguageExt.Unit
|
@using Unit = LanguageExt.Unit
|
||||||
@inherits MultiSelectBase<MovieList>
|
@inherits MultiSelectBase<MovieList>
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
}
|
}
|
||||||
else
|
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;">
|
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||||
OnClick="@PrevPage"
|
OnClick="@PrevPage"
|
||||||
@@ -63,6 +67,12 @@
|
|||||||
}
|
}
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
@if (_data.PageMap.IsSome)
|
||||||
|
{
|
||||||
|
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
|
||||||
|
BaseUri="/media/movies"
|
||||||
|
Query="@_query"/>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private static int PageSize => 100;
|
private static int PageSize => 100;
|
||||||
@@ -71,6 +81,7 @@
|
|||||||
public int PageNumber { get; set; }
|
public int PageNumber { get; set; }
|
||||||
|
|
||||||
private MovieCardResultsViewModel _data;
|
private MovieCardResultsViewModel _data;
|
||||||
|
private string _query;
|
||||||
|
|
||||||
protected override Task OnParametersSetAsync()
|
protected override Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -79,15 +90,40 @@
|
|||||||
PageNumber = 1;
|
PageNumber = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string query = new Uri(NavigationManager.Uri).Query;
|
||||||
|
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||||
|
{
|
||||||
|
_query = value;
|
||||||
|
}
|
||||||
|
|
||||||
return RefreshData();
|
return RefreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task RefreshData() =>
|
protected override async Task RefreshData()
|
||||||
_data = await Mediator.Send(new GetMovieCards(PageNumber, PageSize));
|
{
|
||||||
|
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)
|
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
@if (_selectedItem is not null)
|
@if (_selectedItem is not null)
|
||||||
{
|
{
|
||||||
<div style="display: flex; flex-direction: row;" class="mt-6">
|
<div style="display: flex; flex-direction: row;" class="mt-6">
|
||||||
<div style="max-width: 400px; flex-grow: 1">
|
<div style="flex-grow: 1; max-width: 400px;">
|
||||||
<EditForm Model="_selectedItem">
|
<EditForm Model="_selectedItem">
|
||||||
<FluentValidator/>
|
<FluentValidator/>
|
||||||
<MudCard>
|
<MudCard>
|
||||||
|
|||||||
+63
-16
@@ -1,8 +1,8 @@
|
|||||||
@page "/search"
|
@page "/search"
|
||||||
@using ErsatzTV.Application.MediaCards
|
@using ErsatzTV.Application.MediaCards
|
||||||
@using ErsatzTV.Application.MediaCards.Queries
|
|
||||||
@using ErsatzTV.Application.MediaCollections
|
@using ErsatzTV.Application.MediaCollections
|
||||||
@using ErsatzTV.Application.MediaCollections.Commands
|
@using ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
@using ErsatzTV.Application.Search.Queries
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using Microsoft.Extensions.Primitives
|
@using Microsoft.Extensions.Primitives
|
||||||
@using Unit = LanguageExt.Unit
|
@using Unit = LanguageExt.Unit
|
||||||
@@ -34,23 +34,43 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudText>@_query</MudText>
|
<MudText>@_query</MudText>
|
||||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#movies")">@_data.MovieCards.Count Movies</MudLink>
|
if (_movies.Count > 0)
|
||||||
<MudLink Class="ml-4" Href="@(NavigationManager.Uri.Split("#").Head() + "#shows")">@_data.ShowCards.Count Shows</MudLink>
|
{
|
||||||
|
<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>
|
</div>
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
|
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Style="margin-top: 96px">
|
||||||
@if (_data?.MovieCards.Any() == true)
|
@if (_movies.Count > 0)
|
||||||
{
|
{
|
||||||
<MudText GutterBottom="true"
|
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||||
Typo="Typo.h4"
|
<MudText Typo="Typo.h4"
|
||||||
Style="scroll-margin-top: 160px"
|
Style="scroll-margin-top: 160px"
|
||||||
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
UserAttributes="@(new Dictionary<string, object> { { "id", "movies" } })">
|
||||||
Movies
|
Movies
|
||||||
</MudText>
|
</MudText>
|
||||||
|
@if (_movies.Count > 50)
|
||||||
|
{
|
||||||
|
<MudLink Href="@GetMoviesLink()" Class="ml-4">See All >></MudLink>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
<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"
|
<MediaCard Data="@card"
|
||||||
Link="@($"/media/movies/{card.MovieId}")"
|
Link="@($"/media/movies/{card.MovieId}")"
|
||||||
@@ -62,17 +82,22 @@
|
|||||||
</MudContainer>
|
</MudContainer>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (_data?.ShowCards.Any() == true)
|
@if (_shows.Count > 0)
|
||||||
{
|
{
|
||||||
<MudText GutterBottom="true"
|
<div class="mb-4" style="align-items: baseline; display: flex; flex-direction: row;">
|
||||||
Typo="Typo.h4"
|
<MudText Typo="Typo.h4"
|
||||||
Style="scroll-margin-top: 160px"
|
Style="scroll-margin-top: 160px"
|
||||||
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
UserAttributes="@(new Dictionary<string, object> { { "id", "shows" } })">
|
||||||
Shows
|
Shows
|
||||||
</MudText>
|
</MudText>
|
||||||
|
@if (_shows.Count > 50)
|
||||||
|
{
|
||||||
|
<MudLink Href="@GetShowsLink()" Class="ml-4">See All >></MudLink>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
|
<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"
|
<MediaCard Data="@card"
|
||||||
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
|
||||||
@@ -87,7 +112,8 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string _query;
|
private string _query;
|
||||||
private SearchCardResultsViewModel _data;
|
private MovieCardResultsViewModel _movies;
|
||||||
|
private TelevisionShowCardResultsViewModel _shows;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -96,8 +122,9 @@
|
|||||||
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||||
{
|
{
|
||||||
_query = 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()
|
List<MediaCardViewModel> GetSortedItems()
|
||||||
{
|
{
|
||||||
return _data.MovieCards.OrderBy(m => m.SortTitle)
|
return _movies.Cards.OrderBy(m => m.SortTitle)
|
||||||
.Append<MediaCardViewModel>(_data.ShowCards.OrderBy(s => s.SortTitle))
|
.Append<MediaCardViewModel>(_shows.Cards.OrderBy(s => s.SortTitle))
|
||||||
.ToList();
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
@foreach (string genre in _show.Genres.OrderBy(g => g))
|
@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{genre.ToLowerInvariant()}%22")"/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
<div>
|
<div>
|
||||||
@foreach (string tag in _show.Tags.OrderBy(t => t))
|
@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{tag.ToLowerInvariant()}%22")"/>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
@page "/media/tv/shows"
|
@page "/media/tv/shows"
|
||||||
@page "/media/tv/shows/page/{PageNumber:int}"
|
@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
|
||||||
@using ErsatzTV.Application.MediaCards.Queries
|
|
||||||
@using ErsatzTV.Application.MediaCollections
|
@using ErsatzTV.Application.MediaCollections
|
||||||
@using ErsatzTV.Application.MediaCollections.Commands
|
@using ErsatzTV.Application.MediaCollections.Commands
|
||||||
|
@using ErsatzTV.Application.Search.Queries
|
||||||
@using Unit = LanguageExt.Unit
|
@using Unit = LanguageExt.Unit
|
||||||
@inherits MultiSelectBase<TelevisionShowList>
|
@inherits MultiSelectBase<TelevisionShowList>
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
}
|
}
|
||||||
else
|
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;">
|
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
|
||||||
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
|
||||||
OnClick="@PrevPage"
|
OnClick="@PrevPage"
|
||||||
@@ -63,6 +67,12 @@
|
|||||||
}
|
}
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
</MudContainer>
|
</MudContainer>
|
||||||
|
@if (_data.PageMap.IsSome)
|
||||||
|
{
|
||||||
|
<LetterBar PageMap="@_data.PageMap.ValueUnsafe()"
|
||||||
|
BaseUri="/media/tv/shows"
|
||||||
|
Query="@_query"/>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private static int PageSize => 100;
|
private static int PageSize => 100;
|
||||||
@@ -71,6 +81,7 @@
|
|||||||
public int PageNumber { get; set; }
|
public int PageNumber { get; set; }
|
||||||
|
|
||||||
private TelevisionShowCardResultsViewModel _data;
|
private TelevisionShowCardResultsViewModel _data;
|
||||||
|
private string _query;
|
||||||
|
|
||||||
protected override Task OnParametersSetAsync()
|
protected override Task OnParametersSetAsync()
|
||||||
{
|
{
|
||||||
@@ -79,15 +90,40 @@
|
|||||||
PageNumber = 1;
|
PageNumber = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
string query = new Uri(NavigationManager.Uri).Query;
|
||||||
|
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
|
||||||
|
{
|
||||||
|
_query = value;
|
||||||
|
}
|
||||||
|
|
||||||
return RefreshData();
|
return RefreshData();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task RefreshData() =>
|
protected override async Task RefreshData()
|
||||||
_data = await Mediator.Send(new GetTelevisionShowCards(PageNumber, PageSize));
|
{
|
||||||
|
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)
|
private void SelectClicked(MediaCardViewModel card, MouseEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using ErsatzTV.Application;
|
|||||||
using ErsatzTV.Application.MediaSources.Commands;
|
using ErsatzTV.Application.MediaSources.Commands;
|
||||||
using ErsatzTV.Application.Playouts.Commands;
|
using ErsatzTV.Application.Playouts.Commands;
|
||||||
using ErsatzTV.Application.Plex.Commands;
|
using ErsatzTV.Application.Plex.Commands;
|
||||||
|
using ErsatzTV.Application.Search.Commands;
|
||||||
using ErsatzTV.Core.Domain;
|
using ErsatzTV.Core.Domain;
|
||||||
using ErsatzTV.Core.Interfaces.Locking;
|
using ErsatzTV.Core.Interfaces.Locking;
|
||||||
using ErsatzTV.Infrastructure.Data;
|
using ErsatzTV.Infrastructure.Data;
|
||||||
@@ -54,6 +55,7 @@ namespace ErsatzTV.Services
|
|||||||
|
|
||||||
private async Task DoWork(CancellationToken cancellationToken)
|
private async Task DoWork(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
await RebuildSearchIndex(cancellationToken);
|
||||||
await BuildPlayouts(cancellationToken);
|
await BuildPlayouts(cancellationToken);
|
||||||
await ScanLocalMediaSources(cancellationToken);
|
await ScanLocalMediaSources(cancellationToken);
|
||||||
await ScanPlexMediaSources(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.MediaSources.Commands;
|
||||||
using ErsatzTV.Application.Playouts.Commands;
|
using ErsatzTV.Application.Playouts.Commands;
|
||||||
using ErsatzTV.Application.Plex.Commands;
|
using ErsatzTV.Application.Plex.Commands;
|
||||||
|
using ErsatzTV.Application.Search.Commands;
|
||||||
using ErsatzTV.Core;
|
using ErsatzTV.Core;
|
||||||
using LanguageExt;
|
using LanguageExt;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -82,6 +83,9 @@ namespace ErsatzTV.Services
|
|||||||
synchronizePlexLibraryById.PlexLibraryId,
|
synchronizePlexLibraryById.PlexLibraryId,
|
||||||
error.Value));
|
error.Value));
|
||||||
break;
|
break;
|
||||||
|
case RebuildSearchIndex rebuildSearchIndex:
|
||||||
|
await mediator.Send(rebuildSearchIndex, cancellationToken);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ using ErsatzTV.Core.Interfaces.Metadata;
|
|||||||
using ErsatzTV.Core.Interfaces.Plex;
|
using ErsatzTV.Core.Interfaces.Plex;
|
||||||
using ErsatzTV.Core.Interfaces.Repositories;
|
using ErsatzTV.Core.Interfaces.Repositories;
|
||||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||||
|
using ErsatzTV.Core.Interfaces.Search;
|
||||||
using ErsatzTV.Core.Metadata;
|
using ErsatzTV.Core.Metadata;
|
||||||
using ErsatzTV.Core.Plex;
|
using ErsatzTV.Core.Plex;
|
||||||
using ErsatzTV.Core.Scheduling;
|
using ErsatzTV.Core.Scheduling;
|
||||||
@@ -24,6 +25,7 @@ using ErsatzTV.Infrastructure.Data.Repositories;
|
|||||||
using ErsatzTV.Infrastructure.Images;
|
using ErsatzTV.Infrastructure.Images;
|
||||||
using ErsatzTV.Infrastructure.Locking;
|
using ErsatzTV.Infrastructure.Locking;
|
||||||
using ErsatzTV.Infrastructure.Plex;
|
using ErsatzTV.Infrastructure.Plex;
|
||||||
|
using ErsatzTV.Infrastructure.Search;
|
||||||
using ErsatzTV.Serialization;
|
using ErsatzTV.Serialization;
|
||||||
using ErsatzTV.Services;
|
using ErsatzTV.Services;
|
||||||
using FluentValidation.AspNetCore;
|
using FluentValidation.AspNetCore;
|
||||||
@@ -209,6 +211,7 @@ namespace ErsatzTV
|
|||||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||||
|
services.AddScoped<ISearchIndex, SearchIndex>();
|
||||||
|
|
||||||
services.AddHostedService<PlexService>();
|
services.AddHostedService<PlexService>();
|
||||||
services.AddHostedService<FFmpegLocatorService>();
|
services.AddHostedService<FFmpegLocatorService>();
|
||||||
|
|||||||
@@ -107,3 +107,17 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
text-shadow: 1px 1px 5px #000;
|
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