Browse Source

refactor plex, emby and jellyfin television scanners (#767)

* refactor plex television scanner

* refactor emby television scanner

* refactor jellyfin television scanner

* update changelog
pull/769/head
Jason Dove 3 years ago committed by GitHub
parent
commit
0a0fb71b94
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 529
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  3. 17
      ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs
  4. 18
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs
  5. 26
      ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs
  6. 14
      ErsatzTV.Core/Interfaces/Repositories/IPlexTelevisionRepository.cs
  7. 6
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  8. 538
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  9. 7
      ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs
  10. 617
      ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  11. 1065
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  12. 1017
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  13. 1024
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  14. 90
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  15. 277
      ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs
  16. 188
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  17. 4140
      ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.Designer.cs
  18. 18
      ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.cs
  19. 2
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

4
CHANGELOG.md

@ -8,10 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -8,10 +8,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Cleanly stop all library scans when service termination is requested
- Fix health check crash when trash contains a show or a season
- Fix ability of health check crash to crash home page
- Remove and ignore Season 0/Specials from Plex shows that have no specials
### Changed
- Update Plex, Jellyfin and Emby movie library scanners to share a significant amount of code
- Update Plex, Jellyfin and Emby movie and show library scanners to share a significant amount of code
- This should help maintain feature parity going forward
- Jellyfin and Emby movie and show library scanners now support the `unavailable` media state
- Optimize search-index rebuilding to complete 100x faster
### Added

529
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -10,18 +10,13 @@ using Microsoft.Extensions.Logging; @@ -10,18 +10,13 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby;
public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
public class EmbyTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<EmbyConnectionParameters, EmbyLibrary,
EmbyShow, EmbySeason, EmbyEpisode,
EmbyItemEtag>, IEmbyTelevisionLibraryScanner
{
private readonly IEmbyApiClient _embyApiClient;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<EmbyTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IEmbyPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IEmbyTelevisionRepository _televisionRepository;
public EmbyTelevisionLibraryScanner(
@ -36,18 +31,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -36,18 +31,19 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator,
ILogger<EmbyTelevisionLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
searchRepository,
searchIndex,
mediator,
logger)
{
_embyApiClient = embyApiClient;
_mediaSourceRepository = mediaSourceRepository;
_televisionRepository = televisionRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_mediator = mediator;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@ -58,435 +54,98 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -58,435 +54,98 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
string ffprobePath,
CancellationToken cancellationToken)
{
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);
List<EmbyPathReplacement> pathReplacements =
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.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)
string GetLocalPath(EmbyEpisode episode)
{
return new ScanCanceled();
return _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.GetHeadVersion().MediaFiles.Head().Path,
false);
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, Unit>> ProcessShows(
string address,
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
List<EmbyItemEtag> existingShows,
List<EmbyShow> shows,
CancellationToken cancellationToken)
{
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (EmbyShow incoming in sortedShows)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
if (await _televisionRepository.AddShow(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (EmbyItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyShow> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyShow updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
}
}
}
List<EmbyItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(address, apiKey, incoming.ItemId);
foreach (BaseError error in maybeSeasons.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<EmbySeason> seasons in maybeSeasons.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessSeasons(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
}
}
}
return Unit.Default;
return await ScanLibrary(
_televisionRepository,
new EmbyConnectionParameters(address, apiKey),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
false,
cancellationToken);
}
private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address,
string apiKey,
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
EmbyShow show,
List<EmbyItemEtag> existingSeasons,
List<EmbySeason> seasons,
CancellationToken cancellationToken)
{
foreach (EmbySeason incoming in seasons)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
incoming.LibraryPathId = library.Paths.Head().Id;
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
protected override string MediaServerItemId(EmbyEpisode episode) => episode.ItemId;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
protected override string MediaServerEtag(EmbyShow show) => show.Etag;
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (EmbyItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
foreach (EmbySeason updated in await _televisionRepository.Update(incoming))
{
incoming.Show = show;
foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id))
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { toIndex });
}
}
}
}
List<EmbyItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);
foreach (BaseError error in maybeEpisodes.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<EmbyEpisode> episodes in maybeEpisodes.RightToSeq())
{
var validEpisodes = new List<EmbyEpisode>();
foreach (EmbyEpisode episode in episodes)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping emby episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
Either<BaseError, Unit> scanResult = await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
}
}
}
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> ProcessEpisodes(
string showName,
string seasonName,
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
EmbyLibrary library,
string ffmpegPath,
string ffprobePath,
List<EmbyPathReplacement> pathReplacements,
EmbySeason season,
List<EmbyItemEtag> existingEpisodes,
List<EmbyEpisode> episodes,
CancellationToken cancellationToken)
{
foreach (EmbyEpisode incoming in episodes)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
EmbyEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
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))
{
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);
}
}
foreach (EmbyItemEtag existing in maybeExisting)
{
try
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (EmbyEpisode updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
}
if (updateStatistics)
{
string localPath = _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
Either<BaseError, bool> refreshResult =
await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
incomingEpisode,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
}
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
}
}
}
EmbyConnectionParameters connectionParameters,
EmbyShow show) =>
_embyApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
show.ItemId);
protected override Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
EmbyLibrary library,
EmbyConnectionParameters connectionParameters,
EmbySeason season) =>
_embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyShow> result,
EmbyShow incoming,
bool deepScan) =>
Task.FromResult(Option<ShowMetadata>.None);
return Unit.Default;
}
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbySeason> result,
EmbySeason incoming,
bool deepScan) =>
Task.FromResult(Option<SeasonMetadata>.None);
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyEpisode episode, string localPath)
{
try
{
return await _localSubtitlesProvider.UpdateSubtitles(episode, localPath, false);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library,
MediaItemScanResult<EmbyEpisode> result,
EmbyEpisode incoming,
bool deepScan) =>
Task.FromResult(Option<EpisodeMetadata>.None);
protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata(
MediaItemScanResult<EmbyShow> result,
ShowMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyShow>>>(result);
protected override Task<Either<BaseError, MediaItemScanResult<EmbySeason>>> UpdateMetadata(
MediaItemScanResult<EmbySeason> result,
SeasonMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbySeason>>>(result);
protected override Task<Either<BaseError, MediaItemScanResult<EmbyEpisode>>> UpdateMetadata(
MediaItemScanResult<EmbyEpisode> result,
EpisodeMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyEpisode>>>(result);
}

17
ErsatzTV.Core/Interfaces/Repositories/IEmbyTelevisionRepository.cs

@ -3,20 +3,7 @@ using ErsatzTV.Core.Emby; @@ -3,20 +3,7 @@ using ErsatzTV.Core.Emby;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IEmbyTelevisionRepository
public interface IEmbyTelevisionRepository : IMediaServerTelevisionRepository<EmbyLibrary, EmbyShow, EmbySeason,
EmbyEpisode, EmbyItemEtag>
{
Task<List<EmbyItemEtag>> GetExistingShows(EmbyLibrary library);
Task<List<EmbyItemEtag>> GetExistingSeasons(EmbyLibrary library, string showItemId);
Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId);
Task<bool> AddShow(EmbyShow show);
Task<Option<EmbyShow>> Update(EmbyShow show);
Task<bool> AddSeason(EmbyShow show, EmbySeason season);
Task<Option<EmbySeason>> Update(EmbySeason season);
Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode);
Task<Option<EmbyEpisode>> Update(EmbyEpisode episode);
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds);
Task<List<int>> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds);
Task<Unit> DeleteEmptySeasons(EmbyLibrary library);
Task<List<int>> DeleteEmptyShows(EmbyLibrary library);
}

