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. 11
      ErsatzTV.Application/Health/Queries/GetAllHealthCheckResultsHandler.cs
  4. 40
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs
  5. 38
      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. 267
      ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs
  11. 12
      ErsatzTV.Core/Emby/EmbyPathReplacementService.cs
  12. 399
      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. 264
      ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  26. 2
      ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs
  27. 464
      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. 187
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  32. 254
      ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs
  33. 174
      ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs
  34. 201
      ErsatzTV.Core/Metadata/SongFolderScanner.cs
  35. 221
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  36. 7
      ErsatzTV.Core/Plex/PlexConnectionParameters.cs
  37. 7
      ErsatzTV.Core/Plex/PlexItemEtag.cs
  38. 52
      ErsatzTV.Core/Plex/PlexLibraryScanner.cs
  39. 650
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  40. 348
      ErsatzTV.Infrastructure/Data/Repositories/EmbyMovieRepository.cs
  41. 353
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinMovieRepository.cs
  42. 43
      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.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Fixed
- Cleanly stop 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 ## [0.5.3-beta] - 2022-04-24
### Fixed ### Fixed

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

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

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

@ -13,7 +13,14 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
GetAllHealthCheckResults request, GetAllHealthCheckResults request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken); try
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList(); {
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 :
public Task<Either<BaseError, string>> Handle( public Task<Either<BaseError, string>> Handle(
ForceSynchronizeJellyfinLibraryById request, ForceSynchronizeJellyfinLibraryById request,
CancellationToken cancellationToken) => Handle(request); CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
public Task<Either<BaseError, string>> Handle( public Task<Either<BaseError, string>> Handle(
SynchronizeJellyfinLibraryByIdIfNeeded request, SynchronizeJellyfinLibraryByIdIfNeeded request,
CancellationToken cancellationToken) => Handle(request); CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
private Task<Either<BaseError, string>> private async Task<Either<BaseError, string>>
Handle(ISynchronizeJellyfinLibraryById request) => HandleImpl(ISynchronizeJellyfinLibraryById request, CancellationToken cancellationToken)
Validate(request) {
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name)) Validation<BaseError, RequestParameters> validation = await Validate(request);
.Bind(v => v.ToEitherAsync()); return await validation.Match(
parameters => Synchronize(parameters, cancellationToken),
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
}
private async Task<Unit> Synchronize(RequestParameters parameters) private async Task<Either<BaseError, string>> Synchronize(
RequestParameters parameters,
CancellationToken cancellationToken)
{ {
try try
{ {
@ -77,15 +82,17 @@ public class SynchronizeJellyfinLibraryByIdHandler :
parameters.ConnectionParameters.ApiKey, parameters.ConnectionParameters.ApiKey,
parameters.Library, parameters.Library,
parameters.FFmpegPath, parameters.FFmpegPath,
parameters.FFprobePath), parameters.FFprobePath,
cancellationToken),
LibraryMediaKind.Shows => LibraryMediaKind.Shows =>
await _jellyfinTelevisionLibraryScanner.ScanLibrary( await _jellyfinTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address, parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey, parameters.ConnectionParameters.ApiKey,
parameters.Library, parameters.Library,
parameters.FFmpegPath, parameters.FFmpegPath,
parameters.FFprobePath), parameters.FFprobePath,
_ => BaseError.New("Unsupported library media kind") cancellationToken),
_ => Unit.Default
}; };
if (result.IsRight) if (result.IsRight)
@ -94,17 +101,18 @@ public class SynchronizeJellyfinLibraryByIdHandler :
await _libraryRepository.UpdateLastScan(parameters.Library); await _libraryRepository.UpdateLastScan(parameters.Library);
await _jellyfinWorkerChannel.WriteAsync( await _jellyfinWorkerChannel.WriteAsync(
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId)); new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId),
cancellationToken);
} }
return result.Map(_ => parameters.Library.Name);
} }
else else
{ {
_logger.LogDebug( _logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name);
"Skipping unforced scan of jellyfin media library {Name}",
parameters.Library.Name);
} }
return Unit.Default; return parameters.Library.Name;
} }
finally finally
{ {

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

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

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

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

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

@ -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 @@
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 @@
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 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; }
} }

267
ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs

