Browse Source

refactor movie library scanners (#761)

* catch health check cancellation

* local library scanner cleanup

* emby and jf library scanner cleanup

* rework emby movie library scanner

* refactor emby movie library scanner

* refactor jellyfin movie library scanner

* clear etag until after new media has been processed

* refactor plex movie library scanner

* update changelog
pull/762/head
Jason Dove 3 years ago committed by GitHub
parent
commit
392aebd46f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 9
      CHANGELOG.md
  2. 40
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs
  3. 7
      ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs
  4. 40
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs
  5. 34
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  6. 3
      ErsatzTV.Core/Domain/MediaServer/MediaServerConnectionParameters.cs
  7. 8
      ErsatzTV.Core/Domain/Metadata/MediaServerItemEtag.cs
  8. 5
      ErsatzTV.Core/Emby/EmbyConnectionParameters.cs
  9. 10
      ErsatzTV.Core/Emby/EmbyItemEtag.cs
  10. 253
      ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs
  11. 12
      ErsatzTV.Core/Emby/EmbyPathReplacementService.cs
  12. 217
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  13. 3
      ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs
  14. 3
      ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs
  15. 3
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs
  16. 3
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs
  17. 3
      ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs
  18. 8
      ErsatzTV.Core/Interfaces/Repositories/IEmbyMovieRepository.cs
  19. 9
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinMovieRepository.cs
  20. 16
      ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs
  21. 14
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  22. 6
      ErsatzTV.Core/Interfaces/Repositories/IPlexMovieRepository.cs
  23. 6
      ErsatzTV.Core/Jellyfin/JellyfinConnectionParameters.cs
  24. 10
      ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs
  25. 250
      ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  26. 2
      ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs
  27. 312
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  28. 2
      ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs
  29. 1
      ErsatzTV.Core/Metadata/MediaItemScanResult.cs
  30. 338
      ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs
  31. 19
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  32. 122
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  33. 42
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  34. 51
      ErsatzTV.Core/Metadata/SongFolderScanner.cs
  35. 93
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  36. 7
      ErsatzTV.Core/Plex/PlexConnectionParameters.cs
  37. 7
      ErsatzTV.Core/Plex/PlexItemEtag.cs
  38. 34
      ErsatzTV.Core/Plex/PlexLibraryScanner.cs
  39. 368
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  40. 348
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  41. 353
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  42. 7
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  43. 620
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  44. 90
      ErsatzTV.Infrastructure/Data/Repositories/PlexMovieRepository.cs
  45. 12
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  46. 2
      ErsatzTV/Startup.cs

9
CHANGELOG.md

@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased]
### Fixed
- Cleanly stop local library scan when service termination is requested
### Changed
- Update Plex, Jellyfin and Emby movie library scanners to share a significant amount of code
- This should help maintain feature parity going forward
### Added
- Add `unavailable` state for Emby movie libraries
## [0.5.3-beta] - 2022-04-24
### Fixed

40
ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs

@ -49,19 +49,24 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -49,19 +49,24 @@ public class SynchronizeEmbyLibraryByIdHandler :
public Task<Either<BaseError, string>> Handle(
ForceSynchronizeEmbyLibraryById request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
public Task<Either<BaseError, string>> Handle(
SynchronizeEmbyLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
private Task<Either<BaseError, string>>
Handle(ISynchronizeEmbyLibraryById request) =>
Validate(request)
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Either<BaseError, string>>
HandleImpl(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Unit> Synchronize(RequestParameters parameters)
private async Task<Either<BaseError, string>> Synchronize(
RequestParameters parameters,
CancellationToken cancellationToken)
{
try
{
@ -77,15 +82,17 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -77,15 +82,17 @@ public class SynchronizeEmbyLibraryByIdHandler :
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
parameters.FFprobePath,
cancellationToken),
LibraryMediaKind.Shows =>
await _embyTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
_ => BaseError.New("Unsupported library media kind")
parameters.FFprobePath,
cancellationToken),
_ => Unit.Default
};
if (result.IsRight)
@ -94,17 +101,18 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -94,17 +101,18 @@ public class SynchronizeEmbyLibraryByIdHandler :
await _libraryRepository.UpdateLastScan(parameters.Library);
await _embyWorkerChannel.WriteAsync(
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId));
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId),
cancellationToken);
}
return result.Map(_ => parameters.Library.Name);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of emby media library {Name}",
parameters.Library.Name);
_logger.LogDebug("Skipping unforced scan of emby media library {Name}", parameters.Library.Name);
}
return Unit.Default;
return parameters.Library.Name;
}
finally
{

7
ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs

@ -12,8 +12,15 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck @@ -12,8 +12,15 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
public async Task<List<HealthCheckResult>> Handle(
GetAllHealthCheckResults request,
CancellationToken cancellationToken)
{
try
{
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new List<HealthCheckResult>();
}
}
}

40
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs

@ -49,19 +49,24 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -49,19 +49,24 @@ public class SynchronizeJellyfinLibraryByIdHandler :
public Task<Either<BaseError, string>> Handle(
ForceSynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
public Task<Either<BaseError, string>> Handle(
SynchronizeJellyfinLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request);
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
private Task<Either<BaseError, string>>
Handle(ISynchronizeJellyfinLibraryById request) =>
Validate(request)
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
.Bind(v => v.ToEitherAsync());
private async Task<Either<BaseError, string>>
HandleImpl(ISynchronizeJellyfinLibraryById request, CancellationToken cancellationToken)
{
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Unit> Synchronize(RequestParameters parameters)
private async Task<Either<BaseError, string>> Synchronize(
RequestParameters parameters,
CancellationToken cancellationToken)
{
try
{
@ -77,15 +82,17 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -77,15 +82,17 @@ public class SynchronizeJellyfinLibraryByIdHandler :
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
parameters.FFprobePath,
cancellationToken),
LibraryMediaKind.Shows =>
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath),
_ => BaseError.New("Unsupported library media kind")
parameters.FFprobePath,
cancellationToken),
_ => Unit.Default
};
if (result.IsRight)
@ -94,17 +101,18 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -94,17 +101,18 @@ public class SynchronizeJellyfinLibraryByIdHandler :
await _libraryRepository.UpdateLastScan(parameters.Library);
await _jellyfinWorkerChannel.WriteAsync(
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId));
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId),
cancellationToken);
}
return result.Map(_ => parameters.Library.Name);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of jellyfin media library {Name}",
parameters.Library.Name);
_logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name);
}
return Unit.Default;
return parameters.Library.Name;
}
finally
{

34
ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs

@ -85,57 +85,57 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei @@ -85,57 +85,57 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
{
scanned = true;
switch (localLibrary.MediaKind)
Either<BaseError, Unit> result = localLibrary.MediaKind switch
{
case LibraryMediaKind.Movies:
LibraryMediaKind.Movies =>
await _movieFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.Shows:
cancellationToken),
LibraryMediaKind.Shows =>
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.MusicVideos:
cancellationToken),
LibraryMediaKind.MusicVideos =>
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.OtherVideos:
cancellationToken),
LibraryMediaKind.OtherVideos =>
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax);
break;
case LibraryMediaKind.Songs:
progressMax,
cancellationToken),
LibraryMediaKind.Songs =>
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax,
cancellationToken);
break;
}
cancellationToken),
_ => Unit.Default
};
if (result.IsRight)
{
libraryPath.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(libraryPath);
}
}
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax), cancellationToken);
}

3
ErsatzTV.Core/Domain/MediaServer/MediaServerConnectionParameters.cs

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
namespace ErsatzTV.Core.Domain.MediaServer;
public abstract record MediaServerConnectionParameters;

8
ErsatzTV.Core/Domain/Metadata/MediaServerItemEtag.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain;
public abstract class MediaServerItemEtag
{
public abstract string MediaServerItemId { get; }
public abstract string Etag { get; set; }
public abstract MediaItemState State { get; set; }
}

5
ErsatzTV.Core/Emby/EmbyConnectionParameters.cs

@ -0,0 +1,5 @@ @@ -0,0 +1,5 @@
using ErsatzTV.Core.Domain.MediaServer;
namespace ErsatzTV.Core.Emby;
public record EmbyConnectionParameters(string Address, string ApiKey) : MediaServerConnectionParameters;

10
ErsatzTV.Core/Emby/EmbyItemEtag.cs

@ -1,7 +1,11 @@ @@ -1,7 +1,11 @@
namespace ErsatzTV.Core.Emby;
using ErsatzTV.Core.Domain;
public class EmbyItemEtag
namespace ErsatzTV.Core.Emby;
public class EmbyItemEtag : MediaServerItemEtag
{
public string ItemId { get; set; }
public string Etag { get; set; }
public override string MediaServerItemId => ItemId;
public override string Etag { get; set; }
public override MediaItemState State { get; set; }
}

253
ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs

