Browse Source

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
pull/756/head
Jason Dove 4 years ago committed by GitHub
parent
commit
89a2ac9455
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      CHANGELOG.md
  2. 22
      ErsatzTV.Application/MediaCards/Mapper.cs
  3. 3
      ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs
  4. 39
      ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs
  5. 35
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs
  6. 3
      ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs
  7. 2
      ErsatzTV.Application/Movies/Mapper.cs
  8. 1
      ErsatzTV.Application/Movies/MovieViewModel.cs
  9. 31
      ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs
  10. 47
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  11. 36
      ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs
  12. 39
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs
  13. 3
      ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs
  14. 4
      ErsatzTV.Core/Emby/EmbyPathReplacementService.cs
  15. 8
      ErsatzTV.Core/Errors/ScanCanceled.cs
  16. 44
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  17. 2
      ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs
  18. 2
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs
  19. 3
      ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs
  20. 2
      ErsatzTV.Core/Interfaces/Plex/IPlexPathReplacementService.cs
  21. 3
      ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs
  22. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs
  23. 1
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  24. 10
      ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs
  25. 19
      ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs
  26. 8
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  27. 2
      ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs
  28. 2
      ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs
  29. 5
      ErsatzTV.Core/Plex/PlexItemEtag.cs
  30. 6
      ErsatzTV.Core/Plex/PlexLibraryScanner.cs
  31. 309
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  32. 4
      ErsatzTV.Core/Plex/PlexPathReplacementService.cs
  33. 518
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  34. 44
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  35. 20
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  36. 68
      ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs
  37. 184
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  38. 90
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  39. 4140
      ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.Designer.cs
  40. 52
      ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.cs
  41. 20
      ErsatzTV/Pages/Artist.razor
  42. 18
      ErsatzTV/Pages/Movie.razor
  43. 14
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  44. 6
      ErsatzTV/Shared/MediaCard.razor
  45. 2
      ErsatzTV/Startup.cs

9
CHANGELOG.md

@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. @@ -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

22
ErsatzTV.Application/MediaCards/Mapper.cs

@ -61,7 +61,8 @@ internal static class Mapper @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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(),

3
ErsatzTV.Application/MediaCards/MusicVideoCardViewModel.cs

@ -12,7 +12,8 @@ public record MusicVideoCardViewModel @@ -12,7 +12,8 @@ public record MusicVideoCardViewModel
string Album,
string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
string Path,
string LocalPath) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,

39
ErsatzTV.Application/MediaCards/Queries/GetMusicVideoCardsHandler.cs

@ -1,14 +1,31 @@ @@ -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 @@ -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);
}

35
ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs

@ -1,4 +1,8 @@ @@ -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; @@ -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 @@ -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);
}

3
ErsatzTV.Application/MediaCards/TelevisionEpisodeCardViewModel.cs

@ -18,7 +18,8 @@ public record TelevisionEpisodeCardViewModel @@ -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}",

2
ErsatzTV.Application/Movies/Mapper.cs

@ -11,6 +11,7 @@ internal static class Mapper @@ -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 @@ -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),

1
ErsatzTV.Application/Movies/MovieViewModel.cs

@ -17,6 +17,7 @@ public record MovieViewModel( @@ -17,6 +17,7 @@ public record MovieViewModel(
List<string> Directors,
List<string> Writers,
string Path,
string LocalPath,
MediaItemState MediaItemState)
{
public string Poster { get; set; }

31
ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs

@ -1,4 +1,8 @@ @@ -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; @@ -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 @@ -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 @@ -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;
}
}

47
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs

@ -43,19 +43,24 @@ public class @@ -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,30 +68,36 @@ public class @@ -63,30 +68,36 @@ 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);
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
return result.Map(_ => parameters.Library.Name);
}
else
{
@ -95,7 +106,7 @@ public class @@ -95,7 +106,7 @@ public class
parameters.Library.Name);
}
return Unit.Default;
return parameters.Library.Name;
}
finally
{

36
ErsatzTV.Application/Search/Queries/QuerySearchIndexEpisodesHandler.cs

@ -1,5 +1,9 @@ @@ -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; @@ -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 @@ -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);
}