@ -1,53 +1,49 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby; namespace ErsatzTV.Core.Emby;
public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner public class EmbyMovieLibraryScanner :
MediaServerMovieLibraryScanner<EmbyConnectionParameters, EmbyLibrary, EmbyMovie, EmbyItemEtag>,
IEmbyMovieLibraryScanner
{ {
private readonly IEmbyApiClient _embyApiClient; private readonly IEmbyApiClient _embyApiClient;
private readonly ILocalFileSystem _localFileSystem; private readonly IEmbyMovieRepository _embyMovieRepository;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<EmbyMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly IEmbyPathReplacementService _pathReplacementService; private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public EmbyMovieLibraryScanner( public EmbyMovieLibraryScanner(
IEmbyApiClient embyApiClient, IEmbyApiClient embyApiClient,
ISearchIndex searchIndex, ISearchIndex searchIndex,
IMediator mediator, IMediator mediator,
IMovieRepository movieRepository, IMediaSourceRepository mediaSourceRepository,
IEmbyMovieRepository embyMovieRepository,
ISearchRepository searchRepository, ISearchRepository searchRepository,
IEmbyPathReplacementService pathReplacementService, IEmbyPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository,
ILocalFileSystem localFileSystem, ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<EmbyMovieLibraryScanner> logger) ILogger<EmbyMovieLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{ {
_embyApiClient = embyApiClient; _embyApiClient = embyApiClient;
_searchIndex = searchIndex;
_mediator = mediator;
_movieRepository = movieRepository;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_localFileSystem = localFileSystem; _embyMovieRepository = embyMovieRepository;
_localStatisticsProvider = localStatisticsProvider; _pathReplacementService = pathReplacementService;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
} }
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
@ -55,197 +51,52 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
string apiKey, string apiKey,
EmbyLibrary library, EmbyLibrary library,
string ffmpegPath, string ffmpegPath,
string ffprobePath) string ffprobePath,
CancellationToken cancellationToken)
{ {
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library); List<EmbyPathReplacement> pathReplacements =
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
// 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);
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 localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
movie.MediaVersions.Head().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 GetLocalPath(EmbyMovie movie)
{ {
string localPath = _pathReplacementService.GetReplacementEmbyPath( return _pathReplacementService.GetReplacementEmbyPath(
pathReplacements, pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path, movie.GetHeadVersion().MediaFiles.Head().Path,
false); false);
}
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
incomingMovie,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
}
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);
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)); return await ScanLibrary(
_searchIndex.Commit(); _embyMovieRepository,
}, new EmbyConnectionParameters(address, apiKey),
error => library,
{ GetLocalPath,
_logger.LogWarning( ffmpegPath,
"Error synchronizing emby library {Path}: {Error}", ffprobePath,
library.Name, false,
error.Value); cancellationToken);
}
return Task.CompletedTask; protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
}); protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
_searchIndex.Commit(); protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
return Unit.Default; EmbyConnectionParameters connectionParameters,
} EmbyLibrary library) =>
_embyApiClient.GetMovieLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyMovie movie, string localPath) protected override Task<Option<MovieMetadata>> GetFullMetadata(
{ EmbyConnectionParameters connectionParameters,
try EmbyLibrary library,
{ MediaItemScanResult<EmbyMovie> result,
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false); EmbyMovie incoming,
} bool deepScan) =>
catch (Exception ex) Task.FromResult<Option<MovieMetadata>>(None);
{
return BaseError.New(ex.ToString()); 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
return GetReplacementEmbyPath(replacements, path, log); return GetReplacementEmbyPath(replacements, path, log);
} }
public string GetReplacementEmbyPath( public string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true)
List<EmbyPathReplacement> pathReplacements,
string path,
bool log = true)
{ {
Option<EmbyPathReplacement> maybeReplacement = pathReplacements Option<EmbyPathReplacement> maybeReplacement = pathReplacements
.SingleOrDefault( .SingleOrDefault(
@ -46,9 +43,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
} }
string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/"; string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/";
string prefix = r.EmbyPath.EndsWith(separatorChar) string prefix = r.EmbyPath.EndsWith(separatorChar) ? r.EmbyPath : r.EmbyPath + separatorChar;
? r.EmbyPath
: r.EmbyPath + separatorChar;
return path.StartsWith(prefix); return path.StartsWith(prefix);
}); });
@ -59,8 +54,7 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
{ {
finalPath = finalPath.Replace(@"\", @"/"); finalPath = finalPath.Replace(@"\", @"/");
} }
else if (!IsWindows(replacement.EmbyMediaSource, path) && else if (!IsWindows(replacement.EmbyMediaSource, path) && _runtimeInfo.IsOSPlatform(OSPlatform.Windows))
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
{ {
finalPath = finalPath.Replace(@"/", @"\"); finalPath = finalPath.Replace(@"/", @"\");
} }

399
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -1,10 +1,10 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Emby; using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -55,61 +55,81 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
string apiKey, string apiKey,
EmbyLibrary library, EmbyLibrary library,
string ffmpegPath, string ffmpegPath,
string ffprobePath) string ffprobePath,
CancellationToken cancellationToken)
{ {
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library); try
{
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(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);
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems(
address,
apiKey,
library.ItemId);
foreach (BaseError error in maybeShows.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessShows(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows,
cancellationToken);
// TODO: maybe get quick list of item ids and etags from api to compare first foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
// TODO: paging? {
return error;
}
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository foreach (Unit _ in scanResult.RightToSeq())
.GetEmbyPathReplacements(library.MediaSourceId); {
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
await _searchIndex.RemoveItems(missingShowIds);
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems( await _televisionRepository.DeleteEmptySeasons(library);
address, List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
apiKey, await _searchIndex.RemoveItems(emptyShowIds);
library.ItemId);
foreach (BaseError error in maybeShows.LeftToSeq()) await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
}
}
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{ {
_logger.LogWarning( return new ScanCanceled();
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
} }
finally
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
{ {
await ProcessShows(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows);
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
await _searchIndex.RemoveItems(missingShowIds);
await _televisionRepository.DeleteEmptySeasons(library);
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
await _searchIndex.RemoveItems(emptyShowIds);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
_searchIndex.Commit(); _searchIndex.Commit();
} }
return Unit.Default;
} }
private async Task ProcessShows( private async Task<Either<BaseError, Unit>> ProcessShows(
string address, string address,
string apiKey, string apiKey,
EmbyLibrary library, EmbyLibrary library,
@ -117,13 +137,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
string ffprobePath, string ffprobePath,
List<EmbyPathReplacement> pathReplacements, List<EmbyPathReplacement> pathReplacements,
List<EmbyItemEtag> existingShows, List<EmbyItemEtag> existingShows,
List<EmbyShow> shows) List<EmbyShow> shows,
CancellationToken cancellationToken)
{ {
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList(); var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (EmbyShow incoming in sortedShows) foreach (EmbyShow incoming in sortedShows)
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count; 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); Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone) if (maybeExisting.IsNone)
@ -140,19 +166,17 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
foreach (EmbyItemEtag existing in maybeExisting) 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);
}
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id; incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyShow> updated = await _televisionRepository.Update(incoming); Option<EmbyShow> maybeUpdated = await _televisionRepository.Update(incoming);
if (updated.IsSome) 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
foreach (List<EmbySeason> seasons in maybeSeasons.RightToSeq()) foreach (List<EmbySeason> seasons in maybeSeasons.RightToSeq())
{ {
await ProcessSeasons( Either<BaseError, Unit> scanResult = await ProcessSeasons(
address, address,
apiKey, apiKey,
library, library,
@ -181,19 +205,30 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
pathReplacements, pathReplacements,
incoming, incoming,
existingSeasons, existingSeasons,
seasons); seasons,
cancellationToken);
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
.Filter(i => !incomingSeasonIds.Contains(i.ItemId)) {
.Map(m => m.ItemId) return error;
.ToList(); }
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
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);
}
} }
} }
return Unit.Default;
} }
private async Task ProcessSeasons( private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address, string address,
string apiKey, string apiKey,
EmbyLibrary library, EmbyLibrary library,
@ -202,19 +237,37 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
List<EmbyPathReplacement> pathReplacements, List<EmbyPathReplacement> pathReplacements,
EmbyShow show, EmbyShow show,
List<EmbyItemEtag> existingSeasons, List<EmbyItemEtag> existingSeasons,
List<EmbySeason> seasons) List<EmbySeason> seasons,
CancellationToken cancellationToken)
{ {
foreach (EmbySeason incoming in seasons) foreach (EmbySeason incoming in seasons)
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match( if (maybeExisting.IsNone)
async existing => {
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))
{ {
if (existing.Etag == incoming.Etag) incoming.Show = show;
{ await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
return; }
} }
foreach (EmbyItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug( _logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}", "UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
@ -232,22 +285,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
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<EmbyItemEtag> existingEpisodes = List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
@ -255,40 +294,55 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
Either<BaseError, List<EmbyEpisode>> maybeEpisodes = Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId); await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);
await maybeEpisodes.Match( foreach (BaseError error in maybeEpisodes.LeftToSeq())
async episodes => {
_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)
{ {
var validEpisodes = new List<EmbyEpisode>(); string localPath = _pathReplacementService.GetReplacementEmbyPath(
foreach (EmbyEpisode episode in episodes) pathReplacements,
{ episode.MediaVersions.Head().MediaFiles.Head().Path,
string localPath = _pathReplacementService.GetReplacementEmbyPath( false);
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath))
{ {
_logger.LogWarning( _logger.LogWarning(
"Skipping emby episode that does not exist at {Path}", "Skipping emby episode that does not exist at {Path}",
localPath); localPath);
} }
else else
{ {
validEpisodes.Add(episode); validEpisodes.Add(episode);
}
} }
}
await ProcessEpisodes( Either<BaseError, Unit> scanResult = await ProcessEpisodes(
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title, incoming.SeasonMetadata.Head().Title,
library, library,
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
pathReplacements, pathReplacements,
incoming, incoming,
existingEpisodes, 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 incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) .Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
@ -298,20 +352,14 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds); await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit(); _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 showName,
string seasonName, string seasonName,
EmbyLibrary library, EmbyLibrary library,
@ -320,24 +368,54 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
List<EmbyPathReplacement> pathReplacements, List<EmbyPathReplacement> pathReplacements,
EmbySeason season, EmbySeason season,
List<EmbyItemEtag> existingEpisodes, List<EmbyItemEtag> existingEpisodes,
List<EmbyEpisode> episodes) List<EmbyEpisode> episodes,
CancellationToken cancellationToken)
{ {
foreach (EmbyEpisode incoming in episodes) foreach (EmbyEpisode incoming in episodes)
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
EmbyEpisode incomingEpisode = incoming; EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false; var updateStatistics = false;
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match( if (maybeExisting.IsNone)
async existing => {
try
{ {
try updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
{ {
if (existing.Etag == incoming.Etag) await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
{ }
return; }
} catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
}
foreach (EmbyItemEtag existing in maybeExisting)
{
try
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug( _logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName, showName,
@ -352,46 +430,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
foreach (EmbyEpisode updated in maybeUpdated) foreach (EmbyEpisode updated in maybeUpdated)
{ {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated }); await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
incomingEpisode = updated; incomingEpisode = updated;
} }
} }
catch (Exception ex) }
{ catch (Exception ex)
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
},
async () =>
{ {
try updateStatistics = false;
{ _logger.LogError(
updateStatistics = true; ex,
incoming.LibraryPathId = library.Paths.Head().Id; "Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
_logger.LogDebug( }
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}", }
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
});
if (updateStatistics) if (updateStatistics)
{ {
@ -423,6 +474,8 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
} }
} }
} }
return Unit.Default;
} }
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyEpisode episode, string localPath) 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
string apiKey, string apiKey,
EmbyLibrary library, EmbyLibrary library,
string ffmpegPath, string ffmpegPath,
string ffprobePath); string ffprobePath,
CancellationToken cancellationToken);
} }

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

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

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

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

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

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

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

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

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

@ -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 @@
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 @@
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 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories; namespace ErsatzTV.Core.Interfaces.Repositories;
@ -11,7 +8,6 @@ public interface IMovieRepository
Task<bool> AllMoviesExist(List<int> movieIds); Task<bool> AllMoviesExist(List<int> movieIds);
Task<Option<Movie>> GetMovie(int movieId); Task<Option<Movie>> GetMovie(int movieId);
Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path); 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<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath); Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path); Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
@ -19,18 +15,8 @@ public interface IMovieRepository
Task<bool> AddTag(MovieMetadata metadata, Tag tag); Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio); Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<bool> AddActor(MovieMetadata metadata, Actor actor); Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata); 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> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer); Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path); 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 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories; 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 @@
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 @@
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 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; }
} }