@ -1,53 +1,49 @@ @@ -1,53 +1,49 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby;
public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
public class EmbyMovieLibraryScanner :
MediaServerMovieLibraryScanner<EmbyConnectionParameters, EmbyLibrary, EmbyMovie, EmbyItemEtag>,
IEmbyMovieLibraryScanner
{
private readonly IEmbyApiClient _embyApiClient;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<EmbyMovieLibraryScanner> _logger;
private readonly IEmbyMovieRepository _embyMovieRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public EmbyMovieLibraryScanner(
IEmbyApiClient embyApiClient,
ISearchIndex searchIndex,
IMediator mediator,
IMovieRepository movieRepository,
IMediaSourceRepository mediaSourceRepository,
IEmbyMovieRepository embyMovieRepository,
ISearchRepository searchRepository,
IEmbyPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<EmbyMovieLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{
_embyApiClient = embyApiClient;
_searchIndex = searchIndex;
_mediator = mediator;
_movieRepository = movieRepository;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
_embyMovieRepository = embyMovieRepository;
_pathReplacementService = pathReplacementService;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@ -55,197 +51,52 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner @@ -55,197 +51,52 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath)
string ffprobePath,
CancellationToken cancellationToken)
{
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library);
// TODO: maybe get quick list of item ids and etags from api to compare first
// TODO: paging?
List<EmbyPathReplacement> pathReplacements =
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository
.GetEmbyPathReplacements(library.MediaSourceId);
Either<BaseError, List<EmbyMovie>> maybeMovies = await _embyApiClient.GetMovieLibraryItems(
address,
apiKey,
library.ItemId);
await maybeMovies.Match(
async movies =>
{
var validMovies = new List<EmbyMovie>();
foreach (EmbyMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
string GetLocalPath(EmbyMovie movie)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
return _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
movie.GetHeadVersion().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping emby movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
}
foreach (EmbyMovie incoming in validMovies)
{
EmbyMovie incomingMovie = incoming;
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<EmbyItemEtag> maybeExisting =
existingMovies.Find(ie => ie.ItemId == incoming.ItemId);
var updateStatistics = false;
await maybeExisting.Match(
async existing =>
{
try
{
if (existing.Etag == incoming.Etag)
{
// _logger.LogDebug(
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
return;
}
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
foreach (EmbyMovie updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
incomingMovie = updated;
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error updating movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
},
async () =>
{
try
{
// _logger.LogDebug(
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
if (await _movieRepository.AddEmby(incoming))
{
await _searchIndex.AddItems(
_searchRepository,
new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
});
if (updateStatistics)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
return await ScanLibrary(
_embyMovieRepository,
new EmbyConnectionParameters(address, apiKey),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
incomingMovie,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
false,
cancellationToken);
}
await refreshResult.Match(
async _ =>
{
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
return Task.CompletedTask;
});
}
// TODO: figure out how to rebuild playlists
}
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList();
var movieIds = existingMovies
.Filter(i => !incomingMovieIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> ids = await _movieRepository.RemoveMissingEmbyMovies(library, movieIds);
await _searchIndex.RemoveItems(ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
_searchIndex.Commit();
return Unit.Default;
}
protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetMovieLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyMovie movie, string localPath)
{
try
{
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected override Task<Option<MovieMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyMovie> result,
EmbyMovie incoming,
bool deepScan) =>
Task.FromResult<Option<MovieMetadata>>(None);
protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata(
MediaItemScanResult<EmbyMovie> result,
MovieMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyMovie>>>(result);
}

12
ErsatzTV.Core/Emby/EmbyPathReplacementService.cs

@ -31,10 +31,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService @@ -31,10 +31,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
return GetReplacementEmbyPath(replacements, path, log);
}
public string GetReplacementEmbyPath(
List<EmbyPathReplacement> pathReplacements,
string path,
bool log = true)
public string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true)
{
Option<EmbyPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault(
@ -46,9 +43,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService @@ -46,9 +43,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
}
string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/";
string prefix = r.EmbyPath.EndsWith(separatorChar)
? r.EmbyPath
: r.EmbyPath + separatorChar;
string prefix = r.EmbyPath.EndsWith(separatorChar) ? r.EmbyPath : r.EmbyPath + separatorChar;
return path.StartsWith(prefix);
});
@ -59,8 +54,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService @@ -59,8 +54,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
{
finalPath = finalPath.Replace(@"\", @"/");
}
else if (!IsWindows(replacement.EmbyMediaSource, path) &&
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
else if (!IsWindows(replacement.EmbyMediaSource, path) && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{
finalPath = finalPath.Replace(@"/", @"\");
}

217
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
@ -55,7 +55,10 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -55,7 +55,10 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath)
string ffprobePath,
CancellationToken cancellationToken)
{
try
{
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
@ -80,7 +83,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -80,7 +83,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
{
await ProcessShows(
Either<BaseError, Unit> scanResult = await ProcessShows(
address,
apiKey,
library,
@ -88,8 +91,16 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -88,8 +91,16 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
ffprobePath,
pathReplacements,
existingShows,
shows);
shows,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
@ -102,14 +113,23 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -102,14 +113,23 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
await _searchIndex.RemoveItems(emptyShowIds);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
}
}
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task ProcessShows(
private async Task<Either<BaseError, Unit>> ProcessShows(
string address,
string apiKey,
EmbyLibrary library,
@ -117,13 +137,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -117,13 +137,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
List<EmbyItemEtag> existingShows,
List<EmbyShow> shows)
List<EmbyShow> shows,
CancellationToken cancellationToken)
{
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (EmbyShow incoming in sortedShows)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
@ -140,19 +166,17 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -140,19 +166,17 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
foreach (EmbyItemEtag existing in maybeExisting)
{
if (existing.Etag == incoming.Etag)
if (existing.Etag != incoming.Etag)
{
return;
}
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyShow> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
Option<EmbyShow> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyShow updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated.ValueUnsafe() });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
}
}
}
@ -172,7 +196,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -172,7 +196,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
foreach (List<EmbySeason> seasons in maybeSeasons.RightToSeq())
{
await ProcessSeasons(
Either<BaseError, Unit> scanResult = await ProcessSeasons(
address,
apiKey,
library,
@ -181,8 +205,16 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -181,8 +205,16 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
pathReplacements,
incoming,
existingSeasons,
seasons);
seasons,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
@ -193,7 +225,10 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -193,7 +225,10 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
}
}
private async Task ProcessSeasons(
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address,
string apiKey,
EmbyLibrary library,
@ -202,19 +237,37 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -202,19 +237,37 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
List<EmbyPathReplacement> pathReplacements,
EmbyShow show,
List<EmbyItemEtag> existingSeasons,
List<EmbySeason> seasons)
List<EmbySeason> seasons,
CancellationToken cancellationToken)
{
foreach (EmbySeason incoming in seasons)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
if (maybeExisting.IsNone)
{
if (existing.Etag == incoming.Etag)
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
return;
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (EmbyItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
@ -232,22 +285,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -232,22 +285,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { toIndex });
}
}
},
async () =>
{
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
});
}
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
@ -255,8 +294,15 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -255,8 +294,15 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
foreach (BaseError error in maybeEpisodes.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<EmbyEpisode> episodes in maybeEpisodes.RightToSeq())
{
var validEpisodes = new List<EmbyEpisode>();
foreach (EmbyEpisode episode in episodes)
@ -278,7 +324,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -278,7 +324,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
}
}
await ProcessEpisodes(
Either<BaseError, Unit> scanResult = await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
@ -287,8 +333,16 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -287,8 +333,16 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
validEpisodes,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
@ -298,20 +352,14 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -298,20 +352,14 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
}
}
return Unit.Default;
}
private async Task ProcessEpisodes(
private async Task<Either<BaseError, Unit>> ProcessEpisodes(
string showName,
string seasonName,
EmbyLibrary library,
@ -320,40 +368,36 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -320,40 +368,36 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
List<EmbyPathReplacement> pathReplacements,
EmbySeason season,
List<EmbyItemEtag> existingEpisodes,
List<EmbyEpisode> episodes)
List<EmbyEpisode> episodes,
CancellationToken cancellationToken)
{
foreach (EmbyEpisode incoming in episodes)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
if (maybeExisting.IsNone)
{
try
{
if (existing.Etag == incoming.Etag)
{
return;
}
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyEpisode updated in maybeUpdated)
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
incomingEpisode = updated;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
@ -361,26 +405,33 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -361,26 +405,33 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
},
async () =>
}
foreach (EmbyItemEtag existing in maybeExisting)
{
try
{
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyEpisode updated in maybeUpdated)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
}
catch (Exception ex)
@ -388,10 +439,10 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -388,10 +439,10 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
});
}
if (updateStatistics)
{
@ -423,6 +474,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -423,6 +474,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
}
}
}
return Unit.Default;
}
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyEpisode episode, string localPath)