39
ErsatzTV.Application/Search/Queries/QuerySearchIndexMusicVideosHandler.cs

@ -1,4 +1,9 @@ @@ -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; @@ -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 @@ -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
ErsatzTV.Core/Domain/MediaItem/MediaItemState.cs

@ -3,5 +3,6 @@ @@ -3,5 +3,6 @@
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
FileNotFound = 1,
Unavailable = 2
}

4
ErsatzTV.Core/Emby/EmbyPathReplacementService.cs

@ -23,12 +23,12 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService @@ -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(

8
ErsatzTV.Core/Errors/ScanCanceled.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Errors;
public class ScanCanceled : BaseError
{
public ScanCanceled() : base("Scan was canceled")
{
}
}

44
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -1,4 +1,7 @@ @@ -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 @@ -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
};
}
}

2
ErsatzTV.Core/Interfaces/Emby/IEmbyPathReplacementService.cs

@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Emby; @@ -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);
}

2
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs

@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin; @@ -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);
}

3
ErsatzTV.Core/Interfaces/Plex/IPlexMovieLibraryScanner.cs

@ -11,5 +11,6 @@ public interface IPlexMovieLibraryScanner @@ -11,5 +11,6 @@ public interface IPlexMovieLibraryScanner
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan);
bool deepScan,
CancellationToken cancellationToken);
}

2
ErsatzTV.Core/Interfaces/Plex/IPlexPathReplacementService.cs

@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Plex; @@ -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);
}

3
ErsatzTV.Core/Interfaces/Plex/IPlexTelevisionLibraryScanner.cs

@ -11,5 +11,6 @@ public interface IPlexTelevisionLibraryScanner @@ -11,5 +11,6 @@ public interface IPlexTelevisionLibraryScanner
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan);
bool deepScan,
CancellationToken cancellationToken);
}

2
ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs

@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories; @@ -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);

1
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -20,7 +20,6 @@ public interface IMovieRepository @@ -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);

10
ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs

@ -0,0 +1,10 @@ @@ -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);
}

19
ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs

@ -0,0 +1,19 @@ @@ -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);
}

8
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -1,6 +1,5 @@ @@ -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 @@ -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);
}

2
ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs

@ -23,7 +23,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService @@ -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);

2
ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs

@ -36,7 +36,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider @@ -36,7 +36,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
await _slim.WaitAsync();
try
{
_languageCodes.AddRange(await _mediaItemRepository.GetAllLanguageCodeCultures());
_languageCodes.AddRange(await _mediaItemRepository.GetAllKnownCultures());
}
finally
{

5
ErsatzTV.Core/Plex/PlexItemEtag.cs

@ -1,7 +1,10 @@ @@ -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; }
}

6
ErsatzTV.Core/Plex/PlexLibraryScanner.cs

@ -15,7 +15,7 @@ public abstract class PlexLibraryScanner @@ -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 @@ -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;
}
}

309
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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 @@ -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 @@ -46,6 +49,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexMovieRepository = plexMovieRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
@ -59,113 +63,185 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -59,113 +63,185 @@ 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);
foreach (PlexMovie incoming in movieEntries)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
await entries.Match(
async movieEntries =>
if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false)
{
var validMovies = new List<PlexMovie>();
foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title))
continue;
}
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
if (maybeMovie.IsLeft)
{
foreach (BaseError error in maybeMovie.LeftToSeq())
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogWarning(
"Error processing plex movie at {Key}: {Error}",
incoming.Key,
error.Value);
}
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
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);
}
foreach (PlexMovie incoming in validMovies)
if (result.IsAdded)
{
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
}
}
// deep scan will pull every movie from the plex api
if (!deepScan)
{
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;
}
// 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);
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeMovie.Match(
async result =>
{
await _movieRepository.SetPlexEtag(result.Item, incoming.Etag);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
},
error =>
{
_logger.LogWarning(
"Error processing plex movie at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
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);
var movieKeys = validMovies.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
await _searchIndex.RemoveItems(ids);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
},
error =>
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)
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
if (!_localFileSystem.FileExists(localPath))
{
await _plexMovieRepository.FlagUnavailable(library, incoming);
}
return Task.CompletedTask;
});
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
return false;
}
_searchIndex.Commit();
return Unit.Default;
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
@ -179,7 +255,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -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,32 +280,36 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -204,32 +280,36 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
// 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);
await refreshResult.Match(
async _ =>
{
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
await refreshResult.Match(
async _ =>
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
return Task.CompletedTask;
});
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
}
return result;
@ -480,9 +560,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -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);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
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;
}