264
ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -1,34 +1,29 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Jellyfin; namespace ErsatzTV.Core.Jellyfin;
public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner public class JellyfinMovieLibraryScanner :
MediaServerMovieLibraryScanner<JellyfinConnectionParameters, JellyfinLibrary, JellyfinMovie, JellyfinItemEtag>,
IJellyfinMovieLibraryScanner
{ {
private readonly IJellyfinApiClient _jellyfinApiClient; private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly ILocalFileSystem _localFileSystem; private readonly IJellyfinMovieRepository _jellyfinMovieRepository;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<JellyfinMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMovieRepository _movieRepository;
private readonly IJellyfinPathReplacementService _pathReplacementService; private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public JellyfinMovieLibraryScanner( public JellyfinMovieLibraryScanner(
IJellyfinApiClient jellyfinApiClient, IJellyfinApiClient jellyfinApiClient,
ISearchIndex searchIndex, ISearchIndex searchIndex,
IMediator mediator, IMediator mediator,
IMovieRepository movieRepository, IJellyfinMovieRepository jellyfinMovieRepository,
ISearchRepository searchRepository, ISearchRepository searchRepository,
IJellyfinPathReplacementService pathReplacementService, IJellyfinPathReplacementService pathReplacementService,
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
@ -36,18 +31,19 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<JellyfinMovieLibraryScanner> logger) ILogger<JellyfinMovieLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{ {
_jellyfinApiClient = jellyfinApiClient; _jellyfinApiClient = jellyfinApiClient;
_searchIndex = searchIndex; _jellyfinMovieRepository = jellyfinMovieRepository;
_mediator = mediator;
_movieRepository = movieRepository;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService; _pathReplacementService = pathReplacementService;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
} }
public async Task<Either<BaseError, Unit>> ScanLibrary( public async Task<Either<BaseError, Unit>> ScanLibrary(
@ -55,198 +51,54 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
string apiKey, string apiKey,
JellyfinLibrary library, JellyfinLibrary library,
string ffmpegPath, 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 string GetLocalPath(JellyfinMovie movie)
// TODO: paging? {
return _pathReplacementService.GetReplacementJellyfinPath(
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository pathReplacements,
.GetJellyfinPathReplacements(library.MediaSourceId); movie.GetHeadVersion().MediaFiles.Head().Path,
false);
Either<BaseError, List<JellyfinMovie>> maybeMovies = await _jellyfinApiClient.GetMovieLibraryItems( }
address,
apiKey,
library.MediaSourceId,
library.ItemId);
await maybeMovies.Match(
async movies =>
{
var validMovies = new List<JellyfinMovie>();
foreach (JellyfinMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
{
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
movie.MediaVersions.Head().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(
ffmpegPath,
ffprobePath,
incomingMovie,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
}
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);
return Task.CompletedTask;
});
}
// TODO: figure out how to rebuild playlists
}
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList(); return await ScanLibrary(
var movieIds = existingMovies _jellyfinMovieRepository,
.Filter(i => !incomingMovieIds.Contains(i.ItemId)) new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
.Map(m => m.ItemId) library,
.ToList(); GetLocalPath,
List<int> ids = await _movieRepository.RemoveMissingJellyfinMovies(library, movieIds); ffmpegPath,
await _searchIndex.RemoveItems(ids); ffprobePath,
false,
cancellationToken);
}
await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
_searchIndex.Commit();
},
error =>
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask; protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
});
_searchIndex.Commit(); protected override Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
return Unit.Default; 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) protected override Task<Option<MovieMetadata>> GetFullMetadata(
{ JellyfinConnectionParameters connectionParameters,
try JellyfinLibrary library,
{ MediaItemScanResult<JellyfinMovie> result,
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false); JellyfinMovie incoming,
} bool deepScan) =>
catch (Exception ex) Task.FromResult<Option<MovieMetadata>>(None);
{
return BaseError.New(ex.ToString()); 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
List<JellyfinPathReplacement> replacements = List<JellyfinPathReplacement> replacements =
await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId); await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId);
return GetReplacementJellyfinPath(replacements, path); return GetReplacementJellyfinPath(replacements, path, log);
} }
public string GetReplacementJellyfinPath( public string GetReplacementJellyfinPath(

464
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -1,10 +1,10 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR; using MediatR;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@ -55,26 +55,36 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string apiKey, string apiKey,
JellyfinLibrary library, JellyfinLibrary library,
string ffmpegPath, string ffmpegPath,
string ffprobePath) string ffprobePath,
CancellationToken cancellationToken)
{ {
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library); try
{
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
// TODO: maybe get quick list of item ids and etags from api to compare first // TODO: maybe get quick list of item ids and etags from api to compare first
// TODO: paging? // TODO: paging?
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
.GetJellyfinPathReplacements(library.MediaSourceId); .GetJellyfinPathReplacements(library.MediaSourceId);
Either<BaseError, List<JellyfinShow>> maybeShows = await _jellyfinApiClient.GetShowLibraryItems( Either<BaseError, List<JellyfinShow>> maybeShows = await _jellyfinApiClient.GetShowLibraryItems(
address, address,
apiKey, apiKey,
library.MediaSourceId, library.MediaSourceId,
library.ItemId); library.ItemId);
foreach (BaseError error in maybeShows.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
}
await maybeShows.Match( foreach (List<JellyfinShow> shows in maybeShows.RightToSeq())
async shows =>
{ {
await ProcessShows( Either<BaseError, Unit> scanResult = await ProcessShows(
address, address,
apiKey, apiKey,
library, library,
@ -82,37 +92,45 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
ffprobePath, ffprobePath,
pathReplacements, pathReplacements,
existingShows, existingShows,
shows); shows,
cancellationToken);
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
await _searchIndex.RemoveItems(missingShowIds);
await _televisionRepository.DeleteEmptySeasons(library);
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; foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
}); {
return error;
}
return Unit.Default; foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
await _searchIndex.RemoveItems(missingShowIds);
await _televisionRepository.DeleteEmptySeasons(library);
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
await _searchIndex.RemoveItems(emptyShowIds);
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 address,
string apiKey, string apiKey,
JellyfinLibrary library, JellyfinLibrary library,
@ -120,93 +138,98 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string ffprobePath, string ffprobePath,
List<JellyfinPathReplacement> pathReplacements, List<JellyfinPathReplacement> pathReplacements,
List<JellyfinItemEtag> existingShows, List<JellyfinItemEtag> existingShows,
List<JellyfinShow> shows) List<JellyfinShow> shows,
CancellationToken cancellationToken)
{ {
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList(); var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (JellyfinShow incoming in sortedShows) foreach (JellyfinShow incoming in sortedShows)
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count; 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<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match( if (maybeExisting.IsNone)
async existing => {
{ incoming.LibraryPathId = library.Paths.Head().Id;
if (existing.Etag == incoming.Etag)
{
return;
}
_logger.LogDebug( // _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
"UPDATE: Etag has changed for show {Show}",
incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id; if (await _televisionRepository.AddShow(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
Option<JellyfinShow> updated = await _televisionRepository.Update(incoming); foreach (JellyfinItemEtag existing in maybeExisting)
if (updated.IsSome) {
{ if (existing.Etag != incoming.Etag)
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated.ValueUnsafe() });
}
},
async () =>
{ {
incoming.LibraryPathId = library.Paths.Head().Id; _logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title); incoming.LibraryPathId = library.Paths.Head().Id;
if (await _televisionRepository.AddShow(incoming)) Option<JellyfinShow> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinShow updated in maybeUpdated)
{ {
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming }); await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
} }
}); }
}
List<JellyfinItemEtag> existingSeasons = List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId); await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<JellyfinSeason>> maybeSeasons = Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems( await _jellyfinApiClient.GetSeasonLibraryItems(address, apiKey, library.MediaSourceId, incoming.ItemId);
foreach (BaseError error in maybeSeasons.LeftToSeq())
{
_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, address,
apiKey, apiKey,
library.MediaSourceId, library,
incoming.ItemId); ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons,
cancellationToken);
await maybeSeasons.Match( foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
async seasons =>
{ {
await ProcessSeasons( return error;
address, }
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons);
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId)) .Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId) .Map(m => m.ItemId)
.ToList(); .ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds); await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
}, }
error => }
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return Task.CompletedTask;
});
} }
return Unit.Default;
} }
private async Task ProcessSeasons( private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address, string address,
string apiKey, string apiKey,
JellyfinLibrary library, JellyfinLibrary library,
@ -215,19 +238,37 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
List<JellyfinPathReplacement> pathReplacements, List<JellyfinPathReplacement> pathReplacements,
JellyfinShow show, JellyfinShow show,
List<JellyfinItemEtag> existingSeasons, List<JellyfinItemEtag> existingSeasons,
List<JellyfinSeason> seasons) List<JellyfinSeason> seasons,
CancellationToken cancellationToken)
{ {
foreach (JellyfinSeason incoming in seasons) foreach (JellyfinSeason incoming in seasons)
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match( if (maybeExisting.IsNone)
async existing => {
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))
{ {
if (existing.Etag == incoming.Etag) incoming.Show = show;
{ await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
return; }
} }
foreach (JellyfinItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug( _logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}", "UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
@ -242,27 +283,11 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id)) foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id))
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { toIndex });
_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 = List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
@ -274,64 +299,72 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
library.MediaSourceId, library.MediaSourceId,
incoming.ItemId); incoming.ItemId);
await maybeEpisodes.Match( foreach (BaseError error in maybeEpisodes.LeftToSeq())
async episodes => {
_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)
{ {
var validEpisodes = new List<JellyfinEpisode>(); string localPath = _pathReplacementService.GetReplacementJellyfinPath(
foreach (JellyfinEpisode episode in episodes) pathReplacements,
{ episode.MediaVersions.Head().MediaFiles.Head().Path,
string localPath = _pathReplacementService.GetReplacementJellyfinPath( false);
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath)) if (!_localFileSystem.FileExists(localPath))
{ {
_logger.LogWarning( _logger.LogWarning(
"Skipping jellyfin episode that does not exist at {Path}", "Skipping jellyfin episode that does not exist at {Path}",
localPath); localPath);
}
else
{
validEpisodes.Add(episode);
}
} }
else
{
validEpisodes.Add(episode);
}
}
await ProcessEpisodes( Either<BaseError, Unit> scanResult = await ProcessEpisodes(
show.ShowMetadata.Head().Title, show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title, incoming.SeasonMetadata.Head().Title,
library, library,
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
pathReplacements, pathReplacements,
incoming, incoming,
existingEpisodes, 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 incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) .Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId) .Map(m => m.ItemId)
.ToList(); .ToList();
List<int> missingEpisodeIds = List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds); await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit(); _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 showName,
string seasonName, string seasonName,
JellyfinLibrary library, JellyfinLibrary library,
@ -340,25 +373,54 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
List<JellyfinPathReplacement> pathReplacements, List<JellyfinPathReplacement> pathReplacements,
JellyfinSeason season, JellyfinSeason season,
List<JellyfinItemEtag> existingEpisodes, List<JellyfinItemEtag> existingEpisodes,
List<JellyfinEpisode> episodes) List<JellyfinEpisode> episodes,
CancellationToken cancellationToken)
{ {
foreach (JellyfinEpisode incoming in episodes) foreach (JellyfinEpisode incoming in episodes)
{ {
JellyfinEpisode incomingEpisode = incoming; if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
JellyfinEpisode incomingEpisode = incoming;
var updateStatistics = false; var updateStatistics = false;
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
await maybeExisting.Match( if (maybeExisting.IsNone)
async existing => {
try
{ {
try updateStatistics = true;
incoming.LibraryPathId = library.Paths.Head().Id;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
{ {
if (existing.Etag == incoming.Etag) await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
{ }
return; }
} catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
try
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug( _logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", "UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName, showName,
@ -372,49 +434,20 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming); Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinEpisode updated in maybeUpdated) foreach (JellyfinEpisode updated in maybeUpdated)
{ {
await _searchIndex.UpdateItems( await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
_searchRepository,
new List<MediaItem> { updated });
incomingEpisode = updated; incomingEpisode = updated;
} }
} }
catch (Exception ex) }
{ catch (Exception ex)
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
},
async () =>
{ {
try updateStatistics = false;
{ _logger.LogError(
updateStatistics = true; ex,
incoming.LibraryPathId = library.Paths.Head().Id; "Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
_logger.LogDebug( }
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}", }
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
if (await _televisionRepository.AddEpisode(season, incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error adding episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
});
if (updateStatistics) if (updateStatistics)
{ {
@ -436,15 +469,18 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
refreshResult = await UpdateSubtitles(incomingEpisode, localPath); refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
} }
refreshResult.Match( foreach (BaseError error in refreshResult.LeftToSeq())
_ => { }, {
error => _logger.LogWarning( _logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}", "Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics", "Statistics",
localPath, localPath,
error.Value)); error.Value);
}
} }
} }
return Unit.Default;
} }
private async Task<Either<BaseError, bool>> UpdateSubtitles(JellyfinEpisode episode, string localPath) 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
var subtitles = subtitleStreams.Map(Subtitle.FromMediaStream).ToList(); var subtitles = subtitleStreams.Map(Subtitle.FromMediaStream).ToList();
string mediaItemPath = await localPath.IfNoneAsync(() => mediaItem.GetHeadVersion().MediaFiles.Head().Path); string mediaItemPath = await localPath.IfNoneAsync(() => mediaItem.GetHeadVersion().MediaFiles.Head().Path);
subtitles.AddRange(LocateExternalSubtitles(_languageCodes, mediaItemPath, saveFullPath)); subtitles.AddRange(LocateExternalSubtitles(_languageCodes, mediaItemPath, saveFullPath));
await _metadataRepository.UpdateSubtitles(metadata, subtitles); return await _metadataRepository.UpdateSubtitles(metadata, subtitles);
} }
return false; return false;