3
ErsatzTV.Core/Interfaces/Emby/IEmbyMovieLibraryScanner.cs

@ -9,5 +9,6 @@ public interface IEmbyMovieLibraryScanner @@ -9,5 +9,6 @@ public interface IEmbyMovieLibraryScanner
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath);
string ffprobePath,
CancellationToken cancellationToken);
}

3
ErsatzTV.Core/Interfaces/Emby/IEmbyTelevisionLibraryScanner.cs

@ -9,5 +9,6 @@ public interface IEmbyTelevisionLibraryScanner @@ -9,5 +9,6 @@ public interface IEmbyTelevisionLibraryScanner
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath);
string ffprobePath,
CancellationToken cancellationToken);
}

3
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs

@ -9,5 +9,6 @@ public interface IJellyfinMovieLibraryScanner @@ -9,5 +9,6 @@ public interface IJellyfinMovieLibraryScanner
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath);
string ffprobePath,
CancellationToken cancellationToken);
}

3
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs

@ -9,5 +9,6 @@ public interface IJellyfinTelevisionLibraryScanner @@ -9,5 +9,6 @@ public interface IJellyfinTelevisionLibraryScanner
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath);
string ffprobePath,
CancellationToken cancellationToken);
}

3
ErsatzTV.Core/Interfaces/Metadata/IOtherVideoFolderScanner.cs

@ -9,5 +9,6 @@ public interface IOtherVideoFolderScanner @@ -9,5 +9,6 @@ public interface IOtherVideoFolderScanner
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax);
decimal progressMax,
CancellationToken cancellationToken);
}

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

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IEmbyMovieRepository : IMediaServerMovieRepository<EmbyLibrary, EmbyMovie, EmbyItemEtag>
{
}

9
ErsatzTV.Core/Interfaces/Repositories/IJellyfinMovieRepository.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface
IJellyfinMovieRepository : IMediaServerMovieRepository<JellyfinLibrary, JellyfinMovie, JellyfinItemEtag>
{
}

16
ErsatzTV.Core/Interfaces/Repositories/IMediaServerMovieRepository.cs

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where TLibrary : Library
where TMovie : Movie
where TEtag : MediaServerItemEtag
{
Task<List<TEtag>> GetExistingMovies(TLibrary library);
Task<bool> FlagNormal(TLibrary library, TMovie movie);
Task<Option<int>> FlagUnavailable(TLibrary library, TMovie movie);
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds);
Task<Either<BaseError, MediaItemScanResult<TMovie>>> GetOrAdd(TLibrary library, TMovie item);
Task<Unit> SetEtag(TMovie movie, string etag);
}

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

@ -1,8 +1,5 @@ @@ -1,8 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
@ -11,7 +8,6 @@ public interface IMovieRepository @@ -11,7 +8,6 @@ public interface IMovieRepository
Task<bool> AllMoviesExist(List<int> movieIds);
Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path);
Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item);
Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
@ -19,18 +15,8 @@ public interface IMovieRepository @@ -19,18 +15,8 @@ public interface IMovieRepository
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
Task<bool> AddJellyfin(JellyfinMovie movie);
Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie);
Task<List<EmbyItemEtag>> GetExistingEmbyMovies(EmbyLibrary library);
Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds);
Task<bool> AddEmby(EmbyMovie movie);
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
Task<Unit> SetPlexEtag(PlexMovie movie, string etag);
}

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

@ -1,10 +1,8 @@ @@ -1,10 +1,8 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexMovieRepository
public interface IPlexMovieRepository : IMediaServerMovieRepository<PlexLibrary, PlexMovie, PlexItemEtag>
{
Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie);
Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexMovie movie);
Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys);
}

6
ErsatzTV.Core/Jellyfin/JellyfinConnectionParameters.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core.Domain.MediaServer;
namespace ErsatzTV.Core.Jellyfin;
public record JellyfinConnectionParameters
(string Address, string ApiKey, int MediaSourceId) : MediaServerConnectionParameters;

10
ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs

@ -1,7 +1,11 @@ @@ -1,7 +1,11 @@
namespace ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Domain;
public class JellyfinItemEtag
namespace ErsatzTV.Core.Jellyfin;
public class JellyfinItemEtag : MediaServerItemEtag
{
public string ItemId { get; set; }
public string Etag { get; set; }
public override string MediaServerItemId => ItemId;
public override string Etag { get; set; }
public override MediaItemState State { get; set; }
}