4
ErsatzTV.Core/Plex/PlexPathReplacementService.cs

@ -23,12 +23,12 @@ public class PlexPathReplacementService : IPlexPathReplacementService @@ -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)

518
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -1,4 +1,5 @@ @@ -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 @@ -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 @@ -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 @@ -48,6 +51,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_plexTelevisionRepository = plexTelevisionRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
@ -60,85 +64,129 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -60,85 +64,129 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan)
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
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<PlexShow> showEntries,
CancellationToken cancellationToken)
{
List<PlexItemEtag> existingShows = await _plexTelevisionRepository.GetExistingPlexShows(library);
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
library,
connection,
token);
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), cancellationToken);
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
.GetOrAddPlexShow(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token, deepScan))
.BindT(existing => UpdateArtwork(existing, incoming));
return await entries.Match<Task<Either<BaseError, Unit>>>(
async showEntries =>
if (maybeShow.IsLeft)
{
foreach (PlexShow incoming in showEntries)
foreach (BaseError error in maybeShow.LeftToSeq())
{
decimal percentCompletion = (decimal)showEntries.IndexOf(incoming) / showEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
}
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
.GetOrAddPlexShow(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token, deepScan))
.BindT(existing => UpdateArtwork(existing, incoming));
continue;
}
await maybeShow.Match(
async result =>
{
await ScanSeasons(
library,
pathReplacements,
result.Item,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan);
await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
},
error =>
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
foreach (MediaItemScanResult<PlexShow> result in maybeShow.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ScanSeasons(
library,
pathReplacements,
result.Item,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
var showKeys = showEntries.Map(s => s.Key).ToList();
List<int> ids =
await _televisionRepository.RemoveMissingPlexShows(library, showKeys);
await _searchIndex.RemoveItems(ids);
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
_searchIndex.Commit();
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
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 });
}
}
}
return Left<BaseError, Unit>(error).AsTask();
});
// 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), cancellationToken);
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
@ -152,7 +200,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -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 @@ -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,79 +374,86 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -326,79 +374,86 @@ 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 (PlexSeason incoming in seasonEntries)
{
incoming.ShowId = show.Id;
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(library, incoming)
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming));
var seasonEntries = entries.RightToSeq().Flatten().ToList();
foreach (PlexSeason incoming in seasonEntries)
{
incoming.ShowId = show.Id;
await maybeSeason.Match(
async season =>
{
await ScanEpisodes(
library,
pathReplacements,
season,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan);
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(library, incoming)
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming, deepScan));
foreach (BaseError error in maybeSeason.LeftToSeq())
{
_logger.LogWarning(
"Error processing plex season at {Key}: {Error}",
incoming.Key,
error.Value);
await _televisionRepository.SetPlexEtag(season, incoming.Etag);
return error;
}
season.Show = show;
foreach (PlexSeason season in maybeSeason.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ScanEpisodes(
library,
pathReplacements,
season,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan,
cancellationToken);
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;
});
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
var seasonKeys = seasonEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexSeasons(show.Key, seasonKeys);
await _plexTelevisionRepository.SetPlexEtag(season, incoming.Etag);
return Unit.Default;
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
season.Show = show;
return Left<BaseError, Unit>(error).AsTask();
});
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
}
}
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;
}
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 @@ -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,106 +513,149 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -457,106 +513,149 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
connection,
token);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async episodeEntries =>
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
var episodeEntries = entries.RightToSeq().Flatten().ToList();
foreach (PlexEpisode incoming in episodeEntries)
{
if (cancellationToken.IsCancellationRequested)
{
var validEpisodes = new List<PlexEpisode>();
foreach (PlexEpisode episode in episodeEntries)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
return new ScanCanceled();
}
if (!_localFileSystem.FileExists(localPath))
{
if (await ShouldScanItem(library, pathReplacements, existingEpisodes, incoming, deepScan) == false)
{
continue;
}
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexEpisode>> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
pathReplacements,
existing,
incoming,
library,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
foreach (BaseError error in maybeEpisode.LeftToSeq())
{
switch (error)
{
case ScanCanceled:
return error;
default:
_logger.LogWarning(
"Skipping plex episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value);
break;
}
}
foreach (PlexEpisode incoming in validEpisodes)
{
if (!deepScan)
{
Option<PlexItemEtag> maybeExisting = existingEpisodes.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 episode with key {Key}", incoming.Key);
continue;
}
foreach (MediaItemScanResult<PlexEpisode> result in maybeEpisode.RightToSeq())
{
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
// _logger.LogDebug(
// "UPDATE: Etag has changed for episode {Episode}",
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
}
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexEpisode>> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
pathReplacements,
existing,
incoming,
library,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
await maybeEpisode.Match(
async result =>
{
await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag);
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else
{
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;
});
if (_localFileSystem.FileExists(localPath))
{
await _plexTelevisionRepository.FlagNormal(library, result.Item);
}
else
{
await _plexTelevisionRepository.FlagUnavailable(library, result.Item);
}
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
}
}
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;
}
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);
var episodeKeys = validEpisodes.Map(s => s.Key).ToList();
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
return Unit.Default;
},
error =>
// if media is unavailable, only scan if file now exists
if (existingState == MediaItemState.Unavailable)
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
if (!_localFileSystem.FileExists(localPath))
{
return false;
}
}
else if (existingTag == incoming.Etag)
{
if (!_localFileSystem.FileExists(localPath))
{
await _plexTelevisionRepository.FlagUnavailable(library, incoming);
}
return Left<BaseError, Unit>(error).AsTask();
});
// _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 @@ -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 @@ -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(

