Browse Source

plex scanner improvements (#747)

* plex api cleanup

* improve plex movie scanner

* sync plex collections as tags

* improve plex tv library scanner

* update dependencies

* fix plex season and episode collection tags
pull/748/head
Jason Dove 4 years ago committed by GitHub
parent
commit
ec6eab97b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 64
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs
  3. 64
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs
  4. 162
      ErsatzTV.Application/MediaSources/Commands/ScanLocalLibraryHandler.cs
  5. 64
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  6. 11
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  7. 1
      ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs
  8. 1
      ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs
  9. 1
      ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs
  10. 1
      ErsatzTV.Core/Domain/MediaItem/PlexShow.cs
  11. 3
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  12. 7
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  13. 7
      ErsatzTV.Core/Plex/PlexItemEtag.cs
  14. 383
      ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs
  15. 88
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  16. 30
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  17. 89
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  18. 4079
      ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.Designer.cs
  19. 55
      ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.cs
  20. 12
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  21. 12
      ErsatzTV.Infrastructure/Plex/Models/PlexCollectionResponse.cs
  22. 3
      ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs
  23. 3
      ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs
  24. 3
      ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs
  25. 3
      ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.cs
  26. 244
      ErsatzTV.Infrastructure/Plex/PlexEtag.cs
  27. 42
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  28. 5
      ErsatzTV.Infrastructure/Search/SearchIndex.cs
  29. 2
      ErsatzTV/ErsatzTV.csproj
  30. 6
      scripts/cleanup-all-code.sh
  31. 3
      scripts/cleanup-code.sh

5
CHANGELOG.md

@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -8,6 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add support for burning in embedded text subtitles
- Add support for burning in external text subtitles
- **This requires a one-time full library scan, which may take a long time with large libraries.**
- Sync Plex collections as tags on movies, shows, seasons and episodes
- This allows smart collections that use queries like `tag:"Plex Collection Name"`
### Changed
- Improve the speed and change detection of the Plex library scanners
## [0.5.1-beta] - 2022-04-17
### Fixed

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

@ -59,42 +59,48 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -59,42 +59,48 @@ public class SynchronizeEmbyLibraryByIdHandler :
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
try
{
switch (parameters.Library.MediaKind)
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
case LibraryMediaKind.Movies:
await _embyMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _embyTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
switch (parameters.Library.MediaKind)
{
case LibraryMediaKind.Movies:
await _embyMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _embyTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of emby media library {Name}",
parameters.Library.Name);
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
return Unit.Default;
}
else
finally
{
_logger.LogDebug(
"Skipping unforced scan of emby media library {Name}",
parameters.Library.Name);
_entityLocker.UnlockLibrary(parameters.Library.Id);
}
_entityLocker.UnlockLibrary(parameters.Library.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(

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

@ -59,42 +59,48 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -59,42 +59,48 @@ public class SynchronizeJellyfinLibraryByIdHandler :
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
try
{
switch (parameters.Library.MediaKind)
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
case LibraryMediaKind.Movies:
await _jellyfinMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
switch (parameters.Library.MediaKind)
{
case LibraryMediaKind.Movies:
await _jellyfinMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of jellyfin media library {Name}",
parameters.Library.Name);
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
return Unit.Default;
}
else
finally
{
_logger.LogDebug(
"Skipping unforced scan of jellyfin media library {Name}",
parameters.Library.Name);
_entityLocker.UnlockLibrary(parameters.Library.Id);
}
_entityLocker.UnlockLibrary(parameters.Library.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(

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

@ -65,99 +65,105 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei @@ -65,99 +65,105 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
(LocalLibrary localLibrary, string ffprobePath, string ffmpegPath, bool forceScan,
int libraryRefreshInterval) = parameters;
var sw = new Stopwatch();
sw.Start();
var scanned = false;
for (var i = 0; i < localLibrary.Paths.Count; i++)
try
{
LibraryPath libraryPath = localLibrary.Paths[i];
var sw = new Stopwatch();
sw.Start();
decimal progressMin = (decimal)i / localLibrary.Paths.Count;
decimal progressMax = (decimal)(i + 1) / localLibrary.Paths.Count;
var scanned = false;
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
if (forceScan || nextScan < DateTimeOffset.Now)
for (var i = 0; i < localLibrary.Paths.Count; i++)
{
scanned = true;
LibraryPath libraryPath = localLibrary.Paths[i];
decimal progressMin = (decimal)i / localLibrary.Paths.Count;
decimal progressMax = (decimal)(i + 1) / localLibrary.Paths.Count;
switch (localLibrary.MediaKind)
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
if (forceScan || nextScan < DateTimeOffset.Now)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.OtherVideos:
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax);
break;
case LibraryMediaKind.Songs:
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax,
cancellationToken);
break;
scanned = true;
switch (localLibrary.MediaKind)
{
case LibraryMediaKind.Movies:
await _movieFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.Shows:
await _televisionFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.MusicVideos:
await _musicVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax,
cancellationToken);
break;
case LibraryMediaKind.OtherVideos:
await _otherVideoFolderScanner.ScanFolder(
libraryPath,
ffmpegPath,
ffprobePath,
progressMin,
progressMax);
break;
case LibraryMediaKind.Songs:
await _songFolderScanner.ScanFolder(
libraryPath,
ffprobePath,
ffmpegPath,
progressMin,
progressMax,
cancellationToken);
break;
}
libraryPath.LastScan = DateTime.UtcNow;
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));
}
sw.Stop();
if (scanned)
{
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
}
else
{
_logger.LogDebug(
"Skipping unforced scan of local media library {Name}",
localLibrary.Name);
}
sw.Stop();
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0), cancellationToken);
if (scanned)
{
_logger.LogDebug(
"Scan of library {Name} completed in {Duration}",
localLibrary.Name,
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
return Unit.Default;
}
else
finally
{
_logger.LogDebug(
"Skipping unforced scan of local media library {Name}",
localLibrary.Name);
_entityLocker.UnlockLibrary(localLibrary.Id);
}
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
_entityLocker.UnlockLibrary(localLibrary.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request)

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

@ -57,42 +57,48 @@ public class @@ -57,42 +57,48 @@ public class
private async Task<Unit> Synchronize(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
try
{
switch (parameters.Library.MediaKind)
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? SystemTime.MinValueUtc, TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
case LibraryMediaKind.Movies:
await _plexMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
switch (parameters.Library.MediaKind)
{
case LibraryMediaKind.Movies:
await _plexMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
await _plexTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection,
parameters.ConnectionParameters.PlexServerAuthToken,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
}
else
{
_logger.LogDebug(
"Skipping unforced scan of plex media library {Name}",
parameters.Library.Name);
}
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
return Unit.Default;
}
else
finally
{
_logger.LogDebug(
"Skipping unforced scan of plex media library {Name}",
parameters.Library.Name);
_entityLocker.UnlockLibrary(parameters.Library.Id);
}
_entityLocker.UnlockLibrary(parameters.Library.Id);
return Unit.Default;
}
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>

11
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Tests.Fakes;
@ -73,7 +74,7 @@ public class FakeTelevisionRepository : ITelevisionRepository @@ -73,7 +74,7 @@ public class FakeTelevisionRepository : ITelevisionRepository
throw new NotSupportedException();
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
@ -96,4 +97,12 @@ public class FakeTelevisionRepository : ITelevisionRepository @@ -96,4 +97,12 @@ public class FakeTelevisionRepository : ITelevisionRepository
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
public Task<Unit> SetPlexEtag(PlexEpisode episode, string etag) => throw new NotSupportedException();
public Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season) =>
throw new NotSupportedException();
}