250
ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -1,34 +1,29 @@ @@ -1,34 +1,29 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Jellyfin;
public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
public class JellyfinMovieLibraryScanner :
MediaServerMovieLibraryScanner<JellyfinConnectionParameters, JellyfinLibrary, JellyfinMovie, JellyfinItemEtag>,
IJellyfinMovieLibraryScanner
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<JellyfinMovieLibraryScanner> _logger;
private readonly IJellyfinMovieRepository _jellyfinMovieRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public JellyfinMovieLibraryScanner(
IJellyfinApiClient jellyfinApiClient,
ISearchIndex searchIndex,
IMediator mediator,
IMovieRepository movieRepository,
IJellyfinMovieRepository jellyfinMovieRepository,
ISearchRepository searchRepository,
IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
@ -36,18 +31,19 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner @@ -36,18 +31,19 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<JellyfinMovieLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{
_jellyfinApiClient = jellyfinApiClient;
_searchIndex = searchIndex;
_mediator = mediator;
_movieRepository = movieRepository;
_searchRepository = searchRepository;
_jellyfinMovieRepository = jellyfinMovieRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@ -55,198 +51,54 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner @@ -55,198 +51,54 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath)
string ffprobePath,
CancellationToken cancellationToken)
{
List<JellyfinItemEtag> existingMovies = await _movieRepository.GetExistingJellyfinMovies(library);
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
// TODO: maybe get quick list of item ids and etags from api to compare first
// TODO: paging?
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
.GetJellyfinPathReplacements(library.MediaSourceId);
Either<BaseError, List<JellyfinMovie>> maybeMovies = await _jellyfinApiClient.GetMovieLibraryItems(
address,
apiKey,
library.MediaSourceId,
library.ItemId);
await maybeMovies.Match(
async movies =>
string GetLocalPath(JellyfinMovie movie)
{
var validMovies = new List<JellyfinMovie>();
foreach (JellyfinMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
{
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
return _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
movie.MediaVersions.Head().MediaFiles.Head().Path,
movie.GetHeadVersion().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning("Skipping jellyfin movie that does not exist at {Path}", localPath);
}
else
{
validMovies.Add(movie);
}
}
foreach (JellyfinMovie incoming in validMovies)
{
JellyfinMovie incomingMovie = incoming;
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<JellyfinItemEtag> maybeExisting =
existingMovies.Find(ie => ie.ItemId == incoming.ItemId);
var updateStatistics = false;
await maybeExisting.Match(
async existing =>
{
try
{
if (existing.Etag == incoming.Etag)
{
// _logger.LogDebug(
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
return;
}
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinMovie> maybeUpdated = await _movieRepository.UpdateJellyfin(incoming);
foreach (JellyfinMovie updated in maybeUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
incomingMovie = updated;
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error updating movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
},
async () =>
{
try
{
// _logger.LogDebug(
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
if (await _movieRepository.AddJellyfin(incoming))
{
await _searchIndex.AddItems(
_searchRepository,
new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
});
if (updateStatistics)
{
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
return await ScanLibrary(
_jellyfinMovieRepository,
new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
incomingMovie,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
false,
cancellationToken);
}
await refreshResult.Match(
async _ =>
{
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
return Task.CompletedTask;
});
}
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
// TODO: figure out how to rebuild playlists
}
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList();
var movieIds = existingMovies
.Filter(i => !incomingMovieIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> ids = await _movieRepository.RemoveMissingJellyfinMovies(library, movieIds);
await _searchIndex.RemoveItems(ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
_searchIndex.Commit();
return Unit.Default;
}
protected override Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetMovieLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
connectionParameters.MediaSourceId,
library.ItemId);
private async Task<Either<BaseError, bool>> UpdateSubtitles(JellyfinMovie movie, string localPath)
{
try
{
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected override Task<Option<MovieMetadata>> GetFullMetadata(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinMovie> result,
JellyfinMovie incoming,
bool deepScan) =>
Task.FromResult<Option<MovieMetadata>>(None);
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata(
MediaItemScanResult<JellyfinMovie> result,
MovieMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinMovie>>>(result);
}

2
ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs

@ -28,7 +28,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService @@ -28,7 +28,7 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
List<JellyfinPathReplacement> replacements =
await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId);
return GetReplacementJellyfinPath(replacements, path);
return GetReplacementJellyfinPath(replacements, path, log);
}
public string GetReplacementJellyfinPath(

312
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -1,10 +1,10 @@ @@ -1,10 +1,10 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
@ -55,7 +55,10 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -55,7 +55,10 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath)
string ffprobePath,
CancellationToken cancellationToken)
{
try
{
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
@ -71,10 +74,17 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -71,10 +74,17 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
library.MediaSourceId,
library.ItemId);
await maybeShows.Match(
async shows =>
foreach (BaseError error in maybeShows.LeftToSeq())
{
await ProcessShows(
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<JellyfinShow> shows in maybeShows.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessShows(
address,
apiKey,
library,
@ -82,8 +92,16 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -82,8 +92,16 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
ffprobePath,
pathReplacements,
existingShows,
shows);
shows,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
@ -96,23 +114,23 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -96,23 +114,23 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
await _searchIndex.RemoveItems(emptyShowIds);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
}
}
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task ProcessShows(
private async Task<Either<BaseError, Unit>> ProcessShows(
string address,
string apiKey,
JellyfinLibrary library,
@ -120,38 +138,22 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -120,38 +138,22 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
List<JellyfinItemEtag> existingShows,
List<JellyfinShow> shows)
List<JellyfinShow> shows,
CancellationToken cancellationToken)
{
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (JellyfinShow incoming in sortedShows)
{
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
{
if (existing.Etag == incoming.Etag)
if (cancellationToken.IsCancellationRequested)
{
return;
return new ScanCanceled();
}
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show}",
incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id;
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Option<JellyfinShow> updated = await _televisionRepository.Update(incoming);
if (updated.IsSome)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
},
async () =>
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
incoming.LibraryPathId = library.Paths.Head().Id;
@ -161,22 +163,41 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -161,22 +163,41 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
});
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinShow> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinShow updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
}
}
}
List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await _jellyfinApiClient.GetSeasonLibraryItems(address, apiKey, library.MediaSourceId, incoming.ItemId);
await maybeSeasons.Match(
async seasons =>
foreach (BaseError error in maybeSeasons.LeftToSeq())
{
await ProcessSeasons(
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<JellyfinSeason> seasons in maybeSeasons.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessSeasons(
address,
apiKey,
library,
@ -185,28 +206,30 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -185,28 +206,30 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
pathReplacements,
incoming,
existingSeasons,
seasons);
seasons,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
}
}
private async Task ProcessSeasons(
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address,
string apiKey,
JellyfinLibrary library,
@ -215,19 +238,37 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -215,19 +238,37 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
List<JellyfinPathReplacement> pathReplacements,
JellyfinShow show,
List<JellyfinItemEtag> existingSeasons,
List<JellyfinSeason> seasons)
List<JellyfinSeason> seasons,
CancellationToken cancellationToken)
{
foreach (JellyfinSeason incoming in seasons)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
if (maybeExisting.IsNone)
{
if (existing.Etag == incoming.Etag)
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
return;
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
@ -242,27 +283,11 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -242,27 +283,11 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { toIndex });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { toIndex });
}
}
}
},
async () =>
{
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
});
List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
@ -274,8 +299,15 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -274,8 +299,15 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
library.MediaSourceId,
incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>
foreach (BaseError error in maybeEpisodes.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<JellyfinEpisode> episodes in maybeEpisodes.RightToSeq())
{
var validEpisodes = new List<JellyfinEpisode>();
foreach (JellyfinEpisode episode in episodes)
@ -297,7 +329,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -297,7 +329,7 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
}
}
await ProcessEpisodes(
Either<BaseError, Unit> scanResult = await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
@ -306,32 +338,33 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -306,32 +338,33 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
pathReplacements,
incoming,
existingEpisodes,
validEpisodes);
validEpisodes,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
}
}
}
return Unit.Default;
}
private async Task ProcessEpisodes(
private async Task<Either<BaseError, Unit>> ProcessEpisodes(
string showName,
string seasonName,
JellyfinLibrary library,
@ -340,43 +373,36 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -340,43 +373,36 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
List<JellyfinPathReplacement> pathReplacements,
JellyfinSeason season,
List<JellyfinItemEtag> existingEpisodes,
List<JellyfinEpisode> episodes)
List<JellyfinEpisode> episodes,
CancellationToken cancellationToken)
{
foreach (JellyfinEpisode incoming in episodes)
{
JellyfinEpisode incomingEpisode = incoming;
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
JellyfinEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match(
async existing =>
if (maybeExisting.IsNone)
{
try
{
if (existing.Etag == incoming.Etag)
{
return;
}
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinEpisode updated in maybeUpdated)
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
incomingEpisode = updated;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
@ -384,26 +410,33 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -384,26 +410,33 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
},
async () =>
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
try
{
updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinEpisode updated in maybeUpdated)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
}
catch (Exception ex)
@ -411,10 +444,10 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -411,10 +444,10 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
});
}
if (updateStatistics)
{
@ -436,15 +469,18 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -436,15 +469,18 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
}
refreshResult.Match(
_ => { },
error => _logger.LogWarning(
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value));
error.Value);
}
}
}
return Unit.Default;
}
private async Task<Either<BaseError, bool>> UpdateSubtitles(JellyfinEpisode episode, string localPath)

2
ErsatzTV.Core/Metadata/LocalSubtitlesProvider.cs

@ -69,7 +69,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider @@ -69,7 +69,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
var subtitles = subtitleStreams.Map(Subtitle.FromMediaStream).ToList();
string mediaItemPath = await localPath.IfNoneAsync(() => mediaItem.GetHeadVersion().MediaFiles.Head().Path);
subtitles.AddRange(LocateExternalSubtitles(_languageCodes, mediaItemPath, saveFullPath));
await _metadataRepository.UpdateSubtitles(metadata, subtitles);
return await _metadataRepository.UpdateSubtitles(metadata, subtitles);
}
return false;

1
ErsatzTV.Core/Metadata/MediaItemScanResult.cs

@ -7,6 +7,7 @@ public class MediaItemScanResult<T> where T : MediaItem @@ -7,6 +7,7 @@ public class MediaItemScanResult<T> where T : MediaItem
public MediaItemScanResult(T item) => Item = item;
public T Item { get; set; }
public string LocalPath { get; set; }
public bool IsAdded { get; set; }
public bool IsUpdated { get; set; }

