add unavailable media state for plex media (#754)

* rework plex movie library scanner; add unavailable media item state

* plex television scanner improvements

* reset plex etags as needed

* update changelog
This commit is contained in:
Jason Dove
2022-04-23 22:19:10 -05:00
committed by GitHub
parent 39c05a24d8
commit 89a2ac9455
45 changed files with 5378 additions and 536 deletions
+9
View 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
+15 -7
View File
@@ -61,7 +61,8 @@ internal static class Mapper
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby,
bool isSearchResult) =>
bool isSearchResult,
string localPath) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? SystemTime.MinValueUtc,
@@ -83,7 +84,8 @@ internal static class Mapper
episodeMetadata.Directors.Map(d => d.Name).ToList(),
episodeMetadata.Writers.Map(w => w.Name).ToList(),
episodeMetadata.Episode.State,
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path);
episodeMetadata.Episode.GetHeadVersion().MediaFiles.Head().Path,
localPath);
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
@@ -97,7 +99,9 @@ internal static class Mapper
GetPoster(movieMetadata, maybeJellyfin, maybeEmby),
movieMetadata.Movie.State);
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
internal static MusicVideoCardViewModel ProjectToViewModel(
MusicVideoMetadata musicVideoMetadata,
string localPath) =>
new(
musicVideoMetadata.MusicVideoId,
musicVideoMetadata.Title,
@@ -107,7 +111,8 @@ internal static class Mapper
musicVideoMetadata.Album,
GetThumbnail(musicVideoMetadata, None, None),
musicVideoMetadata.MusicVideo.State,
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path);
musicVideoMetadata.MusicVideo.GetHeadVersion().MediaFiles.Head().Path,
localPath);
internal static OtherVideoCardViewModel ProjectToViewModel(OtherVideoMetadata otherVideoMetadata) =>
new(
@@ -143,7 +148,7 @@ internal static class Mapper
Collection collection,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
new CollectionCardResultsViewModel(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
@@ -155,11 +160,14 @@ internal static class Mapper
.ToList(),
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(),
// collection view doesn't use local paths
collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false))
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby, false, string.Empty))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
// collection view doesn't use local paths
collection.MediaItems.OfType<MusicVideo>()
.Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head(), string.Empty))
.ToList(),
collection.MediaItems.OfType<OtherVideo>().Map(ov => ProjectToViewModel(ov.OtherVideoMetadata.Head()))
.ToList(),
@@ -12,7 +12,8 @@ public record MusicVideoCardViewModel
string Album,
string Poster,
MediaItemState State,
string Path) : MediaCardViewModel(
string Path,
string LocalPath) : MediaCardViewModel(
MusicVideoId,
Title,
Subtitle,
@@ -1,14 +1,31 @@
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards;
public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, MusicVideoCardResultsViewModel>
{
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
public GetMusicVideoCardsHandler(IMusicVideoRepository musicVideoRepository) =>
public GetMusicVideoCardsHandler(
IMusicVideoRepository musicVideoRepository,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService)
{
_musicVideoRepository = musicVideoRepository;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
}
public async Task<MusicVideoCardResultsViewModel> Handle(
GetMusicVideoCards request,
@@ -16,9 +33,21 @@ public class GetMusicVideoCardsHandler : IRequestHandler<GetMusicVideoCards, Mus
{
int count = await _musicVideoRepository.GetMusicVideoCount(request.ArtistId);
List<MusicVideoCardViewModel> results = await _musicVideoRepository
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
List<MusicVideoMetadata> musicVideos = await _musicVideoRepository
.GetPagedMusicVideos(request.ArtistId, request.PageNumber, request.PageSize);
var results = new List<MusicVideoCardViewModel>();
foreach (MusicVideoMetadata musicVideoMetadata in musicVideos)
{
string localPath = await musicVideoMetadata.MusicVideo.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
false);
results.Add(ProjectToViewModel(musicVideoMetadata, localPath));
}
return new MusicVideoCardResultsViewModel(count, results, None);
}
@@ -1,4 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Search;
using static ErsatzTV.Application.MediaCards.Mapper;
@@ -6,18 +10,26 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards;
public class
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards,
TelevisionEpisodeCardResultsViewModel>
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards, TelevisionEpisodeCardResultsViewModel>
{
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionEpisodeCardsHandler(
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService)
{
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
}
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
@@ -32,9 +44,20 @@ public class
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby, false)).ToList());
List<EpisodeMetadata> episodes = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize);
var results = new List<TelevisionEpisodeCardViewModel>();
foreach (EpisodeMetadata episodeMetadata in episodes)
{
string localPath = await episodeMetadata.Episode.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
false);
results.Add(ProjectToViewModel(episodeMetadata, maybeJellyfin, maybeEmby, false, localPath));
}
return new TelevisionEpisodeCardResultsViewModel(count, results, Option<SearchPageMap>.None);
}
@@ -18,7 +18,8 @@ public record TelevisionEpisodeCardViewModel
List<string> Directors,
List<string> Writers,
MediaItemState State,
string Path) : MediaCardViewModel(
string Path,
string LocalPath) : MediaCardViewModel(
EpisodeId,
Title,
$"Episode {Episode}",
+2
View File
@@ -11,6 +11,7 @@ internal static class Mapper
{
internal static MovieViewModel ProjectToViewModel(
Movie movie,
string localPath,
List<string> languageCodes,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
@@ -32,6 +33,7 @@ internal static class Mapper
metadata.Directors.Map(d => d.Name).ToList(),
metadata.Writers.Map(w => w.Name).ToList(),
movie.GetHeadVersion().MediaFiles.Head().Path,
localPath,
movie.State)
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
@@ -17,6 +17,7 @@ public record MovieViewModel(
List<string> Directors,
List<string> Writers,
string Path,
string LocalPath,
MediaItemState MediaItemState)
{
public string Poster { get; set; }
@@ -1,4 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
@@ -10,17 +14,26 @@ namespace ErsatzTV.Application.Movies;
public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieViewModel>>
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
public GetMovieByIdHandler(
IDbContextFactory<TvContext> dbContextFactory,
IMovieRepository movieRepository,
IMediaSourceRepository mediaSourceRepository)
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService)
{
_dbContextFactory = dbContextFactory;
_movieRepository = movieRepository;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
}
public async Task<Option<MovieViewModel>> Handle(
@@ -35,9 +48,9 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
Option<Movie> maybeMovie = await _movieRepository.GetMovie(request.Id);
Option<MediaVersion> maybeVersion = movie.Map(m => m.MediaVersions.HeadOrNone()).Flatten();
Option<MediaVersion> maybeVersion = maybeMovie.Map(m => m.MediaVersions.HeadOrNone()).Flatten();
var languageCodes = new List<string>();
foreach (MediaVersion version in maybeVersion)
{
@@ -49,6 +62,16 @@ public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieVie
languageCodes.AddRange(await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes));
}
return movie.Map(m => ProjectToViewModel(m, languageCodes, maybeJellyfin, maybeEmby));
foreach (Movie movie in maybeMovie)
{
string localPath = await movie.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
false);
return ProjectToViewModel(movie, localPath, languageCodes, maybeJellyfin, maybeEmby);
}
return None;
}
}
@@ -43,19 +43,24 @@ public class
public Task<Either<BaseError, string>> Handle(
ForceSynchronizePlexLibraryById request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
public Task<Either<BaseError, string>> Handle(
SynchronizePlexLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
private Task<Either<BaseError, string>>
Handle(ISynchronizePlexLibraryById request) =>
Validate(request)
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Either<BaseError, string>>
HandleImpl(ISynchronizePlexLibraryById request, CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Unit> Synchronize(RequestParameters parameters)
private async Task<Either<BaseError, string>> Synchronize(
RequestParameters parameters,
CancellationToken cancellationToken)
{
try
{
@@ -63,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
parameters.Library.Name);
}
return Unit.Default;
return parameters.Library.Name;
}
finally
{
@@ -1,5 +1,9 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -8,21 +12,29 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes,
TelevisionEpisodeCardResultsViewModel>
QuerySearchIndexEpisodesHandler : IRequestHandler<QuerySearchIndexEpisodes, TelevisionEpisodeCardResultsViewModel>
{
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexEpisodesHandler(
ISearchIndex searchIndex,
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
}
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
@@ -40,9 +52,21 @@ public class
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionEpisodeCardViewModel> items = await _televisionRepository
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby, true)).ToList());
List<EpisodeMetadata> episodes = await _televisionRepository
.GetEpisodesForCards(searchResult.Items.Map(i => i.Id).ToList());
var items = new List<TelevisionEpisodeCardViewModel>();
foreach (EpisodeMetadata episodeMetadata in episodes)
{
string localPath = await episodeMetadata.Episode.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
false);
items.Add(ProjectToViewModel(episodeMetadata, maybeJellyfin, maybeEmby, true, localPath));
}
return new TelevisionEpisodeCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
@@ -1,4 +1,9 @@
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@@ -7,16 +12,26 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.Search;
public class
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel
>
QuerySearchIndexMusicVideosHandler : IRequestHandler<QuerySearchIndexMusicVideos, MusicVideoCardResultsViewModel>
{
private readonly IEmbyPathReplacementService _embyPathReplacementService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly IMusicVideoRepository _musicVideoRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMusicVideosHandler(ISearchIndex searchIndex, IMusicVideoRepository musicVideoRepository)
public QuerySearchIndexMusicVideosHandler(
ISearchIndex searchIndex,
IMusicVideoRepository musicVideoRepository,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService)
{
_searchIndex = searchIndex;
_musicVideoRepository = musicVideoRepository;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
_embyPathReplacementService = embyPathReplacementService;
}
public async Task<MusicVideoCardResultsViewModel> Handle(
@@ -28,9 +43,21 @@ public class
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
List<MusicVideoCardViewModel> items = await _musicVideoRepository
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
List<MusicVideoMetadata> musicVideos = await _musicVideoRepository
.GetMusicVideosForCards(searchResult.Items.Map(i => i.Id).ToList());
var items = new List<MusicVideoCardViewModel>();
foreach (MusicVideoMetadata musicVideoMetadata in musicVideos)
{
string localPath = await musicVideoMetadata.MusicVideo.GetLocalPath(
_plexPathReplacementService,
_jellyfinPathReplacementService,
_embyPathReplacementService,
false);
items.Add(ProjectToViewModel(musicVideoMetadata, localPath));
}
return new MusicVideoCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
@@ -3,5 +3,6 @@
public enum MediaItemState
{
Normal = 0,
FileNotFound = 1
FileNotFound = 1,
Unavailable = 2
}
@@ -23,12 +23,12 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
_logger = logger;
}
public async Task<string> GetReplacementEmbyPath(int libraryPathId, string path)
public async Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true)
{
List<EmbyPathReplacement> replacements =
await _mediaSourceRepository.GetEmbyPathReplacementsByLibraryId(libraryPathId);
return GetReplacementEmbyPath(replacements, path);
return GetReplacementEmbyPath(replacements, path, log);
}
public string GetReplacementEmbyPath(
+8
View File
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Errors;
public class ScanCanceled : BaseError
{
public ScanCanceled() : base("Scan was canceled")
{
}
}
@@ -1,4 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Plex;
namespace ErsatzTV.Core.Extensions;
@@ -14,4 +17,45 @@ public static class MediaItemExtensions
Song s => s.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
public static async Task<string> GetLocalPath(
this MediaItem mediaItem,
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService,
IEmbyPathReplacementService embyPathReplacementService,
bool log = true)
{
MediaVersion version = mediaItem.GetHeadVersion();
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
return mediaItem switch
{
PlexMovie plexMovie => await plexPathReplacementService.GetReplacementPlexPath(
plexMovie.LibraryPathId,
path,
log),
PlexEpisode plexEpisode => await plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId,
path,
log),
JellyfinMovie jellyfinMovie => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinMovie.LibraryPathId,
path,
log),
JellyfinEpisode jellyfinEpisode => await jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinEpisode.LibraryPathId,
path,
log),
EmbyMovie embyMovie => await embyPathReplacementService.GetReplacementEmbyPath(
embyMovie.LibraryPathId,
path,
log),
EmbyEpisode embyEpisode => await embyPathReplacementService.GetReplacementEmbyPath(
embyEpisode.LibraryPathId,
path,
log),
_ => path
};
}
}
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Emby;
public interface IEmbyPathReplacementService
{
Task<string> GetReplacementEmbyPath(int libraryPathId, string path);
Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true);
string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true);
}
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Jellyfin;
public interface IJellyfinPathReplacementService
{
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path);
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true);
string GetReplacementJellyfinPath(List<JellyfinPathReplacement> pathReplacements, string path, bool log = true);
}
@@ -11,5 +11,6 @@ public interface IPlexMovieLibraryScanner
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan);
bool deepScan,
CancellationToken cancellationToken);
}
@@ -4,6 +4,6 @@ namespace ErsatzTV.Core.Interfaces.Plex;
public interface IPlexPathReplacementService
{
Task<string> GetReplacementPlexPath(int libraryPathId, string path);
Task<string> GetReplacementPlexPath(int libraryPathId, string path, bool log = true);
string GetReplacementPlexPath(List<PlexPathReplacement> pathReplacements, string path, bool log = true);
}
@@ -11,5 +11,6 @@ public interface IPlexTelevisionLibraryScanner
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan);
bool deepScan,
CancellationToken cancellationToken);
}
@@ -5,7 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaItemRepository
{
Task<List<string>> GetAllLanguageCodes();
Task<List<CultureInfo>> GetAllKnownCultures();
Task<List<CultureInfo>> GetAllLanguageCodeCultures();
Task<List<int>> FlagFileNotFound(LibraryPath libraryPath, string path);
Task<Unit> FlagNormal(MediaItem mediaItem);
@@ -20,7 +20,6 @@ public interface IMovieRepository
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library);
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
@@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexMovieRepository
{
Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie);
Task<bool> FlagUnavailable(PlexLibrary library, PlexMovie movie);
Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys);
}
@@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexTelevisionRepository
{
Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library);
Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show);
Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season);
Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode);
Task<bool> FlagUnavailable(PlexLibrary library, PlexEpisode episode);
Task<List<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys);
Task<List<int>> FlagFileNotFoundSeasons(PlexLibrary library, List<string> plexSeasonKeys);
Task<List<int>> FlagFileNotFoundEpisodes(PlexLibrary library, List<string> plexEpisodeKeys);
Task<Unit> SetPlexEtag(PlexShow show, string etag);
Task<Unit> SetPlexEtag(PlexSeason season, string etag);
Task<Unit> SetPlexEtag(PlexEpisode episode, string etag);
}
@@ -1,6 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
@@ -46,15 +45,8 @@ public interface ITelevisionRepository
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
Task<bool> AddActor(ShowMetadata metadata, Actor actor);
Task<bool> AddActor(EpisodeMetadata metadata, Actor actor);
Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys);
Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys);
Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys);
Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata);
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
Task<Unit> SetPlexEtag(PlexShow show, string etag);
Task<Unit> SetPlexEtag(PlexSeason season, string etag);
Task<Unit> SetPlexEtag(PlexEpisode episode, string etag);
Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season);
}
@@ -23,7 +23,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
_logger = logger;
}
public async Task<string> GetReplacementJellyfinPath(int libraryPathId, string path)
public async Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true)
{
List<JellyfinPathReplacement> replacements =
await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId);
@@ -36,7 +36,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
await _slim.WaitAsync();
try
{
_languageCodes.AddRange(await _mediaItemRepository.GetAllLanguageCodeCultures());
_languageCodes.AddRange(await _mediaItemRepository.GetAllKnownCultures());
}
finally
{
+4 -1
View File
@@ -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; }
}
+4 -2
View File
@@ -15,7 +15,7 @@ public abstract class PlexLibraryScanner
_logger = logger;
}
protected async Task<Unit> UpdateArtworkIfNeeded(
protected async Task<bool> UpdateArtworkIfNeeded(
Domain.Metadata existingMetadata,
Domain.Metadata incomingMetadata,
ArtworkKind artworkKind)
@@ -53,8 +53,10 @@ public abstract class PlexLibraryScanner
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
});
return true;
}
return Unit.Default;
return false;
}
}
+199 -116
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -19,6 +20,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexMovieRepository _plexMovieRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
@@ -32,6 +34,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
ISearchRepository searchRepository,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexMovieRepository plexMovieRepository,
IPlexPathReplacementService plexPathReplacementService,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
@@ -46,6 +49,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexMovieRepository = plexMovieRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
@@ -59,115 +63,187 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan)
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
token);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
return await ScanLibrary(
connection,
token,
library,
ffmpegPath,
ffprobePath,
deepScan,
entries.RightToSeq().Flatten().ToList(),
cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
// always commit the search index to prevent corruption
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
List<PlexMovie> movieEntries,
CancellationToken cancellationToken)
{
List<PlexItemEtag> existingMovies = await _movieRepository.GetExistingPlexMovies(library);
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
token);
await entries.Match(
async movieEntries =>
foreach (PlexMovie incoming in movieEntries)
{
if (cancellationToken.IsCancellationRequested)
{
var validMovies = new List<PlexMovie>();
foreach (PlexMovie movie in movieEntries.OrderBy(m => m.MovieMetadata.Head().Title))
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
false);
return new ScanCanceled();
}
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping plex movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false)
{
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())
{
_logger.LogWarning(
"Error processing plex movie at {Key}: {Error}",
incoming.Key,
error.Value);
}
foreach (PlexMovie incoming in validMovies)
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))
{
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// 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;
}
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.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;
});
await _plexMovieRepository.FlagNormal(library, result.Item);
}
else
{
await _plexMovieRepository.FlagUnavailable(library, result.Item);
}
var movieKeys = validMovies.Map(s => s.Key).ToList();
List<int> ids = await _movieRepository.RemoveMissingPlexMovies(library, movieKeys);
await _searchIndex.RemoveItems(ids);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
}
}
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
},
error =>
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
// 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);
return Task.CompletedTask;
});
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
_searchIndex.Commit();
return Unit.Default;
}
private async Task<bool> ShouldScanItem(
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
List<PlexItemEtag> existingMovies,
PlexMovie incoming,
bool deepScan)
{
// deep scan will pull every movie individually from the plex api
if (!deepScan)
{
Option<PlexItemEtag> maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key);
string existingEtag = await maybeExisting
.Map(e => e.Etag ?? string.Empty)
.IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting
.Map(e => e.State)
.IfNoneAsync(MediaItemState.Normal);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
// if media is unavailable, only scan if file now exists
if (existingState == MediaItemState.Unavailable)
{
if (!_localFileSystem.FileExists(localPath))
{
return false;
}
}
else if (existingEtag == incoming.Etag)
{
if (!_localFileSystem.FileExists(localPath))
{
await _plexMovieRepository.FlagUnavailable(library, incoming);
}
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
return false;
}
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexMovie> result,
@@ -179,7 +255,7 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (existing.Etag != incoming.Etag)
if (result.IsAdded || existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0)
{
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{
@@ -204,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 });
}
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
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);
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 Task.CompletedTask;
});
}
}
return result;
@@ -480,9 +560,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
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;
}
@@ -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)
+322 -222
View File
@@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -21,6 +22,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IPlexTelevisionRepository _plexTelevisionRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
@@ -34,6 +36,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IPlexPathReplacementService plexPathReplacementService,
IPlexTelevisionRepository plexTelevisionRepository,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
@@ -48,6 +51,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_plexTelevisionRepository = plexTelevisionRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
@@ -60,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);
return await entries.Match<Task<Either<BaseError, Unit>>>(
async showEntries =>
foreach (PlexShow incoming in showEntries)
{
if (cancellationToken.IsCancellationRequested)
{
foreach (PlexShow incoming in showEntries)
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));
if (maybeShow.IsLeft)
{
foreach (BaseError error in maybeShow.LeftToSeq())
{
decimal percentCompletion = (decimal)showEntries.IndexOf(incoming) / showEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
// 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));
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;
});
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
}
var showKeys = showEntries.Map(s => s.Key).ToList();
List<int> ids =
await _televisionRepository.RemoveMissingPlexShows(library, showKeys);
await _searchIndex.RemoveItems(ids);
continue;
}
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
return Unit.Default;
},
error =>
foreach (MediaItemScanResult<PlexShow> result in maybeShow.RightToSeq())
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
Either<BaseError, Unit> scanResult = await ScanSeasons(
library,
pathReplacements,
result.Item,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan,
cancellationToken);
return Left<BaseError, Unit>(error).AsTask();
});
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
}
}
// 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
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
if (existing.Etag != incoming.Etag || deepScan)
if (result.IsAdded || existing.Etag != incoming.Etag || deepScan)
{
Either<BaseError, ShowMetadata> maybeMetadata =
await _plexServerApiClient.GetShowMetadata(
@@ -308,10 +356,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
if (existing.Etag != incoming.Etag)
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
if (poster || fanArt)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
@@ -326,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));
await _televisionRepository.SetPlexEtag(season, incoming.Etag);
season.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
},
error =>
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return Task.CompletedTask;
});
}
var seasonKeys = seasonEntries.Map(s => s.Key).ToList();
await _televisionRepository.RemoveMissingPlexSeasons(show.Key, seasonKeys);
return Unit.Default;
},
error =>
foreach (BaseError error in maybeSeason.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
"Error processing plex season at {Key}: {Error}",
incoming.Key,
error.Value);
return Left<BaseError, Unit>(error).AsTask();
});
return error;
}
foreach (PlexSeason season in maybeSeason.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ScanEpisodes(
library,
pathReplacements,
season,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
await _plexTelevisionRepository.SetPlexEtag(season, incoming.Etag);
season.Show = show;
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
}
}
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
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
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(
return new ScanCanceled();
}
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,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
existing,
incoming,
library,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping plex episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
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;
}
// _logger.LogDebug(
// "UPDATE: Etag has changed for episode {Episode}",
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
}
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
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);
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;
});
}
var episodeKeys = validEpisodes.Map(s => s.Key).ToList();
List<int> ids = await _televisionRepository.RemoveMissingPlexEpisodes(season.Key, episodeKeys);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
},
error =>
foreach (BaseError error in maybeEpisode.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing plex library {Path}: {Error}",
library.Name,
error.Value);
switch (error)
{
case ScanCanceled:
return error;
default:
_logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value);
break;
}
}
return Left<BaseError, Unit>(error).AsTask();
});
foreach (MediaItemScanResult<PlexEpisode> result in maybeEpisode.RightToSeq())
{
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
if (_localFileSystem.FileExists(localPath))
{
await _plexTelevisionRepository.FlagNormal(library, result.Item);
}
else
{
await _plexTelevisionRepository.FlagUnavailable(library, result.Item);
}
if (result.IsAdded)
{
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);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
// if media is unavailable, only scan if file now exists
if (existingState == MediaItemState.Unavailable)
{
if (!_localFileSystem.FileExists(localPath))
{
return false;
}
}
else if (existingTag == incoming.Etag)
{
if (!_localFileSystem.FileExists(localPath))
{
await _plexTelevisionRepository.FlagUnavailable(library, incoming);
}
// _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key);
return false;
}
// _logger.LogDebug(
// "UPDATE: Etag has changed for episode {Episode}",
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateMetadata(
@@ -607,7 +706,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (existing.Etag != incoming.Etag || deepScan)
if (result.IsAdded || existing.Etag != incoming.Etag || deepScan || existingVersion.Streams.Count == 0)
{
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{
@@ -634,7 +733,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (existing.Etag != incoming.Etag)
if ((existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) &&
_localFileSystem.FileExists(localPath))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
refreshResult = await _localStatisticsProvider.RefreshStatistics(
@@ -14,18 +14,28 @@ public class MediaItemRepository : IMediaItemRepository
public MediaItemRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public async Task<List<string>> GetAllLanguageCodes()
public async Task<List<CultureInfo>> GetAllKnownCultures()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<string>(
@"SELECT LanguageCode FROM
(SELECT Language AS LanguageCode
FROM MediaStream WHERE Language IS NOT NULL
UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode
FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL)
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());
var result = new System.Collections.Generic.HashSet<CultureInfo>();
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
foreach (LanguageCode code in await dbContext.LanguageCodes.ToListAsync())
{
Option<CultureInfo> maybeCulture = allCultures.Find(
c => string.Equals(code.ThreeCode1, c.ThreeLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)
|| string.Equals(
code.ThreeCode2,
c.ThreeLetterISOLanguageName,
StringComparison.OrdinalIgnoreCase));
foreach (CultureInfo culture in maybeCulture)
{
result.Add(culture);
}
}
return result.ToList();
}
public async Task<List<CultureInfo>> GetAllLanguageCodeCultures()
@@ -96,4 +106,18 @@ public class MediaItemRepository : IMediaItemRepository
return Unit.Default;
}
private async Task<List<string>> GetAllLanguageCodes()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<string>(
@"SELECT LanguageCode FROM
(SELECT Language AS LanguageCode
FROM MediaStream WHERE Language IS NOT NULL
UNION ALL SELECT PreferredAudioLanguageCode AS LanguageCode
FROM Channel WHERE PreferredAudioLanguageCode IS NOT NULL)
GROUP BY LanguageCode
ORDER BY COUNT(LanguageCode) DESC")
.Map(result => result.ToList());
}
}
@@ -245,7 +245,7 @@ public class MovieRepository : IMovieRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT Key, Etag FROM PlexMovie
@"SELECT Key, Etag, MI.State FROM PlexMovie
INNER JOIN Movie M on PlexMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
@@ -254,24 +254,6 @@ public class MovieRepository : IMovieRepository
.Map(result => result.ToList());
}
public async Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND pm.Key not in @Keys",
new { LibraryId = library.Id, Keys = movieKeys }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<bool> UpdateSortTitle(MovieMetadata movieMetadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@@ -0,0 +1,68 @@
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class PlexMovieRepository : IPlexMovieRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public PlexMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public async Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.Normal;
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id IN
(SELECT PlexMovie.Id FROM PlexMovie
INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexMovie.Key = @Key)",
new { LibraryId = library.Id, movie.Key }).Map(count => count > 0);
}
public async Task<bool> FlagUnavailable(PlexLibrary library, PlexMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.Unavailable;
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id IN
(SELECT PlexMovie.Id FROM PlexMovie
INNER JOIN MediaItem MI ON MI.Id = PlexMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexMovie.Key = @Key)",
new { LibraryId = library.Id, movie.Key }).Map(count => count > 0);
}
public async Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys)
{
if (plexMovieKeys.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN PlexMovie ON PlexMovie.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE PlexMovie.Key IN @MovieKeys",
new { LibraryId = library.Id, MovieKeys = plexMovieKeys })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
}
@@ -0,0 +1,184 @@
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class PlexTelevisionRepository : IPlexTelevisionRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public PlexTelevisionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT PS.Key, PS.Etag, MI.State FROM PlexShow PS
INNER JOIN MediaItem MI on PS.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT PlexSeason.Key, PlexSeason.Etag, MI.State FROM PlexSeason
INNER JOIN Season S on PlexSeason.Id = S.Id
INNER JOIN MediaItem MI on S.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
INNER JOIN PlexShow PS ON S.ShowId = PS.Id
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
new { LibraryId = library.Id, show.Key })
.Map(result => result.ToList());
}
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT PlexEpisode.Key, PlexEpisode.Etag, MI.State FROM PlexEpisode
INNER JOIN Episode E on PlexEpisode.Id = E.Id
INNER JOIN MediaItem MI on E.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Season S2 on E.SeasonId = S2.Id
INNER JOIN PlexSeason PS on S2.Id = PS.Id
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
new { LibraryId = library.Id, season.Key })
.Map(result => result.ToList());
}
public async Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.Normal;
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id IN
(SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key)",
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
}
public async Task<bool> FlagUnavailable(PlexLibrary library, PlexEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.Unavailable;
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id IN
(SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key)",
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
}
public async Task<List<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys)
{
if (plexShowKeys.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN PlexShow ON PlexShow.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE PlexShow.Key IN @ShowKeys",
new { LibraryId = library.Id, ShowKeys = plexShowKeys })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<List<int>> FlagFileNotFoundSeasons(PlexLibrary library, List<string> plexSeasonKeys)
{
if (plexSeasonKeys.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN PlexSeason ON PlexSeason.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE PlexSeason.Key IN @SeasonKeys",
new { LibraryId = library.Id, SeasonKeys = plexSeasonKeys })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<List<int>> FlagFileNotFoundEpisodes(PlexLibrary library, List<string> plexEpisodeKeys)
{
if (plexEpisodeKeys.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN PlexEpisode ON PlexEpisode.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE PlexEpisode.Key IN @EpisodeKeys",
new { LibraryId = library.Id, EpisodeKeys = plexEpisodeKeys })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<Unit> SetPlexEtag(PlexShow show, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexSeason season, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexEpisode episode, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
}
}
@@ -3,7 +3,6 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@@ -537,38 +536,6 @@ public class TelevisionRepository : ITelevisionRepository
async () => await AddPlexEpisode(dbContext, library, item));
}
public async Task<Unit> RemoveMissingPlexSeasons(string showKey, List<string> seasonKeys)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN Season s ON m.Id = s.Id
INNER JOIN PlexSeason ps ON ps.Id = m.Id
INNER JOIN PlexShow P on P.Id = s.ShowId
WHERE P.Key = @ShowKey AND ps.Key not in @Keys)",
new { ShowKey = showKey, Keys = seasonKeys }).ToUnit();
}
public async Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN Episode e ON m.Id = e.Id
INNER JOIN PlexEpisode pe ON pe.Id = m.Id
INNER JOIN PlexSeason P on P.Id = e.SeasonId
WHERE P.Key = @SeasonKey AND pe.Key not in @Keys",
new { SeasonKey = seasonKey, Keys = episodeKeys }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@@ -603,45 +570,6 @@ public class TelevisionRepository : ITelevisionRepository
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexShow show, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexSeason season, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexEpisode episode, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
}
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT PlexEpisode.Key, PlexEpisode.Etag FROM PlexEpisode
INNER JOIN Episode E on PlexEpisode.Id = E.Id
INNER JOIN MediaItem MI on E.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Season S2 on E.SeasonId = S2.Id
INNER JOIN PlexSeason PS on S2.Id = PS.Id
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
new { LibraryId = library.Id, season.Key })
.Map(result => result.ToList());
}
public async Task<List<Episode>> GetShowItems(int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@@ -777,24 +705,6 @@ public class TelevisionRepository : ITelevisionRepository
.Map(result => result > 0);
}
public async Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN PlexShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND ps.Key not in @Keys",
new { LibraryId = library.Id, Keys = showKeys }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
private static async Task<Either<BaseError, Season>> AddSeason(
TvContext dbContext,
Show show,
@@ -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
View File
@@ -132,6 +132,12 @@
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</MudPaper>
}
else
@@ -144,6 +150,12 @@
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
<MudCardContent Class="ml-3">
@@ -176,6 +188,14 @@
<MudText>@musicVideo.Path</MudText>
</div>
}
else if (musicVideo.State == MediaItemState.Unavailable)
{
<div class="ml-3 mt-3 mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
<MudText>Unavailable:&nbsp;</MudText>
<MudText>@musicVideo.LocalPath</MudText>
</div>
}
</MudCard>
}
</MudContainer>
+18
View File
@@ -46,6 +46,12 @@
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (_movie.MediaItemState == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</div>
}
<div style="display: flex; flex-direction: column; height: 100%">
@@ -81,6 +87,18 @@
</MudCardContent>
</MudCard>
}
else if (_movie.MediaItemState == MediaItemState.Unavailable)
{
<MudCard Class="mb-6">
<MudCardContent>
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
<MudText>Unavailable:&nbsp;</MudText>
<MudText>@_movie.LocalPath</MudText>
</div>
</MudCardContent>
</MudCard>
}
<MudCard Class="mb-6">
<MudCardContent>
@if (_sortedContentRatings.Any())
@@ -89,6 +89,12 @@
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Error" Size="Size.Large"/>
</div>
}
else if (episode.State == MediaItemState.Unavailable)
{
<div style="position: absolute; right: 10px; top: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Size="Size.Large"/>
</div>
}
</MudPaper>
}
<MudCardContent Class="ml-3">
@@ -115,6 +121,14 @@
<MudText>@episode.Path</MudText>
</div>
}
else if (episode.State == MediaItemState.Unavailable)
{
<div class="mb-3" style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" Class="mr-2"/>
<MudText>Unavailable:&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
View File
@@ -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
View File
@@ -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>();