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