18
ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs

@ -3,20 +3,8 @@ using ErsatzTV.Core.Jellyfin; @@ -3,20 +3,8 @@ using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IJellyfinTelevisionRepository
public interface IJellyfinTelevisionRepository : IMediaServerTelevisionRepository<JellyfinLibrary, JellyfinShow,
JellyfinSeason,
JellyfinEpisode, JellyfinItemEtag>
{
Task<List<JellyfinItemEtag>> GetExistingShows(JellyfinLibrary library);
Task<List<JellyfinItemEtag>> GetExistingSeasons(JellyfinLibrary library, string showItemId);
Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId);
Task<bool> AddShow(JellyfinShow show);
Task<Option<JellyfinShow>> Update(JellyfinShow show);
Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season);
Task<Option<JellyfinSeason>> Update(JellyfinSeason season);
Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode);
Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode);
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
Task<List<int>> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds);
Task<Unit> DeleteEmptySeasons(JellyfinLibrary library);
Task<List<int>> DeleteEmptyShows(JellyfinLibrary library);
}

26
ErsatzTV.Core/Interfaces/Repositories/IMediaServerTelevisionRepository.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IMediaServerTelevisionRepository<in TLibrary, TShow, TSeason, TEpisode, TEtag> where TLibrary : Library
where TShow : Show
where TSeason : Season
where TEpisode : Episode
where TEtag : MediaServerItemEtag
{
Task<List<TEtag>> GetExistingShows(TLibrary library);
Task<List<TEtag>> GetExistingSeasons(TLibrary library, TShow show);
Task<List<TEtag>> GetExistingEpisodes(TLibrary library, TSeason season);
Task<Either<BaseError, MediaItemScanResult<TShow>>> GetOrAdd(TLibrary library, TShow item);
Task<Either<BaseError, MediaItemScanResult<TSeason>>> GetOrAdd(TLibrary library, TSeason item);
Task<Either<BaseError, MediaItemScanResult<TEpisode>>> GetOrAdd(TLibrary library, TEpisode item);
Task<Unit> SetEtag(TShow show, string etag);
Task<Unit> SetEtag(TSeason season, string etag);
Task<Unit> SetEtag(TEpisode episode, string etag);
Task<bool> FlagNormal(TLibrary library, TEpisode episode);
Task<List<int>> FlagFileNotFoundShows(TLibrary library, List<string> showItemIds);
Task<List<int>> FlagFileNotFoundSeasons(TLibrary library, List<string> seasonItemIds);
Task<List<int>> FlagFileNotFoundEpisodes(TLibrary library, List<string> episodeItemIds);
Task<Option<int>> FlagUnavailable(TLibrary library, TEpisode episode);
}

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