1
ErsatzTV.Core/Metadata/MediaItemScanResult.cs

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

338
ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -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());
}
}
}

187
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -72,118 +73,132 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
decimal progressMax, decimal progressMax,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
decimal progressSpread = progressMax - progressMin; try
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{ {
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); decimal progressSpread = progressMax - progressMin;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
string movieFolder = folderQueue.Dequeue();
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList(); var foldersCompleted = 0;
var allFiles = filesForEtag var folderQueue = new Queue<string>();
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(f => !Path.GetFileName(f).StartsWith("._")) .Filter(ShouldIncludeFolder)
.Filter( .OrderBy(identity))
f => !ExtraFiles.Any( {
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase))) folderQueue.Enqueue(folder);
.ToList(); }
if (allFiles.Count == 0) while (folderQueue.Count > 0)
{ {
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder) if (cancellationToken.IsCancellationRequested)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{ {
folderQueue.Enqueue(subdirectory); return new ScanCanceled();
} }
continue; decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
} await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
string etag = FolderEtag.Calculate(movieFolder, _localFileSystem); string movieFolder = folderQueue.Dequeue();
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders foldersCompleted++;
.Filter(f => f.Path == movieFolder)
.HeadOrNone();
// skip folder if etag matches var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList();
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
{
continue;
}
_logger.LogDebug( var allFiles = filesForEtag
"UPDATE: Etag has changed for folder {Folder}", .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
movieFolder); .Filter(f => !Path.GetFileName(f).StartsWith("._"))
.Filter(
f => !ExtraFiles.Any(
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
.ToList();
foreach (string file in allFiles.OrderBy(identity)) if (allFiles.Count == 0)
{ {
// TODO: figure out how to rebuild playlists foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder)
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository .Filter(ShouldIncludeFolder)
.GetOrAdd(libraryPath, file) .OrderBy(identity))
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath)) {
.BindT(UpdateMetadata) folderQueue.Enqueue(subdirectory);
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken)) }
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
.BindT(UpdateSubtitles) continue;
.BindT(FlagNormal); }
foreach (BaseError error in maybeMovie.LeftToSeq()) string etag = FolderEtag.Calculate(movieFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == movieFolder)
.HeadOrNone();
// skip folder if etag matches
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
{ {
_logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value); continue;
} }
foreach (MediaItemScanResult<Movie> result in maybeMovie.RightToSeq()) _logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
movieFolder);
foreach (string file in allFiles.OrderBy(identity))
{ {
if (result.IsAdded) // TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
.GetOrAdd(libraryPath, file)
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
.BindT(UpdateSubtitles)
.BindT(FlagNormal);
foreach (BaseError error in maybeMovie.LeftToSeq())
{ {
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item }); _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value);
} }
else if (result.IsUpdated)
foreach (MediaItemScanResult<Movie> result in maybeMovie.RightToSeq())
{ {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item }); if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
} }
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
} }
} }
}
foreach (string path in await _movieRepository.FindMoviePaths(libraryPath)) foreach (string path in await _movieRepository.FindMoviePaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, ids);
}
else if (Path.GetFileName(path).StartsWith("._"))
{ {
_logger.LogInformation("Removing dot underscore file at {Path}", path); if (!_localFileSystem.FileExists(path))
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path); {
await _searchIndex.RemoveItems(ids); _logger.LogInformation("Flagging missing movie at {Path}", path);
List<int> ids = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, ids);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(ids);
}
} }
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
return Unit.Default;
_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( private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(

254
ErsatzTV.Core/Metadata/MusicVideoFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -73,37 +74,55 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
decimal progressMax, decimal progressMax,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
decimal progressSpread = progressMax - progressMin; try
{
decimal progressSpread = progressMax - progressMin;
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path) var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity) .OrderBy(identity)
.ToList(); .ToList();
foreach (string artistFolder in allArtistFolders) foreach (string artistFolder in allArtistFolders)
{ {
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder); // _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
if (cancellationToken.IsCancellationRequested)
decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count; {
await _mediator.Publish( return new ScanCanceled();
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread)); }
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist = decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
await FindOrCreateArtist(libraryPath.Id, artistFolder) await _mediator.Publish(
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder)) new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
.BindT( cancellationToken);
artist => UpdateArtworkForArtist(
artist, Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
artistFolder, await FindOrCreateArtist(libraryPath.Id, artistFolder)
ArtworkKind.Thumbnail, .BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
cancellationToken)) .BindT(
.BindT( artist => UpdateArtworkForArtist(
artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken)); artist,
artistFolder,
await maybeArtist.Match( ArtworkKind.Thumbnail,
async result => cancellationToken))
.BindT(
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);
}
foreach (MediaItemScanResult<Artist> result in maybeArtist.RightToSeq())
{ {
await ScanMusicVideos( Either<BaseError, Unit> scanResult = await ScanMusicVideos(
libraryPath, libraryPath,
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
@ -111,6 +130,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
artistFolder, artistFolder,
cancellationToken); cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
if (result.IsAdded) if (result.IsAdded)
{ {
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item }); await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -119,47 +143,47 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
{ {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item }); 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))
{
_logger.LogInformation("Removing improperly named music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
} }
else if (Path.GetFileName(path).StartsWith("._"))
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
{ {
_logger.LogInformation("Removing dot underscore file at {Path}", path); _logger.LogInformation("Removing improperly named music video at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path); List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds); await _searchIndex.RemoveItems(musicVideoIds);
} }
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing music video at {Path}", path);
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(musicVideoIds);
}
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath); List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
await _searchIndex.RemoveItems(artistIds); await _searchIndex.RemoveItems(artistIds);
_searchIndex.Commit(); return Unit.Default;
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( private async Task<Either<BaseError, MediaItemScanResult<Artist>>> FindOrCreateArtist(
@ -244,7 +268,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
} }
} }
private async Task ScanMusicVideos( private async Task<Either<BaseError, Unit>> ScanMusicVideos(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffmpegPath, string ffmpegPath,
string ffprobePath, string ffprobePath,
@ -257,6 +281,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
while (folderQueue.Count > 0) while (folderQueue.Count > 0)
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
string musicVideoFolder = folderQueue.Dequeue(); string musicVideoFolder = folderQueue.Dequeue();
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder); // _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
@ -293,27 +322,28 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
.BindT(UpdateSubtitles) .BindT(UpdateSubtitles)
.BindT(FlagNormal); .BindT(FlagNormal);
await maybeMusicVideo.Match( foreach (BaseError error in maybeMusicVideo.LeftToSeq())
async result => {
{ _logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
if (result.IsAdded) }
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag); foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq())
}, {
error => if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{ {
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value); await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
return Task.CompletedTask; }
});
await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag);
}
} }
} }
return Unit.Default;
} }
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata( private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
@ -322,37 +352,39 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
try try
{ {
MusicVideo musicVideo = result.Item; MusicVideo musicVideo = result.Item;
await LocateNfoFile(musicVideo).Match(
async nfoFile => Option<string> maybeNfoFile = LocateNfoFile(musicVideo);
if (maybeNfoFile.IsNone)
{
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
{ {
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match( musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate) string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
{ {
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile); result.IsUpdated = true;
if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile))
{
result.IsUpdated = true;
}
} }
}, }
async () => }
foreach (string nfoFile in maybeNfoFile)
{
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
m => m.MetadataKind == MetadataKind.Fallback ||
m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
true);
if (shouldUpdate)
{ {
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any()) _logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile))
{ {
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>(); result.IsUpdated = true;
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; return result;
} }
@ -364,8 +396,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
} }
private Option<string> LocateNfoFileForArtist(string artistFolder) => private Option<string> LocateNfoFileForArtist(string artistFolder) =>
Optional(Path.Combine(artistFolder, "artist.nfo")) Optional(Path.Combine(artistFolder, "artist.nfo")).Filter(s => _localFileSystem.FileExists(s));
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind) private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
{ {
@ -398,12 +429,13 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
try try
{ {
MusicVideo musicVideo = result.Item; 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); MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
}); await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
}
return result; return result;
} }

