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. 517
      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. 520
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  9. 7
      ErsatzTV.Core/Metadata/MediaServerMovieLibraryScanner.cs
  10. 617
      ErsatzTV.Core/Metadata/MediaServerTelevisionLibraryScanner.cs
  11. 803
      ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs
  12. 517
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  13. 560
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  14. 90
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  15. 271
      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

517
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);
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())
string GetLocalPath(EmbyEpisode episode)
{
_logger.LogWarning(
"Error synchronizing emby library {Path}: {Error}",
library.Name,
error.Value);
return _pathReplacementService.GetReplacementEmbyPath(
pathReplacements,
episode.GetHeadVersion().MediaFiles.Head().Path,
false);
}
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessShows(
address,
apiKey,
return await ScanLibrary(
_televisionRepository,
new EmbyConnectionParameters(address, apiKey),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows,
false,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.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);
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
EmbyConnectionParameters connectionParameters,
EmbyLibrary library) =>
_embyApiClient.GetShowLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.ItemId);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
}
}
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
protected override string MediaServerItemId(EmbyEpisode episode) => episode.ItemId;
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
protected override string MediaServerEtag(EmbyShow show) => show.Etag;
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
private async Task<Either<BaseError, Unit>> ProcessShows(
string address,
string apiKey,
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
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;
}
private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address,
string apiKey,
EmbyConnectionParameters connectionParameters,
EmbyShow show) =>
_embyApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
show.ItemId);
protected override Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
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();
}
Option<EmbyItemEtag> maybeExisting = existingSeasons.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} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
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,
EmbyConnectionParameters connectionParameters,
EmbySeason season) =>
_embyApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
season.ItemId);
protected override Task<Option<ShowMetadata>> GetFullMetadata(
EmbyConnectionParameters connectionParameters,
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);
}
}
MediaItemScanResult<EmbyShow> result,
EmbyShow incoming,
bool deepScan) =>
Task.FromResult(Option<ShowMetadata>.None);
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);
}
}
}
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);

520
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);
List<JellyfinPathReplacement> pathReplacements =
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
foreach (BaseError error in maybeShows.LeftToSeq())
string GetLocalPath(JellyfinEpisode episode)
{
_logger.LogWarning(
"Error synchronizing jellyfin library {Path}: {Error}",
library.Name,
error.Value);
return _pathReplacementService.GetReplacementJellyfinPath(
pathReplacements,
episode.GetHeadVersion().MediaFiles.Head().Path,
false);
}
foreach (List<JellyfinShow> shows in maybeShows.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ProcessShows(
address,
apiKey,
return await ScanLibrary(
_televisionRepository,
new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
pathReplacements,
existingShows,
shows,
false,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
{
return error;
}
foreach (Unit _ in scanResult.RightToSeq())
{
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
var showIds = existingShows
.Filter(i => !incomingShowIds.Contains(i.ItemId))
.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);
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
JellyfinConnectionParameters connectionParameters,
JellyfinLibrary library) =>
_jellyfinApiClient.GetShowLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
library.ItemId);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
}
}
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId;
return Unit.Default;
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
_searchIndex.Commit();
}
}
protected override string MediaServerEtag(JellyfinShow show) => show.Etag;
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
private async Task<Either<BaseError, Unit>> ProcessShows(
string address,
string apiKey,
protected override Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
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;
}
JellyfinConnectionParameters connectionParameters,
JellyfinShow show) =>
_jellyfinApiClient.GetSeasonLibraryItems(
connectionParameters.Address,
connectionParameters.ApiKey,
library.MediaSourceId,
show.ItemId);
private async Task<Either<BaseError, Unit>> ProcessSeasons(
string address,
string apiKey,
protected override Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
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();
}
Option<JellyfinItemEtag> maybeExisting = existingSeasons.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} season {Season}",
show.ShowMetadata.Head().Title,
incoming.SeasonMetadata.Head().Title);
if (await _televisionRepository.AddSeason(show, incoming))
{
incoming.Show = show;
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
}
}
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,
JellyfinConnectionParameters connectionParameters,
JellyfinSeason season) =>
_jellyfinApiClient.GetEpisodeLibraryItems(
connectionParameters.Address,
connectionParameters.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);
}
}
season.ItemId);
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<Option<ShowMetadata>> GetFullMetadata(
JellyfinConnectionParameters connectionParameters,
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);
}
}
MediaItemScanResult<JellyfinShow> result,
JellyfinShow incoming,
bool deepScan) =>
Task.FromResult(Option<ShowMetadata>.None);
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);
}
}
}
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());
}
}
}

803
ErsatzTV.Core/Plex/PlexTelevisionLibraryScanner.cs