1
ErsatzTV.Core/Domain/MediaItem/PlexEpisode.cs

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
public class PlexEpisode : Episode
{
public string Key { get; set; }
public string Etag { get; set; }
}

1
ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
public class PlexMovie : Movie
{
public string Key { get; set; }
public string Etag { get; set; }
}

1
ErsatzTV.Core/Domain/MediaItem/PlexSeason.cs

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
public class PlexSeason : Season
{
public string Key { get; set; }
public string Etag { get; set; }
}

1
ErsatzTV.Core/Domain/MediaItem/PlexShow.cs

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
public class PlexShow : Show
{
public string Key { get; set; }
public string Etag { get; set; }
}

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

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
@ -18,6 +19,7 @@ public interface IMovieRepository @@ -18,6 +19,7 @@ public interface IMovieRepository
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library);
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
@ -31,4 +33,5 @@ public interface IMovieRepository @@ -31,4 +33,5 @@ public interface IMovieRepository
Task<bool> AddDirector(MovieMetadata metadata, Director director);
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
Task<Unit> SetPlexEtag(PlexMovie movie, string etag);
}

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
@ -41,7 +42,7 @@ public interface ITelevisionRepository @@ -41,7 +42,7 @@ public interface ITelevisionRepository
GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
Task<bool> AddTag(ShowMetadata metadata, Tag tag);
Task<bool> AddTag(Domain.Metadata metadata, Tag tag);
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);
Task<bool> AddActor(ShowMetadata metadata, Actor actor);
Task<bool> AddActor(EpisodeMetadata metadata, Actor actor);
@ -52,4 +53,8 @@ public interface ITelevisionRepository @@ -52,4 +53,8 @@ public interface ITelevisionRepository
Task<bool> AddDirector(EpisodeMetadata metadata, Director director);
Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer);
Task<Unit> UpdatePath(int mediaFileId, string path);
Task<Unit> SetPlexEtag(PlexShow show, string etag);
Task<Unit> SetPlexEtag(PlexSeason season, string etag);
Task<Unit> SetPlexEtag(PlexEpisode episode, string etag);
Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season);
}