174
ErsatzTV.Core/Metadata/OtherVideoFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -67,75 +68,89 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
string ffmpegPath, string ffmpegPath,
string ffprobePath, string ffprobePath,
decimal progressMin, decimal progressMin,
decimal progressMax) decimal progressMax,
CancellationToken cancellationToken)
{ {
decimal progressSpread = progressMax - progressMin; try
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{ {
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); decimal progressSpread = progressMax - progressMin;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
string otherVideoFolder = folderQueue.Dequeue(); var foldersCompleted = 0;
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList(); var folderQueue = new Queue<string>();
var allFiles = filesForEtag if (ShouldIncludeFolder(libraryPath.Path))
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))) {
.Filter(f => !Path.GetFileName(f).StartsWith("._")) folderQueue.Enqueue(libraryPath.Path);
.ToList(); }
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity)) .OrderBy(identity))
{ {
folderQueue.Enqueue(subdirectory); folderQueue.Enqueue(folder);
} }
string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem); while (folderQueue.Count > 0)
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == otherVideoFolder)
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
{ {
continue; if (cancellationToken.IsCancellationRequested)
} {
return new ScanCanceled();
}
_logger.LogDebug( decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
"UPDATE: Etag has changed for folder {Folder}", await _mediator.Publish(
otherVideoFolder); new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
foreach (string file in allFiles.OrderBy(identity)) string otherVideoFolder = folderQueue.Dequeue();
{ foldersCompleted++;
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file) var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList();
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata) var allFiles = filesForEtag
.BindT(UpdateSubtitles) .Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.BindT(FlagNormal); .Filter(f => !Path.GetFileName(f).StartsWith("._"))
.ToList();
await maybeVideo.Match(
async result => foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == otherVideoFolder)
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
etag)
{
continue;
}
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
otherVideoFolder);
foreach (string file in allFiles.OrderBy(identity))
{
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(UpdateMetadata)
.BindT(UpdateSubtitles)
.BindT(FlagNormal);
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) if (result.IsAdded)
{ {
@ -147,35 +162,38 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
} }
await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag); await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag);
}, }
error => }
{
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
} }
}
foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath)) foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Flagging missing other video at {Path}", path); if (!_localFileSystem.FileExists(path))
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path); {
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds); _logger.LogInformation("Flagging missing other video at {Path}", path);
} List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
else if (Path.GetFileName(path).StartsWith("._")) await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
{ }
_logger.LogInformation("Removing dot underscore file at {Path}", path); else if (Path.GetFileName(path).StartsWith("._"))
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path); {
await _searchIndex.RemoveItems(otherVideoIds); _logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(otherVideoIds);
}
} }
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit(); return Unit.Default;
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( private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateMetadata(

201
ErsatzTV.Core/Metadata/SongFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
@ -68,73 +69,86 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
decimal progressMax, decimal progressMax,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
decimal progressSpread = progressMax - progressMin; try
var foldersCompleted = 0;
var folderQueue = new Queue<string>();
if (ShouldIncludeFolder(libraryPath.Path))
{
folderQueue.Enqueue(libraryPath.Path);
}
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(folder);
}
while (folderQueue.Count > 0)
{ {
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count); decimal progressSpread = progressMax - progressMin;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
string songFolder = folderQueue.Dequeue(); var foldersCompleted = 0;
foldersCompleted++;
var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList(); var folderQueue = new Queue<string>();
var allFiles = filesForEtag if (ShouldIncludeFolder(libraryPath.Path))
.Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f))) {
.Filter(f => !Path.GetFileName(f).StartsWith("._")) folderQueue.Enqueue(libraryPath.Path);
.ToList(); }
foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder) foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity)) .OrderBy(identity))
{ {
folderQueue.Enqueue(subdirectory); folderQueue.Enqueue(folder);
} }
string etag = FolderEtag.Calculate(songFolder, _localFileSystem); while (folderQueue.Count > 0)
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == songFolder)
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
{ {
continue; if (cancellationToken.IsCancellationRequested)
} {
return new ScanCanceled();
}
_logger.LogDebug( decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
"UPDATE: Etag has changed for folder {Folder}", await _mediator.Publish(
songFolder); new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
foreach (string file in allFiles.OrderBy(identity)) string songFolder = folderQueue.Dequeue();
{ foldersCompleted++;
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file) var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList();
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath)) var allFiles = filesForEtag
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken)) .Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f)))
.BindT(FlagNormal); .Filter(f => !Path.GetFileName(f).StartsWith("._"))
.ToList();
await maybeSong.Match(
async result => foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder)
.Filter(ShouldIncludeFolder)
.OrderBy(identity))
{
folderQueue.Enqueue(subdirectory);
}
string etag = FolderEtag.Calculate(songFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == songFolder)
.HeadOrNone();
// skip folder if etag matches
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
etag)
{
continue;
}
_logger.LogDebug(
"UPDATE: Etag has changed for folder {Folder}",
songFolder);
foreach (string file in allFiles.OrderBy(identity))
{
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
.GetOrAdd(libraryPath, file)
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
.BindT(video => UpdateMetadata(video, ffprobePath))
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
.BindT(FlagNormal);
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) if (result.IsAdded)
{ {
@ -146,35 +160,38 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
} }
await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag); await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag);
}, }
error => }
{
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
return Task.CompletedTask;
});
} }
}
foreach (string path in await _songRepository.FindSongPaths(libraryPath)) foreach (string path in await _songRepository.FindSongPaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{ {
_logger.LogInformation("Flagging missing song at {Path}", path); if (!_localFileSystem.FileExists(path))
List<int> songIds = await FlagFileNotFound(libraryPath, path); {
await _searchIndex.RebuildItems(_searchRepository, songIds); _logger.LogInformation("Flagging missing song at {Path}", path);
} List<int> songIds = await FlagFileNotFound(libraryPath, path);
else if (Path.GetFileName(path).StartsWith("._")) await _searchIndex.RebuildItems(_searchRepository, songIds);
{ }
_logger.LogInformation("Removing dot underscore file at {Path}", path); else if (Path.GetFileName(path).StartsWith("._"))
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path); {
await _searchIndex.RemoveItems(songIds); _logger.LogInformation("Removing dot underscore file at {Path}", path);
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
await _searchIndex.RemoveItems(songIds);
}
} }
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
_searchIndex.Commit(); return Unit.Default;
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( private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(
@ -231,20 +248,24 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
} }
Song song = result.Item; 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(); SongMetadata metadata = song.SongMetadata.Head();
await RefreshArtwork( await RefreshArtwork(
thumbnailFile, thumbnailFile,
metadata, metadata,
ArtworkKind.Thumbnail, ArtworkKind.Thumbnail,
ffmpegPath, ffmpegPath,
None, None,
cancellationToken); cancellationToken);
}, }
() => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken));
return result; return result;
} }