44
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -14,18 +14,28 @@ public class MediaItemRepository : IMediaItemRepository @@ -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 @@ -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());
}
}

20
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -245,7 +245,7 @@ public class MovieRepository : IMovieRepository @@ -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 @@ -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();

68
ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs

@ -0,0 +1,68 @@ @@ -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;
}
}

184
ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs

@ -0,0 +1,184 @@ @@ -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);
}
}

90
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -3,7 +3,6 @@ using ErsatzTV.Core; @@ -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 @@ -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 @@ -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 @@ -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,

4140
ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.Designer.cs generated

File diff suppressed because it is too large Load Diff

52
ErsatzTV.Infrastructure/Migrations/20220424025823_Reset_EtagWithMissingPlexStatistics.cs

@ -0,0 +1,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)
{
}
}
}

20
ErsatzTV/Pages/Artist.razor

@ -132,6 +132,12 @@ @@ -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 @@ @@ -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 @@ @@ -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:&nbsp;</MudText>
<MudText>@musicVideo.LocalPath</MudText>
</div>
}
</MudCard>
}
</MudContainer>

18
ErsatzTV/Pages/Movie.razor

@ -46,6 +46,12 @@ @@ -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 @@ @@ -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:&nbsp;</MudText>
<MudText>@_movie.LocalPath</MudText>
</div>
</MudCardContent>
</MudCard>
}
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedContentRatings.Any())

14
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -89,6 +89,12 @@ @@ -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 @@ @@ -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:&nbsp;</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>

6
ErsatzTV/Shared/MediaCard.razor

@ -29,6 +29,12 @@ @@ -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%"

2
ErsatzTV/Startup.cs

@ -372,6 +372,8 @@ public class Startup @@ -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>();

Loading…
Cancel
Save