7
ErsatzTV.Core/Plex/PlexItemEtag.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Core.Plex;
public class PlexItemEtag
{
public string Key { get; set; }
public string Etag { get; set; }
}

383
ErsatzTV.Core/Plex/PlexMovieLibraryScanner.cs

@ -60,6 +60,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -60,6 +60,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
string ffmpegPath,
string ffprobePath)
{
List<PlexItemEtag> existingMovies = await _movieRepository.GetExistingPlexMovies(library);
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
@ -94,6 +96,15 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -94,6 +96,15 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
Option<PlexItemEtag> maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key);
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == incoming.Etag)
{
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
continue;
}
_logger.LogDebug("UPDATE: Etag has changed for movie {Movie}", incoming.MovieMetadata.Head().Title);
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
.GetOrAdd(library, incoming)
@ -106,6 +117,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -106,6 +117,8 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
await maybeMovie.Match(
async result =>
{
await _movieRepository.SetPlexEtag(result.Item, incoming.Etag);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -176,40 +189,37 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -176,40 +189,37 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
}
}
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
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, existing, localPath);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
await refreshResult.Match(
async _ =>
await refreshResult.Match(
async _ =>
{
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
}
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
},
error =>
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
return Task.CompletedTask;
});
return result;
}
@ -224,188 +234,207 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -224,188 +234,207 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
PlexMovie existing = result.Item;
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
if (result.IsAdded || incoming.MovieMetadata.Head().DateUpdated > existingMetadata.DateUpdated)
{
_logger.LogDebug(
"Refreshing {Attribute} from {Path}",
"Plex Metadata",
existing.MediaVersions.Head().MediaFiles.Head().Path);
Either<BaseError, MovieMetadata> maybeMetadata =
await _plexServerApiClient.GetMovieMetadata(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeMetadata.Match(
async fullMetadata =>
_logger.LogDebug(
"Refreshing {Attribute} for {Title}",
"Plex Metadata",
existing.MovieMetadata.Head().Title);
Either<BaseError, MovieMetadata> maybeMetadata =
await _plexServerApiClient.GetMovieMetadata(
library,
incoming.Key.Split("/").Last(),
connection,
token);
await maybeMetadata.Match(
async fullMetadata =>
{
if (existingMetadata.MetadataKind != MetadataKind.External)
{
if (existingMetadata.MetadataKind != MetadataKind.External)
{
existingMetadata.MetadataKind = MetadataKind.External;
await _metadataRepository.MarkAsExternal(existingMetadata);
}
existingMetadata.MetadataKind = MetadataKind.External;
await _metadataRepository.MarkAsExternal(existingMetadata);
}
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
{
existingMetadata.ContentRating = fullMetadata.ContentRating;
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
result.IsUpdated = true;
}
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;
}
}
foreach (Genre genre in existingMetadata.Genres
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Genre genre in fullMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Genres.Add(genre);
if (await _movieRepository.AddGenre(existingMetadata, genre))
{
existingMetadata.Genres.Remove(genre);
if (await _metadataRepository.RemoveGenre(genre))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Genre genre in fullMetadata.Genres
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Studio studio in existingMetadata.Studios
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
existingMetadata.Genres.Add(genre);
if (await _movieRepository.AddGenre(existingMetadata, genre))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Studio studio in existingMetadata.Studios
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
foreach (Studio studio in fullMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
existingMetadata.Studios.Remove(studio);
if (await _metadataRepository.RemoveStudio(studio))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Studio studio in fullMetadata.Studios
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
.ToList())
foreach (Actor actor in existingMetadata.Actors
.Filter(
a => fullMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
existingMetadata.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{
existingMetadata.Studios.Add(studio);
if (await _movieRepository.AddStudio(existingMetadata, studio))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Actor actor in existingMetadata.Actors
.Filter(
a => fullMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
foreach (Actor actor in fullMetadata.Actors
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
existingMetadata.Actors.Add(actor);
if (await _movieRepository.AddActor(existingMetadata, actor))
{
existingMetadata.Actors.Remove(actor);
if (await _metadataRepository.RemoveActor(actor))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Actor actor in fullMetadata.Actors
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
foreach (Director director in existingMetadata.Directors
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
existingMetadata.Actors.Add(actor);
if (await _movieRepository.AddActor(existingMetadata, actor))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Director director in existingMetadata.Directors
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Director director in fullMetadata.Directors
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Directors.Add(director);
if (await _movieRepository.AddDirector(existingMetadata, director))
{
existingMetadata.Directors.Remove(director);
if (await _metadataRepository.RemoveDirector(director))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Director director in fullMetadata.Directors
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Writer writer in existingMetadata.Writers
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
existingMetadata.Directors.Add(director);
if (await _movieRepository.AddDirector(existingMetadata, director))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Writer writer in existingMetadata.Writers
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList())
foreach (Writer writer in fullMetadata.Writers
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Writers.Add(writer);
if (await _movieRepository.AddWriter(existingMetadata, writer))
{
existingMetadata.Writers.Remove(writer);
if (await _metadataRepository.RemoveWriter(writer))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (Writer writer in fullMetadata.Writers
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
.ToList())
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
existingMetadata.Writers.Add(writer);
if (await _movieRepository.AddWriter(existingMetadata, writer))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
existingMetadata.Guids.Remove(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{
existingMetadata.Guids.Add(guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Add(tag);
if (await _movieRepository.AddTag(existingMetadata, tag))
{
existingMetadata.SortTitle = fullMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
result.IsUpdated = true;
}
result.IsUpdated = true;
}
}
if (result.IsUpdated)
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
{
existingMetadata.SortTitle = fullMetadata.SortTitle;
if (await _movieRepository.UpdateSortTitle(existingMetadata))
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
result.IsUpdated = true;
}
},
_ => Task.CompletedTask);
}
// TODO: update other metadata?
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
},
_ => Task.CompletedTask);
// TODO: update other metadata?
return result;
}
@ -417,19 +446,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -417,19 +446,12 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
{
try
{
PlexMovie existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (result.IsAdded || incomingVersion.DateUpdated > existingVersion.DateUpdated)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false);
}
await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false);
return result;
}
@ -447,12 +469,9 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan @@ -447,12 +469,9 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
return result;
}

88
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -95,6 +95,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -95,6 +95,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
ffmpegPath,
ffprobePath);
await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -147,7 +149,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -147,7 +149,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
if (result.IsAdded || incoming.ShowMetadata.Head().DateUpdated > existingMetadata.DateUpdated)
if (existing.Etag != incoming.Etag)
{
Either<BaseError, ShowMetadata> maybeMetadata =
await _plexServerApiClient.GetShowMetadata(
@ -262,6 +264,28 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -262,6 +264,28 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Remove(tag);
if (await _metadataRepository.RemoveTag(tag))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Add(tag);
if (await _televisionRepository.AddTag(existingMetadata, tag))
{
result.IsUpdated = true;
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
@ -281,7 +305,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -281,7 +305,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
if (existing.Etag != incoming.Etag)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
@ -330,7 +354,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -330,7 +354,10 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
ffmpegPath,
ffprobePath);
await _televisionRepository.SetPlexEtag(season, incoming.Etag);
season.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
},
error =>
@ -366,7 +393,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -366,7 +393,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
if (existing.Etag != incoming.Etag)
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
@ -384,6 +411,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -384,6 +411,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
await _metadataRepository.AddGuid(existingMetadata, guid);
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Add(tag);
await _televisionRepository.AddTag(existingMetadata, tag);
}
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
@ -400,6 +443,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -400,6 +443,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
string ffmpegPath,
string ffprobePath)
{
List<PlexItemEtag> existingEpisodes = await _televisionRepository.GetExistingPlexEpisodes(library, season);
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
library,
season,
@ -431,6 +476,17 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -431,6 +476,17 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
foreach (PlexEpisode incoming in validEpisodes)
{
Option<PlexItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.Key == incoming.Key);
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == incoming.Etag)
{
// _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key);
continue;
}
// _logger.LogDebug(
// "UPDATE: Etag has changed for episode {Episode}",
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
@ -453,6 +509,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -453,6 +509,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
await maybeEpisode.Match(
async result =>
{
await _televisionRepository.SetPlexEtag(result.Item, incoming.Etag);
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
@ -555,7 +613,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -555,7 +613,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
}
}
if (incomingVersion.DateUpdated > existingVersion.DateUpdated || !existingVersion.Streams.Any())
if (existing.Etag != incoming.Etag)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
@ -607,6 +665,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -607,6 +665,22 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Add(tag);
await _televisionRepository.AddTag(existingMetadata, tag);
}
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
@ -640,10 +714,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -640,10 +714,8 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
try
{
PlexEpisode existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (result.IsAdded || incomingVersion.DateUpdated > existingVersion.DateUpdated)
if (existing.Etag != incoming.Etag)
{
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
@ -673,7 +745,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -673,7 +745,7 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
if (maybeExistingMetadata.IsSome)
{
EpisodeMetadata existingMetadata = maybeExistingMetadata.ValueUnsafe();
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
if (existing.Etag != incoming.Etag)
{
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);

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

@ -5,6 +5,7 @@ using ErsatzTV.Core.Emby; @@ -5,6 +5,7 @@ using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore;
@ -240,6 +241,19 @@ public class MovieRepository : IMovieRepository @@ -240,6 +241,19 @@ public class MovieRepository : IMovieRepository
.Map(result => result > 0);
}
public async Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT Key, Etag 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<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -812,6 +826,14 @@ public class MovieRepository : IMovieRepository @@ -812,6 +826,14 @@ public class MovieRepository : IMovieRepository
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexMovie movie, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
}
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext,
int libraryPathId,
@ -854,10 +876,18 @@ public class MovieRepository : IMovieRepository @@ -854,10 +876,18 @@ public class MovieRepository : IMovieRepository
{
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 };

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

@ -3,6 +3,7 @@ using ErsatzTV.Core; @@ -3,6 +3,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -478,6 +479,8 @@ public class TelevisionRepository : ITelevisionRepository @@ -478,6 +479,8 @@ public class TelevisionRepository : ITelevisionRepository
.ThenInclude(sm => sm.Artwork)
.Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Guids)
.Include(i => i.SeasonMetadata)
.ThenInclude(sm => sm.Tags)
.Include(s => s.LibraryPath)
.ThenInclude(l => l.Library)
.Include(s => s.TraktListItems)
@ -598,6 +601,45 @@ public class TelevisionRepository : ITelevisionRepository @@ -598,6 +601,45 @@ public class TelevisionRepository : ITelevisionRepository
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexShow show, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexSeason season, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetPlexEtag(PlexEpisode episode, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
}
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@"SELECT PlexEpisode.Key, PlexEpisode.Etag FROM PlexEpisode
INNER JOIN Episode E on PlexEpisode.Id = E.Id
INNER JOIN MediaItem MI on E.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Season S2 on E.SeasonId = S2.Id
INNER JOIN PlexSeason PS on S2.Id = PS.Id
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
new { LibraryId = library.Id, season.Key })
.Map(result => result.ToList());
}
public async Task<List<Episode>> GetShowItems(int showId)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -648,12 +690,27 @@ public class TelevisionRepository : ITelevisionRepository @@ -648,12 +690,27 @@ public class TelevisionRepository : ITelevisionRepository
new { genre.Name, MetadataId = metadata.Id }).Map(result => result > 0);
}
public async Task<bool> AddTag(ShowMetadata metadata, Tag tag)
public async Task<bool> AddTag(Metadata metadata, Tag tag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
switch (metadata)
{
case ShowMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
case SeasonMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
case EpisodeMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
default:
return false;
}
}
public async Task<bool> AddStudio(ShowMetadata metadata, Studio studio)
@ -840,10 +897,18 @@ public class TelevisionRepository : ITelevisionRepository @@ -840,10 +897,18 @@ public class TelevisionRepository : ITelevisionRepository
{
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
await dbContext.PlexShows.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
@ -861,10 +926,18 @@ public class TelevisionRepository : ITelevisionRepository @@ -861,10 +926,18 @@ public class TelevisionRepository : ITelevisionRepository
{
try
{
// blank out etag for initial save in case stats/metadata/etc updates fail
string etag = item.Etag;
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
await dbContext.PlexSeasons.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return item;
@ -887,6 +960,10 @@ public class TelevisionRepository : ITelevisionRepository @@ -887,6 +960,10 @@ public class TelevisionRepository : ITelevisionRepository
return BaseError.New("Multi-episode files are not yet supported");
}
// 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;
foreach (EpisodeMetadata metadata in item.EpisodeMetadata)
{
@ -900,6 +977,10 @@ public class TelevisionRepository : ITelevisionRepository @@ -900,6 +977,10 @@ public class TelevisionRepository : ITelevisionRepository
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
// restore etag
item.Etag = etag;
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();

4079
ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.Designer.cs generated

File diff suppressed because it is too large Load Diff

55
ErsatzTV.Infrastructure/Migrations/20220421014846_Add_PlexEtags.cs

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlexEtags : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Etag",
table: "PlexShow",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Etag",
table: "PlexSeason",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Etag",
table: "PlexMovie",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Etag",
table: "PlexEpisode",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Etag",
table: "PlexShow");
migrationBuilder.DropColumn(
name: "Etag",
table: "PlexSeason");
migrationBuilder.DropColumn(
name: "Etag",
table: "PlexMovie");
migrationBuilder.DropColumn(
name: "Etag",
table: "PlexEpisode");
}
}
}