@ -3,17 +3,7 @@ using ErsatzTV.Core.Plex; @@ -3,17 +3,7 @@ using ErsatzTV.Core.Plex;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IPlexTelevisionRepository
public interface IPlexTelevisionRepository : IMediaServerTelevisionRepository<PlexLibrary, PlexShow, PlexSeason,
PlexEpisode, PlexItemEtag>
{
Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library);
Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show);
Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season);
Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode);
Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexEpisode episode);
Task<List<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys);
Task<List<int>> FlagFileNotFoundSeasons(PlexLibrary library, List<string> plexSeasonKeys);
Task<List<int>> FlagFileNotFoundEpisodes(PlexLibrary library, List<string> plexEpisodeKeys);
Task<Unit> SetPlexEtag(PlexShow show, string etag);
Task<Unit> SetPlexEtag(PlexSeason season, string etag);
Task<Unit> SetPlexEtag(PlexEpisode episode, string etag);
}

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

@ -34,12 +34,6 @@ public interface ITelevisionRepository @@ -34,12 +34,6 @@ public interface ITelevisionRepository
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath);
Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(PlexLibrary library, PlexShow item);
Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item);
Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>>
GetOrAddPlexEpisode(PlexLibrary library, PlexEpisode item);
Task<bool> AddGenre(ShowMetadata metadata, Genre genre);
Task<bool> AddTag(Domain.Metadata metadata, Tag tag);
Task<bool> AddStudio(ShowMetadata metadata, Studio studio);