221
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -1,5 +1,6 @@
using Bugsnag; using Bugsnag;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.FFmpeg; using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images; using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
@ -72,73 +73,100 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
decimal progressMax, decimal progressMax,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
decimal progressSpread = progressMax - progressMin; try
{
decimal progressSpread = progressMax - progressMin;
var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path) var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
.Filter(ShouldIncludeFolder) .Filter(ShouldIncludeFolder)
.OrderBy(identity) .OrderBy(identity)
.ToList(); .ToList();
foreach (string showFolder in allShowFolders) foreach (string showFolder in allShowFolders)
{
decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
await _mediator.Publish(
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.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));
foreach (BaseError error in maybeShow.LeftToSeq())
{ {
_logger.LogWarning( if (cancellationToken.IsCancellationRequested)
"Error processing show in folder {Folder}: {Error}", {
showFolder, return new ScanCanceled();
error.Value); }
}
foreach (MediaItemScanResult<Show> result in maybeShow.RightToSeq()) decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
{ await _mediator.Publish(
await ScanSeasons(libraryPath, ffmpegPath, ffprobePath, result.Item, showFolder, cancellationToken); new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
cancellationToken);
if (result.IsAdded) Either<BaseError, MediaItemScanResult<Show>> maybeShow =
await FindOrCreateShow(libraryPath.Id, showFolder)
.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));
foreach (BaseError error in maybeShow.LeftToSeq())
{ {
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item }); _logger.LogWarning(
"Error processing show in folder {Folder}: {Error}",
showFolder,
error.Value);
} }
else if (result.IsUpdated)
foreach (MediaItemScanResult<Show> result in maybeShow.RightToSeq())
{ {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item }); 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)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
} }
} }
}
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath)) foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
{
if (!_localFileSystem.FileExists(path))
{
_logger.LogInformation("Flagging missing episode at {Path}", path);
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{ {
_logger.LogInformation("Removing dot underscore file at {Path}", path); if (!_localFileSystem.FileExists(path))
await _televisionRepository.DeleteByPath(libraryPath, path); {
_logger.LogInformation("Flagging missing episode at {Path}", path);
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
}
else if (Path.GetFileName(path).StartsWith("._"))
{
_logger.LogInformation("Removing dot underscore file at {Path}", path);
await _televisionRepository.DeleteByPath(libraryPath, path);
}
} }
}
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath); await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
await _televisionRepository.DeleteEmptySeasons(libraryPath); await _televisionRepository.DeleteEmptySeasons(libraryPath);
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath); List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
await _searchIndex.RemoveItems(ids); await _searchIndex.RemoveItems(ids);
_searchIndex.Commit(); return Unit.Default;
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( private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
@ -147,12 +175,16 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
{ {
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder); ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata); Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
return await maybeShow.Match(
show => Right<BaseError, MediaItemScanResult<Show>>(new MediaItemScanResult<Show>(show)).AsTask(), foreach (Show show in maybeShow)
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata)); {
return new MediaItemScanResult<Show>(show);
}
return await _televisionRepository.AddShow(libraryPathId, showFolder, metadata);
} }
private async Task<Unit> ScanSeasons( private async Task<Either<BaseError, Unit>> ScanSeasons(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffmpegPath, string ffmpegPath,
string ffprobePath, string ffprobePath,
@ -163,6 +195,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder) foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
.OrderBy(identity)) .OrderBy(identity))
{ {
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
string etag = FolderEtag.CalculateWithSubfolders(seasonFolder, _localFileSystem); string etag = FolderEtag.CalculateWithSubfolders(seasonFolder, _localFileSystem);
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
.Filter(f => f.Path == seasonFolder) .Filter(f => f.Path == seasonFolder)
@ -175,44 +212,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
} }
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder); Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
await maybeSeasonNumber.IfSomeAsync( foreach (int seasonNumber in maybeSeasonNumber)
async seasonNumber => {
Either<BaseError, Season> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
.BindT(EnsureMetadataExists)
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
foreach (BaseError error in maybeSeason.LeftToSeq())
{ {
Either<BaseError, Season> maybeSeason = await _televisionRepository _logger.LogWarning(
.GetOrAddSeason(show, libraryPath.Id, seasonNumber) "Error processing season in folder {Folder}: {Error}",
.BindT(EnsureMetadataExists) seasonFolder,
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken)); error.Value);
}
await maybeSeason.Match(
async season => foreach (Season season in maybeSeason.RightToSeq())
{ {
await ScanEpisodes( Either<BaseError, Unit> scanResult = await ScanEpisodes(
libraryPath, libraryPath,
ffmpegPath, ffmpegPath,
ffprobePath, ffprobePath,
season, season,
seasonFolder, seasonFolder,
cancellationToken); cancellationToken);
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
season.Show = show; {
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season }); return error;
}, }
error =>
{ await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
_logger.LogWarning(
"Error processing season in folder {Folder}: {Error}", season.Show = show;
seasonFolder, await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
error.Value); }
return Task.FromResult(Unit.Default); }
});
});
} }
return Unit.Default; return Unit.Default;
} }
private async Task<Unit> ScanEpisodes( private async Task<Either<BaseError, Unit>> ScanEpisodes(
LibraryPath libraryPath, LibraryPath libraryPath,
string ffmpegPath, string ffmpegPath,
string ffprobePath, string ffprobePath,
@ -461,14 +502,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
} }
private Option<string> LocateNfoFileForShow(string showFolder) => private Option<string> LocateNfoFileForShow(string showFolder) =>
Optional(Path.Combine(showFolder, "tvshow.nfo")) Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s));
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateNfoFile(Episode episode) private Option<string> LocateNfoFile(Episode episode)
{ {
string path = episode.MediaVersions.Head().MediaFiles.Head().Path; string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
return Optional(Path.ChangeExtension(path, "nfo")) return Optional(Path.ChangeExtension(path, "nfo")).Filter(s => _localFileSystem.FileExists(s));
.Filter(s => _localFileSystem.FileExists(s));
} }
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind) private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)