12
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -2404,6 +2404,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2404,6 +2404,9 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.Episode");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
@ -2414,6 +2417,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2414,6 +2417,9 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.Movie");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
@ -2424,6 +2430,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2424,6 +2430,9 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.Season");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
@ -2434,6 +2443,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -2434,6 +2443,9 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.Show");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");

12
ErsatzTV.Infrastructure/Plex/Models/PlexCollectionResponse.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using System.Xml.Serialization;
namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexCollectionResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("tag")]
public string Tag { get; set; }
}

3
ErsatzTV.Infrastructure/Plex/Models/PlexDirectorResponse.cs

@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models; @@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexDirectorResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("tag")]
public string Tag { get; set; }
}

3
ErsatzTV.Infrastructure/Plex/Models/PlexMetadataResponse.cs

@ -69,4 +69,7 @@ public class PlexMetadataResponse @@ -69,4 +69,7 @@ public class PlexMetadataResponse
[XmlElement("Writer")]
public List<PlexWriterResponse> Writer { get; set; }
[XmlElement("Collection")]
public List<PlexCollectionResponse> Collection { get; set; }
}

3
ErsatzTV.Infrastructure/Plex/Models/PlexRoleResponse.cs

@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models; @@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexRoleResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("tag")]
public string Tag { get; set; }