338
ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -0,0 +1,338 @@ @@ -0,0 +1,338 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata;
public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLibrary, TMovie, TEtag>
where TConnectionParameters : MediaServerConnectionParameters
where TLibrary : Library
where TMovie : Movie
where TEtag : MediaServerItemEtag
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger _logger;
private readonly IMediator _mediator;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
protected MediaServerMovieLibraryScanner(
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILocalFileSystem localFileSystem,
IMediator mediator,
ISearchIndex searchIndex,
ISearchRepository searchRepository,
ILogger logger)
{
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_localFileSystem = localFileSystem;
_mediator = mediator;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_logger = logger;
}
protected async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TMovie, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Either<BaseError, List<TMovie>> entries = await GetMovieLibraryItems(connectionParameters, library);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
return await ScanLibrary(
movieRepository,
connectionParameters,
library,
getLocalPath,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TMovie, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
List<TMovie> movieEntries,
bool deepScan,
CancellationToken cancellationToken)
{
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
foreach (TMovie incoming in movieEntries)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
string localPath = getLocalPath(incoming);
if (await ShouldScanItem(movieRepository, library, existingMovies, incoming, localPath, deepScan) == false)
{
continue;
}
Either<BaseError, MediaItemScanResult<TMovie>> maybeMovie = await movieRepository
.GetOrAdd(library, incoming)
.MapT(
result =>
{
result.LocalPath = localPath;
return result;
})
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan))
.BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath))
.BindT(UpdateSubtitles);
if (maybeMovie.IsLeft)
{
foreach (BaseError error in maybeMovie.LeftToSeq())
{
_logger.LogWarning(
"Error processing movie {Title}: {Error}",
incoming.MovieMetadata.Head().Title,
error.Value);
}
continue;
}
foreach (MediaItemScanResult<TMovie> result in maybeMovie.RightToSeq())
{
await movieRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (_localFileSystem.FileExists(result.LocalPath))
{
if (await movieRepository.FlagNormal(library, result.Item))
{
result.IsUpdated = true;
}
}
else
{
Option<int> flagResult = await movieRepository.FlagUnavailable(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
}
}
// trash items that are no longer present on the media server
var fileNotFoundItemIds = existingMovies.Map(m => m.MediaServerItemId)
.Except(movieEntries.Map(MediaServerItemId)).ToList();
List<int> ids = await movieRepository.FlagFileNotFound(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
return Unit.Default;
}
protected abstract string MediaServerItemId(TMovie movie);
protected abstract string MediaServerEtag(TMovie movie);
protected abstract Task<Either<BaseError, List<TMovie>>> GetMovieLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract Task<Option<MovieMetadata>> GetFullMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMovie> result,
TMovie incoming,
bool deepScan);
protected abstract Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata(
MediaItemScanResult<TMovie> result,
MovieMetadata fullMetadata);
private async Task<bool> ShouldScanItem(
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
TLibrary library,
List<TEtag> existingMovies,
TMovie incoming,
string localPath,
bool deepScan)
{
// deep scan will always pull every movie
if (deepScan)
{
return true;
}
Option<TEtag> maybeExisting =
existingMovies.Find(m => m.MediaServerItemId == MediaServerItemId(incoming));
string existingItemId = await maybeExisting.Map(e => e.MediaServerItemId).IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
if (existingState == MediaItemState.Unavailable)
{
// skip scanning unavailable items that still don't exist locally
if (!_localFileSystem.FileExists(localPath))
{
return false;
}
}
else if (existingItemId == MediaServerItemId(incoming))
{
// item is unchanged, but file does not exist
// don't scan, but mark as unavailable
if (!_localFileSystem.FileExists(localPath))
{
foreach (int id in await movieRepository.FlagUnavailable(library, incoming))
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
}
}
return false;
}
if (maybeExisting.IsNone)
{
_logger.LogDebug("INSERT: new movie {Movie}", incoming.MovieMetadata.Head().Title);
}
else
{
_logger.LogDebug("UPDATE: Etag has changed for movie {Movie}", incoming.MovieMetadata.Head().Title);
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TMovie> result,
TMovie incoming,
bool deepScan)
{
foreach (MovieMetadata fullMetadata in await GetFullMetadata(
connectionParameters,
library,
result,
incoming,
deepScan))
{
// TODO: move some of this code into this scanner
// will have to merge JF, Emby, Plex logic
return await UpdateMetadata(result, fullMetadata);
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateStatistics(
MediaItemScanResult<TMovie> result,
TMovie incoming,
string ffmpegPath,
string ffprobePath)
{
TMovie existing = result.Item;
if (result.IsAdded || MediaServerItemId(existing) != MediaServerItemId(incoming) ||
existing.MediaVersions.Head().Streams.Count == 0)
{
if (_localFileSystem.FileExists(result.LocalPath))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
existing,
result.LocalPath);
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
result.LocalPath,
error.Value);
}
foreach (bool _ in refreshResult.RightToSeq())
{
result.IsUpdated = true;
}
}
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateSubtitles(
MediaItemScanResult<TMovie> existing)
{
try
{
// skip checking subtitles for files that don't exist locally
if (!_localFileSystem.FileExists(existing.LocalPath))
{
return existing;
}
if (await _localSubtitlesProvider.UpdateSubtitles(existing.Item, existing.LocalPath, false))
{
return existing;
}
return BaseError.New("Failed to update local subtitles");
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

19
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -71,6 +72,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -71,6 +72,8 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
decimal progressSpread = progressMax - progressMin;
@ -86,6 +89,11 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -86,6 +89,11 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
while (folderQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
@ -181,10 +189,17 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner @@ -181,10 +189,17 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(
MediaItemScanResult<Movie> result)

122
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -72,6 +73,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -72,6 +73,8 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
decimal progressSpread = progressMax - progressMin;
@ -83,10 +86,15 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -83,10 +86,15 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
foreach (string artistFolder in allArtistFolders)
{
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
await FindOrCreateArtist(libraryPath.Id, artistFolder)
@ -98,12 +106,23 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -98,12 +106,23 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
ArtworkKind.Thumbnail,
cancellationToken))
.BindT(
artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
artist => UpdateArtworkForArtist(
artist,
artistFolder,
ArtworkKind.FanArt,
cancellationToken));
foreach (BaseError error in maybeArtist.LeftToSeq())
{
_logger.LogWarning(
"Error processing artist in folder {Folder}: {Error}",
artistFolder,
error.Value);
}
await maybeArtist.Match(
async result =>
foreach (MediaItemScanResult<Artist> result in maybeArtist.RightToSeq())
{
await ScanMusicVideos(
Either<BaseError, Unit> scanResult = await ScanMusicVideos(
libraryPath,
ffmpegPath,
ffprobePath,
@ -111,6 +130,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -111,6 +130,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
artistFolder,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -119,15 +143,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -119,15 +143,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
},
error =>
{
_logger.LogWarning(
"Error processing artist in folder {Folder}: {Error}",
artistFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
}
}
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
@ -158,9 +174,17 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -158,9 +174,17 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds);
_searchIndex.Commit();
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> FindOrCreateArtist(
int libraryPathId,
@ -244,7 +268,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -244,7 +268,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
}
}
private async Task ScanMusicVideos(
private async Task<Either<BaseError, Unit>> ScanMusicVideos(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
@ -257,6 +281,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -257,6 +281,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
while (folderQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
string musicVideoFolder = folderQueue.Dequeue();
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
@ -293,8 +322,12 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -293,8 +322,12 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
.BindT(UpdateSubtitles)
.BindT(FlagNormal);
await maybeMusicVideo.Match(
async result =>
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
{
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
}
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq())
{
if (result.IsAdded)
{
@ -306,24 +339,37 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -306,24 +339,37 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag);
},
error =>
{
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
MediaItemScanResult<MusicVideo> result)
{
try
{
MusicVideo musicVideo = result.Item;
await LocateNfoFile(musicVideo).Match(
async nfoFile =>
Option<string> maybeNfoFile = LocateNfoFile(musicVideo);
if (maybeNfoFile.IsNone)
{
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
{
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
{
result.IsUpdated = true;
}
}
}
foreach (string nfoFile in maybeNfoFile)
{
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
@ -338,21 +384,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -338,21 +384,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
result.IsUpdated = true;
}
}
},
async () =>
{
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
{
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
{
result.IsUpdated = true;
}
}
});
return result;
}
@ -364,8 +396,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -364,8 +396,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
}
private Option<string> LocateNfoFileForArtist(string artistFolder) =>
Optional(Path.Combine(artistFolder, "artist.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
Optional(Path.Combine(artistFolder, "artist.nfo")).Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
{
@ -398,12 +429,13 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan @@ -398,12 +429,13 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
try
{
MusicVideo musicVideo = result.Item;
await LocateThumbnail(musicVideo).IfSomeAsync(
async thumbnailFile =>
Option<string> maybeThumbnail = LocateThumbnail(musicVideo);
foreach (string thumbnailFile in maybeThumbnail)
{
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
});
}
return result;
}

42
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -67,7 +68,10 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -67,7 +68,10 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
string ffmpegPath,
string ffprobePath,
decimal progressMin,
decimal progressMax)
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
decimal progressSpread = progressMax - progressMin;
@ -89,9 +93,15 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -89,9 +93,15 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
while (folderQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
string otherVideoFolder = folderQueue.Dequeue();
foldersCompleted++;
@ -116,7 +126,8 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -116,7 +126,8 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
etag)
{
continue;
}
@ -134,8 +145,12 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -134,8 +145,12 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
.BindT(UpdateSubtitles)
.BindT(FlagNormal);
await maybeVideo.Match(
async result =>
foreach (BaseError error in maybeVideo.LeftToSeq())
{
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
}
foreach (MediaItemScanResult<OtherVideo> result in maybeVideo.RightToSeq())
{
if (result.IsAdded)
{
@ -147,12 +162,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -147,12 +162,7 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag);
},
error =>
{
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
}
@ -174,9 +184,17 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan @@ -174,9 +184,17 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateMetadata(
MediaItemScanResult<OtherVideo> result)

51
ErsatzTV.Core/Metadata/SongFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
@ -67,6 +68,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -67,6 +68,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
decimal progressSpread = progressMax - progressMin;
@ -88,9 +91,15 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -88,9 +91,15 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
while (folderQueue.Count > 0)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
string songFolder = folderQueue.Dequeue();
foldersCompleted++;
@ -115,7 +124,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -115,7 +124,8 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
etag)
{
continue;
}
@ -133,8 +143,12 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -133,8 +143,12 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
.BindT(FlagNormal);
await maybeSong.Match(
async result =>
foreach (BaseError error in maybeSong.LeftToSeq())
{
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
}
foreach (MediaItemScanResult<Song> result in maybeSong.RightToSeq())
{
if (result.IsAdded)
{
@ -146,12 +160,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -146,12 +160,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag);
},
error =>
{
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
}
}
}
@ -173,9 +182,17 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -173,9 +182,17 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit();
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(
MediaItemScanResult<Song> result,
@ -231,9 +248,14 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -231,9 +248,14 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
}
Song song = result.Item;
Option<string> maybeThumbnail = LocateThumbnail(song);
if (maybeThumbnail.IsNone)
{
await ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken);
}
await LocateThumbnail(song).Match(
async thumbnailFile =>
foreach (string thumbnailFile in maybeThumbnail)
{
SongMetadata metadata = song.SongMetadata.Head();
await RefreshArtwork(
@ -243,8 +265,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner @@ -243,8 +265,7 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
ffmpegPath,
None,
cancellationToken);
},
() => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken));
}
return result;
}