7
ErsatzTV.Core/Plex/PlexConnectionParameters.cs

@ -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 @@
namespace ErsatzTV.Core.Plex; namespace ErsatzTV.Core.Plex;
public class PlexItemEtag public class PlexItemEtag : MediaServerItemEtag
{ {
public string Key { get; set; } public string Key { get; set; }
public string Etag { get; set; } public override string MediaServerItemId => Key;
public MediaItemState State { get; set; } public override string Etag { get; set; }
public override MediaItemState State { get; set; }
} }

52
ErsatzTV.Core/Plex/PlexLibraryScanner.cs

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

650
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -1,5 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors; using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata; using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex; using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
@ -10,21 +10,17 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Plex; 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 ILogger<PlexMovieLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository; private readonly IMetadataRepository _metadataRepository;
private readonly IMovieRepository _movieRepository; private readonly IMovieRepository _movieRepository;
private readonly IPlexMovieRepository _plexMovieRepository; private readonly IPlexMovieRepository _plexMovieRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService; private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient; private readonly IPlexServerApiClient _plexServerApiClient;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public PlexMovieLibraryScanner( public PlexMovieLibraryScanner(
IPlexServerApiClient plexServerApiClient, IPlexServerApiClient plexServerApiClient,
@ -40,20 +36,21 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
ILocalStatisticsProvider localStatisticsProvider, ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider, ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<PlexMovieLibraryScanner> logger) ILogger<PlexMovieLibraryScanner> logger)
: base(metadataRepository, logger) : base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
mediator,
searchIndex,
searchRepository,
logger)
{ {
_plexServerApiClient = plexServerApiClient; _plexServerApiClient = plexServerApiClient;
_movieRepository = movieRepository; _movieRepository = movieRepository;
_metadataRepository = metadataRepository; _metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_plexMovieRepository = plexMovieRepository; _plexMovieRepository = plexMovieRepository;
_plexPathReplacementService = plexPathReplacementService; _plexPathReplacementService = plexPathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger; _logger = logger;
} }
@ -66,262 +63,69 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
bool deepScan, bool deepScan,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
try List<PlexPathReplacement> pathReplacements =
{ await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId);
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
library,
connection,
token);
foreach (BaseError error in entries.LeftToSeq()) string GetLocalPath(PlexMovie movie)
{
return error;
}
return await ScanLibrary(
connection,
token,
library,
ffmpegPath,
ffprobePath,
deepScan,
entries.RightToSeq().Flatten().ToList(),
cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{ {
return new ScanCanceled(); return _plexPathReplacementService.GetReplacementPlexPath(
} pathReplacements,
finally movie.GetHeadVersion().MediaFiles.Head().Path,
{ false);
// always commit the search index to prevent corruption
_searchIndex.Commit();
} }
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
List<PlexMovie> movieEntries,
CancellationToken cancellationToken)
{
List<PlexItemEtag> existingMovies = await _movieRepository.GetExistingPlexMovies(library);
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
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) return await ScanLibrary(
{ _plexMovieRepository,
continue; new PlexConnectionParameters(connection, token),
} library,
GetLocalPath,
// TODO: figure out how to rebuild playlists ffmpegPath,
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository ffprobePath,
.GetOrAdd(library, incoming) deepScan,
.BindT( cancellationToken);
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 protected override string MediaServerItemId(PlexMovie movie) => movie.Key;
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); protected override string MediaServerEtag(PlexMovie movie) => movie.Etag;
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, PlexLibrary library,
List<PlexPathReplacement> pathReplacements, MediaItemScanResult<PlexMovie> result,
List<PlexItemEtag> existingMovies,
PlexMovie incoming, PlexMovie incoming,
bool deepScan) bool deepScan)
{ {
// deep scan will pull every movie individually from the plex api if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
if (!deepScan)
{ {
Option<PlexItemEtag> maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key); Either<BaseError, MovieMetadata> maybeMetadata = await _plexServerApiClient.GetMovieMetadata(
string existingEtag = await maybeExisting library,
.Map(e => e.Etag ?? string.Empty) incoming.Key.Split("/").Last(),
.IfNoneAsync(string.Empty); connectionParameters.Connection,
MediaItemState existingState = await maybeExisting connectionParameters.Token);
.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 (BaseError error in maybeMetadata.LeftToSeq())
{
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{ {
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone()) _logger.LogWarning("Failed to get movie metadata from Plex: {Error}", error.ToString());
{
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( return maybeMetadata.ToOption();
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
// only refresh statistics if the file exists
if (_localFileSystem.FileExists(localPath))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
}
foreach (bool _ in refreshResult.RightToSeq())
{
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
}
}
} }
return result; return None;
} }
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata( protected override async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
MediaItemScanResult<PlexMovie> result, MediaItemScanResult<PlexMovie> result,
PlexMovie incoming, MovieMetadata fullMetadata)
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token)
{ {
PlexMovie existing = result.Item; PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head(); MovieMetadata existingMetadata = existing.MovieMetadata.Head();
@ -329,243 +133,243 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
_logger.LogDebug( _logger.LogDebug(
"Refreshing {Attribute} for {Title}", "Refreshing {Attribute} for {Title}",
"Plex Metadata", "Plex Metadata",
existing.MovieMetadata.Head().Title); existingMetadata.Title);
Either<BaseError, MovieMetadata> maybeMetadata = if (existingMetadata.MetadataKind != MetadataKind.External)
await _plexServerApiClient.GetMovieMetadata( {
library, existingMetadata.MetadataKind = MetadataKind.External;
incoming.Key.Split("/").Last(), await _metadataRepository.MarkAsExternal(existingMetadata);
connection, }
token);
foreach (MovieMetadata fullMetadata in maybeMetadata.RightToSeq()) if (existingMetadata.ContentRating != fullMetadata.ContentRating)
{ {
if (existingMetadata.MetadataKind != MetadataKind.External) existingMetadata.ContentRating = fullMetadata.ContentRating;
{ await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
existingMetadata.MetadataKind = MetadataKind.External; result.IsUpdated = true;
await _metadataRepository.MarkAsExternal(existingMetadata); }
}
if (existingMetadata.ContentRating != fullMetadata.ContentRating) foreach (Genre genre in existingMetadata.Genres
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{ {
existingMetadata.ContentRating = fullMetadata.ContentRating;
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
result.IsUpdated = true; result.IsUpdated = true;
} }
}
foreach (Genre genre in existingMetadata.Genres foreach (Genre genre in fullMetadata.Genres
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name)) .Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{ {
existingMetadata.Genres.Remove(genre); existingMetadata.Genres.Add(genre);
if (await _metadataRepository.RemoveGenre(genre)) if (await _movieRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
}
foreach (Genre genre in fullMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{ {
existingMetadata.Genres.Add(genre); result.IsUpdated = true;
if (await _movieRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
} }
}
foreach (Studio studio in existingMetadata.Studios foreach (Studio studio in existingMetadata.Studios
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name)) .Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList()) .ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{ {
existingMetadata.Studios.Remove(studio); result.IsUpdated = true;
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
} }
}
foreach (Studio studio in fullMetadata.Studios foreach (Studio studio in fullMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name)) .Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList()) .ToList())
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{ {
existingMetadata.Studios.Add(studio); result.IsUpdated = true;
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
} }
}
foreach (Actor actor in existingMetadata.Actors foreach (Actor actor in existingMetadata.Actors
.Filter( .Filter(
a => fullMetadata.Actors.All( a => fullMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList()) .ToList())
{
existingMetadata.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{ {
existingMetadata.Actors.Remove(actor); result.IsUpdated = true;
if (await _metadataRepository.RemoveActor(actor))
{
result.IsUpdated = true;
}
} }
}
foreach (Actor actor in fullMetadata.Actors foreach (Actor actor in fullMetadata.Actors
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name)) .Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList()) .ToList())
{
existingMetadata.Actors.Add(actor);
if (await _movieRepository.AddActor(existingMetadata, actor))
{ {
existingMetadata.Actors.Add(actor); result.IsUpdated = true;
if (await _movieRepository.AddActor(existingMetadata, actor))
{
result.IsUpdated = true;
}
} }
}
foreach (Director director in existingMetadata.Directors foreach (Director director in existingMetadata.Directors
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name)) .Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{
existingMetadata.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{ {
existingMetadata.Directors.Remove(director); result.IsUpdated = true;
if (await _metadataRepository.RemoveDirector(director))
{
result.IsUpdated = true;
}
} }
}
foreach (Director director in fullMetadata.Directors foreach (Director director in fullMetadata.Directors
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name)) .Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{
existingMetadata.Directors.Add(director);
if (await _movieRepository.AddDirector(existingMetadata, director))
{ {
existingMetadata.Directors.Add(director); result.IsUpdated = true;
if (await _movieRepository.AddDirector(existingMetadata, director))
{
result.IsUpdated = true;
}
} }
}
foreach (Writer writer in existingMetadata.Writers foreach (Writer writer in existingMetadata.Writers
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name)) .Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{
existingMetadata.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{ {
existingMetadata.Writers.Remove(writer); result.IsUpdated = true;
if (await _metadataRepository.RemoveWriter(writer))
{
result.IsUpdated = true;
}
} }
}
foreach (Writer writer in fullMetadata.Writers foreach (Writer writer in fullMetadata.Writers
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name)) .Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{
existingMetadata.Writers.Add(writer);
if (await _movieRepository.AddWriter(existingMetadata, writer))
{ {
existingMetadata.Writers.Add(writer); result.IsUpdated = true;
if (await _movieRepository.AddWriter(existingMetadata, writer))
{
result.IsUpdated = true;
}
} }
}
foreach (MetadataGuid guid in existingMetadata.Guids foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid)) .Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList()) .ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{ {
existingMetadata.Guids.Remove(guid); result.IsUpdated = true;
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
} }
}
foreach (MetadataGuid guid in fullMetadata.Guids foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid)) .Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList()) .ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{ {
existingMetadata.Guids.Add(guid); result.IsUpdated = true;
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
} }
}
foreach (Tag tag in existingMetadata.Tags foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name)) .Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{ {
existingMetadata.Tags.Remove(tag); result.IsUpdated = true;
if (await _metadataRepository.RemoveTag(tag))
{
result.IsUpdated = true;
}
} }
}
foreach (Tag tag in fullMetadata.Tags foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name)) .Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList()) .ToList())
{
existingMetadata.Tags.Add(tag);
if (await _movieRepository.AddTag(existingMetadata, tag))
{ {
existingMetadata.Tags.Add(tag); result.IsUpdated = true;
if (await _movieRepository.AddTag(existingMetadata, tag))
{
result.IsUpdated = true;
}
} }
}
if (fullMetadata.SortTitle != existingMetadata.SortTitle) if (fullMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = fullMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{ {
existingMetadata.SortTitle = fullMetadata.SortTitle; result.IsUpdated = true;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
} }
}
if (result.IsUpdated) bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster);
{ bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt);
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated); if (poster || fanArt)
} {
result.IsUpdated = true;
} }
// TODO: update other metadata? if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
return result; return result;
} }
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateSubtitles( private async Task<bool> UpdateArtworkIfNeeded(
List<PlexPathReplacement> pathReplacements, Domain.Metadata existingMetadata,
MediaItemScanResult<PlexMovie> result, Domain.Metadata incomingMetadata,
PlexMovie incoming) ArtworkKind artworkKind)
{ {
try if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{ {
string localPath = _plexPathReplacementService.GetReplacementPlexPath( Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
pathReplacements, .Find(a => a.ArtworkKind == artworkKind);
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false); if (maybeIncomingArtwork.IsNone)
{
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
}
return result; foreach (Artwork incomingArtwork in maybeIncomingArtwork)
} {
catch (Exception ex) _logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
{
return BaseError.New(ex.ToString());
}
}
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateArtwork( Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
MediaItemScanResult<PlexMovie> result, .Find(a => a.ArtworkKind == artworkKind);
PlexMovie incoming)
{
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster); if (maybeExistingArtwork.IsNone)
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt); {
if (poster || fanArt) existingMetadata.Artwork ??= new List<Artwork>();
{ existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated); await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
}
foreach (Artwork existingArtwork in maybeExistingArtwork)
{
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
}
}
return true;
} }
return result; return false;
} }
} }

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