3
ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.mcs.cs → ErsatzTV.Infrastructure/Plex/Models/PlexWriterResponse.cs

@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models; @@ -4,6 +4,9 @@ namespace ErsatzTV.Infrastructure.Plex.Models;
public class PlexWriterResponse
{
[XmlAttribute("id")]
public int Id { get; set; }
[XmlAttribute("tag")]
public string Tag { get; set; }
}

244
ErsatzTV.Infrastructure/Plex/PlexEtag.cs

@ -0,0 +1,244 @@ @@ -0,0 +1,244 @@
using System.Security.Cryptography;
using ErsatzTV.Infrastructure.Plex.Models;
namespace ErsatzTV.Infrastructure.Plex;
public static class PlexEtag
{
public static string ForMovie(PlexMetadataResponse response)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
// video key
bw.Write(response.Key);
// video added at
bw.Write(response.AddedAt);
// video updated at
bw.Write(response.UpdatedAt);
foreach (PlexMediaResponse<PlexPartResponse> media in response.Media)
{
// media id
bw.Write((byte)FieldKey.MediaId);
bw.Write(media.Id);
// media part id
foreach (PlexPartResponse part in media.Part)
{
bw.Write((byte)FieldKey.PartId);
bw.Write(part.Id);
}
}
// genre ids
foreach (PlexGenreResponse genre in Optional(response.Genre).Flatten())
{
bw.Write((byte)FieldKey.GenreTag);
bw.Write(genre.Tag);
}
// director ids
foreach (PlexDirectorResponse director in Optional(response.Director).Flatten())
{
bw.Write((byte)FieldKey.DirectorTag);
bw.Write(director.Tag);
}
// writer ids
foreach (PlexWriterResponse writer in Optional(response.Writer).Flatten())
{
bw.Write((byte)FieldKey.WriterTag);
bw.Write(writer.Tag);
}
// collection ids
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
bw.Write((byte)FieldKey.CollectionTag);
bw.Write(collection.Tag);
}
// role ids
foreach (PlexRoleResponse role in Optional(response.Role).Flatten())
{
bw.Write((byte)FieldKey.RoleTag);
bw.Write(role.Tag);
}
ms.Position = 0;
byte[] hash = SHA1.Create().ComputeHash(ms);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
public static string ForShow(PlexMetadataResponse response)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
// video key
bw.Write(response.Key);
// video added at
bw.Write(response.AddedAt);
// video updated at
bw.Write(response.UpdatedAt);
// genre ids
foreach (PlexGenreResponse genre in Optional(response.Genre).Flatten())
{
bw.Write((byte)FieldKey.GenreTag);
bw.Write(genre.Tag);
}
// collection ids
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
bw.Write((byte)FieldKey.CollectionTag);
bw.Write(collection.Tag);
}
// role ids
foreach (PlexRoleResponse role in Optional(response.Role).Flatten())
{
bw.Write((byte)FieldKey.RoleTag);
bw.Write(role.Tag);
}
ms.Position = 0;
byte[] hash = SHA1.Create().ComputeHash(ms);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
public static string ForSeason(PlexXmlMetadataResponse response)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
// video key
bw.Write(response.Key);
// video added at
bw.Write(response.AddedAt);
// video updated at
bw.Write(response.UpdatedAt);
// collection ids
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
bw.Write((byte)FieldKey.CollectionTag);
bw.Write(collection.Tag);
}
// thumb
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
bw.Write(response.Thumb);
}
// art
if (!string.IsNullOrWhiteSpace(response.Art))
{
bw.Write(response.Art);
}
ms.Position = 0;
byte[] hash = SHA1.Create().ComputeHash(ms);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
public static string ForEpisode(PlexXmlMetadataResponse response)
{
using var ms = new MemoryStream();
using var bw = new BinaryWriter(ms);
// video key
bw.Write(response.Key);
// video added at
bw.Write(response.AddedAt);
// video updated at
bw.Write(response.UpdatedAt);
foreach (PlexMediaResponse<PlexXmlPartResponse> media in response.Media)
{
// media id
bw.Write((byte)FieldKey.MediaId);
bw.Write(media.Id);
// media part id
foreach (PlexXmlPartResponse part in media.Part)
{
bw.Write((byte)FieldKey.PartId);
bw.Write(part.Id);
// media part id
foreach (PlexStreamResponse stream in part.Stream)
{
bw.Write((byte)FieldKey.StreamId);
bw.Write(stream.Id);
}
}
}
// genre ids
foreach (PlexGenreResponse genre in Optional(response.Genre).Flatten())
{
bw.Write((byte)FieldKey.GenreTag);
bw.Write(genre.Tag);
}
// director ids
foreach (PlexDirectorResponse director in Optional(response.Director).Flatten())
{
bw.Write((byte)FieldKey.DirectorTag);
bw.Write(director.Tag);
}
// writer ids
foreach (PlexWriterResponse writer in Optional(response.Writer).Flatten())
{
bw.Write((byte)FieldKey.WriterTag);
bw.Write(writer.Tag);
}
// collection ids
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
bw.Write((byte)FieldKey.CollectionTag);
bw.Write(collection.Tag);
}
// role ids
foreach (PlexRoleResponse role in Optional(response.Role).Flatten())
{
bw.Write((byte)FieldKey.RoleTag);
bw.Write(role.Tag);
}
ms.Position = 0;
byte[] hash = SHA1.Create().ComputeHash(ms);
return BitConverter.ToString(hash).Replace("-", string.Empty);
}
private enum FieldKey : byte
{
MediaId = 0,
PartId = 1,
StreamId = 2,
GenreTag = 10,
DirectorTag = 11,
WriterTag = 12,
CollectionTag = 13,
RoleTag = 14,
Thumb = 20,
Art = 21
}
}