93
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using Bugsnag;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
@ -71,6 +72,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -71,6 +72,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
decimal progressMin,
decimal progressMax,
CancellationToken cancellationToken)
{
try
{
decimal progressSpread = progressMax - progressMin;
@ -81,6 +84,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -81,6 +84,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
foreach (string showFolder in allShowFolders)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
@ -91,7 +99,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -91,7 +99,8 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
.BindT(show => UpdateMetadataForShow(show, showFolder))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
.BindT(
show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
foreach (BaseError error in maybeShow.LeftToSeq())
{
@ -103,7 +112,18 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -103,7 +112,18 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
foreach (MediaItemScanResult<Show> result in maybeShow.RightToSeq())
{
await ScanSeasons(libraryPath, ffmpegPath, ffprobePath, result.Item, showFolder, cancellationToken);
Either<BaseError, Unit> scanResult = await ScanSeasons(
libraryPath,
ffmpegPath,
ffprobePath,
result.Item,
showFolder,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
if (result.IsAdded)
{
@ -137,9 +157,17 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -137,9 +157,17 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
await _searchIndex.RemoveItems(ids);
_searchIndex.Commit();
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
int libraryPathId,
@ -147,12 +175,16 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -147,12 +175,16 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
{
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
return await maybeShow.Match(
show => Right<BaseError, MediaItemScanResult<Show>>(new MediaItemScanResult<Show>(show)).AsTask(),
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
foreach (Show show in maybeShow)
{
return new MediaItemScanResult<Show>(show);
}
private async Task<Unit> ScanSeasons(
return await _televisionRepository.AddShow(libraryPathId, showFolder, metadata);
}
private async Task<Either<BaseError, Unit>> ScanSeasons(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
@ -163,6 +195,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -163,6 +195,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
string etag = FolderEtag.CalculateWithSubfolders(seasonFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == seasonFolder)
@ -175,44 +212,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -175,44 +212,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
}
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
await maybeSeasonNumber.IfSomeAsync(
async seasonNumber =>
foreach (int seasonNumber in maybeSeasonNumber)
{
Either<BaseError, Season> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
.BindT(EnsureMetadataExists)
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
await maybeSeason.Match(
async season =>
foreach (BaseError error in maybeSeason.LeftToSeq())
{
_logger.LogWarning(
"Error processing season in folder {Folder}: {Error}",
seasonFolder,
error.Value);
}
foreach (Season season in maybeSeason.RightToSeq())
{
await ScanEpisodes(
Either<BaseError, Unit> scanResult = await ScanEpisodes(
libraryPath,
ffmpegPath,
ffprobePath,
season,
seasonFolder,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
season.Show = show;
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
},
error =>
{
_logger.LogWarning(
"Error processing season in folder {Folder}: {Error}",
seasonFolder,
error.Value);
return Task.FromResult(Unit.Default);
});
});
}
}
}
return Unit.Default;
}
private async Task<Unit> ScanEpisodes(
private async Task<Either<BaseError, Unit>> ScanEpisodes(
LibraryPath libraryPath,
string ffmpegPath,
string ffprobePath,
@ -461,14 +502,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan @@ -461,14 +502,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
}
private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateNfoFile(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s));
return Optional(Path.ChangeExtension(path, "nfo")).Filter(s => _localFileSystem.FileExists(s));
}
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)

7
ErsatzTV.Core/Plex/PlexConnectionParameters.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.MediaServer;
namespace ErsatzTV.Core.Plex;
public record PlexConnectionParameters
(PlexConnection Connection, PlexServerAuthToken Token) : MediaServerConnectionParameters;

7
ErsatzTV.Core/Plex/PlexItemEtag.cs

@ -2,9 +2,10 @@ @@ -2,9 +2,10 @@
namespace ErsatzTV.Core.Plex;
public class PlexItemEtag
public class PlexItemEtag : MediaServerItemEtag
{
public string Key { get; set; }
public string Etag { get; set; }
public MediaItemState State { get; set; }
public override string MediaServerItemId => Key;
public override string Etag { get; set; }
public override MediaItemState State { get; set; }
}

34
ErsatzTV.Core/Plex/PlexLibraryScanner.cs

@ -25,34 +25,34 @@ public abstract class PlexLibraryScanner @@ -25,34 +25,34 @@ public abstract class PlexLibraryScanner
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await maybeIncomingArtwork.Match(
async incomingArtwork =>
if (maybeIncomingArtwork.IsNone)
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
}
foreach (Artwork incomingArtwork in maybeIncomingArtwork)
{
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await maybeExistingArtwork.Match(
async existingArtwork =>
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
},
async () =>
if (maybeExistingArtwork.IsNone)
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
});
},
async () =>
}
foreach (Artwork existingArtwork in maybeExistingArtwork)
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
});
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
}
}
return true;
}

368
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
@ -10,21 +10,17 @@ using Microsoft.Extensions.Logging; @@ -10,21 +10,17 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Plex;
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
public class PlexMovieLibraryScanner :
MediaServerMovieLibraryScanner<PlexConnectionParameters, PlexLibrary, PlexMovie, PlexItemEtag>,
IPlexMovieLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository;
private readonly IPlexMovieRepository _plexMovieRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient,
@ -40,20 +36,21 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -40,20 +36,21 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{
_plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexMovieRepository = plexMovieRepository;
_plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
}
@ -66,262 +63,69 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -66,262 +63,69 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
token);
List<PlexPathReplacement> pathReplacements =
await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId);
foreach (BaseError error in entries.LeftToSeq())
string GetLocalPath(PlexMovie movie)
{
return error;
return _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
movie.GetHeadVersion().MediaFiles.Head().Path,
false);
}
return await ScanLibrary(
connection,
token,
_plexMovieRepository,
new PlexConnectionParameters(connection, token),
library,
GetLocalPath,
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);
protected override string MediaServerItemId(PlexMovie movie) => movie.Key;
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
protected override string MediaServerEtag(PlexMovie movie) => movie.Etag;
foreach (PlexMovie incoming in movieEntries)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false)
{
continue;
}
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
.BindT(
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
if (maybeMovie.IsLeft)
{
foreach (BaseError error in maybeMovie.LeftToSeq())
{
_logger.LogWarning(
"Error processing plex movie at {Key}: {Error}",
incoming.Key,
error.Value);
}
continue;
}
foreach (MediaItemScanResult<PlexMovie> result in maybeMovie.RightToSeq())
{
await _movieRepository.SetPlexEtag(result.Item, incoming.Etag);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
if (_localFileSystem.FileExists(localPath))
{
await _plexMovieRepository.FlagNormal(library, result.Item);
}
else
{
await _plexMovieRepository.FlagUnavailable(library, result.Item);
}
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
}
}
// trash items that are no longer present on the media server
var fileNotFoundKeys = existingMovies.Map(m => m.Key).Except(movieEntries.Map(m => m.Key)).ToList();
List<int> ids = await _plexMovieRepository.FlagFileNotFound(library, fileNotFoundKeys);
await _searchIndex.RebuildItems(_searchRepository, ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
return Unit.Default;
}
protected override Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetMovieLibraryContents(
library,
connectionParameters.Connection,
connectionParameters.Token);
private async Task<bool> ShouldScanItem(
protected override async Task<Option<MovieMetadata>> GetFullMetadata(
PlexConnectionParameters connectionParameters,
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))
{
foreach (int id in await _plexMovieRepository.FlagUnavailable(library, incoming))
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
}
}
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
return false;
}
_logger.LogDebug(
"UPDATE: Etag has changed for movie {Movie}",
incoming.MovieMetadata.Head().Title);
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
string ffmpegPath,
string ffprobePath)
{
PlexMovie existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (result.IsAdded || existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0)
{
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
{
if (incomingFile.Path != existingFile.Path)
{
_logger.LogDebug(
"Plex movie has moved from {OldPath} to {NewPath}",
existingFile.Path,
incomingFile.Path);
existingFile.Path = incomingFile.Path;
await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path);
}
}
}
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
// only refresh statistics if the file exists
if (_localFileSystem.FileExists(localPath))
bool deepScan)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
foreach (BaseError error in refreshResult.LeftToSeq())
if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
}
Either<BaseError, MovieMetadata> maybeMetadata = await _plexServerApiClient.GetMovieMetadata(
library,
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token);
foreach (bool _ in refreshResult.RightToSeq())
{
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
foreach (BaseError error in maybeMetadata.LeftToSeq())
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
_logger.LogWarning("Failed to get movie metadata from Plex: {Error}", error.ToString());
}
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
}
}
return maybeMetadata.ToOption();
}
return result;
return None;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
protected override async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
MovieMetadata fullMetadata)
{
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
@ -329,17 +133,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -329,17 +133,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
_logger.LogDebug(
"Refreshing {Attribute} for {Title}",
"Plex Metadata",
existing.MovieMetadata.Head().Title);
existingMetadata.Title);
Either<BaseError, MovieMetadata> maybeMetadata =
await _plexServerApiClient.GetMovieMetadata(
library,
incoming.Key.Split("/").Last(),
connection,
token);
foreach (MovieMetadata fullMetadata in maybeMetadata.RightToSeq())
{
if (existingMetadata.MetadataKind != MetadataKind.External)
{
existingMetadata.MetadataKind = MetadataKind.External;
@ -518,54 +313,63 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -518,54 +313,63 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
}
}
bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt);
if (poster || fanArt)
{
result.IsUpdated = true;
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
}
// TODO: update other metadata?
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateSubtitles(
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
private async Task<bool> UpdateArtworkIfNeeded(
Domain.Metadata existingMetadata,
Domain.Metadata incomingMetadata,
ArtworkKind artworkKind)
{
try
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false);
return result;
}
catch (Exception ex)
if (maybeIncomingArtwork.IsNone)
{
return BaseError.New(ex.ToString());
}
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateArtwork(
MediaItemScanResult<PlexMovie> result,
PlexMovie incoming)
foreach (Artwork incomingArtwork in maybeIncomingArtwork)
{
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
if (poster || fanArt)
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
if (maybeExistingArtwork.IsNone)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
}
return result;
foreach (Artwork existingArtwork in maybeExistingArtwork)
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
}
}
return true;
}
return false;
}
}

