add unavailable media state for plex media (#754)
* rework plex movie library scanner; add unavailable media item state * plex television scanner improvements * reset plex etags as needed * update changelog
This commit is contained in:
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
### Fixed
|
||||
- Cleanly stop Plex library scan when service termination is requested
|
||||
- Fix bug introduced with 0.5.2-beta that prevented some Plex content from being played
|
||||
- Fix spammy subtitle error message
|
||||
|
||||
### Changed
|
||||
- No longer remove Plex movies and episodes from ErsatzTV when they do not exist on disk
|
||||
- Instead, a new `unavailable` media state will be used to indicate this condition
|
||||
- After updating mounts, path replacements, etc - a library scan can be used to resolve this state
|
||||
|
||||
## [0.5.2-beta] - 2022-04-22
|
||||
### Fixed
|
||||
|
||||
@@ -61,7 +61,8 @@ internal static class Mapper
|
||||
EpisodeMetadata episodeMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby,
|
||||
bool isSearchResult) =>
|
||||
bool isSearchResult,
|
||||
string localPath) =>
|
||||
new(
|
||||
episodeMetadata.EpisodeId,
|
||||
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
|
||||
@@ -83,7 +84,8 @@ internal static class Mapper
|
||||
episodeMetadata.Directors.Map(d => d.Name).ToList(),
|
||||
episodeMetadata.Writers.Map(w => w.Name).ToList(),
|
||||
episodeMetadata.Episode.State,
|
||||
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
|
||||
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path,
|
||||
localPath);
|
||||
|
||||
internal static MovieCardViewModel ProjectToViewModel(
|
||||
MovieMetadata movieMetadata,
|
||||
@@ -97,7 +99,9 @@ internal static class Mapper
|
||||
GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
|
||||
movieMetadata.Movie.State);
|
||||
|
||||
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
|
||||
internal static MusicVideoCardViewModel ProjectToViewModel(
|
||||
MusicVideoMetadata musicVideoMetadata,
|
||||
string localPath) =>
|
||||
new(
|
||||
musicVideoMetadata.MusicVideoId,
|
||||
musicVideoMetadata.Title,
|
||||
@@ -107,7 +111,8 @@ internal static class Mapper
|
||||
musicVideoMetadata.Album,
|
||||
GetThumbnail(musicVideoMetadata, None, None),
|
||||
musicVideoMetadata.MusicVideo.State,
|
||||
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
|
||||
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path,
|
||||
localPath);
|
||||
|
||||
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
|
||||
new(
|
||||
@@ -143,7 +148,7 @@ internal static class Mapper
|
||||
Collection collection,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby) =>
|
||||
new(
|
||||
new CollectionCardResultsViewModel(
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
|
||||
@@ -155,11 +160,14 @@ internal static class Mapper
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
|
||||
.ToList(),
|
||||
// collection view doesn't use local paths
|
||||
collection.MediaItems.OfType<Episode>()
|
||||
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false))
|
||||
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false, string.Empty))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
|
||||
// collection view doesn't use local paths
|
||||
collection.MediaItems.OfType<MusicVideo>()
|
||||
.Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
|
||||
.ToList(),
|
||||
|
||||
@@ -12,7 +12,8 @@ public record MusicVideoCardViewModel
|
||||
string Album,
|
||||
string Poster,
|
||||
MediaItemState State,
|
||||
string Path) : MediaCardViewModel(
|
||||
string Path,
|
||||
string LocalPath) : MediaCardViewModel(
|
||||
MusicVideoId,
|
||||
Title,
|
||||
Subtitle,
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel>
|
||||
{
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
|
||||
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) =>
|
||||
public GetMusicVideoCardsHandler(
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
{
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
}
|
||||
|
||||
public async Task<MusicVideoCardResultsViewModel> Handle(
|
||||
GetMusicVideoCards request,
|
||||
@@ -16,9 +33,21 @@ public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, Mus
|
||||
{
|
||||
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId);
|
||||
|
||||
List<MusicVideoCardViewModel> results = await _musicVideoRepository
|
||||
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
List<MusicVideoMetadata> musicVideos = await _musicVideoRepository
|
||||
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize);
|
||||
|
||||
var results = new List<MusicVideoCardViewModel>();
|
||||
|
||||
foreach (MusicVideoMetadata musicVideoMetadata in musicVideos)
|
||||
{
|
||||
string localPath = await musicVideoMetadata.MusicVideo.GetLocalPath(
|
||||
_plexPathReplacementService,
|
||||
_jellyfinPathReplacementService,
|
||||
_embyPathReplacementService,
|
||||
false);
|
||||
|
||||
results.Add(ProjectToViewModel(musicVideoMetadata, localPath));
|
||||
}
|
||||
|
||||
return new MusicVideoCardResultsViewModel(count, results, None);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Search;
|
||||
using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
@@ -6,18 +10,26 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.MediaCards;
|
||||
|
||||
public class
|
||||
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards,
|
||||
TelevisionEpisodeCardResultsViewModel>
|
||||
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards, TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionEpisodeCardsHandler(
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
{
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
}
|
||||
|
||||
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
|
||||
@@ -32,9 +44,20 @@ public class
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
|
||||
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby, false)).ToList());
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository
|
||||
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize);
|
||||
|
||||
var results = new List<TelevisionEpisodeCardViewModel>();
|
||||
foreach (EpisodeMetadata episodeMetadata in episodes)
|
||||
{
|
||||
string localPath = await episodeMetadata.Episode.GetLocalPath(
|
||||
_plexPathReplacementService,
|
||||
_jellyfinPathReplacementService,
|
||||
_embyPathReplacementService,
|
||||
false);
|
||||
|
||||
results.Add(ProjectToViewModel(episodeMetadata, maybeJellyfin, maybeEmby, false, localPath));
|
||||
}
|
||||
|
||||
return new TelevisionEpisodeCardResultsViewModel(count, results, Option<SearchPageMap>.None);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ public record TelevisionEpisodeCardViewModel
|
||||
List<string> Directors,
|
||||
List<string> Writers,
|
||||
MediaItemState State,
|
||||
string Path) : MediaCardViewModel(
|
||||
string Path,
|
||||
string LocalPath) : MediaCardViewModel(
|
||||
EpisodeId,
|
||||
Title,
|
||||
$"Episode {Episode}",
|
||||
|
||||
@@ -11,6 +11,7 @@ internal static class Mapper
|
||||
{
|
||||
internal static MovieViewModel ProjectToViewModel(
|
||||
Movie movie,
|
||||
string localPath,
|
||||
List<string> languageCodes,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby)
|
||||
@@ -32,6 +33,7 @@ internal static class Mapper
|
||||
metadata.Directors.Map(d => d.Name).ToList(),
|
||||
metadata.Writers.Map(w => w.Name).ToList(),
|
||||
movie.GetHeadVersion().MediaFiles.Head().Path,
|
||||
localPath,
|
||||
movie.State)
|
||||
{
|
||||
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
|
||||
|
||||
@@ -17,6 +17,7 @@ public record MovieViewModel(
|
||||
List<string> Directors,
|
||||
List<string> Writers,
|
||||
string Path,
|
||||
string LocalPath,
|
||||
MediaItemState MediaItemState)
|
||||
{
|
||||
public string Poster { get; set; }
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
@@ -10,17 +14,26 @@ namespace ErsatzTV.Application.Movies;
|
||||
public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieViewModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
|
||||
public GetMovieByIdHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IMovieRepository movieRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
{
|
||||
_dbContextFactory = dbContextFactory;
|
||||
_movieRepository = movieRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
}
|
||||
|
||||
public async Task<Option<MovieViewModel>> Handle(
|
||||
@@ -35,9 +48,9 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
|
||||
Option<Movie> maybeMovie = await _movieRepository.GetMovie(request.Id);
|
||||
|
||||
Option<MediaVersion> maybeVersion = movie.Map(m => m.MediaVersions.HeadOrNone()).Flatten();
|
||||
Option<MediaVersion> maybeVersion = maybeMovie.Map(m => m.MediaVersions.HeadOrNone()).Flatten();
|
||||
var languageCodes = new List<string>();
|
||||
foreach (MediaVersion version in maybeVersion)
|
||||
{
|
||||
@@ -49,6 +62,16 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
|
||||
languageCodes.AddRange(await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes));
|
||||
}
|
||||
|
||||
return movie.Map(m => ProjectToViewModel(m, languageCodes, maybeJellyfin, maybeEmby));
|
||||
foreach (Movie movie in maybeMovie)
|
||||
{
|
||||
string localPath = await movie.GetLocalPath(
|
||||
_plexPathReplacementService,
|
||||
_jellyfinPathReplacementService,
|
||||
_embyPathReplacementService,
|
||||
false);
|
||||
return ProjectToViewModel(movie, localPath, languageCodes, maybeJellyfin, maybeEmby);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,19 +43,24 @@ public class
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceSynchronizePlexLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
SynchronizePlexLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
||||
|
||||
private Task<Either<BaseError, string>>
|
||||
Handle(ISynchronizePlexLibraryById request) =>
|
||||
Validate(request)
|
||||
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
private async Task<Either<BaseError, string>>
|
||||
HandleImpl(ISynchronizePlexLibraryById request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, RequestParameters> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
parameters => Synchronize(parameters, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
private async Task<Either<BaseError, string>> Synchronize(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -63,31 +68,37 @@ public class
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
Either<BaseError, Unit> result = parameters.Library.MediaKind switch
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
LibraryMediaKind.Movies =>
|
||||
await _plexMovieLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath,
|
||||
parameters.DeepScan);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Shows =>
|
||||
await _plexTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection,
|
||||
parameters.ConnectionParameters.PlexServerAuthToken,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath,
|
||||
parameters.DeepScan);
|
||||
break;
|
||||
}
|
||||
parameters.DeepScan,
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
if (result.IsRight)
|
||||
{
|
||||
parameters.Library.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
@@ -95,7 +106,7 @@ public class
|
||||
parameters.Library.Name);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return parameters.Library.Name;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -8,21 +12,29 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes,
|
||||
TelevisionEpisodeCardResultsViewModel>
|
||||
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexEpisodesHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
}
|
||||
|
||||
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
|
||||
@@ -40,9 +52,21 @@ public class
|
||||
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionEpisodeCardViewModel> items = await _televisionRepository
|
||||
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby, true)).ToList());
|
||||
List<EpisodeMetadata> episodes = await _televisionRepository
|
||||
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList());
|
||||
|
||||
var items = new List<TelevisionEpisodeCardViewModel>();
|
||||
|
||||
foreach (EpisodeMetadata episodeMetadata in episodes)
|
||||
{
|
||||
string localPath = await episodeMetadata.Episode.GetLocalPath(
|
||||
_plexPathReplacementService,
|
||||
_jellyfinPathReplacementService,
|
||||
_embyPathReplacementService,
|
||||
false);
|
||||
|
||||
items.Add(ProjectToViewModel(episodeMetadata, maybeJellyfin, maybeEmby, true, localPath));
|
||||
}
|
||||
|
||||
return new TelevisionEpisodeCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -7,16 +12,26 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.Search;
|
||||
|
||||
public class
|
||||
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel
|
||||
>
|
||||
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel>
|
||||
{
|
||||
private readonly IEmbyPathReplacementService _embyPathReplacementService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly IMusicVideoRepository _musicVideoRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMusicVideosHandler(ISearchIndex searchIndex, IMusicVideoRepository musicVideoRepository)
|
||||
public QuerySearchIndexMusicVideosHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IMusicVideoRepository musicVideoRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_musicVideoRepository = musicVideoRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
_embyPathReplacementService = embyPathReplacementService;
|
||||
}
|
||||
|
||||
public async Task<MusicVideoCardResultsViewModel> Handle(
|
||||
@@ -28,9 +43,21 @@ public class
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
List<MusicVideoCardViewModel> items = await _musicVideoRepository
|
||||
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
List<MusicVideoMetadata> musicVideos = await _musicVideoRepository
|
||||
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList());
|
||||
|
||||
var items = new List<MusicVideoCardViewModel>();
|
||||
|
||||
foreach (MusicVideoMetadata musicVideoMetadata in musicVideos)
|
||||
{
|
||||
string localPath = await musicVideoMetadata.MusicVideo.GetLocalPath(
|
||||
_plexPathReplacementService,
|
||||
_jellyfinPathReplacementService,
|
||||
_embyPathReplacementService,
|
||||
false);
|
||||
|
||||
items.Add(ProjectToViewModel(musicVideoMetadata, localPath));
|
||||
}
|
||||
|
||||
return new MusicVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
public enum MediaItemState
|
||||
{
|
||||
Normal = 0,
|
||||
FileNotFound = 1
|
||||
FileNotFound = 1,
|
||||
Unavailable = 2
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GetReplacementEmbyPath(int libraryPathId, string path)
|
||||
public async Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true)
|
||||
{
|
||||
List<EmbyPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetEmbyPathReplacementsByLibraryId(libraryPathId);
|
||||
|
||||
return GetReplacementEmbyPath(replacements, path);
|
||||
return GetReplacementEmbyPath(replacements, path, log);
|
||||
}
|
||||
|
||||
public string GetReplacementEmbyPath(
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Errors;
|
||||
|
||||
public class ScanCanceled : BaseError
|
||||
{
|
||||
public ScanCanceled() : base("Scan was canceled")
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Extensions;
|
||||
|
||||
@@ -14,4 +17,45 @@ public static class MediaItemExtensions
|
||||
Song s => s.MediaVersions.Head(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
public static async Task<string> GetLocalPath(
|
||||
this MediaItem mediaItem,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService,
|
||||
IEmbyPathReplacementService embyPathReplacementService,
|
||||
bool log = true)
|
||||
{
|
||||
MediaVersion version = mediaItem.GetHeadVersion();
|
||||
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
string path = file.Path;
|
||||
return mediaItem switch
|
||||
{
|
||||
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexMovie.LibraryPathId,
|
||||
path,
|
||||
log),
|
||||
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexEpisode.LibraryPathId,
|
||||
path,
|
||||
log),
|
||||
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinMovie.LibraryPathId,
|
||||
path,
|
||||
log),
|
||||
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinEpisode.LibraryPathId,
|
||||
path,
|
||||
log),
|
||||
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyMovie.LibraryPathId,
|
||||
path,
|
||||
log),
|
||||
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
|
||||
embyEpisode.LibraryPathId,
|
||||
path,
|
||||
log),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Emby;
|
||||
|
||||
public interface IEmbyPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementEmbyPath(int libraryPathId, string path);
|
||||
Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true);
|
||||
string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
|
||||
public interface IJellyfinPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path);
|
||||
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true);
|
||||
string GetReplacementJellyfinPath(List<JellyfinPathReplacement> pathReplacements, string path, bool log = true);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ public interface IPlexMovieLibraryScanner
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan);
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Plex;
|
||||
|
||||
public interface IPlexPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementPlexPath(int libraryPathId, string path);
|
||||
Task<string> GetReplacementPlexPath(int libraryPathId, string path, bool log = true);
|
||||
string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true);
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@ public interface IPlexTelevisionLibraryScanner
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan);
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMediaItemRepository
|
||||
{
|
||||
Task<List<string>> GetAllLanguageCodes();
|
||||
Task<List<CultureInfo>> GetAllKnownCultures();
|
||||
Task<List<CultureInfo>> GetAllLanguageCodeCultures();
|
||||
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
|
||||
Task<Unit> FlagNormal(MediaItem mediaItem);
|
||||
|
||||
@@ -20,7 +20,6 @@ public interface IMovieRepository
|
||||
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
|
||||
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
|
||||
Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library);
|
||||
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
||||
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
|
||||
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
|
||||
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IPlexMovieRepository
|
||||
{
|
||||
Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie);
|
||||
Task<bool> FlagUnavailable(PlexLibrary library, PlexMovie movie);
|
||||
Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IPlexTelevisionRepository
|
||||
{
|
||||
Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library);
|
||||
Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show);
|
||||
Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season);
|
||||
Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode);
|
||||
Task<bool> FlagUnavailable(PlexLibrary library, PlexEpisode episode);
|
||||
Task<List<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys);
|
||||
Task<List<int>> FlagFileNotFoundSeasons(PlexLibrary library, List<string> plexSeasonKeys);
|
||||
Task<List<int>> FlagFileNotFoundEpisodes(PlexLibrary library, List<string> plexEpisodeKeys);
|
||||
Task<Unit> SetPlexEtag(PlexShow show, string etag);
|
||||
Task<Unit> SetPlexEtag(PlexSeason season, string etag);
|
||||
Task<Unit> SetPlexEtag(PlexEpisode episode, string etag);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -46,15 +45,8 @@ public interface ITelevisionRepository
|
||||
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
|
||||
Task<bool> AddActor(ShowMetadata metadata, Actor actor);
|
||||
Task<bool> AddActor(EpisodeMetadata metadata, Actor actor);
|
||||
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
|
||||
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
|
||||
Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
|
||||
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
|
||||
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
Task<Unit> SetPlexEtag(PlexShow show, string etag);
|
||||
Task<Unit> SetPlexEtag(PlexSeason season, string etag);
|
||||
Task<Unit> SetPlexEtag(PlexEpisode episode, string etag);
|
||||
Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GetReplacementJellyfinPath(int libraryPathId, string path)
|
||||
public async Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true)
|
||||
{
|
||||
List<JellyfinPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId);
|
||||
|
||||
@@ -36,7 +36,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
|
||||
await _slim.WaitAsync();
|
||||
try
|
||||
{
|
||||
_languageCodes.AddRange(await _mediaItemRepository.GetAllLanguageCodeCultures());
|
||||
_languageCodes.AddRange(await _mediaItemRepository.GetAllKnownCultures());
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace ErsatzTV.Core.Plex;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Plex;
|
||||
|
||||
public class PlexItemEtag
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public MediaItemState State { get; set; }
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ public abstract class PlexLibraryScanner
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Unit> UpdateArtworkIfNeeded(
|
||||
protected async Task<bool> UpdateArtworkIfNeeded(
|
||||
Domain.Metadata existingMetadata,
|
||||
Domain.Metadata incomingMetadata,
|
||||
ArtworkKind artworkKind)
|
||||
@@ -53,8 +53,10 @@ public abstract class PlexLibraryScanner
|
||||
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
|
||||
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -19,6 +20,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IPlexMovieRepository _plexMovieRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
@@ -32,6 +34,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
ISearchRepository searchRepository,
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexMovieRepository plexMovieRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
@@ -46,6 +49,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
_searchRepository = searchRepository;
|
||||
_mediator = mediator;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexMovieRepository = plexMovieRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
@@ -59,60 +63,72 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan)
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
|
||||
library,
|
||||
connection,
|
||||
token);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
connection,
|
||||
token,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
deepScan,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// always commit the search index to prevent corruption
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
List<PlexMovie> movieEntries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexItemEtag> existingMovies = await _movieRepository.GetExistingPlexMovies(library);
|
||||
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
|
||||
library,
|
||||
connection,
|
||||
token);
|
||||
|
||||
await entries.Match(
|
||||
async movieEntries =>
|
||||
foreach (PlexMovie incoming in movieEntries)
|
||||
{
|
||||
var validMovies = new List<PlexMovie>();
|
||||
foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title))
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
movie.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
validMovies.Add(movie);
|
||||
}
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
foreach (PlexMovie incoming in validMovies)
|
||||
{
|
||||
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
// deep scan will pull every movie from the plex api
|
||||
if (!deepScan)
|
||||
if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false)
|
||||
{
|
||||
Option<PlexItemEtag> maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key);
|
||||
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
incoming.Etag)
|
||||
{
|
||||
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
@@ -122,52 +138,112 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
await maybeMovie.Match(
|
||||
async result =>
|
||||
if (maybeMovie.IsLeft)
|
||||
{
|
||||
foreach (BaseError error in maybeMovie.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex movie at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<PlexMovie> result in maybeMovie.RightToSeq())
|
||||
{
|
||||
await _movieRepository.SetPlexEtag(result.Item, incoming.Etag);
|
||||
|
||||
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
plexPath,
|
||||
false);
|
||||
|
||||
if (_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
await _plexMovieRepository.FlagNormal(library, result.Item);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _plexMovieRepository.FlagUnavailable(library, result.Item);
|
||||
}
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { result.Item });
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex movie at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
var movieKeys = validMovies.Map(s => s.Key).ToList();
|
||||
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
// trash items that are no longer present on the media server
|
||||
var fileNotFoundKeys = existingMovies.Map(m => m.Key).Except(movieEntries.Map(m => m.Key)).ToList();
|
||||
List<int> ids = await _plexMovieRepository.FlagFileNotFound(library, fileNotFoundKeys);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing plex library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldScanItem(
|
||||
PlexLibrary library,
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
List<PlexItemEtag> existingMovies,
|
||||
PlexMovie incoming,
|
||||
bool deepScan)
|
||||
{
|
||||
// deep scan will pull every movie individually from the plex api
|
||||
if (!deepScan)
|
||||
{
|
||||
Option<PlexItemEtag> maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key);
|
||||
string existingEtag = await maybeExisting
|
||||
.Map(e => e.Etag ?? string.Empty)
|
||||
.IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting
|
||||
.Map(e => e.State)
|
||||
.IfNoneAsync(MediaItemState.Normal);
|
||||
|
||||
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
plexPath,
|
||||
false);
|
||||
|
||||
// if media is unavailable, only scan if file now exists
|
||||
if (existingState == MediaItemState.Unavailable)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (existingEtag == incoming.Etag)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
await _plexMovieRepository.FlagUnavailable(library, incoming);
|
||||
}
|
||||
|
||||
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
@@ -179,7 +255,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (existing.Etag != incoming.Etag)
|
||||
if (result.IsAdded || existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0)
|
||||
{
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
@@ -204,6 +280,9 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
// only refresh statistics if the file exists
|
||||
if (_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
|
||||
@@ -231,6 +310,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -480,9 +560,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
if (poster || fanArt)
|
||||
{
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ public class PlexPathReplacementService : IPlexPathReplacementService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
|
||||
public async Task<string> GetReplacementPlexPath(int libraryPathId, string path, bool log = true)
|
||||
{
|
||||
List<PlexPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
|
||||
|
||||
return GetReplacementPlexPath(replacements, path);
|
||||
return GetReplacementPlexPath(replacements, path, log);
|
||||
}
|
||||
|
||||
public string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -21,6 +22,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly IPlexTelevisionRepository _plexTelevisionRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
@@ -34,6 +36,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IPlexTelevisionRepository plexTelevisionRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
@@ -48,6 +51,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
_mediator = mediator;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_plexTelevisionRepository = plexTelevisionRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
@@ -60,23 +64,66 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan)
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
|
||||
library,
|
||||
connection,
|
||||
token);
|
||||
|
||||
return await entries.Match<Task<Either<BaseError, Unit>>>(
|
||||
async showEntries =>
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
connection,
|
||||
token,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
deepScan,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// always commit the search index to prevent corruption
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
List<PlexShow> showEntries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexItemEtag> existingShows = await _plexTelevisionRepository.GetExistingPlexShows(library);
|
||||
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
|
||||
foreach (PlexShow incoming in showEntries)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)showEntries.IndexOf(incoming) / showEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
|
||||
@@ -84,10 +131,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token, deepScan))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
await maybeShow.Match(
|
||||
async result =>
|
||||
if (maybeShow.IsLeft)
|
||||
{
|
||||
await ScanSeasons(
|
||||
foreach (BaseError error in maybeShow.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex show at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<PlexShow> result in maybeShow.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ScanSeasons(
|
||||
library,
|
||||
pathReplacements,
|
||||
result.Item,
|
||||
@@ -95,9 +154,17 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
deepScan);
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag);
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
|
||||
|
||||
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -109,36 +176,17 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
_searchRepository,
|
||||
new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex show at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var showKeys = showEntries.Map(s => s.Key).ToList();
|
||||
List<int> ids =
|
||||
await _televisionRepository.RemoveMissingPlexShows(library, showKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
// trash items that are no longer present on the media server
|
||||
var fileNotFoundKeys = existingShows.Map(m => m.Key).Except(showEntries.Map(m => m.Key)).ToList();
|
||||
List<int> ids = await _plexTelevisionRepository.FlagFileNotFoundShows(library, fileNotFoundKeys);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing plex library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Left<BaseError, Unit>(error).AsTask();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
|
||||
@@ -152,7 +200,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexShow existing = result.Item;
|
||||
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
|
||||
|
||||
if (existing.Etag != incoming.Etag || deepScan)
|
||||
if (result.IsAdded || existing.Etag != incoming.Etag || deepScan)
|
||||
{
|
||||
Either<BaseError, ShowMetadata> maybeMetadata =
|
||||
await _plexServerApiClient.GetShowMetadata(
|
||||
@@ -308,10 +356,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
|
||||
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
|
||||
|
||||
if (existing.Etag != incoming.Etag)
|
||||
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
if (poster || fanArt)
|
||||
{
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
@@ -326,17 +374,23 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan)
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexItemEtag> existingSeasons = await _plexTelevisionRepository.GetExistingPlexSeasons(library, show);
|
||||
|
||||
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
|
||||
library,
|
||||
show,
|
||||
connection,
|
||||
token);
|
||||
|
||||
return await entries.Match<Task<Either<BaseError, Unit>>>(
|
||||
async seasonEntries =>
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
var seasonEntries = entries.RightToSeq().Flatten().ToList();
|
||||
foreach (PlexSeason incoming in seasonEntries)
|
||||
{
|
||||
incoming.ShowId = show.Id;
|
||||
@@ -344,12 +398,21 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
|
||||
.GetOrAddPlexSeason(library, incoming)
|
||||
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
|
||||
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming, deepScan));
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season =>
|
||||
foreach (BaseError error in maybeSeason.LeftToSeq())
|
||||
{
|
||||
await ScanEpisodes(
|
||||
_logger.LogWarning(
|
||||
"Error processing plex season at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (PlexSeason season in maybeSeason.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ScanEpisodes(
|
||||
library,
|
||||
pathReplacements,
|
||||
season,
|
||||
@@ -357,48 +420,40 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
token,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
deepScan);
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
|
||||
await _televisionRepository.SetPlexEtag(season, incoming.Etag);
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await _plexTelevisionRepository.SetPlexEtag(season, incoming.Etag);
|
||||
|
||||
season.Show = show;
|
||||
|
||||
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
|
||||
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex show at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var seasonKeys = seasonEntries.Map(s => s.Key).ToList();
|
||||
await _televisionRepository.RemoveMissingPlexSeasons(show.Key, seasonKeys);
|
||||
var fileNotFoundKeys = existingSeasons.Map(m => m.Key).Except(seasonEntries.Map(m => m.Key)).ToList();
|
||||
List<int> ids = await _plexTelevisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundKeys);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
return Unit.Default;
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing plex library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Left<BaseError, Unit>(error).AsTask();
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, PlexSeason>> UpdateMetadataAndArtwork(
|
||||
PlexSeason existing,
|
||||
PlexSeason incoming)
|
||||
PlexSeason incoming,
|
||||
bool deepScan)
|
||||
{
|
||||
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
|
||||
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
|
||||
|
||||
if (existing.Etag != incoming.Etag)
|
||||
if (existing.Etag != incoming.Etag || deepScan)
|
||||
{
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
@@ -447,9 +502,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
PlexServerAuthToken token,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan)
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexItemEtag> existingEpisodes = await _televisionRepository.GetExistingPlexEpisodes(library, season);
|
||||
List<PlexItemEtag> existingEpisodes = await _plexTelevisionRepository.GetExistingPlexEpisodes(library, season);
|
||||
|
||||
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
|
||||
library,
|
||||
@@ -457,46 +513,24 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
connection,
|
||||
token);
|
||||
|
||||
return await entries.Match<Task<Either<BaseError, Unit>>>(
|
||||
async episodeEntries =>
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
{
|
||||
var validEpisodes = new List<PlexEpisode>();
|
||||
foreach (PlexEpisode episode in episodeEntries)
|
||||
{
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
episode.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping plex episode that does not exist at {Path}",
|
||||
localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
validEpisodes.Add(episode);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (PlexEpisode incoming in validEpisodes)
|
||||
var episodeEntries = entries.RightToSeq().Flatten().ToList();
|
||||
foreach (PlexEpisode incoming in episodeEntries)
|
||||
{
|
||||
if (!deepScan)
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Option<PlexItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.Key == incoming.Key);
|
||||
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
incoming.Etag)
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
if (await ShouldScanItem(library, pathReplacements, existingEpisodes, incoming, deepScan) == false)
|
||||
{
|
||||
// _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key);
|
||||
continue;
|
||||
}
|
||||
|
||||
// _logger.LogDebug(
|
||||
// "UPDATE: Etag has changed for episode {Episode}",
|
||||
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
|
||||
}
|
||||
|
||||
incoming.SeasonId = season.Id;
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
@@ -517,10 +551,40 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
await maybeEpisode.Match(
|
||||
async result =>
|
||||
foreach (BaseError error in maybeEpisode.LeftToSeq())
|
||||
{
|
||||
await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag);
|
||||
switch (error)
|
||||
{
|
||||
case ScanCanceled:
|
||||
return error;
|
||||
default:
|
||||
_logger.LogWarning(
|
||||
"Error processing plex episode at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<PlexEpisode> result in maybeEpisode.RightToSeq())
|
||||
{
|
||||
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
|
||||
|
||||
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
plexPath,
|
||||
false);
|
||||
|
||||
if (_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
await _plexTelevisionRepository.FlagNormal(library, result.Item);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _plexTelevisionRepository.FlagUnavailable(library, result.Item);
|
||||
}
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
@@ -530,33 +594,68 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex episode at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var episodeKeys = validEpisodes.Map(s => s.Key).ToList();
|
||||
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
var fileNotFoundKeys = existingEpisodes.Map(m => m.Key).Except(episodeEntries.Map(m => m.Key)).ToList();
|
||||
List<int> ids = await _plexTelevisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundKeys);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
return Unit.Default;
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing plex library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
return Left<BaseError, Unit>(error).AsTask();
|
||||
});
|
||||
private async Task<bool> ShouldScanItem(
|
||||
PlexLibrary library,
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
List<PlexItemEtag> existingEpisodes,
|
||||
PlexEpisode incoming,
|
||||
bool deepScan)
|
||||
{
|
||||
// deep scan will pull every episode individually from the plex api
|
||||
if (!deepScan)
|
||||
{
|
||||
Option<PlexItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.Key == incoming.Key);
|
||||
string existingTag = await maybeExisting
|
||||
.Map(e => e.Etag ?? string.Empty)
|
||||
.IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting
|
||||
.Map(e => e.State)
|
||||
.IfNoneAsync(MediaItemState.Normal);
|
||||
|
||||
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
plexPath,
|
||||
false);
|
||||
|
||||
// if media is unavailable, only scan if file now exists
|
||||
if (existingState == MediaItemState.Unavailable)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (existingTag == incoming.Etag)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
await _plexTelevisionRepository.FlagUnavailable(library, incoming);
|
||||
}
|
||||
|
||||
// _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key);
|
||||
return false;
|
||||
}
|
||||
|
||||
// _logger.LogDebug(
|
||||
// "UPDATE: Etag has changed for episode {Episode}",
|
||||
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateMetadata(
|
||||
@@ -607,7 +706,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (existing.Etag != incoming.Etag || deepScan)
|
||||
if (result.IsAdded || existing.Etag != incoming.Etag || deepScan || existingVersion.Streams.Count == 0)
|
||||
{
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
@@ -634,7 +733,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (existing.Etag != incoming.Etag)
|
||||
if ((existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) &&
|
||||
_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
refreshResult = await _localStatisticsProvider.RefreshStatistics(
|
||||
|
||||
@@ -14,18 +14,28 @@ public class MediaItemRepository : IMediaItemRepository
|
||||
|
||||
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<string>> GetAllLanguageCodes()
|
||||
public async Task<List<CultureInfo>> GetAllKnownCultures()
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<string>(
|
||||
@"SELECT LanguageCode FROM
|
||||
(SELECT Language AS LanguageCode
|
||||
FROM MediaStream WHERE Language IS NOT NULL
|
||||
UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode
|
||||
FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL)
|
||||
GROUP BY LanguageCode
|
||||
ORDER BY COUNT(LanguageCode) DESC")
|
||||
.Map(result => result.ToList());
|
||||
|
||||
var result = new System.Collections.Generic.HashSet<CultureInfo>();
|
||||
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
foreach (LanguageCode code in await dbContext.LanguageCodes.ToListAsync())
|
||||
{
|
||||
Option<CultureInfo> maybeCulture = allCultures.Find(
|
||||
c => string.Equals(code.ThreeCode1, c.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(
|
||||
code.ThreeCode2,
|
||||
c.ThreeLetterISOLanguageName,
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
foreach (CultureInfo culture in maybeCulture)
|
||||
{
|
||||
result.Add(culture);
|
||||
}
|
||||
}
|
||||
|
||||
return result.ToList();
|
||||
}
|
||||
|
||||
public async Task<List<CultureInfo>> GetAllLanguageCodeCultures()
|
||||
@@ -96,4 +106,18 @@ public class MediaItemRepository : IMediaItemRepository
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<List<string>> GetAllLanguageCodes()
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<string>(
|
||||
@"SELECT LanguageCode FROM
|
||||
(SELECT Language AS LanguageCode
|
||||
FROM MediaStream WHERE Language IS NOT NULL
|
||||
UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode
|
||||
FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL)
|
||||
GROUP BY LanguageCode
|
||||
ORDER BY COUNT(LanguageCode) DESC")
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ public class MovieRepository : IMovieRepository
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT Key, Etag FROM PlexMovie
|
||||
@"SELECT Key, Etag, MI.State FROM PlexMovie
|
||||
INNER JOIN Movie M on PlexMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
@@ -254,24 +254,6 @@ public class MovieRepository : IMovieRepository
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.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 dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSortTitle(MovieMetadata movieMetadata)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
|
||||
public class PlexMovieRepository : IPlexMovieRepository
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public PlexMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
movie.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT PlexMovie.Id FROM PlexMovie
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE PlexMovie.Key = @Key)",
|
||||
new { LibraryId = library.Id, movie.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagUnavailable(PlexLibrary library, PlexMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
movie.State = MediaItemState.Unavailable;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 2 WHERE Id IN
|
||||
(SELECT PlexMovie.Id FROM PlexMovie
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE PlexMovie.Key = @Key)",
|
||||
new { LibraryId = library.Id, movie.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys)
|
||||
{
|
||||
if (plexMovieKeys.Count == 0)
|
||||
{
|
||||
return new List<int>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM MediaItem M
|
||||
INNER JOIN PlexMovie ON PlexMovie.Id = M.Id
|
||||
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
WHERE PlexMovie.Key IN @MovieKeys",
|
||||
new { LibraryId = library.Id, MovieKeys = plexMovieKeys })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
|
||||
public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public PlexTelevisionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PS.Key, PS.Etag, MI.State FROM PlexShow PS
|
||||
INNER JOIN MediaItem MI on PS.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexSeason.Key, PlexSeason.Etag, MI.State FROM PlexSeason
|
||||
INNER JOIN Season S on PlexSeason.Id = S.Id
|
||||
INNER JOIN MediaItem MI on S.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
INNER JOIN PlexShow PS ON S.ShowId = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, show.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexEpisode.Key, PlexEpisode.Etag, MI.State FROM PlexEpisode
|
||||
INNER JOIN Episode E on PlexEpisode.Id = E.Id
|
||||
INNER JOIN MediaItem MI on E.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
INNER JOIN Season S2 on E.SeasonId = S2.Id
|
||||
INNER JOIN PlexSeason PS on S2.Id = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, season.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
episode.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT PlexEpisode.Id FROM PlexEpisode
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE PlexEpisode.Key = @Key)",
|
||||
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<bool> FlagUnavailable(PlexLibrary library, PlexEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
episode.State = MediaItemState.Unavailable;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 2 WHERE Id IN
|
||||
(SELECT PlexEpisode.Id FROM PlexEpisode
|
||||
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE PlexEpisode.Key = @Key)",
|
||||
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys)
|
||||
{
|
||||
if (plexShowKeys.Count == 0)
|
||||
{
|
||||
return new List<int>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM MediaItem M
|
||||
INNER JOIN PlexShow ON PlexShow.Id = M.Id
|
||||
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
WHERE PlexShow.Key IN @ShowKeys",
|
||||
new { LibraryId = library.Id, ShowKeys = plexShowKeys })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundSeasons(PlexLibrary library, List<string> plexSeasonKeys)
|
||||
{
|
||||
if (plexSeasonKeys.Count == 0)
|
||||
{
|
||||
return new List<int>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM MediaItem M
|
||||
INNER JOIN PlexSeason ON PlexSeason.Id = M.Id
|
||||
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
WHERE PlexSeason.Key IN @SeasonKeys",
|
||||
new { LibraryId = library.Id, SeasonKeys = plexSeasonKeys })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundEpisodes(PlexLibrary library, List<string> plexEpisodeKeys)
|
||||
{
|
||||
if (plexEpisodeKeys.Count == 0)
|
||||
{
|
||||
return new List<int>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM MediaItem M
|
||||
INNER JOIN PlexEpisode ON PlexEpisode.Id = M.Id
|
||||
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
WHERE PlexEpisode.Key IN @EpisodeKeys",
|
||||
new { LibraryId = library.Id, EpisodeKeys = plexEpisodeKeys })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexShow show, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexSeason season, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexEpisode episode, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
@@ -537,38 +536,6 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
async () => await AddPlexEpisode(dbContext, library, item));
|
||||
}
|
||||
|
||||
public async Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN Season s ON m.Id = s.Id
|
||||
INNER JOIN PlexSeason ps ON ps.Id = m.Id
|
||||
INNER JOIN PlexShow P on P.Id = s.ShowId
|
||||
WHERE P.Key = @ShowKey AND ps.Key not in @Keys)",
|
||||
new { ShowKey = showKey, Keys = seasonKeys }).ToUnit();
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN Episode e ON m.Id = e.Id
|
||||
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
|
||||
INNER JOIN PlexSeason P on P.Id = e.SeasonId
|
||||
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys",
|
||||
new { SeasonKey = seasonKey, Keys = episodeKeys }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -603,45 +570,6 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexShow show, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexSeason season, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexEpisode episode, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexEpisode.Key, PlexEpisode.Etag FROM PlexEpisode
|
||||
INNER JOIN Episode E on PlexEpisode.Id = E.Id
|
||||
INNER JOIN MediaItem MI on E.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
INNER JOIN Season S2 on E.SeasonId = S2.Id
|
||||
INNER JOIN PlexSeason PS on S2.Id = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, season.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<Episode>> GetShowItems(int showId)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -777,24 +705,6 @@ public class TelevisionRepository : ITelevisionRepository
|
||||
.Map(result => result > 0);
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.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 dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, Season>> AddSeason(
|
||||
TvContext dbContext,
|
||||
Show show,
|
||||
|
||||
Generated
+4140
File diff suppressed because it is too large
Load Diff
+52
@@ -0,0 +1,52 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Migrations
|
||||
{
|
||||
public partial class Reset_EtagWithMissingPlexStatistics : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// clear etag on plex movies where no streams are present (caused by bug in recent version)
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE PlexMovie SET Etag = NULL WHERE PlexMovie.Id IN
|
||||
(SELECT PlexMovie.Id FROM PlexMovie
|
||||
INNER JOIN Movie M on M.Id = PlexMovie.Id
|
||||
INNER JOIN MediaVersion MV on M.Id = MV.MovieId
|
||||
LEFT JOIN MediaStream MS on MV.Id = MS.MediaVersionId
|
||||
WHERE MS.Id IS NULL)");
|
||||
|
||||
// clear etag on plex episodes where no streams are present (caused by bug in recent version)
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE PlexEpisode SET Etag = NULL WHERE PlexEpisode.Id IN
|
||||
(SELECT PlexEpisode.Id FROM PlexEpisode
|
||||
INNER JOIN Episode E on E.Id = PlexEpisode.Id
|
||||
INNER JOIN MediaVersion MV on E.Id = MV.EpisodeId
|
||||
LEFT JOIN MediaStream MS on MV.Id = MS.MediaVersionId
|
||||
WHERE MS.Id IS NULL)");
|
||||
|
||||
// force scanning libraries with NULL etag movies
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Library.Id IN
|
||||
(SELECT DISTINCT Library.Id FROM Library
|
||||
INNER JOIN LibraryPath LP on Library.Id = LP.LibraryId
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN PlexMovie PM ON MI.Id = PM.Id
|
||||
WHERE PM.Etag IS NULL)");
|
||||
|
||||
// force scanning libraries with NULL etag episodes
|
||||
migrationBuilder.Sql(
|
||||
@"UPDATE Library SET LastScan = '0001-01-01 00:00:00' WHERE Library.Id IN
|
||||
(SELECT DISTINCT Library.Id FROM Library
|
||||
INNER JOIN LibraryPath LP on Library.Id = LP.LibraryId
|
||||
INNER JOIN MediaItem MI on LP.Id = MI.LibraryPathId
|
||||
INNER JOIN PlexEpisode PE ON MI.Id = PE.Id
|
||||
WHERE PE.Etag IS NULL)");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,12 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
else if (musicVideo.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div style="position: absolute; right: 10px; top: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
else
|
||||
@@ -144,6 +150,12 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
else if (musicVideo.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div style="position: absolute; right: 10px; top: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<MudCardContent Class="ml-3">
|
||||
@@ -176,6 +188,14 @@
|
||||
<MudText>@musicVideo.Path</MudText>
|
||||
</div>
|
||||
}
|
||||
else if (musicVideo.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
|
||||
<MudText>Unavailable: </MudText>
|
||||
<MudText>@musicVideo.LocalPath</MudText>
|
||||
</div>
|
||||
}
|
||||
</MudCard>
|
||||
}
|
||||
</MudContainer>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
else if (_movie.MediaItemState == MediaItemState.Unavailable)
|
||||
{
|
||||
<div style="position: absolute; right: 10px; top: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div style="display: flex; flex-direction: column; height: 100%">
|
||||
@@ -81,6 +87,18 @@
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
else if (_movie.MediaItemState == MediaItemState.Unavailable)
|
||||
{
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
|
||||
<MudText>Unavailable: </MudText>
|
||||
<MudText>@_movie.LocalPath</MudText>
|
||||
</div>
|
||||
</MudCardContent>
|
||||
</MudCard>
|
||||
}
|
||||
<MudCard Class="mb-6">
|
||||
<MudCardContent>
|
||||
@if (_sortedContentRatings.Any())
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
else if (episode.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div style="position: absolute; right: 10px; top: 8px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
|
||||
</div>
|
||||
}
|
||||
</MudPaper>
|
||||
}
|
||||
<MudCardContent Class="ml-3">
|
||||
@@ -115,6 +121,14 @@
|
||||
<MudText>@episode.Path</MudText>
|
||||
</div>
|
||||
}
|
||||
else if (episode.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
|
||||
<MudText>Unavailable: </MudText>
|
||||
<MudText>@episode.LocalPath</MudText>
|
||||
</div>
|
||||
}
|
||||
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
|
||||
<MudText GutterBottom="true">Released: @episode.Aired.ToShortDateString()</MudText>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error"/>
|
||||
</div>
|
||||
}
|
||||
else if (Data.State == MediaItemState.Unavailable)
|
||||
{
|
||||
<div style="position: absolute; right: 12px; top: 12px;">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning"/>
|
||||
</div>
|
||||
}
|
||||
<div class="media-card-overlay" style="">
|
||||
<MudButton Link="@(IsSelectMode ? null : Link)"
|
||||
Style="height: 100%; width: 100%"
|
||||
|
||||
@@ -372,6 +372,8 @@ public class Startup
|
||||
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
|
||||
services.AddScoped<IPlexTelevisionLibraryScanner, PlexTelevisionLibraryScanner>();
|
||||
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
|
||||
services.AddScoped<IPlexMovieRepository, PlexMovieRepository>();
|
||||
services.AddScoped<IPlexTelevisionRepository, PlexTelevisionRepository>();
|
||||
services.AddScoped<IJellyfinMovieLibraryScanner, JellyfinMovieLibraryScanner>();
|
||||
services.AddScoped<IJellyfinTelevisionLibraryScanner, JellyfinTelevisionLibraryScanner>();
|
||||
services.AddScoped<IJellyfinCollectionScanner, JellyfinCollectionScanner>();
|
||||
|
||||
Reference in New Issue
Block a user