@ -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 @@
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());
}
}
}

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

@ -513,30 +513,37 @@ public class MetadataRepository : IMetadataRepository
.ToList(); .ToList();
var toUpdate = subtitles.Except(toAdd).ToList(); var toUpdate = subtitles.Except(toAdd).ToList();
// add if (toAdd.Any() || toRemove.Any() || toUpdate.Any())
existing.Subtitles.AddRange(toAdd); {
// add
existing.Subtitles.AddRange(toAdd);
// remove // remove
existing.Subtitles.RemoveAll(s => toRemove.Contains(s)); existing.Subtitles.RemoveAll(s => toRemove.Contains(s));
// update // update
foreach (Subtitle incomingSubtitle in toUpdate) foreach (Subtitle incomingSubtitle in toUpdate)
{ {
Subtitle existingSubtitle = Subtitle existingSubtitle =
existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex); existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex);
existingSubtitle.Codec = incomingSubtitle.Codec; existingSubtitle.Codec = incomingSubtitle.Codec;
existingSubtitle.Default = incomingSubtitle.Default; existingSubtitle.Default = incomingSubtitle.Default;
existingSubtitle.Forced = incomingSubtitle.Forced; existingSubtitle.Forced = incomingSubtitle.Forced;
existingSubtitle.SDH = incomingSubtitle.SDH; existingSubtitle.SDH = incomingSubtitle.SDH;
existingSubtitle.Language = incomingSubtitle.Language; existingSubtitle.Language = incomingSubtitle.Language;
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind; existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated; existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
}
return await dbContext.SaveChangesAsync() > 0;
} }
return await dbContext.SaveChangesAsync() > 0; // nothing to do
return true;
} }
// no metadata
return false; return false;
} }

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

@ -1,12 +1,8 @@
using Dapper; using Dapper;
using ErsatzTV.Core; using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata; using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -95,48 +91,6 @@ public class MovieRepository : IMovieRepository
async () => await AddMovie(dbContext, libraryPath.Id, path)); 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) public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -241,19 +195,6 @@ public class MovieRepository : IMovieRepository
.Map(result => result > 0); .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) public async Task<bool> UpdateSortTitle(MovieMetadata movieMetadata)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -262,530 +203,6 @@ public class MovieRepository : IMovieRepository
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0); 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) public async Task<bool> AddDirector(MovieMetadata metadata, Director director)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -810,14 +227,6 @@ public class MovieRepository : IMovieRepository
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default); 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( private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext, TvContext dbContext,
int libraryPathId, int libraryPathId,
@ -852,33 +261,4 @@ public class MovieRepository : IMovieRepository
return BaseError.New(ex.Message); 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 @@
using Dapper; using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories; namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -11,6 +15,19 @@ public class PlexMovieRepository : IPlexMovieRepository
public PlexMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory; 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) public async Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -73,4 +90,77 @@ public class PlexMovieRepository : IPlexMovieRepository
return ids; 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
try try
{ {
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -819,9 +818,6 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexShows.AddAsync(item); await dbContext.PlexShows.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true }; return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
@ -840,7 +836,6 @@ public class TelevisionRepository : ITelevisionRepository
try try
{ {
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -848,9 +843,6 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexSeasons.AddAsync(item); await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return item; return item;
@ -874,7 +866,6 @@ public class TelevisionRepository : ITelevisionRepository
} }
// blank out etag for initial save in case stats/metadata/etc updates fail // blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty; item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id; item.LibraryPathId = library.Paths.Head().Id;
@ -891,9 +882,6 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexEpisodes.AddAsync(item); await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync(); await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync(); await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync(); await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();

2
ErsatzTV/Startup.cs

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

Loading…
Cancel
Save