refactor plex, emby and jellyfin television scanners (#767)
* refactor plex television scanner * refactor emby television scanner * refactor jellyfin television scanner * update changelog
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
|
||||
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
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
List<EmbyPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
|
||||
|
||||
string GetLocalPath(EmbyEpisode episode)
|
||||
{
|
||||
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
|
||||
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetEmbyPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeShows.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing emby library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows,
|
||||
cancellationToken);
|
||||
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (Unit _ in scanResult.RightToSeq())
|
||||
{
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
.Filter(i => !incomingShowIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
|
||||
await _searchIndex.RemoveItems(missingShowIds);
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(library);
|
||||
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
|
||||
await _searchIndex.RemoveItems(emptyShowIds);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
return _pathReplacementService.GetReplacementEmbyPath(
|
||||
pathReplacements,
|
||||
episode.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
_televisionRepository,
|
||||
new EmbyConnectionParameters(address, apiKey),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ProcessShows(
|
||||
string address,
|
||||
string apiKey,
|
||||
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetShowLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.ItemId);
|
||||
|
||||
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
|
||||
protected override string MediaServerItemId(EmbyEpisode episode) => episode.ItemId;
|
||||
|
||||
protected override string MediaServerEtag(EmbyShow show) => show.Etag;
|
||||
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
|
||||
|
||||
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();
|
||||
}
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyShow show) =>
|
||||
_embyApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
show.ItemId);
|
||||
|
||||
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,
|
||||
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();
|
||||
}
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbySeason season) =>
|
||||
_embyApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
season.ItemId);
|
||||
|
||||
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,
|
||||
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();
|
||||
}
|
||||
MediaItemScanResult<EmbyShow> result,
|
||||
EmbyShow incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<ShowMetadata>.None);
|
||||
|
||||
EmbyEpisode incomingEpisode = incoming;
|
||||
var updateStatistics = false;
|
||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
MediaItemScanResult<EmbySeason> result,
|
||||
EmbySeason incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<SeasonMetadata>.None);
|
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
if (maybeExisting.IsNone)
|
||||
{
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
MediaItemScanResult<EmbyEpisode> result,
|
||||
EmbyEpisode incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<EpisodeMetadata>.None);
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata(
|
||||
MediaItemScanResult<EmbyShow> result,
|
||||
ShowMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyShow>>>(result);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<EmbySeason>>> UpdateMetadata(
|
||||
MediaItemScanResult<EmbySeason> result,
|
||||
SeasonMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbySeason>>>(result);
|
||||
|
||||
foreach (EmbyItemEtag existing in maybeExisting)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag != incoming.Etag)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
|
||||
foreach (EmbyEpisode updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
incomingEpisode = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateStatistics)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementEmbyPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingEpisode,
|
||||
localPath);
|
||||
|
||||
if (refreshResult.Map(t => t).IfLeft(false))
|
||||
{
|
||||
refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
|
||||
}
|
||||
|
||||
foreach (BaseError error in refreshResult.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
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<Either<BaseError, MediaItemScanResult<EmbyEpisode>>> UpdateMetadata(
|
||||
MediaItemScanResult<EmbyEpisode> result,
|
||||
EpisodeMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyEpisode>>>(result);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
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
|
||||
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
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
List<JellyfinPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
string GetLocalPath(JellyfinEpisode episode)
|
||||
{
|
||||
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
|
||||
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<JellyfinShow>> maybeShows = await _jellyfinApiClient.GetShowLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeShows.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (List<JellyfinShow> shows in maybeShows.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows,
|
||||
cancellationToken);
|
||||
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (Unit _ in scanResult.RightToSeq())
|
||||
{
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
.Filter(i => !incomingShowIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
|
||||
await _searchIndex.RemoveItems(missingShowIds);
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(library);
|
||||
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
|
||||
await _searchIndex.RemoveItems(emptyShowIds);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
return _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
episode.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
_televisionRepository,
|
||||
new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ProcessShows(
|
||||
string address,
|
||||
string apiKey,
|
||||
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetShowLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
|
||||
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId;
|
||||
|
||||
protected override string MediaServerEtag(JellyfinShow show) => show.Etag;
|
||||
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
|
||||
|
||||
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();
|
||||
}
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinShow show) =>
|
||||
_jellyfinApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
show.ItemId);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinSeason season) =>
|
||||
_jellyfinApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
season.ItemId);
|
||||
|
||||
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,
|
||||
library.MediaSourceId,
|
||||
incoming.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeEpisodes.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (List<JellyfinEpisode> episodes in maybeEpisodes.RightToSeq())
|
||||
{
|
||||
var validEpisodes = new List<JellyfinEpisode>();
|
||||
foreach (JellyfinEpisode episode in episodes)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
episode.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping jellyfin episode that does not exist at {Path}",
|
||||
localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
validEpisodes.Add(episode);
|
||||
}
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> scanResult = await ProcessEpisodes(
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingEpisodes,
|
||||
validEpisodes,
|
||||
cancellationToken);
|
||||
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
foreach (Unit _ in scanResult.RightToSeq())
|
||||
{
|
||||
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
|
||||
var episodeIds = existingEpisodes
|
||||
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> missingEpisodeIds =
|
||||
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
|
||||
await _searchIndex.RemoveItems(missingEpisodeIds);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ProcessEpisodes(
|
||||
string showName,
|
||||
string seasonName,
|
||||
protected override Task<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();
|
||||
}
|
||||
MediaItemScanResult<JellyfinShow> result,
|
||||
JellyfinShow incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<ShowMetadata>.None);
|
||||
|
||||
JellyfinEpisode incomingEpisode = incoming;
|
||||
var updateStatistics = false;
|
||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinSeason> result,
|
||||
JellyfinSeason incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<SeasonMetadata>.None);
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
if (maybeExisting.IsNone)
|
||||
{
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinEpisode> result,
|
||||
JellyfinEpisode incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<EpisodeMetadata>.None);
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinShow>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinShow> result,
|
||||
ShowMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinShow>>>(result);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinSeason> result,
|
||||
SeasonMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinSeason>>>(result);
|
||||
|
||||
foreach (JellyfinItemEtag existing in maybeExisting)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag != incoming.Etag)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<JellyfinEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
|
||||
foreach (JellyfinEpisode updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
incomingEpisode = updated;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateStatistics)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingEpisode,
|
||||
localPath);
|
||||
|
||||
if (refreshResult.Map(t => t).IfLeft(false))
|
||||
{
|
||||
refreshResult = await UpdateSubtitles(incomingEpisode, localPath);
|
||||
}
|
||||
|
||||
foreach (BaseError error in refreshResult.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
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<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinEpisode> result,
|
||||
EpisodeMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinEpisode>>>(result);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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,46 +16,6 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
public PlexTelevisionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexShows(PlexLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PS.Key, PS.Etag, MI.State FROM PlexShow PS
|
||||
INNER JOIN MediaItem MI on PS.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexSeasons(PlexLibrary library, PlexShow show)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexSeason.Key, PlexSeason.Etag, MI.State FROM PlexSeason
|
||||
INNER JOIN Season S on PlexSeason.Id = S.Id
|
||||
INNER JOIN MediaItem MI on S.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
INNER JOIN PlexShow PS ON S.ShowId = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, show.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexEpisodes(PlexLibrary library, PlexSeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexEpisode.Key, PlexEpisode.Etag, MI.State FROM PlexEpisode
|
||||
INNER JOIN Episode E on PlexEpisode.Id = E.Id
|
||||
INNER JOIN MediaItem MI on E.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
INNER JOIN Season S2 on E.SeasonId = S2.Id
|
||||
INNER JOIN PlexSeason PS on S2.Id = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, season.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(PlexLibrary library, PlexEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -91,6 +54,170 @@ public class PlexTelevisionRepository : IPlexTelevisionRepository
|
||||
return None;
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingShows(PlexLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PS.Key, PS.Etag, MI.State FROM PlexShow PS
|
||||
INNER JOIN MediaItem MI on PS.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingSeasons(PlexLibrary library, PlexShow show)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexSeason.Key, PlexSeason.Etag, MI.State FROM PlexSeason
|
||||
INNER JOIN Season S on PlexSeason.Id = S.Id
|
||||
INNER JOIN MediaItem MI on S.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
INNER JOIN PlexShow PS ON S.ShowId = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, show.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingEpisodes(PlexLibrary library, PlexSeason season)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT PlexEpisode.Key, PlexEpisode.Etag, MI.State FROM PlexEpisode
|
||||
INNER JOIN Episode E on PlexEpisode.Id = E.Id
|
||||
INNER JOIN MediaItem MI on E.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
INNER JOIN Season S2 on E.SeasonId = S2.Id
|
||||
INNER JOIN PlexSeason PS on S2.Id = PS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND PS.Key = @Key",
|
||||
new { LibraryId = library.Id, season.Key })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<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 };
|
||||
}
|
||||
|
||||
return await AddShow(dbContext, library, item);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
return await AddEpisode(dbContext, library, item);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetEtag(PlexShow show, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexShow SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, show.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetEtag(PlexSeason season, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexSeason SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, season.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetEtag(PlexEpisode episode, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexEpisode SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, episode.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFoundShows(PlexLibrary library, List<string> plexShowKeys)
|
||||
{
|
||||
if (plexShowKeys.Count == 0)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user