538
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
@ -10,18 +10,14 @@ using Microsoft.Extensions.Logging; @@ -10,18 +10,14 @@ using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Jellyfin;
public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanner
public class JellyfinTelevisionLibraryScanner : MediaServerTelevisionLibraryScanner<JellyfinConnectionParameters,
JellyfinLibrary,
JellyfinShow, JellyfinSeason, JellyfinEpisode,
JellyfinItemEtag>, IJellyfinTelevisionLibraryScanner
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<JellyfinTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IJellyfinPathReplacementService _pathReplacementService;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly IJellyfinTelevisionRepository _televisionRepository;
public JellyfinTelevisionLibraryScanner(
@ -36,18 +32,19 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -36,18 +32,19 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
ILocalSubtitlesProvider localSubtitlesProvider,
IMediator mediator,
ILogger<JellyfinTelevisionLibraryScanner> logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
searchRepository,
searchIndex,
mediator,
logger)
{
_jellyfinApiClient = jellyfinApiClient;
_mediaSourceRepository = mediaSourceRepository;
_televisionRepository = televisionRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_pathReplacementService = pathReplacementService;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_mediator = mediator;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanLibrary(
@ -58,440 +55,101 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne @@ -58,440 +55,101 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
string ffprobePath,
CancellationToken cancellationToken)
{
try
{
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
// TODO: maybe get quick list of item ids and etags from api to compare first
// TODO: paging?
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
.GetJellyfinPathReplacements(library.MediaSourceId);
Either<BaseError, List<JellyfinShow>> maybeShows = await _jellyfinApiClient.GetShowLibraryItems(
address,
apiKey,
library.MediaSourceId,
library.ItemId);
foreach (BaseError error in maybeShows.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<JellyfinShow> shows in maybeShows.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessShows(
address,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows,
cancellationToken);
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.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)
string GetLocalPath(JellyfinEpisode episode)
{
return new ScanCanceled();
return _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
episode.GetHeadVersion().MediaFiles.Head().Path,
false);
}
finally
{
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, Unit>> ProcessShows(
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
List<JellyfinItemEtag> existingShows,
List<JellyfinShow> shows,
CancellationToken cancellationToken)
{
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
foreach (JellyfinShow incoming in sortedShows)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
incoming.LibraryPathId = library.Paths.Head().Id;
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
if (await _televisionRepository.AddShow(incoming))
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinShow> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinShow updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
}
}
}
List<JellyfinItemEtag> existingSeasons =
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
await _jellyfinApiClient.GetSeasonLibraryItems(address, apiKey, library.MediaSourceId, incoming.ItemId);
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,
apiKey,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingSeasons,
seasons,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
var seasonIds = existingSeasons
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
}
}
}
return Unit.Default;
return await ScanLibrary(
_televisionRepository,
new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
false,
cancellationToken);
}
private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address,
string apiKey,
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
JellyfinShow show,
List<JellyfinItemEtag> existingSeasons,
List<JellyfinSeason> seasons,
CancellationToken cancellationToken)
{
foreach (JellyfinSeason incoming in seasons)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetShowLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library.ItemId);
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
incoming.LibraryPathId = library.Paths.Head().Id;
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId;
_logger.LogDebug(
"INSERT: Item id is new for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
protected override string MediaServerEtag(JellyfinShow show) => show.Etag;
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
incoming.ShowId = show.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
foreach (JellyfinSeason updated in await _televisionRepository.Update(incoming))
{
incoming.Show = show;
foreach (MediaItem toIndex in await _searchRepository.GetItemToIndex(updated.Id))
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { toIndex });
}
}
}
}
List<JellyfinItemEtag> existingEpisodes =
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
await _jellyfinApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
foreach (BaseError error in maybeEpisodes.LeftToSeq())
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
}
foreach (List<JellyfinEpisode> episodes in maybeEpisodes.RightToSeq())
{
var validEpisodes = new List<JellyfinEpisode>();
foreach (JellyfinEpisode episode in episodes)
{
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
episode.MediaVersions.Head().MediaFiles.Head().Path,
false);
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning(
"Skipping jellyfin episode that does not exist at {Path}",
localPath);
}
else
{
validEpisodes.Add(episode);
}
}
Either<BaseError, Unit> scanResult = await ProcessEpisodes(
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title,
library,
ffmpegPath,
ffprobePath,
pathReplacements,
incoming,
existingEpisodes,
validEpisodes,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
var episodeIds = existingEpisodes
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
.Map(m => m.ItemId)
.ToList();
List<int> missingEpisodeIds =
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
await _searchIndex.RemoveItems(missingEpisodeIds);
_searchIndex.Commit();
}
}
}
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> ProcessEpisodes(
string showName,
string seasonName,
protected override Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
JellyfinLibrary library,
string ffmpegPath,
string ffprobePath,
List<JellyfinPathReplacement> pathReplacements,
JellyfinSeason season,
List<JellyfinItemEtag> existingEpisodes,
List<JellyfinEpisode> episodes,
CancellationToken cancellationToken)
{
foreach (JellyfinEpisode incoming in episodes)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
JellyfinEpisode incomingEpisode = incoming;
var updateStatistics = false;
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
if (maybeExisting.IsNone)
{
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))
{
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);
}
}
foreach (JellyfinItemEtag existing in maybeExisting)
{
try
{
if (existing.Etag != incoming.Etag)
{
_logger.LogDebug(
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
updateStatistics = true;
incoming.SeasonId = season.Id;
incoming.LibraryPathId = library.Paths.Head().Id;
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
foreach (JellyfinEpisode updated in maybeUpdated)
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
incomingEpisode = updated;
}
}
}
catch (Exception ex)
{
updateStatistics = false;
_logger.LogError(
ex,
"Error updating episode {Path}",
incoming.MediaVersions.Head().MediaFiles.Head().Path);
}
}
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,
incomingEpisode,
localPath);
if (refreshResult.Map(t => t).IfLeft(false))
{
refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
}
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
}
}
}
JellyfinConnectionParameters connectionParameters,
JellyfinShow show) =>
_jellyfinApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
show.ItemId);
protected override Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
JellyfinLibrary library,
JellyfinConnectionParameters connectionParameters,
JellyfinSeason season) =>
_jellyfinApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinShow> result,
JellyfinShow incoming,
bool deepScan) =>
Task.FromResult(Option<ShowMetadata>.None);
return Unit.Default;
}
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinSeason> result,
JellyfinSeason incoming,
bool deepScan) =>
Task.FromResult(Option<SeasonMetadata>.None);
private async Task<Either<BaseError, bool>> UpdateSubtitles(JellyfinEpisode episode, string localPath)
{
try
{
return await _localSubtitlesProvider.UpdateSubtitles(episode, localPath, false);
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library,
MediaItemScanResult<JellyfinEpisode> result,
JellyfinEpisode incoming,
bool deepScan) =>
Task.FromResult(Option<EpisodeMetadata>.None);
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata(
MediaItemScanResult<JellyfinShow> result,
ShowMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinShow>>>(result);
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> UpdateMetadata(
MediaItemScanResult<JellyfinSeason> result,
SeasonMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinSeason>>>(result);
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> UpdateMetadata(
MediaItemScanResult<JellyfinEpisode> result,
EpisodeMetadata fullMetadata) =>
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>>(result);
}

7
ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs

@ -94,14 +94,15 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -94,14 +94,15 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
{
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
foreach (TMovie incoming in movieEntries)
var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList();
foreach (TMovie incoming in sortedMovies)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
string localPath = getLocalPath(incoming);
@ -167,7 +168,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib @@ -167,7 +168,7 @@ public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLib
}
}
// trash items that are no longer present on the media server
// trash movies 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);

617
ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs

@ -0,0 +1,617 @@ @@ -0,0 +1,617 @@
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 MediaServerTelevisionLibraryScanner<TConnectionParameters, TLibrary, TShow, TSeason, TEpisode,
TEtag>
where TConnectionParameters : MediaServerConnectionParameters
where TLibrary : Library
where TShow : Show
where TSeason : Season
where TEpisode : Episode
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 MediaServerTelevisionLibraryScanner(
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILocalFileSystem localFileSystem,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
IMediator mediator,
ILogger logger)
{
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_localFileSystem = localFileSystem;
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_mediator = mediator;
_logger = logger;
}
protected async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TEpisode, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Either<BaseError, List<TShow>> entries = await GetShowLibraryItems(connectionParameters, library);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
return await ScanLibrary(
televisionRepository,
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();
}
}
protected abstract Task<Either<BaseError, List<TShow>>> GetShowLibraryItems(
TConnectionParameters connectionParameters,
TLibrary library);
protected abstract string MediaServerItemId(TShow show);
protected abstract string MediaServerItemId(TSeason season);
protected abstract string MediaServerItemId(TEpisode episode);
protected abstract string MediaServerEtag(TShow show);
protected abstract string MediaServerEtag(TSeason season);
protected abstract string MediaServerEtag(TEpisode episode);
private async Task<Either<BaseError, Unit>> ScanLibrary(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TConnectionParameters connectionParameters,
TLibrary library,
Func<TEpisode, string> getLocalPath,
string ffmpegPath,
string ffprobePath,
List<TShow> showEntries,
bool deepScan,
CancellationToken cancellationToken)
{
List<TEtag> existingShows = await televisionRepository.GetExistingShows(library);
var sortedShows = showEntries.OrderBy(s => s.ShowMetadata.Head().SortTitle).ToList();
foreach (TShow incoming in showEntries)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / sortedShows.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
Either<BaseError, MediaItemScanResult<TShow>> maybeShow = await televisionRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan));
if (maybeShow.IsLeft)
{
foreach (BaseError error in maybeShow.LeftToSeq())
{
_logger.LogWarning(
"Error processing show {Title}: {Error}",
incoming.ShowMetadata.Head().Title,
error.Value);
}
continue;
}
foreach (MediaItemScanResult<TShow> result in maybeShow.RightToSeq())
{
Either<BaseError, List<TSeason>> entries = await GetSeasonLibraryItems(
library,
connectionParameters,
result.Item);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> scanResult = await ScanSeasons(
televisionRepository,
library,
getLocalPath,
result.Item,
connectionParameters,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (result.IsAdded || result.IsUpdated)
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
}
}
}
// trash shows that are no longer present on the media server
var fileNotFoundItemIds = existingShows.Map(s => s.MediaServerItemId)
.Except(showEntries.Map(MediaServerItemId)).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundShows(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
return Unit.Default;
}
protected abstract Task<Either<BaseError, List<TSeason>>> GetSeasonLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,
TShow show);
protected abstract Task<Either<BaseError, List<TEpisode>>> GetEpisodeLibraryItems(
TLibrary library,
TConnectionParameters connectionParameters,
TSeason season);
protected abstract Task<Option<ShowMetadata>> GetFullMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TShow> result,
TShow incoming,
bool deepScan);
protected abstract Task<Option<SeasonMetadata>> GetFullMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TSeason> result,
TSeason incoming,
bool deepScan);
protected abstract Task<Option<EpisodeMetadata>> GetFullMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TEpisode> result,
TEpisode incoming,
bool deepScan);
protected abstract Task<Either<BaseError, MediaItemScanResult<TShow>>> UpdateMetadata(
MediaItemScanResult<TShow> result,
ShowMetadata fullMetadata);
protected abstract Task<Either<BaseError, MediaItemScanResult<TSeason>>> UpdateMetadata(
MediaItemScanResult<TSeason> result,
SeasonMetadata fullMetadata);
protected abstract Task<Either<BaseError, MediaItemScanResult<TEpisode>>> UpdateMetadata(
MediaItemScanResult<TEpisode> result,
EpisodeMetadata fullMetadata);
private async Task<Either<BaseError, Unit>> ScanSeasons(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TLibrary library,
Func<TEpisode, string> getLocalPath,
TShow show,
TConnectionParameters connectionParameters,
string ffmpegPath,
string ffprobePath,
List<TSeason> seasonEntries,
bool deepScan,
CancellationToken cancellationToken)
{
List<TEtag> existingSeasons = await televisionRepository.GetExistingSeasons(library, show);
var sortedSeasons = seasonEntries.OrderBy(s => s.SeasonNumber).ToList();
foreach (TSeason incoming in sortedSeasons)
{
incoming.ShowId = show.Id;
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
Either<BaseError, MediaItemScanResult<TSeason>> maybeSeason = await televisionRepository
.GetOrAdd(library, incoming)
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan));
if (maybeSeason.IsLeft)
{
foreach (BaseError error in maybeSeason.LeftToSeq())
{
_logger.LogWarning(
"Error processing show {Title} season {SeasonNumber}: {Error}",
show.ShowMetadata.Head().Title,
incoming.SeasonNumber,
error.Value);
}
continue;
}
foreach (MediaItemScanResult<TSeason> result in maybeSeason.RightToSeq())
{
Either<BaseError, List<TEpisode>> entries = await GetEpisodeLibraryItems(
library,
connectionParameters,
result.Item);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
Either<BaseError, Unit> scanResult = await ScanEpisodes(
televisionRepository,
library,
getLocalPath,
show,
result.Item,
connectionParameters,
ffmpegPath,
ffprobePath,
entries.RightToSeq().Flatten().ToList(),
deepScan,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
result.Item.Show = show;
if (result.IsAdded || result.IsUpdated)
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
}
}
}
// trash seasons that are no longer present on the media server
var fileNotFoundItemIds = existingSeasons.Map(s => s.MediaServerItemId)
.Except(seasonEntries.Map(MediaServerItemId)).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
return Unit.Default;
}
private async Task<Either<BaseError, Unit>> ScanEpisodes(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TLibrary library,
Func<TEpisode, string> getLocalPath,
TShow show,
TSeason season,
TConnectionParameters connectionParameters,
string ffmpegPath,
string ffprobePath,
List<TEpisode> episodeEntries,
bool deepScan,
CancellationToken cancellationToken)
{
List<TEtag> existingEpisodes = await televisionRepository.GetExistingEpisodes(library, season);
var sortedEpisodes = episodeEntries.OrderBy(s => s.EpisodeMetadata.Head().EpisodeNumber).ToList();
foreach (TEpisode incoming in sortedEpisodes)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
string localPath = getLocalPath(incoming);
if (await ShouldScanItem(
televisionRepository,
library,
show,
season,
existingEpisodes,
incoming,
localPath,
deepScan) == false)
{
continue;
}
incoming.SeasonId = season.Id;
Either<BaseError, MediaItemScanResult<TEpisode>> maybeEpisode = await televisionRepository
.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 (maybeEpisode.IsLeft)
{
foreach (BaseError error in maybeEpisode.LeftToSeq())
{
_logger.LogWarning(
"Error processing episode {Title} s{SeasonNumber:00}e{EpisodeNumber:00}: {Error}",
show.ShowMetadata.Head().Title,
season.SeasonNumber,
incoming.EpisodeMetadata.Head().EpisodeNumber,
error.Value);
}
continue;
}
foreach (MediaItemScanResult<TEpisode> result in maybeEpisode.RightToSeq())
{
await televisionRepository.SetEtag(result.Item, MediaServerEtag(incoming));
if (_localFileSystem.FileExists(result.LocalPath))
{
if (await televisionRepository.FlagNormal(library, result.Item))
{
result.IsUpdated = true;
}
}
else
{
Option<int> flagResult = await televisionRepository.FlagUnavailable(library, result.Item);
if (flagResult.IsSome)
{
result.IsUpdated = true;
}
}
if (result.IsAdded || result.IsUpdated)
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
}
}
}
// trash episodes that are no longer present on the media server
var fileNotFoundItemIds = existingEpisodes.Map(m => m.MediaServerItemId)
.Except(episodeEntries.Map(MediaServerItemId)).ToList();
List<int> ids = await televisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundItemIds);
await _searchIndex.RebuildItems(_searchRepository, ids);
return Unit.Default;
}
private async Task<bool> ShouldScanItem(
IMediaServerTelevisionRepository<TLibrary, TShow, TSeason, TEpisode, TEtag> televisionRepository,
TLibrary library,
Show show,
Season season,
List<TEtag> existingEpisodes,
TEpisode incoming,
string localPath,
bool deepScan)
{
// deep scan will always pull every episode
if (deepScan)
{
return true;
}
Option<TEtag> maybeExisting = existingEpisodes.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 televisionRepository.FlagUnavailable(library, incoming))
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
}
}
return false;
}
if (maybeExisting.IsNone)
{
_logger.LogDebug(
"INSERT: new episode {Show} s{SeasonNumber:00}e{EpisodeNumber:00}",
show.ShowMetadata.Head().Title,
season.SeasonNumber,
incoming.EpisodeMetadata.Head().EpisodeNumber);
}
else
{
_logger.LogDebug(
"UPDATE: Etag has changed for episode {Show} s{SeasonNumber:00}e{EpisodeNumber:00}",
show.ShowMetadata.Head().Title,
season.SeasonNumber,
incoming.EpisodeMetadata.Head().EpisodeNumber);
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<TShow>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TShow> result,
TShow incoming,
bool deepScan)
{
foreach (ShowMetadata 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<TSeason>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TSeason> result,
TSeason incoming,
bool deepScan)
{
foreach (SeasonMetadata 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<TEpisode>>> UpdateMetadata(
TConnectionParameters connectionParameters,
TLibrary library,
MediaItemScanResult<TEpisode> result,
TEpisode incoming,
bool deepScan)
{
foreach (EpisodeMetadata 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<TEpisode>>> UpdateStatistics(
MediaItemScanResult<TEpisode> result,
TEpisode incoming,
string ffmpegPath,
string ffprobePath)
{
TEpisode 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<TEpisode>>> UpdateSubtitles(
MediaItemScanResult<TEpisode> 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());
}
}
}

1065
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

File diff suppressed because it is too large Load Diff

1017
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

File diff suppressed because it is too large Load Diff

1024
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

File diff suppressed because it is too large Load Diff

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

@ -901,69 +901,109 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -901,69 +901,109 @@ public class MediaSourceRepository : IMediaSourceRepository
List<int> movieIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyMovie WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Movie WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyEpisode WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Episode WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbySeason WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Season WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
INNER JOIN EmbySeason es ON es.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
List<int> showIds = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM EmbyShow WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Show WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId AND lp.LibraryId IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();

277
ErsatzTV.Infrastructure/Data/Repositories/PlexTelevisionRepository.cs

@ -1,7 +1,10 @@ @@ -1,7 +1,10 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -13,7 +16,45 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -13,7 +16,45 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
public PlexTelevisionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library)
public async Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.Normal;
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id IN
(SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key)",
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
}
public async Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key",
new { LibraryId = library.Id, episode.Key });
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<PlexItemEtag>> GetExistingShows(PlexLibrary library)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@ -24,7 +65,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -24,7 +65,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
.Map(result => result.ToList());
}
public async Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show)
public async Task<List<PlexItemEtag>> GetExistingSeasons(PlexLibrary library, PlexShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@ -38,7 +79,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -38,7 +79,7 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
.Map(result => result.ToList());
}
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
public async Task<List<PlexItemEtag>> GetExistingEpisodes(PlexLibrary library, PlexSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
@ -53,42 +94,128 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -53,42 +94,128 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
.Map(result => result.ToList());
}
public async Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode)
public async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAdd(PlexLibrary library, PlexShow item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.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 (PlexShow plexShow in maybeExisting)
{
return new MediaItemScanResult<PlexShow>(plexShow) { IsAdded = false };
}
episode.State = MediaItemState.Normal;
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 0 WHERE Id IN
(SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key)",
new { LibraryId = library.Id, episode.Key }).Map(count => count > 0);
return await AddShow(dbContext, library, item);
}
public async Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexEpisode episode)
public async Task<Either<BaseError, MediaItemScanResult<PlexSeason>>> GetOrAdd(PlexLibrary library, PlexSeason item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
.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)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(i => i.Key, i => i.Key == item.Key);
foreach (PlexSeason plexSeason in maybeExisting)
{
return new MediaItemScanResult<PlexSeason>(plexSeason) { IsAdded = false };
}
episode.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT PlexEpisode.Id FROM PlexEpisode
INNER JOIN MediaItem MI ON MI.Id = PlexEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE PlexEpisode.Key = @Key",
new { LibraryId = library.Id, episode.Key });
return await AddSeason(dbContext, library, item);
}
foreach (int id in maybeId)
public async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> GetOrAdd(
PlexLibrary library,
PlexEpisode item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.ThenInclude(a => a.Artwork)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(e => e.Season)
.Include(e => e.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(i => i.Key, i => i.Key == item.Key);
foreach (PlexEpisode plexEpisode in maybeExisting)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
return new MediaItemScanResult<PlexEpisode>(plexEpisode) { IsAdded = false };
}
return None;
return await AddEpisode(dbContext, library, item);
}
public async Task<Unit> SetEtag(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> SetEtag(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> SetEtag(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<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys)
@ -166,27 +293,93 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository @@ -166,27 +293,93 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
return ids;
}
public async Task<Unit> SetPlexEtag(PlexShow show, string etag)
private static async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> AddShow(
TvContext dbContext,
PlexLibrary library,
PlexShow item)
{
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);
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.PlexShows.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<PlexShow>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<Unit> SetPlexEtag(PlexSeason season, string etag)
private static async Task<Either<BaseError, MediaItemScanResult<PlexSeason>>> AddSeason(
TvContext dbContext,
PlexLibrary library,
PlexSeason item)
{
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);
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.PlexSeasons.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<PlexSeason>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
public async Task<Unit> SetPlexEtag(PlexEpisode episode, string etag)
private static async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> AddEpisode(
TvContext dbContext,
PlexLibrary library,
PlexEpisode item)
{
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);
try
{
if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path))
{
return BaseError.New("Multi-episode files are not yet supported");
}
// blank out etag for initial save in case stats/metadata/etc updates fail
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
foreach (EpisodeMetadata metadata in item.EpisodeMetadata)
{
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Directors ??= new List<Director>();
metadata.Writers ??= new List<Writer>();
}
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
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();
return new MediaItemScanResult<PlexEpisode>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}

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