@ -1,30 +1,25 @@ @@ -1,30 +1,25 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Metadata;
using LanguageExt.UnsafeValueAccess;
using MediatR;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Plex;
public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionLibraryScanner
public class PlexTelevisionLibraryScanner :
MediaServerTelevisionLibraryScanner<PlexConnectionParameters, PlexLibrary, PlexShow, PlexSeason, PlexEpisode,
PlexItemEtag>, IPlexTelevisionLibraryScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
private readonly ILogger<PlexTelevisionLibraryScanner> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMediator _mediator;
private readonly IMetadataRepository _metadataRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
private readonly IPlexServerApiClient _plexServerApiClient;
private readonly IPlexTelevisionRepository _plexTelevisionRepository;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public PlexTelevisionLibraryScanner(
@ -41,20 +36,21 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -41,20 +36,21 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
ILocalStatisticsProvider localStatisticsProvider,
ILocalSubtitlesProvider localSubtitlesProvider,
ILogger<PlexTelevisionLibraryScanner> logger)
: base(metadataRepository, logger)
: base(
localStatisticsProvider,
localSubtitlesProvider,
localFileSystem,
searchRepository,
searchIndex,
mediator,
logger)
{
_plexServerApiClient = plexServerApiClient;
_televisionRepository = televisionRepository;
_metadataRepository = metadataRepository;
_searchIndex = searchIndex;
_searchRepository = searchRepository;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_plexPathReplacementService = plexPathReplacementService;
_plexTelevisionRepository = plexTelevisionRepository;
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_localSubtitlesProvider = localSubtitlesProvider;
_logger = logger;
}
@ -67,151 +63,184 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -67,151 +63,184 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
bool deepScan,
CancellationToken cancellationToken)
{
try
{
Either<BaseError, List<PlexShow>> entries = await _plexServerApiClient.GetShowLibraryContents(
library,
connection,
token);
List<PlexPathReplacement> pathReplacements =
await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId);
foreach (BaseError error in entries.LeftToSeq())
string GetLocalPath(PlexEpisode episode)
{
return error;
return _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
episode.GetHeadVersion().MediaFiles.Head().Path,
false);
}
return await ScanLibrary(
connection,
token,
_plexTelevisionRepository,
new PlexConnectionParameters(connection, token),
library,
GetLocalPath,
ffmpegPath,
ffprobePath,
deepScan,
entries.RightToSeq().Flatten().ToList(),
cancellationToken);
}
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
{
return new ScanCanceled();
}
finally
{
// always commit the search index to prevent corruption
_searchIndex.Commit();
}
}
private async Task<Either<BaseError, Unit>> ScanLibrary(
PlexConnection connection,
PlexServerAuthToken token,
// TODO: add or remove metadata?
// private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateMetadata(
// MediaItemScanResult<PlexEpisode> result,
// PlexEpisode incoming)
// {
// PlexEpisode existing = result.Item;
//
// var toUpdate = existing.EpisodeMetadata
// .Where(em => incoming.EpisodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
// .ToList();
// var toRemove = existing.EpisodeMetadata.Except(toUpdate).ToList();
// var toAdd = incoming.EpisodeMetadata
// .Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
// .ToList();
//
// foreach (EpisodeMetadata metadata in toRemove)
// {
// await _televisionRepository.RemoveMetadata(existing, metadata);
// }
//
// foreach (EpisodeMetadata metadata in toAdd)
// {
// metadata.EpisodeId = existing.Id;
// metadata.Episode = existing;
// existing.EpisodeMetadata.Add(metadata);
//
// await _metadataRepository.Add(metadata);
// }
//
// // TODO: update existing metadata
//
// return result;
// }
// foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
// {
// foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
// {
// if (incomingFile.Path != existingFile.Path)
// {
// _logger.LogDebug(
// "Plex episode has moved from {OldPath} to {NewPath}",
// existingFile.Path,
// incomingFile.Path);
//
// existingFile.Path = incomingFile.Path;
//
// await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path);
// }
// }
// }
protected override Task<Either<BaseError, List<PlexShow>>> GetShowLibraryItems(
PlexConnectionParameters connectionParameters,
PlexLibrary library) =>
_plexServerApiClient.GetShowLibraryContents(
library,
connectionParameters.Connection,
connectionParameters.Token);
protected override Task<Either<BaseError, List<PlexSeason>>> GetSeasonLibraryItems(
PlexLibrary library,
string ffmpegPath,
string ffprobePath,
bool deepScan,
List<PlexShow> showEntries,
CancellationToken cancellationToken)
{
List<PlexItemEtag> existingShows = await _plexTelevisionRepository.GetExistingPlexShows(library);
PlexConnectionParameters connectionParameters,
PlexShow show) =>
_plexServerApiClient.GetShowSeasons(
library,
show,
connectionParameters.Connection,
connectionParameters.Token);
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
.GetPlexPathReplacements(library.MediaSourceId);
protected override Task<Either<BaseError, List<PlexEpisode>>> GetEpisodeLibraryItems(
PlexLibrary library,
PlexConnectionParameters connectionParameters,
PlexSeason season) =>
_plexServerApiClient.GetSeasonEpisodes(
library,
season,
connectionParameters.Connection,
connectionParameters.Token);
foreach (PlexShow incoming in showEntries)
protected override async Task<Option<ShowMetadata>> GetFullMetadata(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexShow> result,
PlexShow incoming,
bool deepScan)
{
if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
{
if (cancellationToken.IsCancellationRequested)
Either<BaseError, ShowMetadata> maybeMetadata = await _plexServerApiClient.GetShowMetadata(
library,
incoming.Key.Replace("/children", string.Empty).Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token);
foreach (BaseError error in maybeMetadata.LeftToSeq())
{
return new ScanCanceled();
_logger.LogWarning("Failed to get show metadata from Plex: {Error}", error.ToString());
}
decimal percentCompletion = (decimal)showEntries.IndexOf(incoming) / showEntries.Count;
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
return maybeMetadata.ToOption();
}
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexShow>> maybeShow = await _televisionRepository
.GetOrAddPlexShow(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token, deepScan))
.BindT(existing => UpdateArtwork(existing, incoming));
return None;
}
if (maybeShow.IsLeft)
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexSeason> result,
PlexSeason incoming,
bool deepScan)
{
foreach (BaseError error in maybeShow.LeftToSeq())
if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
{
_logger.LogWarning(
"Error processing plex show at {Key}: {Error}",
incoming.Key,
error.Value);
return incoming.SeasonMetadata.HeadOrNone().AsTask();
}
continue;
return Option<SeasonMetadata>.None.AsTask();
}
foreach (MediaItemScanResult<PlexShow> result in maybeShow.RightToSeq())
protected override async Task<Option<EpisodeMetadata>> GetFullMetadata(
PlexConnectionParameters connectionParameters,
PlexLibrary library,
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming,
bool deepScan)
{
if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
{
Either<BaseError, Unit> scanResult = await ScanSeasons(
Either<BaseError, EpisodeMetadata> maybeMetadata =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
pathReplacements,
result.Item,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan,
cancellationToken);
incoming.Key.Split("/").Last(),
connectionParameters.Connection,
connectionParameters.Token)
.MapT(tuple => tuple.Item1); // drop the statistics part from plex, we scan locally
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
foreach (BaseError error in maybeMetadata.LeftToSeq())
{
return error;
_logger.LogWarning("Failed to get episode metadata from Plex: {Error}", error.ToString());
}
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
return maybeMetadata.ToOption();
}
else if (result.IsUpdated)
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { result.Item });
}
}
}
// trash items that are no longer present on the media server
var fileNotFoundKeys = existingShows.Map(m => m.Key).Except(showEntries.Map(m => m.Key)).ToList();
List<int> ids = await _plexTelevisionRepository.FlagFileNotFoundShows(library, fileNotFoundKeys);
await _searchIndex.RebuildItems(_searchRepository, ids);
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
return Unit.Default;
return None;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
protected override async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateMetadata(
MediaItemScanResult<PlexShow> result,
PlexShow incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token,
bool deepScan)
ShowMetadata fullMetadata)
{
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
if (result.IsAdded || existing.Etag != incoming.Etag || deepScan)
{
Either<BaseError, ShowMetadata> maybeMetadata =
await _plexServerApiClient.GetShowMetadata(
library,
incoming.Key.Replace("/children", string.Empty).Split("/").Last(),
connection,
token);
await maybeMetadata.Match(
async fullMetadata =>
{
if (existingMetadata.MetadataKind != MetadataKind.External)
{
existingMetadata.MetadataKind = MetadataKind.External;
@ -337,535 +366,193 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL @@ -337,535 +366,193 @@ public class PlexTelevisionLibraryScanner : PlexLibraryScanner, IPlexTelevisionL
}
}
if (result.IsUpdated)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
},
_ => Task.CompletedTask);
}
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexShow>>> UpdateArtwork(
MediaItemScanResult<PlexShow> result,
PlexShow incoming)
{
PlexShow existing = result.Item;
ShowMetadata existingMetadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster);
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt);
if (poster || fanArt)
{
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
}
return result;
}
private async Task<Either<BaseError, Unit>> ScanSeasons(
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
PlexShow show,
PlexConnection connection,
PlexServerAuthToken token,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken)
{
List<PlexItemEtag> existingSeasons = await _plexTelevisionRepository.GetExistingPlexSeasons(library, show);
Either<BaseError, List<PlexSeason>> entries = await _plexServerApiClient.GetShowSeasons(
library,
show,
connection,
token);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
var seasonEntries = entries.RightToSeq().Flatten().ToList();
foreach (PlexSeason incoming in seasonEntries)
{
incoming.ShowId = show.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, PlexSeason> maybeSeason = await _televisionRepository
.GetOrAddPlexSeason(library, incoming)
.BindT(existing => UpdateMetadataAndArtwork(existing, incoming, deepScan));
foreach (BaseError error in maybeSeason.LeftToSeq())
{
_logger.LogWarning(
"Error processing plex season at {Key}: {Error}",
incoming.Key,
error.Value);
return error;
result.IsUpdated = true;
}
foreach (PlexSeason season in maybeSeason.RightToSeq())
{
Either<BaseError, Unit> scanResult = await ScanEpisodes(
library,
pathReplacements,
season,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan,
cancellationToken);
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
if (result.IsUpdated)
{
return error;
}
await _plexTelevisionRepository.SetPlexEtag(season, incoming.Etag);
season.Show = show;
// TODO: if any seasons are unavailable or not found, flag show as unavailable/not found
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { season });
}
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
}
var fileNotFoundKeys = existingSeasons.Map(m => m.Key).Except(seasonEntries.Map(m => m.Key)).ToList();
List<int> ids = await _plexTelevisionRepository.FlagFileNotFoundSeasons(library, fileNotFoundKeys);
await _searchIndex.RebuildItems(_searchRepository, ids);
return Unit.Default;
return result;
}
private async Task<Either<BaseError, PlexSeason>> UpdateMetadataAndArtwork(
PlexSeason existing,
PlexSeason incoming,
bool deepScan)
protected override async Task<Either<BaseError, MediaItemScanResult<PlexSeason>>> UpdateMetadata(
MediaItemScanResult<PlexSeason> result,
SeasonMetadata fullMetadata)
{
PlexSeason existing = result.Item;
SeasonMetadata existingMetadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
if (existing.Etag != incoming.Etag || deepScan)
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in incomingMetadata.Guids
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
if (await _metadataRepository.RemoveTag(tag))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in incomingMetadata.Tags
foreach (Tag tag in fullMetadata.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);
}
return existing;
}
private async Task<Either<BaseError, Unit>> ScanEpisodes(
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
PlexSeason season,
PlexConnection connection,
PlexServerAuthToken token,
string ffmpegPath,
string ffprobePath,
bool deepScan,
CancellationToken cancellationToken)
{
List<PlexItemEtag> existingEpisodes = await _plexTelevisionRepository.GetExistingPlexEpisodes(library, season);
Either<BaseError, List<PlexEpisode>> entries = await _plexServerApiClient.GetSeasonEpisodes(
library,
season,
connection,
token);
foreach (BaseError error in entries.LeftToSeq())
{
return error;
}
var episodeEntries = entries.RightToSeq().Flatten().ToList();
foreach (PlexEpisode incoming in episodeEntries)
{
if (cancellationToken.IsCancellationRequested)
{
return new ScanCanceled();
}
if (await ShouldScanItem(library, pathReplacements, existingEpisodes, incoming, deepScan) == false)
{
continue;
}
incoming.SeasonId = season.Id;
// TODO: figure out how to rebuild playlists
Either<BaseError, MediaItemScanResult<PlexEpisode>> maybeEpisode = await _televisionRepository
.GetOrAddPlexEpisode(library, incoming)
.BindT(existing => UpdateMetadata(existing, incoming))
.BindT(
existing => UpdateStatistics(
pathReplacements,
existing,
incoming,
library,
connection,
token,
ffmpegPath,
ffprobePath,
deepScan))
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
.BindT(existing => UpdateArtwork(existing, incoming));
foreach (BaseError error in maybeEpisode.LeftToSeq())
{
switch (error)
{
case ScanCanceled:
return error;
default:
_logger.LogWarning(
"Error processing plex episode at {Key}: {Error}",
incoming.Key,
error.Value);
break;
}
}
foreach (MediaItemScanResult<PlexEpisode> result in maybeEpisode.RightToSeq())
{
await _plexTelevisionRepository.SetPlexEtag(result.Item, incoming.Etag);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
if (_localFileSystem.FileExists(localPath))
{
await _plexTelevisionRepository.FlagNormal(library, result.Item);
}
else
{
await _plexTelevisionRepository.FlagUnavailable(library, result.Item);
}
if (result.IsAdded)
{
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
}
else
{
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
}
}
}
var fileNotFoundKeys = existingEpisodes.Map(m => m.Key).Except(episodeEntries.Map(m => m.Key)).ToList();
List<int> ids = await _plexTelevisionRepository.FlagFileNotFoundEpisodes(library, fileNotFoundKeys);
await _searchIndex.RebuildItems(_searchRepository, ids);
_searchIndex.Commit();
return Unit.Default;
}
private async Task<bool> ShouldScanItem(
PlexLibrary library,
List<PlexPathReplacement> pathReplacements,
List<PlexItemEtag> existingEpisodes,
PlexEpisode incoming,
bool deepScan)
{
// deep scan will pull every episode individually from the plex api
if (!deepScan)
{
Option<PlexItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.Key == incoming.Key);
string existingTag = await maybeExisting
.Map(e => e.Etag ?? string.Empty)
.IfNoneAsync(string.Empty);
MediaItemState existingState = await maybeExisting
.Map(e => e.State)
.IfNoneAsync(MediaItemState.Normal);
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
plexPath,
false);
// if media is unavailable, only scan if file now exists
if (existingState == MediaItemState.Unavailable)
{
if (!_localFileSystem.FileExists(localPath))
{
return false;
}
}
else if (existingTag == incoming.Etag)
{
if (!_localFileSystem.FileExists(localPath))
{
foreach (int id in await _plexTelevisionRepository.FlagUnavailable(library, incoming))
if (await _televisionRepository.AddTag(existingMetadata, tag))
{
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
}
}
// _logger.LogDebug("NOOP: etag has not changed for plex episode with key {Key}", incoming.Key);
return false;
}
// _logger.LogDebug(
// "UPDATE: Etag has changed for episode {Episode}",
// $"s{season.SeasonNumber}e{incoming.EpisodeMetadata.Head().EpisodeNumber}");
result.IsUpdated = true;
}
return true;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateMetadata(
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming)
if (await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster))
{
PlexEpisode existing = result.Item;
var toUpdate = existing.EpisodeMetadata
.Where(em => incoming.EpisodeMetadata.Any(em2 => em2.EpisodeNumber == em.EpisodeNumber))
.ToList();
var toRemove = existing.EpisodeMetadata.Except(toUpdate).ToList();
var toAdd = incoming.EpisodeMetadata
.Where(em => existing.EpisodeMetadata.All(em2 => em2.EpisodeNumber != em.EpisodeNumber))
.ToList();
foreach (EpisodeMetadata metadata in toRemove)
{
await _televisionRepository.RemoveMetadata(existing, metadata);
}
foreach (EpisodeMetadata metadata in toAdd)
{
metadata.EpisodeId = existing.Id;
metadata.Episode = existing;
existing.EpisodeMetadata.Add(metadata);
await _metadataRepository.Add(metadata);
result.IsUpdated = true;
}
// TODO: update existing metadata
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateStatistics(
List<PlexPathReplacement> pathReplacements,
protected override async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateMetadata(
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming,
PlexLibrary library,
PlexConnection connection,
PlexServerAuthToken token,
string ffmpegPath,
string ffprobePath,
bool deepScan)
EpisodeMetadata fullMetadata)
{
PlexEpisode existing = result.Item;
MediaVersion existingVersion = existing.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
if (result.IsAdded || existing.Etag != incoming.Etag || deepScan || existingVersion.Streams.Count == 0)
{
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
{
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
{
if (incomingFile.Path != existingFile.Path)
{
_logger.LogDebug(
"Plex episode has moved from {OldPath} to {NewPath}",
existingFile.Path,
incomingFile.Path);
existingFile.Path = incomingFile.Path;
await _televisionRepository.UpdatePath(existingFile.Id, incomingFile.Path);
}
}
}
Either<BaseError, bool> refreshResult = true;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
if ((existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0) &&
_localFileSystem.FileExists(localPath))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
refreshResult = await _localStatisticsProvider.RefreshStatistics(
ffmpegPath,
ffprobePath,
existing,
localPath);
}
EpisodeMetadata existingMetadata = existing.EpisodeMetadata.Head();
foreach (BaseError error in refreshResult.LeftToSeq())
{
_logger.LogWarning(
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
"Statistics",
localPath,
error.Value);
}
foreach (var _ in refreshResult.RightToSeq())
{
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
{
await _searchIndex.UpdateItems(
_searchRepository,
new List<MediaItem> { updated });
}
Either<BaseError, Tuple<EpisodeMetadata, MediaVersion>> maybeStatistics =
await _plexServerApiClient.GetEpisodeMetadataAndStatistics(
library,
incoming.Key.Split("/").Last(),
connection,
token);
foreach (Tuple<EpisodeMetadata, MediaVersion> tuple in maybeStatistics.RightToSeq())
{
(EpisodeMetadata incomingMetadata, MediaVersion mediaVersion) = tuple;
Option<EpisodeMetadata> maybeExisting = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
foreach (EpisodeMetadata existingMetadata in maybeExisting)
{
foreach (MetadataGuid guid in existingMetadata.Guids
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Remove(guid);
await _metadataRepository.RemoveGuid(guid);
if (await _metadataRepository.RemoveGuid(guid))
{
result.IsUpdated = true;
}
}
foreach (MetadataGuid guid in incomingMetadata.Guids
foreach (MetadataGuid guid in fullMetadata.Guids
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
.ToList())
{
existingMetadata.Guids.Add(guid);
await _metadataRepository.AddGuid(existingMetadata, guid);
if (await _metadataRepository.AddGuid(existingMetadata, guid))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in existingMetadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Remove(tag);
await _metadataRepository.RemoveTag(tag);
if (await _metadataRepository.RemoveTag(tag))
{
result.IsUpdated = true;
}
}
foreach (Tag tag in incomingMetadata.Tags
foreach (Tag tag in fullMetadata.Tags
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
existingMetadata.Tags.Add(tag);
await _televisionRepository.AddTag(existingMetadata, tag);
if (await _televisionRepository.AddTag(existingMetadata, tag))
{
result.IsUpdated = true;
}
}
existingVersion.SampleAspectRatio = mediaVersion.SampleAspectRatio;
existingVersion.VideoScanKind = mediaVersion.VideoScanKind;
existingVersion.DateUpdated = mediaVersion.DateUpdated;
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, mediaVersion);
}
}
if (await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Thumbnail))
{
result.IsUpdated = true;
}
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
return result;
}
private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateSubtitles(
List<PlexPathReplacement> pathReplacements,
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming)
{
try
{
PlexEpisode existing = result.Item;
protected override string MediaServerItemId(PlexShow show) => show.Key;
protected override string MediaServerItemId(PlexSeason season) => season.Key;
protected override string MediaServerItemId(PlexEpisode episode) => episode.Key;
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
pathReplacements,
incoming.MediaVersions.Head().MediaFiles.Head().Path,
false);
protected override string MediaServerEtag(PlexShow show) => show.Etag;
protected override string MediaServerEtag(PlexSeason season) => season.Etag;
protected override string MediaServerEtag(PlexEpisode episode) => episode.Etag;
await _localSubtitlesProvider.UpdateSubtitles(existing, localPath, false);
private async Task<bool> UpdateArtworkIfNeeded(
Domain.Metadata existingMetadata,
Domain.Metadata incomingMetadata,
ArtworkKind artworkKind)
{
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
{
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
return result;
}
catch (Exception ex)
if (maybeIncomingArtwork.IsNone)
{
return BaseError.New(ex.ToString());
}
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
}
private async Task<Either<BaseError, MediaItemScanResult<PlexEpisode>>> UpdateArtwork(
MediaItemScanResult<PlexEpisode> result,
PlexEpisode incoming)
foreach (Artwork incomingArtwork in maybeIncomingArtwork)
{
PlexEpisode existing = result.Item;
foreach (EpisodeMetadata incomingMetadata in incoming.EpisodeMetadata)
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
.Find(a => a.ArtworkKind == artworkKind);
if (maybeExistingArtwork.IsNone)
{
Option<EpisodeMetadata> maybeExistingMetadata = existing.EpisodeMetadata
.Find(em => em.EpisodeNumber == incomingMetadata.EpisodeNumber);
if (maybeExistingMetadata.IsSome)
existingMetadata.Artwork ??= new List<Artwork>();
existingMetadata.Artwork.Add(incomingArtwork);
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
}
foreach (Artwork existingArtwork in maybeExistingArtwork)
{
EpisodeMetadata existingMetadata = maybeExistingMetadata.ValueUnsafe();
await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Thumbnail);
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
existingArtwork.Path = incomingArtwork.Path;
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
await _metadataRepository.UpdateArtworkPath(existingArtwork);
}
}
return result;
return true;
}
return false;
}
}

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

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt.UnsafeValueAccess;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -18,7 +20,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -18,7 +20,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
@"SELECT ItemId, Etag FROM EmbyShow
@"SELECT ItemId, Etag, MI.State FROM EmbyShow
INNER JOIN Show S on EmbyShow.Id = S.Id
INNER JOIN MediaItem MI on S.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
@ -27,51 +29,37 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -27,51 +29,37 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
.Map(result => result.ToList());
}
public async Task<List<EmbyItemEtag>> GetExistingSeasons(EmbyLibrary library, string showItemId)
public async Task<List<EmbyItemEtag>> GetExistingSeasons(EmbyLibrary library, EmbyShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
@"SELECT EmbySeason.ItemId, EmbySeason.Etag FROM EmbySeason
@"SELECT EmbySeason.ItemId, EmbySeason.Etag, MI.State FROM EmbySeason
INNER JOIN Season S on EmbySeason.Id = S.Id
INNER JOIN MediaItem MI on S.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Show S2 on S.ShowId = S2.Id
INNER JOIN EmbyShow JS on S2.Id = JS.Id
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId",
new { LibraryId = library.Id, ShowItemId = showItemId })
new { LibraryId = library.Id, ShowItemId = show.ItemId })
.Map(result => result.ToList());
}
public async Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId)
public async Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, EmbySeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
@"SELECT EmbyEpisode.ItemId, EmbyEpisode.Etag FROM EmbyEpisode
@"SELECT EmbyEpisode.ItemId, EmbyEpisode.Etag, MI.State FROM EmbyEpisode
INNER JOIN Episode E on EmbyEpisode.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 EmbySeason JS on S2.Id = JS.Id
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId",
new { LibraryId = library.Id, SeasonItemId = seasonItemId })
new { LibraryId = library.Id, SeasonItemId = season.ItemId })
.Map(result => result.ToList());
}
public async Task<bool> AddShow(EmbyShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(show);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Option<EmbyShow>> Update(EmbyShow show)
public async Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> GetOrAdd(EmbyLibrary library, EmbyShow item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyShow> maybeExisting = await dbContext.EmbyShows
@ -91,23 +79,243 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -91,23 +79,243 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
.ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
.SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId);
if (maybeExisting.IsSome)
foreach (EmbyShow embyShow in maybeExisting)
{
var result = new MediaItemScanResult<EmbyShow>(embyShow) { IsAdded = false };
if (embyShow.Etag != item.Etag)
{
EmbyShow existing = maybeExisting.ValueUnsafe();
await UpdateShow(dbContext, embyShow, item);
result.IsUpdated = true;
}
// library path is used for search indexing later
show.LibraryPath = existing.LibraryPath;
show.Id = existing.Id;
return result;
}
return await AddShow(dbContext, library, item);
}
public async Task<Either<BaseError, MediaItemScanResult<EmbySeason>>> GetOrAdd(EmbyLibrary library, EmbySeason item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId);
foreach (EmbySeason embySeason in maybeExisting)
{
var result = new MediaItemScanResult<EmbySeason>(embySeason) { IsAdded = false };
if (embySeason.Etag != item.Etag)
{
await UpdateSeason(dbContext, embySeason, item);
result.IsUpdated = true;
}
return result;
}
return await AddSeason(dbContext, library, item);
}
public async Task<Either<BaseError, MediaItemScanResult<EmbyEpisode>>> GetOrAdd(
EmbyLibrary library,
EmbyEpisode item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyEpisode> maybeExisting = await dbContext.EmbyEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId);
foreach (EmbyEpisode embyEpisode in maybeExisting)
{
var result = new MediaItemScanResult<EmbyEpisode>(embyEpisode) { IsAdded = false };
if (embyEpisode.Etag != item.Etag)
{
await UpdateEpisode(dbContext, embyEpisode, item);
result.IsUpdated = true;
}
return result;
}
return await AddEpisode(dbContext, library, item);
}
public async Task<Unit> SetEtag(EmbyShow show, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyShow SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetEtag(EmbySeason season, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbySeason SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetEtag(EmbyEpisode episode, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyEpisode SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
}
public async Task<bool> FlagNormal(EmbyLibrary library, EmbyEpisode 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 EmbyEpisode.Id FROM EmbyEpisode
INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE EmbyEpisode.ItemId = @ItemId)",
new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0);
}
public async Task<List<int>> FlagFileNotFoundShows(EmbyLibrary library, List<string> showItemIds)
{
if (showItemIds.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN EmbyShow ON EmbyShow.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE EmbyShow.ItemId IN @ShowItemIds",
new { LibraryId = library.Id, ShowItemIds = showItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<List<int>> FlagFileNotFoundSeasons(EmbyLibrary library, List<string> seasonItemIds)
{
if (seasonItemIds.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN EmbySeason ON EmbySeason.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE EmbySeason.ItemId IN @SeasonItemIds",
new { LibraryId = library.Id, SeasonItemIds = seasonItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<List<int>> FlagFileNotFoundEpisodes(EmbyLibrary library, List<string> episodeItemIds)
{
if (episodeItemIds.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN EmbyEpisode ON EmbyEpisode.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE EmbyEpisode.ItemId IN @EpisodeItemIds",
new { LibraryId = library.Id, EpisodeItemIds = episodeItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
existing.Etag = show.Etag;
public async Task<Option<int>> FlagUnavailable(EmbyLibrary library, EmbyEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT EmbyEpisode.Id FROM EmbyEpisode
INNER JOIN MediaItem MI ON MI.Id = EmbyEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE EmbyEpisode.ItemId = @ItemId",
new { LibraryId = library.Id, episode.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
private async Task UpdateShow(TvContext dbContext, EmbyShow existing, EmbyShow incoming)
{
// library path is used for search indexing later
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
// metadata
ShowMetadata metadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = show.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
metadata.MetadataKind = incomingMetadata.MetadataKind;
metadata.ContentRating = incomingMetadata.ContentRating;
metadata.Title = incomingMetadata.Title;
@ -239,66 +447,21 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -239,66 +447,21 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
{
metadata.Artwork.Remove(artworkToRemove);
}
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<bool> AddSeason(EmbyShow show, EmbySeason season)
{
try
private async Task UpdateSeason(TvContext dbContext, EmbySeason existing, EmbySeason incoming)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
season.ShowId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId",
new { show.ItemId });
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<Option<EmbySeason>> Update(EmbySeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == season.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
{
EmbySeason existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
season.LibraryPath = existing.LibraryPath;
season.Id = existing.Id;
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
existing.Etag = season.Etag;
existing.SeasonNumber = season.SeasonNumber;
existing.SeasonNumber = incoming.SeasonNumber;
// metadata
SeasonMetadata metadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = season.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Year = incomingMetadata.Year;
@ -379,87 +542,20 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -379,87 +542,20 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
{
metadata.Artwork.Remove(artworkToRemove);
}
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<bool> AddEpisode(EmbySeason season, EmbyEpisode episode)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.SeasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId",
new { season.ItemId });
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<Option<EmbyEpisode>> Update(EmbyEpisode episode)
public async Task UpdateEpisode(TvContext dbContext, EmbyEpisode existing, EmbyEpisode incoming)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<EmbyEpisode> maybeExisting = await dbContext.EmbyEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
{
EmbyEpisode existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
episode.LibraryPath = existing.LibraryPath;
episode.Id = existing.Id;
existing.Etag = episode.Etag;
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
// metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
@ -541,7 +637,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -541,7 +637,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = episode.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
@ -549,83 +645,82 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -549,83 +645,82 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds)
private async Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> AddShow(
TvContext dbContext,
EmbyLibrary library,
EmbyShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
try
{
// blank out etag for initial save in case other updates fail
show.Etag = string.Empty;
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyShow js ON js.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds",
new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList());
show.LibraryPathId = library.Paths.Head().Id;
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
await dbContext.AddAsync(show);
await dbContext.SaveChangesAsync();
return ids;
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyShow>(show) { IsAdded = true };
}
public async Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds)
catch (Exception ex)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN EmbySeason js ON js.Id = m.Id
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
return BaseError.New(ex.ToString());
}
}
public async Task<List<int>> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds)
private async Task<Either<BaseError, MediaItemScanResult<EmbySeason>>> AddSeason(
TvContext dbContext,
EmbyLibrary library,
EmbySeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
try
{
// blank out etag for initial save in case other updates fail
season.Etag = string.Empty;
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN EmbyEpisode ee ON ee.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND ee.ItemId IN @EpisodeIds",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList());
season.LibraryPathId = library.Paths.Head().Id;
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
await dbContext.AddAsync(season);
await dbContext.SaveChangesAsync();
return ids;
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbySeason>(season) { IsAdded = true };
}
public async Task<Unit> DeleteEmptySeasons(EmbyLibrary library)
catch (Exception ex)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<EmbySeason> seasons = await dbContext.EmbySeasons
.Filter(s => s.LibraryPath.LibraryId == library.Id)
.Filter(s => s.Episodes.Count == 0)
.ToListAsync();
dbContext.Seasons.RemoveRange(seasons);
await dbContext.SaveChangesAsync();
return Unit.Default;
return BaseError.New(ex.ToString());
}
}
public async Task<List<int>> DeleteEmptyShows(EmbyLibrary library)
private async Task<Either<BaseError, MediaItemScanResult<EmbyEpisode>>> AddEpisode(
TvContext dbContext,
EmbyLibrary library,
EmbyEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<EmbyShow> shows = await dbContext.EmbyShows
.Filter(s => s.LibraryPath.LibraryId == library.Id)
.Filter(s => s.Seasons.Count == 0)
.ToListAsync();
var ids = shows.Map(s => s.Id).ToList();
dbContext.Shows.RemoveRange(shows);
try
{
// blank out etag for initial save in case other updates fail
episode.Etag = string.Empty;
episode.LibraryPathId = library.Paths.Head().Id;
await dbContext.AddAsync(episode);
await dbContext.SaveChangesAsync();
return ids;
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<EmbyEpisode>(episode) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

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

@ -1,8 +1,10 @@ @@ -1,8 +1,10 @@
using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using LanguageExt.UnsafeValueAccess;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
@ -18,7 +20,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -18,7 +20,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
@"SELECT ItemId, Etag FROM JellyfinShow
@"SELECT ItemId, Etag, MI.State FROM JellyfinShow
INNER JOIN Show S on JellyfinShow.Id = S.Id
INNER JOIN MediaItem MI on S.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
@ -27,51 +29,39 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -27,51 +29,39 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
.Map(result => result.ToList());
}
public async Task<List<JellyfinItemEtag>> GetExistingSeasons(JellyfinLibrary library, string showItemId)
public async Task<List<JellyfinItemEtag>> GetExistingSeasons(JellyfinLibrary library, JellyfinShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
@"SELECT JellyfinSeason.ItemId, JellyfinSeason.Etag FROM JellyfinSeason
@"SELECT JellyfinSeason.ItemId, JellyfinSeason.Etag, MI.State FROM JellyfinSeason
INNER JOIN Season S on JellyfinSeason.Id = S.Id
INNER JOIN MediaItem MI on S.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
INNER JOIN Show S2 on S.ShowId = S2.Id
INNER JOIN JellyfinShow JS on S2.Id = JS.Id
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId",
new { LibraryId = library.Id, ShowItemId = showItemId })
new { LibraryId = library.Id, ShowItemId = show.ItemId })
.Map(result => result.ToList());
}
public async Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId)
public async Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, JellyfinSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
@"SELECT JellyfinEpisode.ItemId, JellyfinEpisode.Etag FROM JellyfinEpisode
@"SELECT JellyfinEpisode.ItemId, JellyfinEpisode.Etag, MI.State FROM JellyfinEpisode
INNER JOIN Episode E on JellyfinEpisode.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 JellyfinSeason JS on S2.Id = JS.Id
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId",
new { LibraryId = library.Id, SeasonItemId = seasonItemId })
new { LibraryId = library.Id, SeasonItemId = season.ItemId })
.Map(result => result.ToList());
}
public async Task<bool> AddShow(JellyfinShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(show);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Option<JellyfinShow>> Update(JellyfinShow show)
public async Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> GetOrAdd(
JellyfinLibrary library,
JellyfinShow item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinShow> maybeExisting = await dbContext.JellyfinShows
@ -91,23 +81,245 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -91,23 +81,245 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
.ThenInclude(mm => mm.Guids)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == show.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
.SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId);
if (maybeExisting.IsSome)
foreach (JellyfinShow jellyfinShow in maybeExisting)
{
JellyfinShow existing = maybeExisting.ValueUnsafe();
var result = new MediaItemScanResult<JellyfinShow>(jellyfinShow) { IsAdded = false };
if (jellyfinShow.Etag != item.Etag)
{
await UpdateShow(dbContext, jellyfinShow, item);
result.IsUpdated = true;
}
// library path is used for search indexing later
show.LibraryPath = existing.LibraryPath;
show.Id = existing.Id;
return result;
}
return await AddShow(dbContext, library, item);
}
public async Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> GetOrAdd(
JellyfinLibrary library,
JellyfinSeason item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId);
foreach (JellyfinSeason jellyfinSeason in maybeExisting)
{
var result = new MediaItemScanResult<JellyfinSeason>(jellyfinSeason) { IsAdded = false };
if (jellyfinSeason.Etag != item.Etag)
{
await UpdateSeason(dbContext, jellyfinSeason, item);
result.IsUpdated = true;
}
return result;
}
return await AddSeason(dbContext, library, item);
}
public async Task<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> GetOrAdd(
JellyfinLibrary library,
JellyfinEpisode item)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinEpisode> maybeExisting = await dbContext.JellyfinEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.SelectOneAsync(s => s.ItemId, s => s.ItemId == item.ItemId);
foreach (JellyfinEpisode jellyfinEpisode in maybeExisting)
{
var result = new MediaItemScanResult<JellyfinEpisode>(jellyfinEpisode) { IsAdded = false };
if (jellyfinEpisode.Etag != item.Etag)
{
await UpdateEpisode(dbContext, jellyfinEpisode, item);
result.IsUpdated = true;
}
existing.Etag = show.Etag;
return result;
}
return await AddEpisode(dbContext, library, item);
}
public async Task<Unit> SetEtag(JellyfinShow show, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinShow SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetEtag(JellyfinSeason season, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinSeason SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
}
public async Task<Unit> SetEtag(JellyfinEpisode episode, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinEpisode SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
}
public async Task<bool> FlagNormal(JellyfinLibrary library, JellyfinEpisode 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 JellyfinEpisode.Id FROM JellyfinEpisode
INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinEpisode.ItemId = @ItemId)",
new { LibraryId = library.Id, episode.ItemId }).Map(count => count > 0);
}
public async Task<List<int>> FlagFileNotFoundShows(JellyfinLibrary library, List<string> showItemIds)
{
if (showItemIds.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN JellyfinShow ON JellyfinShow.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE JellyfinShow.ItemId IN @ShowItemIds",
new { LibraryId = library.Id, ShowItemIds = showItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<List<int>> FlagFileNotFoundSeasons(JellyfinLibrary library, List<string> seasonItemIds)
{
if (seasonItemIds.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN JellyfinSeason ON JellyfinSeason.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE JellyfinSeason.ItemId IN @SeasonItemIds",
new { LibraryId = library.Id, SeasonItemIds = seasonItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<List<int>> FlagFileNotFoundEpisodes(JellyfinLibrary library, List<string> episodeItemIds)
{
if (episodeItemIds.Count == 0)
{
return new List<int>();
}
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT M.Id
FROM MediaItem M
INNER JOIN JellyfinEpisode ON JellyfinEpisode.Id = M.Id
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
WHERE JellyfinEpisode.ItemId IN @EpisodeItemIds",
new { LibraryId = library.Id, EpisodeItemIds = episodeItemIds })
.Map(result => result.ToList());
await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<Option<int>> FlagUnavailable(JellyfinLibrary library, JellyfinEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.State = MediaItemState.Unavailable;
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT JellyfinEpisode.Id FROM JellyfinEpisode
INNER JOIN MediaItem MI ON MI.Id = JellyfinEpisode.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
WHERE JellyfinEpisode.ItemId = @ItemId",
new { LibraryId = library.Id, episode.ItemId });
foreach (int id in maybeId)
{
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
}
return None;
}
private async Task UpdateShow(TvContext dbContext, JellyfinShow existing, JellyfinShow incoming)
{
// library path is used for search indexing later
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
// metadata
ShowMetadata metadata = existing.ShowMetadata.Head();
ShowMetadata incomingMetadata = show.ShowMetadata.Head();
ShowMetadata incomingMetadata = incoming.ShowMetadata.Head();
metadata.MetadataKind = incomingMetadata.MetadataKind;
metadata.ContentRating = incomingMetadata.ContentRating;
metadata.Title = incomingMetadata.Title;
@ -215,23 +427,6 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -215,23 +427,6 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
poster.DateUpdated = incomingPoster.DateUpdated;
}
// thumbnail
Artwork incomingThumbnail =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
if (incomingThumbnail != null)
{
Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
if (thumb == null)
{
thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail };
metadata.Artwork.Add(thumb);
}
thumb.Path = incomingThumbnail.Path;
thumb.DateAdded = incomingThumbnail.DateAdded;
thumb.DateUpdated = incomingThumbnail.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
@ -256,66 +451,21 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -256,66 +451,21 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
{
metadata.Artwork.Remove(artworkToRemove);
}
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<bool> AddSeason(JellyfinShow show, JellyfinSeason season)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
season.ShowId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
new { show.ItemId });
await dbContext.AddAsync(season);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<Option<JellyfinSeason>> Update(JellyfinSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
.Include(m => m.LibraryPath)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.SeasonMetadata)
.ThenInclude(mm => mm.Guids)
.Filter(m => m.ItemId == season.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
private async Task UpdateSeason(TvContext dbContext, JellyfinSeason existing, JellyfinSeason incoming)
{
JellyfinSeason existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
season.LibraryPath = existing.LibraryPath;
season.Id = existing.Id;
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
existing.Etag = season.Etag;
existing.SeasonNumber = season.SeasonNumber;
existing.SeasonNumber = incoming.SeasonNumber;
// metadata
SeasonMetadata metadata = existing.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = season.SeasonMetadata.Head();
SeasonMetadata incomingMetadata = incoming.SeasonMetadata.Head();
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Year = incomingMetadata.Year;
@ -340,6 +490,23 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -340,6 +490,23 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
poster.DateUpdated = incomingPoster.DateUpdated;
}
// thumbnail
Artwork incomingThumbnail =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
if (incomingThumbnail != null)
{
Artwork thumb = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
if (thumb == null)
{
thumb = new Artwork { ArtworkKind = ArtworkKind.Thumbnail };
metadata.Artwork.Add(thumb);
}
thumb.Path = incomingThumbnail.Path;
thumb.DateAdded = incomingThumbnail.DateAdded;
thumb.DateUpdated = incomingThumbnail.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
@ -381,86 +548,18 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -381,86 +548,18 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
}
await dbContext.SaveChangesAsync();
await dbContext.Entry(existing.LibraryPath).Reference(lp => lp.Library).LoadAsync();
}
return maybeExisting;
}
public async Task<bool> AddEpisode(JellyfinSeason season, JellyfinEpisode episode)
{
try
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
episode.SeasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
new { season.ItemId });
await dbContext.AddAsync(episode);
if (await dbContext.SaveChangesAsync() <= 0)
public async Task UpdateEpisode(TvContext dbContext, JellyfinEpisode existing, JellyfinEpisode incoming)
{
return false;
}
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
await dbContext.Entry(episode).Reference(e => e.Season).LoadAsync();
return true;
}
catch (Exception)
{
return false;
}
}
public async Task<Option<JellyfinEpisode>> Update(JellyfinEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
Option<JellyfinEpisode> maybeExisting = await dbContext.JellyfinEpisodes
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Guids)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Directors)
.Include(m => m.EpisodeMetadata)
.ThenInclude(mm => mm.Writers)
.Include(m => m.Season)
.Include(m => m.TraktListItems)
.ThenInclude(tli => tli.TraktList)
.Filter(m => m.ItemId == episode.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
{
JellyfinEpisode existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
episode.LibraryPath = existing.LibraryPath;
episode.Id = existing.Id;
existing.Etag = episode.Etag;
incoming.LibraryPath = existing.LibraryPath;
incoming.Id = existing.Id;
// metadata
// TODO: multiple metadata?
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
EpisodeMetadata incomingMetadata = incoming.EpisodeMetadata.Head();
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
@ -468,7 +567,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -468,7 +567,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
metadata.EpisodeNumber = metadata.EpisodeNumber;
metadata.EpisodeNumber = incomingMetadata.EpisodeNumber;
// thumbnail
Artwork incomingThumbnail =
@ -542,7 +641,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -542,7 +641,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = episode.MediaVersions.Head();
MediaVersion incomingVersion = incoming.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
@ -550,81 +649,82 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -550,81 +649,82 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
public async Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds)
private async Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> AddShow(
TvContext dbContext,
JellyfinLibrary library,
JellyfinShow show)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinShow js ON js.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds",
new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList());
try
{
// blank out etag for initial save in case other updates fail
show.Etag = string.Empty;
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
show.LibraryPathId = library.Paths.Head().Id;
return ids;
}
await dbContext.AddAsync(show);
await dbContext.SaveChangesAsync();
public async Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds)
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinShow>(show) { IsAdded = true };
}
catch (Exception ex)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinSeason js ON js.Id = m.Id
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
return BaseError.New(ex.ToString());
}
}
public async Task<List<int>> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds)
private async Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> AddSeason(
TvContext dbContext,
JellyfinLibrary library,
JellyfinSeason season)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<int> ids = await dbContext.Connection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode je ON je.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
WHERE lp.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds",
new { LibraryId = library.Id, EpisodeIds = episodeIds }).Map(result => result.ToList());
try
{
// blank out etag for initial save in case other updates fail
season.Etag = string.Empty;
await dbContext.Connection.ExecuteAsync(
"DELETE FROM MediaItem WHERE Id IN @Ids",
new { Ids = ids });
season.LibraryPathId = library.Paths.Head().Id;
return ids;
}
await dbContext.AddAsync(season);
await dbContext.SaveChangesAsync();
public async Task<Unit> DeleteEmptySeasons(JellyfinLibrary library)
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinSeason>(season) { IsAdded = true };
}
catch (Exception ex)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<JellyfinSeason> seasons = await dbContext.JellyfinSeasons
.Filter(s => s.LibraryPath.LibraryId == library.Id)
.Filter(s => s.Episodes.Count == 0)
.ToListAsync();
dbContext.Seasons.RemoveRange(seasons);
await dbContext.SaveChangesAsync();
return Unit.Default;
return BaseError.New(ex.ToString());
}
}
public async Task<List<int>> DeleteEmptyShows(JellyfinLibrary library)
private async Task<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> AddEpisode(
TvContext dbContext,
JellyfinLibrary library,
JellyfinEpisode episode)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
List<JellyfinShow> shows = await dbContext.JellyfinShows
.Filter(s => s.LibraryPath.LibraryId == library.Id)
.Filter(s => s.Seasons.Count == 0)
.ToListAsync();
var ids = shows.Map(s => s.Id).ToList();
dbContext.Shows.RemoveRange(shows);
try
{
// blank out etag for initial save in case other updates fail
episode.Etag = string.Empty;
episode.LibraryPathId = library.Paths.Head().Id;
await dbContext.AddAsync(episode);
await dbContext.SaveChangesAsync();
return ids;
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return new MediaItemScanResult<JellyfinEpisode>(episode) { IsAdded = true };
}
catch (Exception ex)
{
return BaseError.New(ex.ToString());
}
}
}

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

271
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 AddShow(dbContext, library, item);
}
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<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 };
}
public async Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexEpisode episode)
return await AddSeason(dbContext, library, item);
}
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 new MediaItemScanResult<PlexEpisode>(plexEpisode) { IsAdded = false };
}
episode.State = MediaItemState.Unavailable;
return await AddEpisode(dbContext, library, item);
}
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 });
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);
}
foreach (int id in maybeId)
public async Task<Unit> SetEtag(PlexSeason season, string etag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
}
return None;
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