42
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -158,9 +158,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -158,9 +158,7 @@ public class PlexServerApiClient : IPlexServerApiClient
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone())
.Map(r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0))
.MapT(response => ProjectToMovieMetadata(response.Metadata, library.MediaSourceId))
.Map(o => o.ToEither<BaseError>("Unable to locate metadata"));
}
@ -202,9 +200,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -202,9 +200,7 @@ public class PlexServerApiClient : IPlexServerApiClient
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone());
.Map(r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0));
return maybeResponse.Match(
response =>
{
@ -233,9 +229,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -233,9 +229,7 @@ public class PlexServerApiClient : IPlexServerApiClient
Option<PlexXmlVideoMetadataResponseContainer> maybeResponse = await service
.GetVideoMetadata(key, token.AuthToken)
.Map(Optional)
.Map(
r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0)
.HeadOrNone());
.Map(r => r.Filter(m => m.Metadata.Media.Count > 0 && m.Metadata.Media[0].Part.Count > 0));
return maybeResponse.Match(
response =>
{
@ -348,6 +342,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -348,6 +342,7 @@ public class PlexServerApiClient : IPlexServerApiClient
var movie = new PlexMovie
{
Etag = PlexEtag.ForMovie(response),
Key = response.Key,
MovieMetadata = new List<MovieMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version },
@ -402,6 +397,11 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -402,6 +397,11 @@ public class PlexServerApiClient : IPlexServerApiClient
metadata.Guids = new List<MetadataGuid>();
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
}
if (!string.IsNullOrWhiteSpace(response.Studio))
{
metadata.Studios.Add(new Studio { Name = response.Studio });
@ -523,6 +523,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -523,6 +523,7 @@ public class PlexServerApiClient : IPlexServerApiClient
var show = new PlexShow
{
Key = response.Key,
Etag = PlexEtag.ForShow(response),
ShowMetadata = new List<ShowMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
};
@ -578,6 +579,11 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -578,6 +579,11 @@ public class PlexServerApiClient : IPlexServerApiClient
metadata.Studios.Add(new Studio { Name = response.Studio });
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
{
metadata.ReleaseDate = releaseDate;
@ -628,7 +634,8 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -628,7 +634,8 @@ public class PlexServerApiClient : IPlexServerApiClient
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Year = response.Year,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
DateUpdated = lastWriteTime,
Tags = new List<Tag>()
};
metadata.Guids = Optional(response.Guid).Flatten().Map(g => new MetadataGuid { Guid = g.Id }).ToList();
@ -644,6 +651,11 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -644,6 +651,11 @@ public class PlexServerApiClient : IPlexServerApiClient
}
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
string path = $"plex/{mediaSourceId}{response.Thumb}";
@ -677,6 +689,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -677,6 +689,7 @@ public class PlexServerApiClient : IPlexServerApiClient
var season = new PlexSeason
{
Key = response.Key,
Etag = PlexEtag.ForSeason(response),
SeasonNumber = response.Index,
SeasonMetadata = new List<SeasonMetadata> { metadata },
TraktListItems = new List<TraktListItem>()
@ -717,6 +730,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -717,6 +730,7 @@ public class PlexServerApiClient : IPlexServerApiClient
var episode = new PlexEpisode
{
Key = response.Key,
Etag = PlexEtag.ForEpisode(response),
EpisodeMetadata = new List<EpisodeMetadata> { metadata },
MediaVersions = new List<MediaVersion> { version },
TraktListItems = new List<TraktListItem>()
@ -744,7 +758,8 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -744,7 +758,8 @@ public class PlexServerApiClient : IPlexServerApiClient
Actors = Optional(response.Role).Flatten().Map(r => ProjectToModel(r, dateAdded, lastWriteTime))
.ToList(),
Directors = Optional(response.Director).Flatten().Map(d => new Director { Name = d.Tag }).ToList(),
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList()
Writers = Optional(response.Writer).Flatten().Map(w => new Writer { Name = w.Tag }).ToList(),
Tags = new List<Tag>()
};
if (response is PlexXmlMetadataResponse xml)
@ -772,6 +787,11 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -772,6 +787,11 @@ public class PlexServerApiClient : IPlexServerApiClient
metadata.ReleaseDate = releaseDate;
}
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
string path = $"plex/{mediaSourceId}{response.Thumb}";

5
ErsatzTV.Infrastructure/Search/SearchIndex.cs

@ -567,6 +567,11 @@ public sealed class SearchIndex : ISearchIndex @@ -567,6 +567,11 @@ public sealed class SearchIndex : ISearchIndex
doc.Add(new StringField(TraktListField, item.TraktList.TraktId.ToString(), Field.Store.NO));
}
foreach (Tag tag in metadata.Tags)
{
doc.Add(new TextField(TagField, tag.Name, Field.Store.NO));
}
_writer.UpdateDocument(new Term(IdField, season.Id.ToString()), doc);
}
catch (Exception ex)