@ -438,104 +438,6 @@ public class TelevisionRepository : ITelevisionRepository @@ -438,104 +438,6 @@ public class TelevisionRepository : ITelevisionRepository
return ids;
}
public async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
PlexLibrary library,
PlexShow item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexShow> maybeExisting = await dbContext.PlexShows
.AsNoTracking()
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Genres)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Tags)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Studios)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Artwork)
.Include(i => i.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.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(
plexShow => Right<BaseError, MediaItemScanResult<PlexShow>>(
new MediaItemScanResult<PlexShow>(plexShow) { IsAdded = false }).AsTask(),
async () => await AddPlexShow(dbContext, library, item));
}
public async Task<Either<BaseError, PlexSeason>> GetOrAddPlexSeason(PlexLibrary library, PlexSeason item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexSeason> maybeExisting = await dbContext.PlexSeasons
.AsNoTracking()
.Include(i => i.SeasonMetadata)
.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)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexSeason => Right<BaseError, PlexSeason>(plexSeason).AsTask(),
async () => await AddPlexSeason(dbContext, library, item));
}
public async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> GetOrAddPlexEpisode(
PlexLibrary library,
PlexEpisode item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<PlexEpisode> maybeExisting = await dbContext.PlexEpisodes
.AsNoTracking()
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(i => i.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Actors)
.ThenInclude(a => a.Artwork)
.Include(e => e.EpisodeMetadata)
.ThenInclude(em => em.Guids)
.Include(i => i.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(e => e.Season)
.Include(e => e.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexEpisode => Right<BaseError, MediaItemScanResult<PlexEpisode>>(
new MediaItemScanResult<PlexEpisode>(plexEpisode) { IsAdded = false }).AsTask(),
async () => await AddPlexEpisode(dbContext, library, item));
}
public async Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
@ -802,94 +704,4 @@ public class TelevisionRepository : ITelevisionRepository @@ -802,94 +704,4 @@ public class TelevisionRepository : ITelevisionRepository
return BaseError.New(ex.Message);
}
}
private static async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> AddPlexShow(
TvContext dbContext,
PlexLibrary library,
PlexShow 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.PlexShows.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<PlexShow>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private static async Task<Either<BaseError, PlexSeason>> AddPlexSeason(
TvContext dbContext,
PlexLibrary library,
PlexSeason 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.PlexSeasons.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 item;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private static async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> AddPlexEpisode(
TvContext dbContext,
PlexLibrary library,
PlexEpisode item)
{
try
{
if (dbContext.MediaFiles.Any(mf => mf.Path == item.MediaVersions.Head().MediaFiles.Head().Path))
{
return BaseError.New("Multi-episode files are not yet supported");
}
// blank out etag for initial save in case stats/metadata/etc updates fail
item.Etag = string.Empty;
item.LibraryPathId = library.Paths.Head().Id;
foreach (EpisodeMetadata metadata in item.EpisodeMetadata)
{
metadata.Genres ??= new List<Genre>();
metadata.Tags ??= new List<Tag>();
metadata.Studios ??= new List<Studio>();
metadata.Actors ??= new List<Actor>();
metadata.Directors ??= new List<Director>();
metadata.Writers ??= new List<Writer>();
}
await dbContext.PlexEpisodes.AddAsync(item);
await dbContext.SaveChangesAsync();
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();
return new MediaItemScanResult<PlexEpisode>(item) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}

4140
ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.Designer.cs generated

File diff suppressed because it is too large Load Diff

18
ErsatzTV.Infrastructure/Migrations/20220428020137_Remove_InvalidPlexSeasons.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Remove_InvalidPlexSeasons : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"DELETE FROM PlexSeason WHERE Key LIKE '%allLeaves%'");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

2
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -120,7 +120,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -120,7 +120,7 @@ public class PlexServerApiClient : IPlexServerApiClient
{
IPlexServerApi service = XmlServiceFor(connection.Uri);
return await service.GetShowChildren(show.Key.Split("/").Reverse().Skip(1).Head(), token.AuthToken)
.Map(r => r.Metadata)
.Map(r => r.Metadata.Filter(m => !m.Key.Contains("allLeaves")))
.Map(list => list.Map(metadata => ProjectToSeason(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)

Loading…
Cancel
Save