348
ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs

@ -0,0 +1,348 @@ @@ -0,0 +1,348 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class EmbyMovieRepository : IEmbyMovieRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public EmbyMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public async Task<List<EmbyItemEtag>> GetExistingMovies(EmbyLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
@"SELECT ItemId, Etag, MI.State FROM EmbyMovie
INNER JOIN Movie M on EmbyMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<bool> FlagNormal(EmbyLibrary library, EmbyMovie 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 EmbyMovie.Id FROM EmbyMovie
INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE EmbyMovie.ItemId = @ItemId)",
new { LibraryId = library.Id, movie.ItemId }).Map(count => count > 0);
}
public async Task<Option<int>> FlagUnavailable(EmbyLibrary library, EmbyMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT EmbyMovie.Id FROM EmbyMovie
INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE EmbyMovie.ItemId = @ItemId",
new { LibraryId = library.Id, movie.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<List<int>> FlagFileNotFound(EmbyLibrary library, List<string> movieItemIds)
{
if (movieItemIds.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 EmbyMovie ON EmbyMovie.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE EmbyMovie.ItemId IN @MovieItemIds",
new { LibraryId = library.Id, MovieItemIds = movieItemIds })
.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<Either<BaseError, MediaItemScanResult<EmbyMovie>>> GetOrAdd(EmbyLibrary library, EmbyMovie item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(m => m.ItemId, m => m.ItemId == item.ItemId);
foreach (EmbyMovie embyMovie in maybeExisting)
{
var result = new MediaItemScanResult<EmbyMovie>(embyMovie) { IsAdded = false };
if (embyMovie.Etag != item.Etag)
{
await UpdateMovie(dbContext, embyMovie, item);
result.IsUpdated = true;
}
return result;
}
return await AddMovie(dbContext, library, item);
}
public async Task<Unit> SetEtag(EmbyMovie movie, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyMovie SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
}
private async Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> AddMovie(
TvContext dbContext,
EmbyLibrary library,
EmbyMovie movie)
{
try
{
// blank out etag for initial save in case other updates fail
movie.Etag = string.Empty;
movie.LibraryPathId = library.Paths.Head().Id;
await dbContext.AddAsync(movie);
await dbContext.SaveChangesAsync();
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyMovie>(movie) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
private async Task UpdateMovie(TvContext dbContext, EmbyMovie existing, EmbyMovie incoming)
{
// library path is used for search indexing later
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
// metadata
MovieMetadata metadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
metadata.MetadataKind = incomingMetadata.MetadataKind;
metadata.ContentRating = incomingMetadata.ContentRating;
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
metadata.Year = incomingMetadata.Year;
metadata.Tagline = incomingMetadata.Tagline;
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
// genres
foreach (Genre genre in metadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Remove(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Add(genre);
}
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Tags.Add(tag);
}
// studios
foreach (Studio studio in metadata.Studios
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Remove(studio);
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Add(studio);
}
// actors
foreach (Actor actor in metadata.Actors
.Filter(
a => incomingMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
metadata.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
metadata.Actors.Add(actor);
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
Artwork incomingPoster =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (incomingPoster != null)
{
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (poster == null)
{
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
metadata.Artwork.Add(poster);
}
poster.Path = incomingPoster.Path;
poster.DateAdded = incomingPoster.DateAdded;
poster.DateUpdated = incomingPoster.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (incomingFanArt != null)
{
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (fanArt == null)
{
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
metadata.Artwork.Add(fanArt);
}
fanArt.Path = incomingFanArt.Path;
fanArt.DateAdded = incomingFanArt.DateAdded;
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
// media file
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
await dbContext.SaveChangesAsync();
}
}

353
ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs

@ -0,0 +1,353 @@ @@ -0,0 +1,353 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class JellyfinMovieRepository : IJellyfinMovieRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public JellyfinMovieRepository(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<JellyfinItemEtag>> GetExistingMovies(JellyfinLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
@"SELECT ItemId, Etag, MI.State FROM JellyfinMovie
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<bool> FlagNormal(JellyfinLibrary library, JellyfinMovie 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 JellyfinMovie.Id FROM JellyfinMovie
INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinMovie.ItemId = @ItemId)",
new { LibraryId = library.Id, movie.ItemId }).Map(count => count > 0);
}
public async Task<Option<int>> FlagUnavailable(JellyfinLibrary library, JellyfinMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
movie.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinMovie.ItemId = @ItemId",
new { LibraryId = library.Id, movie.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
public async Task<List<int>> FlagFileNotFound(JellyfinLibrary library, List<string> movieItemIds)
{
if (movieItemIds.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 JellyfinMovie ON JellyfinMovie.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE JellyfinMovie.ItemId IN @MovieItemIds",
new { LibraryId = library.Id, MovieItemIds = movieItemIds })
.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<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> GetOrAdd(
JellyfinLibrary library,
JellyfinMovie item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(m => m.ItemId, m => m.ItemId == item.ItemId);
foreach (JellyfinMovie jellyfinMovie in maybeExisting)
{
var result = new MediaItemScanResult<JellyfinMovie>(jellyfinMovie) { IsAdded = false };
if (jellyfinMovie.Etag != item.Etag)
{
await UpdateMovie(dbContext, jellyfinMovie, item);
result.IsUpdated = true;
}
return result;
}
return await AddMovie(dbContext, library, item);
}
public async Task<Unit> SetEtag(JellyfinMovie movie, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinMovie SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
}
private async Task UpdateMovie(TvContext dbContext, JellyfinMovie existing, JellyfinMovie incoming)
{
// library path is used for search indexing later
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
existing.Etag = incoming.Etag;
// metadata
MovieMetadata metadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
metadata.MetadataKind = incomingMetadata.MetadataKind;
metadata.ContentRating = incomingMetadata.ContentRating;
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
metadata.Year = incomingMetadata.Year;
metadata.Tagline = incomingMetadata.Tagline;
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
// genres
foreach (Genre genre in metadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Remove(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Add(genre);
}
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Tags.Add(tag);
}
// studios
foreach (Studio studio in metadata.Studios
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Remove(studio);
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Add(studio);
}
// actors
foreach (Actor actor in metadata.Actors
.Filter(
a => incomingMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
metadata.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
metadata.Actors.Add(actor);
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
Artwork incomingPoster =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (incomingPoster != null)
{
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (poster == null)
{
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
metadata.Artwork.Add(poster);
}
poster.Path = incomingPoster.Path;
poster.DateAdded = incomingPoster.DateAdded;
poster.DateUpdated = incomingPoster.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (incomingFanArt != null)
{
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (fanArt == null)
{
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
metadata.Artwork.Add(fanArt);
}
fanArt.Path = incomingFanArt.Path;
fanArt.DateAdded = incomingFanArt.DateAdded;
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
// media file
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
await dbContext.SaveChangesAsync();
}
private async Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> AddMovie(
TvContext dbContext,
JellyfinLibrary library,
JellyfinMovie movie)
{
try
{
// blank out etag for initial save in case other updates fail
movie.Etag = string.Empty;
movie.LibraryPathId = library.Paths.Head().Id;
await dbContext.AddAsync(movie);
await dbContext.SaveChangesAsync();
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinMovie>(movie) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

7
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -513,6 +513,8 @@ public class MetadataRepository : IMetadataRepository @@ -513,6 +513,8 @@ public class MetadataRepository : IMetadataRepository
.ToList();
var toUpdate = subtitles.Except(toAdd).ToList();
if (toAdd.Any() || toRemove.Any() || toUpdate.Any())
{
// add
existing.Subtitles.AddRange(toAdd);
@ -537,6 +539,11 @@ public class MetadataRepository : IMetadataRepository @@ -537,6 +539,11 @@ public class MetadataRepository : IMetadataRepository
return await dbContext.SaveChangesAsync() > 0;
}
// nothing to do
return true;
}
// no metadata
return false;
}

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

@ -1,12 +1,8 @@ @@ -1,12 +1,8 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -95,48 +91,6 @@ public class MovieRepository : IMovieRepository @@ -95,48 +91,6 @@ public class MovieRepository : IMovieRepository
async () => await AddMovie(dbContext, libraryPath.Id, path));
}
public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(
PlexLibrary library,
PlexMovie item)
{
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
Option<PlexMovie> maybeExisting = await context.PlexMovies
.AsNoTracking()
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexMovie =>
Right<BaseError, MediaItemScanResult<PlexMovie>>(
new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false }).AsTask(),
async () => await AddPlexMovie(context, library, item));
}
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -241,19 +195,6 @@ public class MovieRepository : IMovieRepository @@ -241,19 +195,6 @@ public class MovieRepository : IMovieRepository
.Map(result => result > 0);
}
public async Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT Key, Etag, MI.State FROM PlexMovie
INNER JOIN Movie M on PlexMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<bool> UpdateSortTitle(MovieMetadata movieMetadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -262,530 +203,6 @@ public class MovieRepository : IMovieRepository @@ -262,530 +203,6 @@ public class MovieRepository : IMovieRepository
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
}
public async Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
@"SELECT ItemId, Etag FROM JellyfinMovie
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds",
new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<bool> AddJellyfin(JellyfinMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(movie);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
{
JellyfinMovie existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
movie.LibraryPath = existing.LibraryPath;
movie.Id = existing.Id;
existing.Etag = movie.Etag;
// metadata
MovieMetadata metadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
metadata.MetadataKind = incomingMetadata.MetadataKind;
metadata.ContentRating = incomingMetadata.ContentRating;
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
metadata.Year = incomingMetadata.Year;
metadata.Tagline = incomingMetadata.Tagline;
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
// genres
foreach (Genre genre in metadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Remove(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Add(genre);
}
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Tags.Add(tag);
}
// studios
foreach (Studio studio in metadata.Studios
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Remove(studio);
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Add(studio);
}
// actors
foreach (Actor actor in metadata.Actors
.Filter(
a => incomingMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
metadata.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
metadata.Actors.Add(actor);
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
Artwork incomingPoster =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (incomingPoster != null)
{
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (poster == null)
{
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
metadata.Artwork.Add(poster);
}
poster.Path = incomingPoster.Path;
poster.DateAdded = incomingPoster.DateAdded;
poster.DateUpdated = incomingPoster.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (incomingFanArt != null)
{
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (fanArt == null)
{
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
metadata.Artwork.Add(fanArt);
}
fanArt.Path = incomingFanArt.Path;
fanArt.DateAdded = incomingFanArt.DateAdded;
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = movie.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
// media file
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<List<EmbyItemEtag>> GetExistingEmbyMovies(EmbyLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
@"SELECT ItemId, Etag FROM EmbyMovie
INNER JOIN Movie M on EmbyMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT EmbyMovie.Id FROM EmbyMovie
INNER JOIN Movie M on EmbyMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds",
new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<bool> AddEmby(EmbyMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(movie);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == movie.ItemId)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
{
EmbyMovie existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
movie.LibraryPath = existing.LibraryPath;
movie.Id = existing.Id;
existing.Etag = movie.Etag;
// metadata
MovieMetadata metadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
metadata.MetadataKind = incomingMetadata.MetadataKind;
metadata.ContentRating = incomingMetadata.ContentRating;
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
metadata.Year = incomingMetadata.Year;
metadata.Tagline = incomingMetadata.Tagline;
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
// genres
foreach (Genre genre in metadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Remove(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Add(genre);
}
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Tags.Add(tag);
}
// studios
foreach (Studio studio in metadata.Studios
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Remove(studio);
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Add(studio);
}
// actors
foreach (Actor actor in metadata.Actors
.Filter(
a => incomingMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
metadata.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
metadata.Actors.Add(actor);
}
// directors
foreach (Director director in metadata.Directors
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Remove(director);
}
foreach (Director director in incomingMetadata.Directors
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
.ToList())
{
metadata.Directors.Add(director);
}
// writers
foreach (Writer writer in metadata.Writers
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Remove(writer);
}
foreach (Writer writer in incomingMetadata.Writers
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
.ToList())
{
metadata.Writers.Add(writer);
}
// guids
foreach (MetadataGuid guid in metadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Remove(guid);
}
foreach (MetadataGuid guid in incomingMetadata.Guids
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
metadata.Guids.Add(guid);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
Artwork incomingPoster =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (incomingPoster != null)
{
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (poster == null)
{
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
metadata.Artwork.Add(poster);
}
poster.Path = incomingPoster.Path;
poster.DateAdded = incomingPoster.DateAdded;
poster.DateUpdated = incomingPoster.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (incomingFanArt != null)
{
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (fanArt == null)
{
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
metadata.Artwork.Add(fanArt);
}
fanArt.Path = incomingFanArt.Path;
fanArt.DateAdded = incomingFanArt.DateAdded;
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = movie.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
// media file
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<bool> AddDirector(MovieMetadata metadata, Director director)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -810,14 +227,6 @@ public class MovieRepository : IMovieRepository @@ -810,14 +227,6 @@ public class MovieRepository : IMovieRepository
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexMovie movie, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
}
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext,
int libraryPathId,
@ -852,33 +261,4 @@ public class MovieRepository : IMovieRepository @@ -852,33 +261,4 @@ public class MovieRepository : IMovieRepository
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddPlexMovie(
TvContext context,
PlexLibrary library,
PlexMovie item)
{
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
await context.PlexMovies.AddAsync(item);
await context.SaveChangesAsync();
// restore etag
item.Etag = etag;
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

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

@ -1,6 +1,10 @@ @@ -1,6 +1,10 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -11,6 +15,19 @@ public class PlexMovieRepository : IPlexMovieRepository @@ -11,6 +15,19 @@ public class PlexMovieRepository : IPlexMovieRepository
public PlexMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
public async Task<List<PlexItemEtag>> GetExistingMovies(PlexLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT Key, Etag, MI.State FROM PlexMovie
INNER JOIN Movie M on PlexMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
}
public async Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -73,4 +90,77 @@ public class PlexMovieRepository : IPlexMovieRepository @@ -73,4 +90,77 @@ public class PlexMovieRepository : IPlexMovieRepository
return ids;
}
public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexMovie> maybeExisting = await dbContext.PlexMovies
.AsNoTracking()
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MovieMetadata)
.ThenInclude(mm => mm.Guids)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(i => i.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(i => i.Key, i => i.Key == item.Key);
foreach (PlexMovie plexMovie in maybeExisting)
{
return new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false };
}
return await AddMovie(dbContext, library, item);
}
public async Task<Unit> SetEtag(PlexMovie movie, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddMovie(
TvContext dbContext,
PlexLibrary library,
PlexMovie item)
{
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
await dbContext.PlexMovies.AddAsync(item);
await dbContext.SaveChangesAsync();
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

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

@ -811,7 +811,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -811,7 +811,6 @@ public class TelevisionRepository : ITelevisionRepository
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
@ -819,9 +818,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -819,9 +818,6 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexShows.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
@ -840,7 +836,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -840,7 +836,6 @@ public class TelevisionRepository : ITelevisionRepository
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
@ -848,9 +843,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -848,9 +843,6 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return item;
@ -874,7 +866,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -874,7 +866,6 @@ public class TelevisionRepository : ITelevisionRepository
}
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
@ -891,9 +882,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -891,9 +882,6 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();

2
ErsatzTV/Startup.cs

@ -382,6 +382,7 @@ public class Startup @@ -382,6 +382,7 @@ public class Startup
services.AddScoped<IJellyfinPathReplacementService, JellyfinPathReplacementService>();
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>();
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
services.AddScoped<IJellyfinMovieRepository, JellyfinMovieRepository>();
services.AddScoped<IEmbyApiClient, EmbyApiClient>();
services.AddScoped<IEmbyMovieLibraryScanner, EmbyMovieLibraryScanner>();
services.AddScoped<IEmbyTelevisionLibraryScanner, EmbyTelevisionLibraryScanner>();
@ -389,6 +390,7 @@ public class Startup @@ -389,6 +390,7 @@ public class Startup
services.AddScoped<IEmbyPathReplacementService, EmbyPathReplacementService>();
services.AddScoped<IEmbyTelevisionRepository, EmbyTelevisionRepository>();
services.AddScoped<IEmbyCollectionRepository, EmbyCollectionRepository>();
services.AddScoped<IEmbyMovieRepository, EmbyMovieRepository>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();

Loading…
Cancel
Save