2
ErsatzTV/ErsatzTV.csproj

@ -59,7 +59,7 @@ @@ -59,7 +59,7 @@
<PackageReference Include="FluentValidation.AspNetCore" Version="10.4.0" />
<PackageReference Include="HtmlSanitizer" Version="7.1.488" />
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
<PackageReference Include="Markdig" Version="0.28.1" />
<PackageReference Include="Markdig" Version="0.30.0" />
<PackageReference Include="MediatR.Courier.DependencyInjection" Version="5.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.4" />

6
scripts/cleanup-all-code.sh

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
#! /usr/bin/env bash
cd "$(git rev-parse --show-cdup)" || exit
dotnet tool restore
dotnet jb cleanupcode ErsatzTV.sln --exclude='CHANGELOG.md;scripts/**;generated/**;ErsatzTV/client-app/**'

3
scripts/cleanup-code.sh

@ -3,4 +3,5 @@ @@ -3,4 +3,5 @@
cd "$(git rev-parse --show-cdup)" || exit
dotnet tool restore
dotnet jb cleanupcode ErsatzTV.sln --exclude='generated/**;ErsatzTV/client-app/**'
CHANGED=$(git status --porcelain | sed 's/^...//' | paste -sd ";" -)
dotnet jb cleanupcode ErsatzTV.sln --exclude='CHANGELOG.md;scripts/**;generated/**;ErsatzTV/client-app/**' --include="$CHANGED"

Loading…
Cancel
Save