Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87aa69f4cc | ||
|
|
404ea49e35 | ||
|
|
4ed40acfbe | ||
|
|
17f540dc99 | ||
|
|
780ebc01ee | ||
|
|
0a0fb71b94 | ||
|
|
53d6ecae8d | ||
|
|
837f311ec0 | ||
|
|
a9a89d04ea | ||
|
|
2e1073eb53 | ||
|
|
7687278b80 | ||
|
|
392aebd46f |
+27
-1
@@ -5,6 +5,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.3-beta] - 2022-04-29
|
||||
### Fixed
|
||||
- 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
|
||||
- Automatically delete and rebuild the search index on startup if it has become corrupt
|
||||
- Automatically scan Jellyfin and Emby libraries on startup and periodically
|
||||
- Properly remove un-synchronized Plex, Jellyfin and Emby items from the database and search index
|
||||
- Fix synchronizing movies within a collection from Jellyfin
|
||||
|
||||
### Changed
|
||||
- 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
|
||||
- Optimize search-index rebuilding to complete 100x faster
|
||||
- **No longer use network paths to source content from Jellyfin and Emby**
|
||||
- **If you previously used path replacements to convert network paths to local paths, you should remove them**
|
||||
|
||||
### Added
|
||||
- Add `unavailable` state for Jellyfin and Emby movie and show libraries
|
||||
- Add `height` and `width` to search index for all videos
|
||||
- Add `season_number` and `episode_number` to search index for all episodes
|
||||
- Add `season_number` to search index for seasons
|
||||
- Add `show_title` to search index for seasons and episodes
|
||||
|
||||
## [0.5.3-beta] - 2022-04-24
|
||||
### Fixed
|
||||
- Cleanly stop Plex library scan when service termination is requested
|
||||
@@ -1113,7 +1138,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.4-beta...HEAD
|
||||
[0.5.4-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.3-beta...v0.5.4-beta
|
||||
[0.5.3-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.2-beta...v0.5.3-beta
|
||||
[0.5.2-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.1-beta...v0.5.2-beta
|
||||
[0.5.1-beta]: https://github.com/jasongdove/ErsatzTV/compare/v0.5.0-beta...v0.5.1-beta
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.4.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,8 +8,7 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public class
|
||||
SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
|
||||
public class SynchronizeEmbyLibrariesHandler : IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly IEmbySecretStore _embySecretStore;
|
||||
@@ -72,32 +71,33 @@ public class
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
|
||||
await maybeLibraries.Match(
|
||||
async libraries =>
|
||||
{
|
||||
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.EmbyMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
if (ids.Any())
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}",
|
||||
connectionParameters.EmbyMediaSource.ServerName,
|
||||
error.Value);
|
||||
foreach (BaseError error in maybeLibraries.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}",
|
||||
connectionParameters.EmbyMediaSource.ServerName,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
foreach (List<EmbyLibrary> libraries in maybeLibraries.RightToSeq())
|
||||
{
|
||||
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.EmbyMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
if (ids.Any())
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -49,19 +49,24 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceSynchronizeEmbyLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeEmbyLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
||||
|
||||
private Task<Either<BaseError, string>>
|
||||
Handle(ISynchronizeEmbyLibraryById request) =>
|
||||
Validate(request)
|
||||
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
private async Task<Either<BaseError, string>>
|
||||
HandleImpl(ISynchronizeEmbyLibraryById request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, RequestParameters> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
parameters => Synchronize(parameters, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
private async Task<Either<BaseError, string>> Synchronize(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -77,15 +82,17 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath),
|
||||
parameters.FFprobePath,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Shows =>
|
||||
await _embyTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath),
|
||||
_ => BaseError.New("Unsupported library media kind")
|
||||
parameters.FFprobePath,
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
if (result.IsRight)
|
||||
@@ -94,17 +101,18 @@ public class SynchronizeEmbyLibraryByIdHandler :
|
||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
||||
|
||||
await _embyWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId));
|
||||
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of emby media library {Name}",
|
||||
parameters.Library.Name);
|
||||
_logger.LogDebug("Skipping unforced scan of emby media library {Name}", parameters.Library.Name);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return parameters.Library.Name;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Emby;
|
||||
|
||||
public record EmbyLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
|
||||
: LibraryViewModel("Emby", Id, Name, MediaKind);
|
||||
public record EmbyLibraryViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool ShouldSyncItems,
|
||||
int MediaSourceId)
|
||||
: LibraryViewModel("Emby", Id, Name, MediaKind, MediaSourceId);
|
||||
|
||||
@@ -11,7 +11,7 @@ internal static class Mapper
|
||||
embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
|
||||
|
||||
internal static EmbyLibraryViewModel ProjectToViewModel(EmbyLibrary library) =>
|
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
|
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems, library.MediaSourceId);
|
||||
|
||||
internal static EmbyPathReplacementViewModel ProjectToViewModel(EmbyPathReplacement pathReplacement) =>
|
||||
new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath);
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.1.46">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.Resolutions;
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
@@ -26,6 +27,14 @@ internal static class Mapper
|
||||
profile.NormalizeFramerate,
|
||||
profile.DeinterlaceVideo == true);
|
||||
|
||||
internal static FFmpegProfileResponseModel ProjectToResponseModel(FFmpegProfile ffmpegProfile) =>
|
||||
new(
|
||||
ffmpegProfile.Id,
|
||||
ffmpegProfile.Name,
|
||||
$"{ffmpegProfile.Resolution.Width}x{ffmpegProfile.Resolution.Height}",
|
||||
ffmpegProfile.VideoFormat.ToString().ToLowerInvariant(),
|
||||
ffmpegProfile.AudioFormat.ToString().ToLowerInvariant());
|
||||
|
||||
private static ResolutionViewModel Project(Resolution resolution) =>
|
||||
new(resolution.Id, resolution.Name, resolution.Width, resolution.Height);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public record GetAllFFmpegProfilesForApi : IRequest<List<FFmpegProfileResponseModel>>;
|
||||
@@ -0,0 +1,28 @@
|
||||
using ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles;
|
||||
|
||||
public class
|
||||
GetAllFFmpegProfilesForApiHandler : IRequestHandler<GetAllFFmpegProfilesForApi, List<FFmpegProfileResponseModel>>
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public GetAllFFmpegProfilesForApiHandler(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<FFmpegProfileResponseModel>> Handle(
|
||||
GetAllFFmpegProfilesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
List<FFmpegProfile> ffmpegProfiles = await dbContext.FFmpegProfiles
|
||||
.AsNoTracking()
|
||||
.Include(p => p.Resolution)
|
||||
.ToListAsync(cancellationToken);
|
||||
return ffmpegProfiles.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,14 @@ public class GetAllHealthCheckResultsHandler : IRequestHandler<GetAllHealthCheck
|
||||
GetAllHealthCheckResults request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
try
|
||||
{
|
||||
List<HealthCheckResult> results = await _healthCheckService.PerformHealthChecks(cancellationToken);
|
||||
return results.Filter(r => r.Status != HealthCheckStatus.NotApplicable).ToList();
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new List<HealthCheckResult>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public class
|
||||
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries,
|
||||
Either<BaseError, Unit>>
|
||||
|
||||
SynchronizeJellyfinLibrariesHandler : IRequestHandler<SynchronizeJellyfinLibraries, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
@@ -74,32 +72,34 @@ public class
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
|
||||
await maybeLibraries.Match(
|
||||
async libraries =>
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
if (ids.Any())
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
error.Value);
|
||||
foreach (BaseError error in maybeLibraries.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
foreach (List<JellyfinLibrary> libraries in maybeLibraries.RightToSeq())
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries
|
||||
.OfType<JellyfinLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toUpdate = libraries
|
||||
.Filter(l => toAdd.All(a => a.ItemId != l.ItemId) && toRemove.All(r => r.ItemId != l.ItemId)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove,
|
||||
toUpdate);
|
||||
if (ids.Any())
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -49,19 +49,24 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
CancellationToken cancellationToken) => HandleImpl(request, cancellationToken);
|
||||
|
||||
private Task<Either<BaseError, string>>
|
||||
Handle(ISynchronizeJellyfinLibraryById request) =>
|
||||
Validate(request)
|
||||
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
private async Task<Either<BaseError, string>>
|
||||
HandleImpl(ISynchronizeJellyfinLibraryById request, CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, RequestParameters> validation = await Validate(request);
|
||||
return await validation.Match(
|
||||
parameters => Synchronize(parameters, cancellationToken),
|
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join()));
|
||||
}
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
private async Task<Either<BaseError, string>> Synchronize(
|
||||
RequestParameters parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -77,15 +82,17 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath),
|
||||
parameters.FFprobePath,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Shows =>
|
||||
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFmpegPath,
|
||||
parameters.FFprobePath),
|
||||
_ => BaseError.New("Unsupported library media kind")
|
||||
parameters.FFprobePath,
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
if (result.IsRight)
|
||||
@@ -94,17 +101,18 @@ public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
||||
|
||||
await _jellyfinWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId));
|
||||
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of jellyfin media library {Name}",
|
||||
parameters.Library.Name);
|
||||
_logger.LogDebug("Skipping unforced scan of jellyfin media library {Name}", parameters.Library.Name);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
return parameters.Library.Name;
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
@@ -3,5 +3,10 @@ using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record JellyfinLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
|
||||
: LibraryViewModel("Jellyfin", Id, Name, MediaKind);
|
||||
public record JellyfinLibraryViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool ShouldSyncItems,
|
||||
int MediaSourceId)
|
||||
: LibraryViewModel("Jellyfin", Id, Name, MediaKind, MediaSourceId);
|
||||
|
||||
@@ -11,7 +11,7 @@ internal static class Mapper
|
||||
jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
|
||||
|
||||
internal static JellyfinLibraryViewModel ProjectToViewModel(JellyfinLibrary library) =>
|
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
|
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems, library.MediaSourceId);
|
||||
|
||||
internal static JellyfinPathReplacementViewModel ProjectToViewModel(JellyfinPathReplacement pathReplacement) =>
|
||||
new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath);
|
||||
|
||||
@@ -32,7 +32,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
return await LanguageExtensions.Apply(validation, localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
private async Task<LocalLibraryViewModel> PersistLocalLibrary(
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind);
|
||||
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId);
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
|
||||
: LibraryViewModel("Local", Id, Name, MediaKind);
|
||||
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
|
||||
: LibraryViewModel("Local", Id, Name, MediaKind, MediaSourceId);
|
||||
|
||||
@@ -10,14 +10,19 @@ internal static class Mapper
|
||||
library switch
|
||||
{
|
||||
LocalLibrary l => ProjectToViewModel(l),
|
||||
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
|
||||
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems),
|
||||
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems),
|
||||
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind, p.MediaSourceId),
|
||||
JellyfinLibrary j => new JellyfinLibraryViewModel(
|
||||
j.Id,
|
||||
j.Name,
|
||||
j.MediaKind,
|
||||
j.ShouldSyncItems,
|
||||
j.MediaSourceId),
|
||||
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems, e.MediaSourceId),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(library))
|
||||
};
|
||||
|
||||
public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) =>
|
||||
new(library.Id, library.Name, library.MediaKind);
|
||||
new(library.Id, library.Name, library.MediaKind, library.MediaSourceId);
|
||||
|
||||
public static LocalLibraryPathViewModel ProjectToViewModel(LibraryPath libraryPath) =>
|
||||
new(libraryPath.Id, libraryPath.LibraryId, libraryPath.Path);
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
namespace ErsatzTV.Application.Libraries;
|
||||
|
||||
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
|
||||
: LibraryViewModel("Plex", Id, Name, MediaKind);
|
||||
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, int MediaSourceId)
|
||||
: LibraryViewModel("Plex", Id, Name, MediaKind, MediaSourceId);
|
||||
|
||||
@@ -148,7 +148,7 @@ internal static class Mapper
|
||||
Collection collection,
|
||||
Option<JellyfinMediaSource> maybeJellyfin,
|
||||
Option<EmbyMediaSource> maybeEmby) =>
|
||||
new CollectionCardResultsViewModel(
|
||||
new(
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
|
||||
|
||||
@@ -56,13 +56,7 @@ public class DeleteTraktListHandler : TraktCommandBase, IRequestHandler<DeleteTr
|
||||
dbContext.TraktLists.Remove(traktList);
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
foreach (int mediaItemId in mediaItemIds)
|
||||
{
|
||||
foreach (MediaItem mediaItem in await _searchRepository.GetItemToIndex(mediaItemId))
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new[] { mediaItem }.ToList());
|
||||
}
|
||||
}
|
||||
await _searchIndex.RebuildItems(_searchRepository, mediaItemIds);
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -156,15 +156,9 @@ public abstract class TraktCommandBase
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
foreach (int mediaItemId in ids)
|
||||
if (await dbContext.SaveChangesAsync() > 0)
|
||||
{
|
||||
Option<MediaItem> maybeItem = await _searchRepository.GetItemToIndex(mediaItemId);
|
||||
foreach (MediaItem item in maybeItem)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new[] { item }.ToList());
|
||||
}
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids.ToList());
|
||||
}
|
||||
|
||||
_searchIndex.Commit();
|
||||
|
||||
@@ -85,56 +85,56 @@ public class ScanLocalLibraryHandler : IRequestHandler<ForceScanLocalLibrary, Ei
|
||||
{
|
||||
scanned = true;
|
||||
|
||||
switch (localLibrary.MediaKind)
|
||||
Either<BaseError, Unit> result = localLibrary.MediaKind switch
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
LibraryMediaKind.Movies =>
|
||||
await _movieFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Shows =>
|
||||
await _televisionFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
case LibraryMediaKind.MusicVideos:
|
||||
cancellationToken),
|
||||
LibraryMediaKind.MusicVideos =>
|
||||
await _musicVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
case LibraryMediaKind.OtherVideos:
|
||||
cancellationToken),
|
||||
LibraryMediaKind.OtherVideos =>
|
||||
await _otherVideoFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
progressMin,
|
||||
progressMax);
|
||||
break;
|
||||
case LibraryMediaKind.Songs:
|
||||
progressMax,
|
||||
cancellationToken),
|
||||
LibraryMediaKind.Songs =>
|
||||
await _songFolderScanner.ScanFolder(
|
||||
libraryPath,
|
||||
ffprobePath,
|
||||
ffmpegPath,
|
||||
progressMin,
|
||||
progressMax,
|
||||
cancellationToken);
|
||||
break;
|
||||
}
|
||||
cancellationToken),
|
||||
_ => Unit.Default
|
||||
};
|
||||
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(libraryPath);
|
||||
if (result.IsRight)
|
||||
{
|
||||
libraryPath.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(libraryPath);
|
||||
}
|
||||
}
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(libraryPath.LibraryId, progressMax), cancellationToken);
|
||||
|
||||
@@ -71,31 +71,29 @@ public class
|
||||
connectionParameters.ActiveConnection,
|
||||
connectionParameters.PlexServerAuthToken);
|
||||
|
||||
await maybeLibraries.Match(
|
||||
async libraries =>
|
||||
{
|
||||
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.PlexMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
if (ids.Any())
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
|
||||
connectionParameters.PlexMediaSource.ServerName,
|
||||
error.Value);
|
||||
foreach (BaseError error in maybeLibraries.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
|
||||
connectionParameters.PlexMediaSource.ServerName,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
foreach (List<PlexLibrary> libraries in maybeLibraries.RightToSeq())
|
||||
{
|
||||
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.PlexMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
if (ids.Any())
|
||||
{
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using Humanizer;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Application.Search;
|
||||
@@ -33,7 +35,7 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
{
|
||||
bool indexFolderExists = Directory.Exists(FileSystemLayout.SearchIndexFolder);
|
||||
|
||||
await _searchIndex.Initialize(_localFileSystem);
|
||||
await _searchIndex.Initialize(_localFileSystem, _configElementRepository);
|
||||
|
||||
if (!indexFolderExists ||
|
||||
await _configElementRepository.GetValue<int>(ConfigElementKey.SearchIndexVersion) <
|
||||
@@ -41,12 +43,13 @@ public class RebuildSearchIndexHandler : IRequestHandler<RebuildSearchIndex, Uni
|
||||
{
|
||||
_logger.LogInformation("Migrating search index to version {Version}", _searchIndex.Version);
|
||||
|
||||
List<int> itemIds = await _searchRepository.GetItemIdsToIndex();
|
||||
await _searchIndex.Rebuild(_searchRepository, itemIds);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await _searchIndex.Rebuild(_searchRepository);
|
||||
|
||||
await _configElementRepository.Upsert(ConfigElementKey.SearchIndexVersion, _searchIndex.Version);
|
||||
sw.Stop();
|
||||
|
||||
_logger.LogInformation("Done migrating search index");
|
||||
_logger.LogInformation("Done migrating search index in {Duration}", sw.Elapsed.Humanize());
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -77,6 +77,30 @@ public class EmbyPathReplacementServiceTests
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EmbyWindows_To_EtvLinux_NetworkPath()
|
||||
{
|
||||
var mediaSource = new EmbyMediaSource { OperatingSystem = "Windows" };
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new EmbyPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<EmbyPathReplacementService>>().Object);
|
||||
|
||||
string result = service.ReplaceNetworkPath(
|
||||
mediaSource,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv",
|
||||
@"\\192.168.1.100\Something\Some Shared Folder",
|
||||
@"C:\mnt\something else\Some Shared Folder");
|
||||
|
||||
result.Should().Be(@"C:\mnt\something else\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task EmbyWindows_To_EtvLinux_UncPath()
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.6.0" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" />
|
||||
@@ -24,7 +24,7 @@
|
||||
<PackageReference Include="Moq" Version="4.17.2" />
|
||||
<PackageReference Include="NUnit" Version="3.13.3" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog" Version="2.11.0" />
|
||||
<PackageReference Include="Serilog.Extensions.Logging" Version="3.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Debug" Version="2.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -60,6 +60,22 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
|
||||
public Task<List<int>> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
|
||||
public Task<Either<BaseError, MediaItemScanResult<PlexShow>>> GetOrAddPlexShow(
|
||||
PlexLibrary library,
|
||||
PlexShow item) =>
|
||||
@@ -73,14 +89,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
PlexEpisode item) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddGenre(ShowMetadata metadata, Genre genre) => throw new NotSupportedException();
|
||||
public Task<bool> AddTag(Domain.Metadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -90,13 +98,6 @@ public class FakeTelevisionRepository : ITelevisionRepository
|
||||
public Task<List<int>> RemoveMissingPlexEpisodes(string seasonKey, List<string> episodeKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> RemoveMetadata(Episode episode, EpisodeMetadata metadata) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddDirector(EpisodeMetadata metadata, Director director) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddWriter(EpisodeMetadata metadata, Writer writer) => throw new NotSupportedException();
|
||||
public Task<Unit> UpdatePath(int mediaFileId, string path) => throw new NotSupportedException();
|
||||
public Task<Unit> SetPlexEtag(PlexShow show, string etag) => throw new NotSupportedException();
|
||||
|
||||
public Task<Unit> SetPlexEtag(PlexSeason season, string etag) => throw new NotSupportedException();
|
||||
|
||||
@@ -77,6 +77,30 @@ public class JellyfinPathReplacementServiceTests
|
||||
result.Should().Be(@"/mnt/something else/Some Shared Folder/Some Movie/Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JellyfinWindows_To_EtvLinux_NetworkPath()
|
||||
{
|
||||
var mediaSource = new JellyfinMediaSource { OperatingSystem = "Windows" };
|
||||
|
||||
var repo = new Mock<IMediaSourceRepository>();
|
||||
|
||||
var runtime = new Mock<IRuntimeInfo>();
|
||||
runtime.Setup(x => x.IsOSPlatform(OSPlatform.Windows)).Returns(false);
|
||||
|
||||
var service = new JellyfinPathReplacementService(
|
||||
repo.Object,
|
||||
runtime.Object,
|
||||
new Mock<ILogger<JellyfinPathReplacementService>>().Object);
|
||||
|
||||
string result = service.ReplaceNetworkPath(
|
||||
mediaSource,
|
||||
@"\\192.168.1.100\Something\Some Shared Folder\Some Movie\Some Movie.mkv",
|
||||
@"\\192.168.1.100\Something\Some Shared Folder",
|
||||
@"C:\mnt\something else\Some Shared Folder");
|
||||
|
||||
result.Should().Be(@"C:\mnt\something else\Some Shared Folder\Some Movie\Some Movie.mkv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task JellyfinWindows_To_EtvLinux_UncPath()
|
||||
{
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ErsatzTV.Core.Api.Channels;
|
||||
|
||||
public record ChannelResponseModel(
|
||||
int Id,
|
||||
string Number,
|
||||
string Name,
|
||||
[property: JsonProperty("ffmpegProfile")]
|
||||
string FFmpegProfile,
|
||||
string Language,
|
||||
string StreamingMode);
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Api.FFmpegProfiles;
|
||||
|
||||
public record FFmpegProfileResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
string Resolution,
|
||||
string Video,
|
||||
string Audio);
|
||||
@@ -5,4 +5,5 @@ public enum ChannelSubtitleMode
|
||||
None = 0,
|
||||
Forced = 1,
|
||||
Default = 2,
|
||||
Any = 3}
|
||||
Any = 3
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class EmbyLibrary : Library
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public bool ShouldSyncItems { get; set; }
|
||||
public List<EmbyPathInfo> PathInfos { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class JellyfinLibrary : Library
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public bool ShouldSyncItems { get; set; }
|
||||
public List<JellyfinPathInfo> PathInfos { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Domain.MediaServer;
|
||||
|
||||
public abstract record MediaServerConnectionParameters;
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public abstract class MediaServerItemEtag
|
||||
{
|
||||
public abstract string MediaServerItemId { get; }
|
||||
public abstract string Etag { get; set; }
|
||||
public abstract MediaItemState State { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain.MediaServer;
|
||||
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
|
||||
public record EmbyConnectionParameters(string Address, string ApiKey) : MediaServerConnectionParameters;
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
public class EmbyItemEtag
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
|
||||
public class EmbyItemEtag : MediaServerItemEtag
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public override string MediaServerItemId => ItemId;
|
||||
public override string Etag { get; set; }
|
||||
public override MediaItemState State { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,53 +1,49 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
|
||||
public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
|
||||
public class EmbyMovieLibraryScanner :
|
||||
MediaServerMovieLibraryScanner<EmbyConnectionParameters, EmbyLibrary, EmbyMovie, EmbyItemEtag>,
|
||||
IEmbyMovieLibraryScanner
|
||||
{
|
||||
private readonly IEmbyApiClient _embyApiClient;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger<EmbyMovieLibraryScanner> _logger;
|
||||
private readonly IEmbyMovieRepository _embyMovieRepository;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IEmbyPathReplacementService _pathReplacementService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public EmbyMovieLibraryScanner(
|
||||
IEmbyApiClient embyApiClient,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
IMovieRepository movieRepository,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IEmbyMovieRepository embyMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
IEmbyPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
ILogger<EmbyMovieLibraryScanner> logger)
|
||||
: base(
|
||||
localStatisticsProvider,
|
||||
localSubtitlesProvider,
|
||||
localFileSystem,
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
logger)
|
||||
{
|
||||
_embyApiClient = embyApiClient;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_movieRepository = movieRepository;
|
||||
_searchRepository = searchRepository;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_logger = logger;
|
||||
_embyMovieRepository = embyMovieRepository;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
@@ -55,197 +51,52 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library);
|
||||
List<EmbyPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
string GetLocalPath(EmbyMovie movie)
|
||||
{
|
||||
return _pathReplacementService.GetReplacementEmbyPath(
|
||||
pathReplacements,
|
||||
movie.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetEmbyPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<EmbyMovie>> maybeMovies = await _embyApiClient.GetMovieLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.ItemId);
|
||||
|
||||
await maybeMovies.Match(
|
||||
async movies =>
|
||||
{
|
||||
var validMovies = new List<EmbyMovie>();
|
||||
foreach (EmbyMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementEmbyPath(
|
||||
pathReplacements,
|
||||
movie.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Skipping emby movie that does not exist at {Path}", localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
validMovies.Add(movie);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (EmbyMovie incoming in validMovies)
|
||||
{
|
||||
EmbyMovie incomingMovie = incoming;
|
||||
|
||||
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
Option<EmbyItemEtag> maybeExisting =
|
||||
existingMovies.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
|
||||
var updateStatistics = false;
|
||||
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
Option<EmbyMovie> maybeUpdated = await _movieRepository.UpdateEmby(incoming);
|
||||
foreach (EmbyMovie updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
|
||||
incomingMovie = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
if (await _movieRepository.AddEmby(incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error adding movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
});
|
||||
|
||||
if (updateStatistics)
|
||||
{
|
||||
string 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,
|
||||
incomingMovie,
|
||||
localPath);
|
||||
|
||||
if (refreshResult.Map(t => t).IfLeft(false))
|
||||
{
|
||||
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
|
||||
}
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
}
|
||||
|
||||
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList();
|
||||
var movieIds = existingMovies
|
||||
.Filter(i => !incomingMovieIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> ids = await _movieRepository.RemoveMissingEmbyMovies(library, movieIds);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
_searchIndex.Commit();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing emby library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
return await ScanLibrary(
|
||||
_embyMovieRepository,
|
||||
new EmbyConnectionParameters(address, apiKey),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, bool>> UpdateSubtitles(EmbyMovie movie, string localPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
protected override string MediaServerItemId(EmbyMovie movie) => movie.ItemId;
|
||||
protected override string MediaServerEtag(EmbyMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetMovieLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library);
|
||||
|
||||
protected override Task<Option<MovieMetadata>> GetFullMetadata(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
MediaItemScanResult<EmbyMovie> result,
|
||||
EmbyMovie incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult<Option<MovieMetadata>>(None);
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<EmbyMovie> result,
|
||||
MovieMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyMovie>>>(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Emby;
|
||||
|
||||
public class EmbyPathInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string NetworkPath { get; set; }
|
||||
}
|
||||
@@ -34,7 +34,36 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
|
||||
public string GetReplacementEmbyPath(
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
string path,
|
||||
bool log = true)
|
||||
bool log = true) =>
|
||||
GetReplacementEmbyPath(pathReplacements, path, _runtimeInfo.IsOSPlatform(OSPlatform.Windows), log);
|
||||
|
||||
public string ReplaceNetworkPath(
|
||||
EmbyMediaSource embyMediaSource,
|
||||
string path,
|
||||
string networkPath,
|
||||
string replacement)
|
||||
{
|
||||
var replacements = new List<EmbyPathReplacement>
|
||||
{
|
||||
new() { EmbyPath = networkPath, LocalPath = replacement, EmbyMediaSource = embyMediaSource }
|
||||
};
|
||||
|
||||
// we want to target the emby platform with the network path replacement
|
||||
bool isTargetPlatformWindows = embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
return GetReplacementEmbyPath(replacements, path, isTargetPlatformWindows, false);
|
||||
}
|
||||
|
||||
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
|
||||
{
|
||||
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
|
||||
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
|
||||
private string GetReplacementEmbyPath(
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
string path,
|
||||
bool isTargetPlatformWindows,
|
||||
bool log)
|
||||
{
|
||||
Option<EmbyPathReplacement> maybeReplacement = pathReplacements
|
||||
.SingleOrDefault(
|
||||
@@ -46,21 +75,18 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
|
||||
}
|
||||
|
||||
string separatorChar = IsWindows(r.EmbyMediaSource, path) ? @"\" : @"/";
|
||||
string prefix = r.EmbyPath.EndsWith(separatorChar)
|
||||
? r.EmbyPath
|
||||
: r.EmbyPath + separatorChar;
|
||||
string prefix = r.EmbyPath.EndsWith(separatorChar) ? r.EmbyPath : r.EmbyPath + separatorChar;
|
||||
return path.StartsWith(prefix);
|
||||
});
|
||||
|
||||
foreach (EmbyPathReplacement replacement in maybeReplacement)
|
||||
{
|
||||
string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath);
|
||||
if (IsWindows(replacement.EmbyMediaSource, path) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
if (IsWindows(replacement.EmbyMediaSource, path) && !isTargetPlatformWindows)
|
||||
{
|
||||
finalPath = finalPath.Replace(@"\", @"/");
|
||||
}
|
||||
else if (!IsWindows(replacement.EmbyMediaSource, path) &&
|
||||
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
else if (!IsWindows(replacement.EmbyMediaSource, path) && isTargetPlatformWindows)
|
||||
{
|
||||
finalPath = finalPath.Replace(@"/", @"\");
|
||||
}
|
||||
@@ -79,10 +105,4 @@ public class EmbyPathReplacementService : IEmbyPathReplacementService
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsWindows(EmbyMediaSource embyMediaSource, string path)
|
||||
{
|
||||
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
|
||||
return isUnc || embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.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(
|
||||
@@ -55,385 +51,102 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
List<EmbyPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetEmbyPathReplacements(library.MediaSourceId);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
string GetLocalPath(EmbyEpisode episode)
|
||||
{
|
||||
return _pathReplacementService.GetReplacementEmbyPath(
|
||||
pathReplacements,
|
||||
episode.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetEmbyPathReplacements(library.MediaSourceId);
|
||||
return await ScanLibrary(
|
||||
_televisionRepository,
|
||||
new EmbyConnectionParameters(address, apiKey),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
protected override Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library) =>
|
||||
_embyApiClient.GetShowLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.ItemId);
|
||||
|
||||
foreach (BaseError error in maybeShows.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing emby library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
}
|
||||
protected override string MediaServerItemId(EmbyShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(EmbySeason season) => season.ItemId;
|
||||
protected override string MediaServerItemId(EmbyEpisode episode) => episode.ItemId;
|
||||
|
||||
foreach (List<EmbyShow> shows in maybeShows.RightToSeq())
|
||||
{
|
||||
await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows);
|
||||
protected override string MediaServerEtag(EmbyShow show) => show.Etag;
|
||||
protected override string MediaServerEtag(EmbySeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(EmbyEpisode episode) => episode.Etag;
|
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
.Filter(i => !incomingShowIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
|
||||
await _searchIndex.RemoveItems(missingShowIds);
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(library);
|
||||
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
|
||||
await _searchIndex.RemoveItems(emptyShowIds);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task ProcessShows(
|
||||
string address,
|
||||
string apiKey,
|
||||
protected override Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<EmbyPathReplacement> pathReplacements,
|
||||
List<EmbyItemEtag> existingShows,
|
||||
List<EmbyShow> shows)
|
||||
{
|
||||
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
|
||||
foreach (EmbyShow incoming in sortedShows)
|
||||
{
|
||||
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyShow show) =>
|
||||
_embyApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
show.ItemId);
|
||||
|
||||
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)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("UPDATE: Etag has changed for show {Show}", incoming.ShowMetadata.Head().Title);
|
||||
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<EmbyShow> updated = await _televisionRepository.Update(incoming);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
{
|
||||
await ProcessSeasons(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingSeasons,
|
||||
seasons);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task 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)
|
||||
{
|
||||
foreach (EmbySeason incoming in seasons)
|
||||
{
|
||||
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbySeason season) =>
|
||||
_embyApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
season.ItemId);
|
||||
|
||||
_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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season}",
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
if (await _televisionRepository.AddSeason(show, incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
List<EmbyItemEtag> existingEpisodes =
|
||||
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
|
||||
|
||||
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
|
||||
await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);
|
||||
|
||||
await maybeEpisodes.Match(
|
||||
async episodes =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await ProcessEpisodes(
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingEpisodes,
|
||||
validEpisodes);
|
||||
|
||||
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();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing emby library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task 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)
|
||||
{
|
||||
foreach (EmbyEpisode incoming in episodes)
|
||||
{
|
||||
EmbyEpisode incomingEpisode = incoming;
|
||||
var updateStatistics = false;
|
||||
MediaItemScanResult<EmbyShow> result,
|
||||
EmbyShow incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<ShowMetadata>.None);
|
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
protected override Task<Option<SeasonMetadata>> GetFullMetadata(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
MediaItemScanResult<EmbySeason> result,
|
||||
EmbySeason incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<SeasonMetadata>.None);
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
EmbyConnectionParameters connectionParameters,
|
||||
EmbyLibrary library,
|
||||
MediaItemScanResult<EmbyEpisode> result,
|
||||
EmbyEpisode incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<EpisodeMetadata>.None);
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<EmbyShow>>> UpdateMetadata(
|
||||
MediaItemScanResult<EmbyShow> result,
|
||||
ShowMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbyShow>>>(result);
|
||||
|
||||
Option<EmbyEpisode> maybeUpdated = await _televisionRepository.Update(incoming);
|
||||
foreach (EmbyEpisode updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { updated });
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<EmbySeason>>> UpdateMetadata(
|
||||
MediaItemScanResult<EmbySeason> result,
|
||||
SeasonMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<EmbySeason>>>(result);
|
||||
|
||||
incomingEpisode = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
if (await _televisionRepository.AddEpisode(season, incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error adding episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Bugsnag" Version="3.0.1" />
|
||||
<PackageReference Include="Destructurama.Attributed" Version="3.0.0" />
|
||||
<PackageReference Include="Flurl" Version="3.0.4" />
|
||||
<PackageReference Include="Flurl" Version="3.0.5" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="MediatR" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" />
|
||||
@@ -21,7 +21,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
|
||||
<PackageReference Include="Serilog" Version="2.10.0" />
|
||||
<PackageReference Include="Serilog" Version="2.11.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
public interface IHealthCheck
|
||||
{
|
||||
string Title { get; }
|
||||
Task<HealthCheckResult> Check(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public interface IEmbyApiClient
|
||||
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
string libraryId);
|
||||
EmbyLibrary library);
|
||||
|
||||
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
@@ -26,6 +26,7 @@ public interface IEmbyApiClient
|
||||
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string seasonId);
|
||||
|
||||
Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface IEmbyMovieLibraryScanner
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface IEmbyPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementEmbyPath(int libraryPathId, string path, bool log = true);
|
||||
string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true);
|
||||
string ReplaceNetworkPath(EmbyMediaSource embyMediaSource, string path, string networkPath, string replacement);
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface IEmbyTelevisionLibraryScanner
|
||||
string apiKey,
|
||||
EmbyLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ public interface IJellyfinApiClient
|
||||
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId);
|
||||
JellyfinLibrary library);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
@@ -30,7 +29,7 @@ public interface IJellyfinApiClient
|
||||
Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
JellyfinLibrary library,
|
||||
string seasonId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface IJellyfinMovieLibraryScanner
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ public interface IJellyfinPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path, bool log = true);
|
||||
string GetReplacementJellyfinPath(List<JellyfinPathReplacement> pathReplacements, string path, bool log = true);
|
||||
string ReplaceNetworkPath(JellyfinMediaSource mediaSource, string path, string networkPath, string replacement);
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface IJellyfinTelevisionLibraryScanner
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath);
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ public interface IOtherVideoFolderScanner
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax);
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IEmbyMovieRepository : IMediaServerMovieRepository<EmbyLibrary, EmbyMovie, EmbyItemEtag>
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface
|
||||
IJellyfinMovieRepository : IMediaServerMovieRepository<JellyfinLibrary, JellyfinMovie, JellyfinItemEtag>
|
||||
{
|
||||
}
|
||||
@@ -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,16 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IMediaServerMovieRepository<in TLibrary, TMovie, TEtag> where TLibrary : Library
|
||||
where TMovie : Movie
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
Task<List<TEtag>> GetExistingMovies(TLibrary library);
|
||||
Task<bool> FlagNormal(TLibrary library, TMovie movie);
|
||||
Task<Option<int>> FlagUnavailable(TLibrary library, TMovie movie);
|
||||
Task<List<int>> FlagFileNotFound(TLibrary library, List<string> movieItemIds);
|
||||
Task<Either<BaseError, MediaItemScanResult<TMovie>>> GetOrAdd(TLibrary library, TMovie item);
|
||||
Task<Unit> SetEtag(TMovie movie, string etag);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -26,12 +26,14 @@ public interface IMediaSourceRepository
|
||||
Task<List<int>> UpdateLibraries(
|
||||
int jellyfinMediaSourceId,
|
||||
List<JellyfinLibrary> toAdd,
|
||||
List<JellyfinLibrary> toDelete);
|
||||
List<JellyfinLibrary> toDelete,
|
||||
List<JellyfinLibrary> toUpdate);
|
||||
|
||||
Task<List<int>> UpdateLibraries(
|
||||
int embyMediaSourceId,
|
||||
List<EmbyLibrary> toAdd,
|
||||
List<EmbyLibrary> toDelete);
|
||||
List<EmbyLibrary> toDelete,
|
||||
List<EmbyLibrary> toUpdate);
|
||||
|
||||
Task<Unit> UpdatePathReplacements(
|
||||
int plexMediaSourceId,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -11,7 +8,6 @@ public interface IMovieRepository
|
||||
Task<bool> AllMoviesExist(List<int> movieIds);
|
||||
Task<Option<Movie>> GetMovie(int movieId);
|
||||
Task<Either<BaseError, MediaItemScanResult<Movie>>> GetOrAdd(LibraryPath libraryPath, string path);
|
||||
Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item);
|
||||
Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids);
|
||||
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
|
||||
Task<List<int>> DeleteByPath(LibraryPath libraryPath, string path);
|
||||
@@ -19,18 +15,8 @@ public interface IMovieRepository
|
||||
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
|
||||
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
|
||||
Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library);
|
||||
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
|
||||
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
|
||||
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
|
||||
Task<bool> AddJellyfin(JellyfinMovie movie);
|
||||
Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie);
|
||||
Task<List<EmbyItemEtag>> GetExistingEmbyMovies(EmbyLibrary library);
|
||||
Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds);
|
||||
Task<bool> AddEmby(EmbyMovie movie);
|
||||
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
|
||||
Task<bool> AddDirector(MovieMetadata metadata, Director director);
|
||||
Task<bool> AddWriter(MovieMetadata metadata, Writer writer);
|
||||
Task<Unit> UpdatePath(int mediaFileId, string path);
|
||||
Task<Unit> SetPlexEtag(PlexMovie movie, string etag);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Plex;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface IPlexMovieRepository
|
||||
public interface IPlexMovieRepository : IMediaServerMovieRepository<PlexLibrary, PlexMovie, PlexItemEtag>
|
||||
{
|
||||
Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie);
|
||||
Task<Option<int>> FlagUnavailable(PlexLibrary library, PlexMovie movie);
|
||||
Task<List<int>> FlagFileNotFound(PlexLibrary library, List<string> plexMovieKeys);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ namespace ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
public interface ISearchRepository
|
||||
{
|
||||
Task<List<int>> GetItemIdsToIndex();
|
||||
Task<Option<MediaItem>> GetItemToIndex(int id);
|
||||
Task<List<string>> GetLanguagesForShow(Show show);
|
||||
Task<List<string>> GetLanguagesForSeason(Season season);
|
||||
Task<List<string>> GetLanguagesForArtist(Artist artist);
|
||||
Task<List<string>> GetAllLanguageCodes(List<string> mediaCodes);
|
||||
IAsyncEnumerable<MediaItem> GetAllMediaItems();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,10 +8,9 @@ namespace ErsatzTV.Core.Interfaces.Search;
|
||||
public interface ISearchIndex : IDisposable
|
||||
{
|
||||
public int Version { get; }
|
||||
Task<bool> Initialize(ILocalFileSystem localFileSystem);
|
||||
Task<Unit> Rebuild(ISearchRepository searchRepository, List<int> itemIds);
|
||||
Task<bool> Initialize(ILocalFileSystem localFileSystem, IConfigElementRepository configElementRepository);
|
||||
Task<Unit> Rebuild(ISearchRepository searchRepository);
|
||||
Task<Unit> RebuildItems(ISearchRepository searchRepository, List<int> itemIds);
|
||||
Task<Unit> AddItems(ISearchRepository searchRepository, List<MediaItem> items);
|
||||
Task<Unit> UpdateItems(ISearchRepository searchRepository, List<MediaItem> items);
|
||||
Task<Unit> RemoveItems(List<int> ids);
|
||||
Task<SearchResult> Search(string query, int skip, int limit, string searchField = "");
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core.Domain.MediaServer;
|
||||
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public record JellyfinConnectionParameters
|
||||
(string Address, string ApiKey, int MediaSourceId) : MediaServerConnectionParameters;
|
||||
@@ -1,7 +1,11 @@
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
public class JellyfinItemEtag
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public class JellyfinItemEtag : MediaServerItemEtag
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public override string MediaServerItemId => ItemId;
|
||||
public override string Etag { get; set; }
|
||||
public override MediaItemState State { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
public class JellyfinMovieLibraryScanner :
|
||||
MediaServerMovieLibraryScanner<JellyfinConnectionParameters, JellyfinLibrary, JellyfinMovie, JellyfinItemEtag>,
|
||||
IJellyfinMovieLibraryScanner
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger<JellyfinMovieLibraryScanner> _logger;
|
||||
private readonly IJellyfinMovieRepository _jellyfinMovieRepository;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IJellyfinPathReplacementService _pathReplacementService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public JellyfinMovieLibraryScanner(
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
IMovieRepository movieRepository,
|
||||
IJellyfinMovieRepository jellyfinMovieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
@@ -36,18 +31,19 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
ILogger<JellyfinMovieLibraryScanner> logger)
|
||||
: base(
|
||||
localStatisticsProvider,
|
||||
localSubtitlesProvider,
|
||||
localFileSystem,
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
logger)
|
||||
{
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_movieRepository = movieRepository;
|
||||
_searchRepository = searchRepository;
|
||||
_jellyfinMovieRepository = jellyfinMovieRepository;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
@@ -55,198 +51,53 @@ public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinItemEtag> existingMovies = await _movieRepository.GetExistingJellyfinMovies(library);
|
||||
List<JellyfinPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
string GetLocalPath(JellyfinMovie movie)
|
||||
{
|
||||
return _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
movie.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<JellyfinMovie>> maybeMovies = await _jellyfinApiClient.GetMovieLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
|
||||
await maybeMovies.Match(
|
||||
async movies =>
|
||||
{
|
||||
var validMovies = new List<JellyfinMovie>();
|
||||
foreach (JellyfinMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
movie.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning("Skipping jellyfin movie that does not exist at {Path}", localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
validMovies.Add(movie);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (JellyfinMovie incoming in validMovies)
|
||||
{
|
||||
JellyfinMovie incomingMovie = incoming;
|
||||
|
||||
decimal percentCompletion = (decimal)validMovies.IndexOf(incoming) / validMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting =
|
||||
existingMovies.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
|
||||
var updateStatistics = false;
|
||||
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
Option<JellyfinMovie> maybeUpdated = await _movieRepository.UpdateJellyfin(incoming);
|
||||
foreach (JellyfinMovie updated in maybeUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
|
||||
incomingMovie = updated;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
if (await _movieRepository.AddJellyfin(incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error adding movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
});
|
||||
|
||||
if (updateStatistics)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
incomingMovie,
|
||||
localPath);
|
||||
|
||||
if (refreshResult.Map(t => t).IfLeft(false))
|
||||
{
|
||||
refreshResult = await UpdateSubtitles(incomingMovie, localPath);
|
||||
}
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incomingMovie.Id);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
}
|
||||
|
||||
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList();
|
||||
var movieIds = existingMovies
|
||||
.Filter(i => !incomingMovieIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> ids = await _movieRepository.RemoveMissingJellyfinMovies(library, movieIds);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
_searchIndex.Commit();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
return await ScanLibrary(
|
||||
_jellyfinMovieRepository,
|
||||
new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, bool>> UpdateSubtitles(JellyfinMovie movie, string localPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _localSubtitlesProvider.UpdateSubtitles(movie, localPath, false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
protected override string MediaServerItemId(JellyfinMovie movie) => movie.ItemId;
|
||||
|
||||
protected override string MediaServerEtag(JellyfinMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetMovieLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library);
|
||||
|
||||
protected override Task<Option<MovieMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinMovie> result,
|
||||
JellyfinMovie incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult<Option<MovieMetadata>>(None);
|
||||
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinMovie> result,
|
||||
MovieMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinMovie>>>(result);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Jellyfin;
|
||||
|
||||
public class JellyfinPathInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Path { get; set; }
|
||||
public string NetworkPath { get; set; }
|
||||
}
|
||||
@@ -28,13 +28,42 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
|
||||
List<JellyfinPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId);
|
||||
|
||||
return GetReplacementJellyfinPath(replacements, path);
|
||||
return GetReplacementJellyfinPath(replacements, path, log);
|
||||
}
|
||||
|
||||
public string GetReplacementJellyfinPath(
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
string path,
|
||||
bool log = true)
|
||||
bool log = true) =>
|
||||
GetReplacementJellyfinPath(pathReplacements, path, _runtimeInfo.IsOSPlatform(OSPlatform.Windows), log);
|
||||
|
||||
public string ReplaceNetworkPath(
|
||||
JellyfinMediaSource jellyfinMediaSource,
|
||||
string path,
|
||||
string networkPath,
|
||||
string replacement)
|
||||
{
|
||||
var replacements = new List<JellyfinPathReplacement>
|
||||
{
|
||||
new() { JellyfinPath = networkPath, LocalPath = replacement, JellyfinMediaSource = jellyfinMediaSource }
|
||||
};
|
||||
|
||||
// we want to target the jellyfin platform with the network path replacement
|
||||
bool isTargetPlatformWindows = jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
return GetReplacementJellyfinPath(replacements, path, isTargetPlatformWindows, false);
|
||||
}
|
||||
|
||||
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource, string path)
|
||||
{
|
||||
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
|
||||
return isUnc || jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
|
||||
private string GetReplacementJellyfinPath(
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
string path,
|
||||
bool isTargetPlatformWindows,
|
||||
bool log)
|
||||
{
|
||||
Option<JellyfinPathReplacement> maybeReplacement = pathReplacements
|
||||
.SingleOrDefault(
|
||||
@@ -55,13 +84,11 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
|
||||
foreach (JellyfinPathReplacement replacement in maybeReplacement)
|
||||
{
|
||||
string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath);
|
||||
if (IsWindows(replacement.JellyfinMediaSource, path) &&
|
||||
!_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
if (IsWindows(replacement.JellyfinMediaSource, path) && !isTargetPlatformWindows)
|
||||
{
|
||||
finalPath = finalPath.Replace(@"\", @"/");
|
||||
}
|
||||
else if (!IsWindows(replacement.JellyfinMediaSource, path) &&
|
||||
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
else if (!IsWindows(replacement.JellyfinMediaSource, path) && isTargetPlatformWindows)
|
||||
{
|
||||
finalPath = finalPath.Replace(@"/", @"\");
|
||||
}
|
||||
@@ -80,10 +107,4 @@ public class JellyfinPathReplacementService : IJellyfinPathReplacementService
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource, string path)
|
||||
{
|
||||
bool isUnc = Uri.TryCreate(path, UriKind.Absolute, out Uri uri) && uri.IsUnc;
|
||||
return isUnc || jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.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(
|
||||
@@ -55,407 +52,104 @@ public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanne
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
string ffprobePath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
List<JellyfinPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
string GetLocalPath(JellyfinEpisode episode)
|
||||
{
|
||||
return _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
episode.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
return await ScanLibrary(
|
||||
_televisionRepository,
|
||||
new JellyfinConnectionParameters(address, apiKey, library.MediaSourceId),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
false,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
Either<BaseError, List<JellyfinShow>> maybeShows = await _jellyfinApiClient.GetShowLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
protected override Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library) =>
|
||||
_jellyfinApiClient.GetShowLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
|
||||
await maybeShows.Match(
|
||||
async shows =>
|
||||
{
|
||||
await ProcessShows(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
existingShows,
|
||||
shows);
|
||||
protected override string MediaServerItemId(JellyfinShow show) => show.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinSeason season) => season.ItemId;
|
||||
protected override string MediaServerItemId(JellyfinEpisode episode) => episode.ItemId;
|
||||
|
||||
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);
|
||||
protected override string MediaServerEtag(JellyfinShow show) => show.Etag;
|
||||
protected override string MediaServerEtag(JellyfinSeason season) => season.Etag;
|
||||
protected override string MediaServerEtag(JellyfinEpisode episode) => episode.Etag;
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(library);
|
||||
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
|
||||
await _searchIndex.RemoveItems(emptyShowIds);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
_searchIndex.Commit();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task ProcessShows(
|
||||
string address,
|
||||
string apiKey,
|
||||
protected override Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
|
||||
JellyfinLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
List<JellyfinItemEtag> existingShows,
|
||||
List<JellyfinShow> shows)
|
||||
{
|
||||
var sortedShows = shows.OrderBy(s => s.ShowMetadata.Head().Title).ToList();
|
||||
foreach (JellyfinShow incoming in sortedShows)
|
||||
{
|
||||
decimal percentCompletion = (decimal)sortedShows.IndexOf(incoming) / shows.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinShow show) =>
|
||||
_jellyfinApiClient.GetSeasonLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library.MediaSourceId,
|
||||
show.ItemId);
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show}",
|
||||
incoming.ShowMetadata.Head().Title);
|
||||
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<JellyfinShow> updated = await _televisionRepository.Update(incoming);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
List<JellyfinItemEtag> existingSeasons =
|
||||
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
|
||||
|
||||
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
|
||||
await _jellyfinApiClient.GetSeasonLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
incoming.ItemId);
|
||||
|
||||
await maybeSeasons.Match(
|
||||
async seasons =>
|
||||
{
|
||||
await ProcessSeasons(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingSeasons,
|
||||
seasons);
|
||||
|
||||
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);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task 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)
|
||||
{
|
||||
foreach (JellyfinSeason incoming in seasons)
|
||||
{
|
||||
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinSeason season) =>
|
||||
_jellyfinApiClient.GetEpisodeLibraryItems(
|
||||
connectionParameters.Address,
|
||||
connectionParameters.ApiKey,
|
||||
library,
|
||||
season.ItemId);
|
||||
|
||||
_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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season}",
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
if (await _televisionRepository.AddSeason(show, incoming))
|
||||
{
|
||||
incoming.Show = show;
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
List<JellyfinItemEtag> existingEpisodes =
|
||||
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
|
||||
|
||||
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
|
||||
await _jellyfinApiClient.GetEpisodeLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
incoming.ItemId);
|
||||
|
||||
await maybeEpisodes.Match(
|
||||
async episodes =>
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await ProcessEpisodes(
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingEpisodes,
|
||||
validEpisodes);
|
||||
|
||||
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();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task 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)
|
||||
{
|
||||
foreach (JellyfinEpisode incoming in episodes)
|
||||
{
|
||||
JellyfinEpisode incomingEpisode = incoming;
|
||||
MediaItemScanResult<JellyfinShow> result,
|
||||
JellyfinShow incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<ShowMetadata>.None);
|
||||
|
||||
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);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
protected override Task<Option<EpisodeMetadata>> GetFullMetadata(
|
||||
JellyfinConnectionParameters connectionParameters,
|
||||
JellyfinLibrary library,
|
||||
MediaItemScanResult<JellyfinEpisode> result,
|
||||
JellyfinEpisode incoming,
|
||||
bool deepScan) =>
|
||||
Task.FromResult(Option<EpisodeMetadata>.None);
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed 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);
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
protected override Task<Either<BaseError, MediaItemScanResult<JellyfinSeason>>> UpdateMetadata(
|
||||
MediaItemScanResult<JellyfinSeason> result,
|
||||
SeasonMetadata fullMetadata) =>
|
||||
Task.FromResult<Either<BaseError, MediaItemScanResult<JellyfinSeason>>>(result);
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
incoming.EpisodeMetadata.HeadOrNone().Map(em => em.EpisodeNumber));
|
||||
|
||||
if (await _televisionRepository.AddEpisode(season, incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error adding episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
error => _logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ public class LocalSubtitlesProvider : ILocalSubtitlesProvider
|
||||
var subtitles = subtitleStreams.Map(Subtitle.FromMediaStream).ToList();
|
||||
string mediaItemPath = await localPath.IfNoneAsync(() => mediaItem.GetHeadVersion().MediaFiles.Head().Path);
|
||||
subtitles.AddRange(LocateExternalSubtitles(_languageCodes, mediaItemPath, saveFullPath));
|
||||
await _metadataRepository.UpdateSubtitles(metadata, subtitles);
|
||||
return await _metadataRepository.UpdateSubtitles(metadata, subtitles);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -7,6 +7,7 @@ public class MediaItemScanResult<T> where T : MediaItem
|
||||
public MediaItemScanResult(T item) => Item = item;
|
||||
|
||||
public T Item { get; set; }
|
||||
public string LocalPath { get; set; }
|
||||
|
||||
public bool IsAdded { get; set; }
|
||||
public bool IsUpdated { get; set; }
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.MediaServer;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Metadata;
|
||||
|
||||
public abstract class MediaServerMovieLibraryScanner<TConnectionParameters, TLibrary, TMovie, TEtag>
|
||||
where TConnectionParameters : MediaServerConnectionParameters
|
||||
where TLibrary : Library
|
||||
where TMovie : Movie
|
||||
where TEtag : MediaServerItemEtag
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger _logger;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
protected MediaServerMovieLibraryScanner(
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IMediator mediator,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
ILogger logger)
|
||||
{
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_localFileSystem = localFileSystem;
|
||||
_mediator = mediator;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
Func<TMovie, string> getLocalPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<TMovie>> entries = await GetMovieLibraryItems(connectionParameters, library);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
movieRepository,
|
||||
connectionParameters,
|
||||
library,
|
||||
getLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
Func<TMovie, string> getLocalPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
List<TMovie> movieEntries,
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<TEtag> existingMovies = await movieRepository.GetExistingMovies(library);
|
||||
|
||||
var sortedMovies = movieEntries.OrderBy(m => m.MovieMetadata.Head().SortTitle).ToList();
|
||||
foreach (TMovie incoming in sortedMovies)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)sortedMovies.IndexOf(incoming) / sortedMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
string localPath = getLocalPath(incoming);
|
||||
|
||||
if (await ShouldScanItem(movieRepository, library, existingMovies, incoming, localPath, deepScan) == false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Either<BaseError, MediaItemScanResult<TMovie>> maybeMovie = await movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.MapT(
|
||||
result =>
|
||||
{
|
||||
result.LocalPath = localPath;
|
||||
return result;
|
||||
})
|
||||
.BindT(existing => UpdateMetadata(connectionParameters, library, existing, incoming, deepScan))
|
||||
.BindT(existing => UpdateStatistics(existing, incoming, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateSubtitles);
|
||||
|
||||
if (maybeMovie.IsLeft)
|
||||
{
|
||||
foreach (BaseError error in maybeMovie.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing movie {Title}: {Error}",
|
||||
incoming.MovieMetadata.Head().Title,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<TMovie> result in maybeMovie.RightToSeq())
|
||||
{
|
||||
await movieRepository.SetEtag(result.Item, MediaServerEtag(incoming));
|
||||
|
||||
if (_localFileSystem.FileExists(result.LocalPath))
|
||||
{
|
||||
if (await movieRepository.FlagNormal(library, result.Item))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Option<int> flagResult = await movieRepository.FlagUnavailable(library, result.Item);
|
||||
if (flagResult.IsSome)
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
protected abstract string MediaServerItemId(TMovie movie);
|
||||
protected abstract string MediaServerEtag(TMovie movie);
|
||||
|
||||
protected abstract Task<Either<BaseError, List<TMovie>>> GetMovieLibraryItems(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library);
|
||||
|
||||
protected abstract Task<Option<MovieMetadata>> GetFullMetadata(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
MediaItemScanResult<TMovie> result,
|
||||
TMovie incoming,
|
||||
bool deepScan);
|
||||
|
||||
protected abstract Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<TMovie> result,
|
||||
MovieMetadata fullMetadata);
|
||||
|
||||
private async Task<bool> ShouldScanItem(
|
||||
IMediaServerMovieRepository<TLibrary, TMovie, TEtag> movieRepository,
|
||||
TLibrary library,
|
||||
List<TEtag> existingMovies,
|
||||
TMovie incoming,
|
||||
string localPath,
|
||||
bool deepScan)
|
||||
{
|
||||
// deep scan will always pull every movie
|
||||
if (deepScan)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
Option<TEtag> maybeExisting =
|
||||
existingMovies.Find(m => m.MediaServerItemId == MediaServerItemId(incoming));
|
||||
string existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
|
||||
|
||||
if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
|
||||
{
|
||||
// skip scanning unavailable items that are unchanged and still don't exist locally
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (existingEtag == MediaServerEtag(incoming))
|
||||
{
|
||||
// item is unchanged, but file does not exist
|
||||
// don't scan, but mark as unavailable
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
foreach (int id in await movieRepository.FlagUnavailable(library, incoming))
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maybeExisting.IsNone)
|
||||
{
|
||||
_logger.LogDebug("INSERT: new movie {Movie}", incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("UPDATE: Etag has changed for movie {Movie}", incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateMetadata(
|
||||
TConnectionParameters connectionParameters,
|
||||
TLibrary library,
|
||||
MediaItemScanResult<TMovie> result,
|
||||
TMovie incoming,
|
||||
bool deepScan)
|
||||
{
|
||||
foreach (MovieMetadata fullMetadata in await GetFullMetadata(
|
||||
connectionParameters,
|
||||
library,
|
||||
result,
|
||||
incoming,
|
||||
deepScan))
|
||||
{
|
||||
// TODO: move some of this code into this scanner
|
||||
// will have to merge JF, Emby, Plex logic
|
||||
return await UpdateMetadata(result, fullMetadata);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateStatistics(
|
||||
MediaItemScanResult<TMovie> result,
|
||||
TMovie incoming,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
TMovie existing = result.Item;
|
||||
|
||||
if (result.IsAdded || MediaServerEtag(existing) != MediaServerEtag(incoming) ||
|
||||
existing.MediaVersions.Head().Streams.Count == 0)
|
||||
{
|
||||
if (_localFileSystem.FileExists(result.LocalPath))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", result.LocalPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
existing,
|
||||
result.LocalPath);
|
||||
|
||||
foreach (BaseError error in refreshResult.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
result.LocalPath,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (bool _ in refreshResult.RightToSeq())
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<TMovie>>> UpdateSubtitles(
|
||||
MediaItemScanResult<TMovie> existing)
|
||||
{
|
||||
try
|
||||
{
|
||||
// skip checking subtitles for files that don't exist locally
|
||||
if (!_localFileSystem.FileExists(existing.LocalPath))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (await _localSubtitlesProvider.UpdateSubtitles(existing.Item, existing.LocalPath, false))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
return BaseError.New("Failed to update local subtitles");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 existingEtag = await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting.Map(e => e.State).IfNoneAsync(MediaItemState.Normal);
|
||||
|
||||
if (existingState == MediaItemState.Unavailable && existingEtag == MediaServerEtag(incoming))
|
||||
{
|
||||
// skip scanning unavailable items that are unchanged and still don't exist locally
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (existingEtag == MediaServerEtag(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 || MediaServerEtag(existing) != MediaServerEtag(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -72,118 +73,128 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
try
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
var foldersCompleted = 0;
|
||||
|
||||
string movieFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.Filter(
|
||||
f => !ExtraFiles.Any(
|
||||
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (allFiles.Count == 0)
|
||||
var folderQueue = new Queue<string>();
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
continue;
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(movieFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == movieFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
movieFolder);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(UpdateSubtitles)
|
||||
.BindT(FlagNormal);
|
||||
|
||||
foreach (BaseError error in maybeMovie.LeftToSeq())
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value);
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<Movie> result in maybeMovie.RightToSeq())
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
|
||||
string movieFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
var filesForEtag = _localFileSystem.ListFiles(movieFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.Filter(
|
||||
f => !ExtraFiles.Any(
|
||||
e => Path.GetFileNameWithoutExtension(f).EndsWith(e, StringComparison.OrdinalIgnoreCase)))
|
||||
.ToList();
|
||||
|
||||
if (allFiles.Count == 0)
|
||||
{
|
||||
if (result.IsAdded)
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(movieFolder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
|
||||
continue;
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(movieFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == movieFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
movieFolder);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<Movie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(movie => UpdateStatistics(movie, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(movie => UpdateArtwork(movie, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(UpdateSubtitles)
|
||||
.BindT(FlagNormal);
|
||||
|
||||
foreach (BaseError error in maybeMovie.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<Movie> result in maybeMovie.RightToSeq())
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, movieFolder, etag);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in await _movieRepository.FindMoviePaths(libraryPath))
|
||||
foreach (string path in await _movieRepository.FindMoviePaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing movie at {Path}", path);
|
||||
List<int> ids = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
}
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing movie at {Path}", path);
|
||||
List<int> ids = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> ids = await _movieRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
}
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Movie>>> UpdateMetadata(
|
||||
@@ -192,7 +203,7 @@ public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
|
||||
try
|
||||
{
|
||||
Movie movie = result.Item;
|
||||
|
||||
|
||||
Option<string> maybeNfoFile = LocateNfoFile(movie);
|
||||
if (maybeNfoFile.IsNone)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -73,37 +74,55 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
.ToList();
|
||||
|
||||
foreach (string artistFolder in allArtistFolders)
|
||||
try
|
||||
{
|
||||
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
var allArtistFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
.ToList();
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
|
||||
await FindOrCreateArtist(libraryPath.Id, artistFolder)
|
||||
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
|
||||
.BindT(
|
||||
artist => UpdateArtworkForArtist(
|
||||
artist,
|
||||
artistFolder,
|
||||
ArtworkKind.Thumbnail,
|
||||
cancellationToken))
|
||||
.BindT(
|
||||
artist => UpdateArtworkForArtist(artist, artistFolder, ArtworkKind.FanArt, cancellationToken));
|
||||
|
||||
await maybeArtist.Match(
|
||||
async result =>
|
||||
foreach (string artistFolder in allArtistFolders)
|
||||
{
|
||||
// _logger.LogDebug("Scanning artist folder {Folder}", artistFolder);
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await ScanMusicVideos(
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)allArtistFolders.IndexOf(artistFolder) / allArtistFolders.Count;
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Artist>> maybeArtist =
|
||||
await FindOrCreateArtist(libraryPath.Id, artistFolder)
|
||||
.BindT(artist => UpdateMetadataForArtist(artist, artistFolder))
|
||||
.BindT(
|
||||
artist => UpdateArtworkForArtist(
|
||||
artist,
|
||||
artistFolder,
|
||||
ArtworkKind.Thumbnail,
|
||||
cancellationToken))
|
||||
.BindT(
|
||||
artist => UpdateArtworkForArtist(
|
||||
artist,
|
||||
artistFolder,
|
||||
ArtworkKind.FanArt,
|
||||
cancellationToken));
|
||||
|
||||
foreach (BaseError error in maybeArtist.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing artist in folder {Folder}: {Error}",
|
||||
artistFolder,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<Artist> result in maybeArtist.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ScanMusicVideos(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
@@ -111,55 +130,56 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
artistFolder,
|
||||
cancellationToken);
|
||||
|
||||
if (result.IsAdded)
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
return error;
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing artist in folder {Folder}: {Error}",
|
||||
artistFolder,
|
||||
error.Value);
|
||||
return Task.FromResult(Unit.Default);
|
||||
});
|
||||
}
|
||||
|
||||
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
|
||||
{
|
||||
_logger.LogInformation("Removing improperly named music video at {Path}", path);
|
||||
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(musicVideoIds);
|
||||
}
|
||||
|
||||
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing music video at {Path}", path);
|
||||
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
|
||||
}
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
|
||||
foreach (string path in await _musicVideoRepository.FindOrphanPaths(libraryPath))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
_logger.LogInformation("Removing improperly named music video at {Path}", path);
|
||||
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(musicVideoIds);
|
||||
}
|
||||
|
||||
foreach (string path in await _musicVideoRepository.FindMusicVideoPaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing music video at {Path}", path);
|
||||
List<int> musicVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, musicVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> musicVideoIds = await _musicVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(musicVideoIds);
|
||||
}
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
|
||||
await _searchIndex.RemoveItems(artistIds);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
List<int> artistIds = await _artistRepository.DeleteEmptyArtists(libraryPath);
|
||||
await _searchIndex.RemoveItems(artistIds);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Artist>>> FindOrCreateArtist(
|
||||
@@ -244,7 +264,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanMusicVideos(
|
||||
private async Task<Either<BaseError, Unit>> ScanMusicVideos(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
@@ -257,6 +277,11 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
string musicVideoFolder = folderQueue.Dequeue();
|
||||
// _logger.LogDebug("Scanning music video folder {Folder}", musicVideoFolder);
|
||||
|
||||
@@ -293,27 +318,24 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
.BindT(UpdateSubtitles)
|
||||
.BindT(FlagNormal);
|
||||
|
||||
await maybeMusicVideo.Match(
|
||||
async result =>
|
||||
{
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
foreach (BaseError error in maybeMusicVideo.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag);
|
||||
},
|
||||
error =>
|
||||
foreach (MediaItemScanResult<MusicVideo> result in maybeMusicVideo.RightToSeq())
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
_logger.LogWarning("Error processing music video at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, musicVideoFolder, etag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<MusicVideo>>> UpdateMetadata(
|
||||
@@ -322,37 +344,39 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
try
|
||||
{
|
||||
MusicVideo musicVideo = result.Item;
|
||||
await LocateNfoFile(musicVideo).Match(
|
||||
async nfoFile =>
|
||||
{
|
||||
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
Option<string> maybeNfoFile = LocateNfoFile(musicVideo);
|
||||
if (maybeNfoFile.IsNone)
|
||||
{
|
||||
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
|
||||
{
|
||||
if (!Optional(musicVideo.MusicVideoMetadata).Flatten().Any())
|
||||
{
|
||||
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
|
||||
musicVideo.MusicVideoMetadata ??= new List<MusicVideoMetadata>();
|
||||
|
||||
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
string path = musicVideo.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", path);
|
||||
if (await _localMetadataProvider.RefreshFallbackMetadata(musicVideo))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string nfoFile in maybeNfoFile)
|
||||
{
|
||||
bool shouldUpdate = Optional(musicVideo.MusicVideoMetadata).Flatten().HeadOrNone().Match(
|
||||
m => m.MetadataKind == MetadataKind.Fallback ||
|
||||
m.DateUpdated != _localFileSystem.GetLastWriteTime(nfoFile),
|
||||
true);
|
||||
|
||||
if (shouldUpdate)
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
|
||||
if (await _localMetadataProvider.RefreshSidecarMetadata(musicVideo, nfoFile))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -364,8 +388,7 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
}
|
||||
|
||||
private Option<string> LocateNfoFileForArtist(string artistFolder) =>
|
||||
Optional(Path.Combine(artistFolder, "artist.nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
Optional(Path.Combine(artistFolder, "artist.nfo")).Filter(s => _localFileSystem.FileExists(s));
|
||||
|
||||
private Option<string> LocateArtworkForArtist(string artistFolder, ArtworkKind artworkKind)
|
||||
{
|
||||
@@ -398,12 +421,13 @@ public class MusicVideoFolderScanner : LocalFolderScanner, IMusicVideoFolderScan
|
||||
try
|
||||
{
|
||||
MusicVideo musicVideo = result.Item;
|
||||
await LocateThumbnail(musicVideo).IfSomeAsync(
|
||||
async thumbnailFile =>
|
||||
{
|
||||
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
|
||||
});
|
||||
|
||||
Option<string> maybeThumbnail = LocateThumbnail(musicVideo);
|
||||
foreach (string thumbnailFile in maybeThumbnail)
|
||||
{
|
||||
MusicVideoMetadata metadata = musicVideo.MusicVideoMetadata.Head();
|
||||
await RefreshArtwork(thumbnailFile, metadata, ArtworkKind.Thumbnail, None, None, cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -67,115 +68,128 @@ public class OtherVideoFolderScanner : LocalFolderScanner, IOtherVideoFolderScan
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
decimal progressMin,
|
||||
decimal progressMax)
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
if (ShouldIncludeFolder(libraryPath.Path))
|
||||
try
|
||||
{
|
||||
folderQueue.Enqueue(libraryPath.Path);
|
||||
}
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
var foldersCompleted = 0;
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
string otherVideoFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
if (ShouldIncludeFolder(libraryPath.Path))
|
||||
{
|
||||
folderQueue.Enqueue(libraryPath.Path);
|
||||
}
|
||||
|
||||
var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == otherVideoFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
otherVideoFolder);
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateSubtitles)
|
||||
.BindT(FlagNormal);
|
||||
string otherVideoFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
await maybeVideo.Match(
|
||||
async result =>
|
||||
var filesForEtag = _localFileSystem.ListFiles(otherVideoFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(otherVideoFolder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(otherVideoFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == otherVideoFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
otherVideoFolder);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<OtherVideo>> maybeVideo = await _otherVideoRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(UpdateMetadata)
|
||||
.BindT(UpdateSubtitles)
|
||||
.BindT(FlagNormal);
|
||||
|
||||
foreach (BaseError error in maybeVideo.LeftToSeq())
|
||||
{
|
||||
if (result.IsAdded)
|
||||
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<OtherVideo> result in maybeVideo.RightToSeq())
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, otherVideoFolder, etag);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Error processing other video at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath))
|
||||
foreach (string path in await _otherVideoRepository.FindOtherVideoPaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing other video at {Path}", path);
|
||||
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(otherVideoIds);
|
||||
}
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing other video at {Path}", path);
|
||||
List<int> otherVideoIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, otherVideoIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> otherVideoIds = await _otherVideoRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(otherVideoIds);
|
||||
}
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<OtherVideo>>> UpdateMetadata(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
@@ -68,113 +69,125 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
var foldersCompleted = 0;
|
||||
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
if (ShouldIncludeFolder(libraryPath.Path))
|
||||
try
|
||||
{
|
||||
folderQueue.Enqueue(libraryPath.Path);
|
||||
}
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
var foldersCompleted = 0;
|
||||
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread));
|
||||
var folderQueue = new Queue<string>();
|
||||
|
||||
string songFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
if (ShouldIncludeFolder(libraryPath.Path))
|
||||
{
|
||||
folderQueue.Enqueue(libraryPath.Path);
|
||||
}
|
||||
|
||||
var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder)
|
||||
foreach (string folder in _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
folderQueue.Enqueue(folder);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(songFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == songFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) == etag)
|
||||
while (folderQueue.Count > 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
songFolder);
|
||||
decimal percentCompletion = (decimal)foldersCompleted / (foldersCompleted + folderQueue.Count);
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(video => UpdateMetadata(video, ffprobePath))
|
||||
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
string songFolder = folderQueue.Dequeue();
|
||||
foldersCompleted++;
|
||||
|
||||
await maybeSong.Match(
|
||||
async result =>
|
||||
var filesForEtag = _localFileSystem.ListFiles(songFolder).ToList();
|
||||
|
||||
var allFiles = filesForEtag
|
||||
.Filter(f => AudioFileExtensions.Contains(Path.GetExtension(f)))
|
||||
.Filter(f => !Path.GetFileName(f).StartsWith("._"))
|
||||
.ToList();
|
||||
|
||||
foreach (string subdirectory in _localFileSystem.ListSubdirectories(songFolder)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
folderQueue.Enqueue(subdirectory);
|
||||
}
|
||||
|
||||
string etag = FolderEtag.Calculate(songFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == songFolder)
|
||||
.HeadOrNone();
|
||||
|
||||
// skip folder if etag matches
|
||||
if (!allFiles.Any() || await knownFolder.Map(f => f.Etag ?? string.Empty).IfNoneAsync(string.Empty) ==
|
||||
etag)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for folder {Folder}",
|
||||
songFolder);
|
||||
|
||||
foreach (string file in allFiles.OrderBy(identity))
|
||||
{
|
||||
Either<BaseError, MediaItemScanResult<Song>> maybeSong = await _songRepository
|
||||
.GetOrAdd(libraryPath, file)
|
||||
.BindT(video => UpdateStatistics(video, ffmpegPath, ffprobePath))
|
||||
.BindT(video => UpdateMetadata(video, ffprobePath))
|
||||
.BindT(video => UpdateThumbnail(video, ffmpegPath, cancellationToken))
|
||||
.BindT(FlagNormal);
|
||||
|
||||
foreach (BaseError error in maybeSong.LeftToSeq())
|
||||
{
|
||||
if (result.IsAdded)
|
||||
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<Song> result in maybeSong.RightToSeq())
|
||||
{
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, songFolder, etag);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning("Error processing song at {Path}: {Error}", file, error.Value);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in await _songRepository.FindSongPaths(libraryPath))
|
||||
foreach (string path in await _songRepository.FindSongPaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing song at {Path}", path);
|
||||
List<int> songIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, songIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(songIds);
|
||||
}
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing song at {Path}", path);
|
||||
List<int> songIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, songIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
List<int> songIds = await _songRepository.DeleteByPath(libraryPath, path);
|
||||
await _searchIndex.RemoveItems(songIds);
|
||||
}
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Song>>> UpdateMetadata(
|
||||
@@ -231,20 +244,24 @@ public class SongFolderScanner : LocalFolderScanner, ISongFolderScanner
|
||||
}
|
||||
|
||||
Song song = result.Item;
|
||||
Option<string> maybeThumbnail = LocateThumbnail(song);
|
||||
if (maybeThumbnail.IsNone)
|
||||
{
|
||||
await ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken);
|
||||
}
|
||||
|
||||
await LocateThumbnail(song).Match(
|
||||
async thumbnailFile =>
|
||||
{
|
||||
SongMetadata metadata = song.SongMetadata.Head();
|
||||
await RefreshArtwork(
|
||||
thumbnailFile,
|
||||
metadata,
|
||||
ArtworkKind.Thumbnail,
|
||||
ffmpegPath,
|
||||
None,
|
||||
cancellationToken);
|
||||
},
|
||||
() => ExtractEmbeddedArtwork(song, ffmpegPath, cancellationToken));
|
||||
|
||||
foreach (string thumbnailFile in maybeThumbnail)
|
||||
{
|
||||
SongMetadata metadata = song.SongMetadata.Head();
|
||||
await RefreshArtwork(
|
||||
thumbnailFile,
|
||||
metadata,
|
||||
ArtworkKind.Thumbnail,
|
||||
ffmpegPath,
|
||||
None,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Bugsnag;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Images;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
@@ -72,73 +73,96 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
decimal progressMax,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
.ToList();
|
||||
|
||||
foreach (string showFolder in allShowFolders)
|
||||
try
|
||||
{
|
||||
decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
decimal progressSpread = progressMax - progressMin;
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
|
||||
var allShowFolders = _localFileSystem.ListSubdirectories(libraryPath.Path)
|
||||
.Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity)
|
||||
.ToList();
|
||||
|
||||
foreach (BaseError error in maybeShow.LeftToSeq())
|
||||
foreach (string showFolder in allShowFolders)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing show in folder {Folder}: {Error}",
|
||||
showFolder,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<Show> result in maybeShow.RightToSeq())
|
||||
{
|
||||
await ScanSeasons(libraryPath, ffmpegPath, ffprobePath, result.Item, showFolder, cancellationToken);
|
||||
|
||||
if (result.IsAdded)
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
return new ScanCanceled();
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
|
||||
decimal percentCompletion = (decimal)allShowFolders.IndexOf(showFolder) / allShowFolders.Count;
|
||||
await _mediator.Publish(
|
||||
new LibraryScanProgress(libraryPath.LibraryId, progressMin + percentCompletion * progressSpread),
|
||||
cancellationToken);
|
||||
|
||||
Either<BaseError, MediaItemScanResult<Show>> maybeShow =
|
||||
await FindOrCreateShow(libraryPath.Id, showFolder)
|
||||
.BindT(show => UpdateMetadataForShow(show, showFolder))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Poster, cancellationToken))
|
||||
.BindT(show => UpdateArtworkForShow(show, showFolder, ArtworkKind.FanArt, cancellationToken))
|
||||
.BindT(
|
||||
show => UpdateArtworkForShow(show, showFolder, ArtworkKind.Thumbnail, cancellationToken));
|
||||
|
||||
foreach (BaseError error in maybeShow.LeftToSeq())
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
_logger.LogWarning(
|
||||
"Error processing show in folder {Folder}: {Error}",
|
||||
showFolder,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<Show> result in maybeShow.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ScanSeasons(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
result.Item,
|
||||
showFolder,
|
||||
cancellationToken);
|
||||
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
if (result.IsAdded || result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { result.Item.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
|
||||
foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath))
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing episode at {Path}", path);
|
||||
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
await _televisionRepository.DeleteByPath(libraryPath, path);
|
||||
}
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(libraryPath);
|
||||
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(path))
|
||||
{
|
||||
_logger.LogInformation("Flagging missing episode at {Path}", path);
|
||||
List<int> episodeIds = await FlagFileNotFound(libraryPath, path);
|
||||
await _searchIndex.RebuildItems(_searchRepository, episodeIds);
|
||||
}
|
||||
else if (Path.GetFileName(path).StartsWith("._"))
|
||||
{
|
||||
_logger.LogInformation("Removing dot underscore file at {Path}", path);
|
||||
await _televisionRepository.DeleteByPath(libraryPath, path);
|
||||
}
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
|
||||
await _libraryRepository.CleanEtagsForLibraryPath(libraryPath);
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(libraryPath);
|
||||
List<int> ids = await _televisionRepository.DeleteEmptyShows(libraryPath);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<Show>>> FindOrCreateShow(
|
||||
@@ -147,12 +171,16 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
{
|
||||
ShowMetadata metadata = await _localMetadataProvider.GetMetadataForShow(showFolder);
|
||||
Option<Show> maybeShow = await _televisionRepository.GetShowByMetadata(libraryPathId, metadata);
|
||||
return await maybeShow.Match(
|
||||
show => Right<BaseError, MediaItemScanResult<Show>>(new MediaItemScanResult<Show>(show)).AsTask(),
|
||||
async () => await _televisionRepository.AddShow(libraryPathId, showFolder, metadata));
|
||||
|
||||
foreach (Show show in maybeShow)
|
||||
{
|
||||
return new MediaItemScanResult<Show>(show);
|
||||
}
|
||||
|
||||
return await _televisionRepository.AddShow(libraryPathId, showFolder, metadata);
|
||||
}
|
||||
|
||||
private async Task<Unit> ScanSeasons(
|
||||
private async Task<Either<BaseError, Unit>> ScanSeasons(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
@@ -163,6 +191,11 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(showFolder).Filter(ShouldIncludeFolder)
|
||||
.OrderBy(identity))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
string etag = FolderEtag.CalculateWithSubfolders(seasonFolder, _localFileSystem);
|
||||
Option<LibraryFolder> knownFolder = libraryPath.LibraryFolders
|
||||
.Filter(f => f.Path == seasonFolder)
|
||||
@@ -175,44 +208,48 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
}
|
||||
|
||||
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
|
||||
await maybeSeasonNumber.IfSomeAsync(
|
||||
async seasonNumber =>
|
||||
foreach (int seasonNumber in maybeSeasonNumber)
|
||||
{
|
||||
Either<BaseError, Season> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
|
||||
.BindT(EnsureMetadataExists)
|
||||
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
|
||||
|
||||
foreach (BaseError error in maybeSeason.LeftToSeq())
|
||||
{
|
||||
Either<BaseError, Season> maybeSeason = await _televisionRepository
|
||||
.GetOrAddSeason(show, libraryPath.Id, seasonNumber)
|
||||
.BindT(EnsureMetadataExists)
|
||||
.BindT(season => UpdatePoster(season, seasonFolder, cancellationToken));
|
||||
_logger.LogWarning(
|
||||
"Error processing season in folder {Folder}: {Error}",
|
||||
seasonFolder,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
await maybeSeason.Match(
|
||||
async season =>
|
||||
{
|
||||
await ScanEpisodes(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
season,
|
||||
seasonFolder,
|
||||
cancellationToken);
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
foreach (Season season in maybeSeason.RightToSeq())
|
||||
{
|
||||
Either<BaseError, Unit> scanResult = await ScanEpisodes(
|
||||
libraryPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
season,
|
||||
seasonFolder,
|
||||
cancellationToken);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { season });
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing season in folder {Folder}: {Error}",
|
||||
seasonFolder,
|
||||
error.Value);
|
||||
return Task.FromResult(Unit.Default);
|
||||
});
|
||||
});
|
||||
foreach (ScanCanceled error in scanResult.LeftToSeq().OfType<ScanCanceled>())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
|
||||
await _libraryRepository.SetEtag(libraryPath, knownFolder, seasonFolder, etag);
|
||||
|
||||
season.Show = show;
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { season.Id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Unit> ScanEpisodes(
|
||||
private async Task<Either<BaseError, Unit>> ScanEpisodes(
|
||||
LibraryPath libraryPath,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
@@ -250,7 +287,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
|
||||
foreach (Episode episode in maybeEpisode.RightToSeq())
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { episode });
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { episode.Id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +303,7 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
try
|
||||
{
|
||||
Show show = result.Item;
|
||||
|
||||
|
||||
Option<string> maybeNfo = LocateNfoFileForShow(showFolder);
|
||||
if (maybeNfo.IsNone)
|
||||
{
|
||||
@@ -461,14 +498,12 @@ public class TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScan
|
||||
}
|
||||
|
||||
private Option<string> LocateNfoFileForShow(string showFolder) =>
|
||||
Optional(Path.Combine(showFolder, "tvshow.nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
Optional(Path.Combine(showFolder, "tvshow.nfo")).Filter(s => _localFileSystem.FileExists(s));
|
||||
|
||||
private Option<string> LocateNfoFile(Episode episode)
|
||||
{
|
||||
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
return Optional(Path.ChangeExtension(path, "nfo"))
|
||||
.Filter(s => _localFileSystem.FileExists(s));
|
||||
return Optional(Path.ChangeExtension(path, "nfo")).Filter(s => _localFileSystem.FileExists(s));
|
||||
}
|
||||
|
||||
private Option<string> LocateArtworkForShow(string showFolder, ArtworkKind artworkKind)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.MediaServer;
|
||||
|
||||
namespace ErsatzTV.Core.Plex;
|
||||
|
||||
public record PlexConnectionParameters
|
||||
(PlexConnection Connection, PlexServerAuthToken Token) : MediaServerConnectionParameters;
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace ErsatzTV.Core.Plex;
|
||||
|
||||
public class PlexItemEtag
|
||||
public class PlexItemEtag : MediaServerItemEtag
|
||||
{
|
||||
public string Key { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public MediaItemState State { get; set; }
|
||||
public override string MediaServerItemId => Key;
|
||||
public override string Etag { get; set; }
|
||||
public override MediaItemState State { get; set; }
|
||||
}
|
||||
|
||||
@@ -25,34 +25,34 @@ public abstract class PlexLibraryScanner
|
||||
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
await maybeIncomingArtwork.Match(
|
||||
async incomingArtwork =>
|
||||
{
|
||||
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
|
||||
if (maybeIncomingArtwork.IsNone)
|
||||
{
|
||||
existingMetadata.Artwork ??= new List<Artwork>();
|
||||
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
|
||||
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
|
||||
}
|
||||
|
||||
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
foreach (Artwork incomingArtwork in maybeIncomingArtwork)
|
||||
{
|
||||
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
|
||||
|
||||
await maybeExistingArtwork.Match(
|
||||
async existingArtwork =>
|
||||
{
|
||||
existingArtwork.Path = incomingArtwork.Path;
|
||||
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
|
||||
await _metadataRepository.UpdateArtworkPath(existingArtwork);
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
existingMetadata.Artwork ??= new List<Artwork>();
|
||||
existingMetadata.Artwork.Add(incomingArtwork);
|
||||
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
|
||||
});
|
||||
},
|
||||
async () =>
|
||||
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
if (maybeExistingArtwork.IsNone)
|
||||
{
|
||||
existingMetadata.Artwork ??= new List<Artwork>();
|
||||
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
|
||||
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
|
||||
});
|
||||
existingMetadata.Artwork.Add(incomingArtwork);
|
||||
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
|
||||
}
|
||||
|
||||
foreach (Artwork existingArtwork in maybeExistingArtwork)
|
||||
{
|
||||
existingArtwork.Path = incomingArtwork.Path;
|
||||
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
|
||||
await _metadataRepository.UpdateArtworkPath(existingArtwork);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -10,21 +10,17 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Plex;
|
||||
|
||||
public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScanner
|
||||
public class PlexMovieLibraryScanner :
|
||||
MediaServerMovieLibraryScanner<PlexConnectionParameters, PlexLibrary, PlexMovie, PlexItemEtag>,
|
||||
IPlexMovieLibraryScanner
|
||||
{
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILocalSubtitlesProvider _localSubtitlesProvider;
|
||||
private readonly ILogger<PlexMovieLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMetadataRepository _metadataRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IPlexMovieRepository _plexMovieRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
private readonly IPlexServerApiClient _plexServerApiClient;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public PlexMovieLibraryScanner(
|
||||
IPlexServerApiClient plexServerApiClient,
|
||||
@@ -40,20 +36,21 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILocalSubtitlesProvider localSubtitlesProvider,
|
||||
ILogger<PlexMovieLibraryScanner> logger)
|
||||
: base(metadataRepository, logger)
|
||||
: base(
|
||||
localStatisticsProvider,
|
||||
localSubtitlesProvider,
|
||||
localFileSystem,
|
||||
mediator,
|
||||
searchIndex,
|
||||
searchRepository,
|
||||
logger)
|
||||
{
|
||||
_plexServerApiClient = plexServerApiClient;
|
||||
_movieRepository = movieRepository;
|
||||
_metadataRepository = metadataRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_mediator = mediator;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_plexMovieRepository = plexMovieRepository;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_localSubtitlesProvider = localSubtitlesProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -66,262 +63,69 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
bool deepScan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, List<PlexMovie>> entries = await _plexServerApiClient.GetMovieLibraryContents(
|
||||
library,
|
||||
connection,
|
||||
token);
|
||||
List<PlexPathReplacement> pathReplacements =
|
||||
await _mediaSourceRepository.GetPlexPathReplacements(library.MediaSourceId);
|
||||
|
||||
foreach (BaseError error in entries.LeftToSeq())
|
||||
{
|
||||
return error;
|
||||
}
|
||||
string GetLocalPath(PlexMovie movie)
|
||||
{
|
||||
return _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
movie.GetHeadVersion().MediaFiles.Head().Path,
|
||||
false);
|
||||
}
|
||||
|
||||
return await ScanLibrary(
|
||||
connection,
|
||||
token,
|
||||
library,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
deepScan,
|
||||
entries.RightToSeq().Flatten().ToList(),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// always commit the search index to prevent corruption
|
||||
_searchIndex.Commit();
|
||||
}
|
||||
return await ScanLibrary(
|
||||
_plexMovieRepository,
|
||||
new PlexConnectionParameters(connection, token),
|
||||
library,
|
||||
GetLocalPath,
|
||||
ffmpegPath,
|
||||
ffprobePath,
|
||||
deepScan,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
protected override string MediaServerItemId(PlexMovie movie) => movie.Key;
|
||||
|
||||
protected override string MediaServerEtag(PlexMovie movie) => movie.Etag;
|
||||
|
||||
protected override Task<Either<BaseError, List<PlexMovie>>> GetMovieLibraryItems(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library) =>
|
||||
_plexServerApiClient.GetMovieLibraryContents(
|
||||
library,
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
protected override async Task<Option<MovieMetadata>> GetFullMetadata(
|
||||
PlexConnectionParameters connectionParameters,
|
||||
PlexLibrary library,
|
||||
string ffmpegPath,
|
||||
string ffprobePath,
|
||||
bool deepScan,
|
||||
List<PlexMovie> movieEntries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexItemEtag> existingMovies = await _movieRepository.GetExistingPlexMovies(library);
|
||||
|
||||
List<PlexPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetPlexPathReplacements(library.MediaSourceId);
|
||||
|
||||
foreach (PlexMovie incoming in movieEntries)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new ScanCanceled();
|
||||
}
|
||||
|
||||
decimal percentCompletion = (decimal)movieEntries.IndexOf(incoming) / movieEntries.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion), cancellationToken);
|
||||
|
||||
if (await ShouldScanItem(library, pathReplacements, existingMovies, incoming, deepScan) == false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
Either<BaseError, MediaItemScanResult<PlexMovie>> maybeMovie = await _movieRepository
|
||||
.GetOrAdd(library, incoming)
|
||||
.BindT(
|
||||
existing => UpdateStatistics(pathReplacements, existing, incoming, ffmpegPath, ffprobePath))
|
||||
.BindT(existing => UpdateMetadata(existing, incoming, library, connection, token))
|
||||
.BindT(existing => UpdateSubtitles(pathReplacements, existing, incoming))
|
||||
.BindT(existing => UpdateArtwork(existing, incoming));
|
||||
|
||||
if (maybeMovie.IsLeft)
|
||||
{
|
||||
foreach (BaseError error in maybeMovie.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error processing plex movie at {Key}: {Error}",
|
||||
incoming.Key,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (MediaItemScanResult<PlexMovie> result in maybeMovie.RightToSeq())
|
||||
{
|
||||
await _movieRepository.SetPlexEtag(result.Item, incoming.Etag);
|
||||
|
||||
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
plexPath,
|
||||
false);
|
||||
|
||||
if (_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
await _plexMovieRepository.FlagNormal(library, result.Item);
|
||||
}
|
||||
else
|
||||
{
|
||||
await _plexMovieRepository.FlagUnavailable(library, result.Item);
|
||||
}
|
||||
|
||||
if (result.IsAdded)
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
else if (result.IsUpdated)
|
||||
{
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { result.Item });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trash items that are no longer present on the media server
|
||||
var fileNotFoundKeys = existingMovies.Map(m => m.Key).Except(movieEntries.Map(m => m.Key)).ToList();
|
||||
List<int> ids = await _plexMovieRepository.FlagFileNotFound(library, fileNotFoundKeys);
|
||||
await _searchIndex.RebuildItems(_searchRepository, ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0), cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<bool> ShouldScanItem(
|
||||
PlexLibrary library,
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
List<PlexItemEtag> existingMovies,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
bool deepScan)
|
||||
{
|
||||
// deep scan will pull every movie individually from the plex api
|
||||
if (!deepScan)
|
||||
if (result.IsAdded || result.Item.Etag != incoming.Etag || deepScan)
|
||||
{
|
||||
Option<PlexItemEtag> maybeExisting = existingMovies.Find(ie => ie.Key == incoming.Key);
|
||||
string existingEtag = await maybeExisting
|
||||
.Map(e => e.Etag ?? string.Empty)
|
||||
.IfNoneAsync(string.Empty);
|
||||
MediaItemState existingState = await maybeExisting
|
||||
.Map(e => e.State)
|
||||
.IfNoneAsync(MediaItemState.Normal);
|
||||
Either<BaseError, MovieMetadata> maybeMetadata = await _plexServerApiClient.GetMovieMetadata(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connectionParameters.Connection,
|
||||
connectionParameters.Token);
|
||||
|
||||
string plexPath = incoming.MediaVersions.Head().MediaFiles.Head().Path;
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
plexPath,
|
||||
false);
|
||||
|
||||
// if media is unavailable, only scan if file now exists
|
||||
if (existingState == MediaItemState.Unavailable)
|
||||
foreach (BaseError error in maybeMetadata.LeftToSeq())
|
||||
{
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else if (existingEtag == incoming.Etag)
|
||||
{
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
foreach (int id in await _plexMovieRepository.FlagUnavailable(library, incoming))
|
||||
{
|
||||
await _searchIndex.RebuildItems(_searchRepository, new List<int> { id });
|
||||
}
|
||||
}
|
||||
|
||||
// _logger.LogDebug("NOOP: etag has not changed for plex movie with key {Key}", incoming.Key);
|
||||
return false;
|
||||
_logger.LogWarning("Failed to get movie metadata from Plex: {Error}", error.ToString());
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
return maybeMetadata.ToOption();
|
||||
}
|
||||
|
||||
return true;
|
||||
return None;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateStatistics(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
protected override async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
string ffmpegPath,
|
||||
string ffprobePath)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MediaVersion existingVersion = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
|
||||
if (result.IsAdded || existing.Etag != incoming.Etag || existingVersion.Streams.Count == 0)
|
||||
{
|
||||
foreach (MediaFile incomingFile in incomingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
foreach (MediaFile existingFile in existingVersion.MediaFiles.HeadOrNone())
|
||||
{
|
||||
if (incomingFile.Path != existingFile.Path)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Plex movie has moved from {OldPath} to {NewPath}",
|
||||
existingFile.Path,
|
||||
incomingFile.Path);
|
||||
|
||||
existingFile.Path = incomingFile.Path;
|
||||
|
||||
await _movieRepository.UpdatePath(existingFile.Id, incomingFile.Path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
// only refresh statistics if the file exists
|
||||
if (_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffmpegPath, ffprobePath, existing, localPath);
|
||||
|
||||
foreach (BaseError error in refreshResult.LeftToSeq())
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
}
|
||||
|
||||
foreach (bool _ in refreshResult.RightToSeq())
|
||||
{
|
||||
foreach (MediaItem updated in await _searchRepository.GetItemToIndex(incoming.Id))
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated });
|
||||
}
|
||||
|
||||
await _metadataRepository.UpdatePlexStatistics(existingVersion.Id, incomingVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateMetadata(
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming,
|
||||
PlexLibrary library,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token)
|
||||
MovieMetadata fullMetadata)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
|
||||
@@ -329,243 +133,243 @@ public class PlexMovieLibraryScanner : PlexLibraryScanner, IPlexMovieLibraryScan
|
||||
_logger.LogDebug(
|
||||
"Refreshing {Attribute} for {Title}",
|
||||
"Plex Metadata",
|
||||
existing.MovieMetadata.Head().Title);
|
||||
existingMetadata.Title);
|
||||
|
||||
Either<BaseError, MovieMetadata> maybeMetadata =
|
||||
await _plexServerApiClient.GetMovieMetadata(
|
||||
library,
|
||||
incoming.Key.Split("/").Last(),
|
||||
connection,
|
||||
token);
|
||||
|
||||
foreach (MovieMetadata fullMetadata in maybeMetadata.RightToSeq())
|
||||
if (existingMetadata.MetadataKind != MetadataKind.External)
|
||||
{
|
||||
if (existingMetadata.MetadataKind != MetadataKind.External)
|
||||
{
|
||||
existingMetadata.MetadataKind = MetadataKind.External;
|
||||
await _metadataRepository.MarkAsExternal(existingMetadata);
|
||||
}
|
||||
existingMetadata.MetadataKind = MetadataKind.External;
|
||||
await _metadataRepository.MarkAsExternal(existingMetadata);
|
||||
}
|
||||
|
||||
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
|
||||
if (existingMetadata.ContentRating != fullMetadata.ContentRating)
|
||||
{
|
||||
existingMetadata.ContentRating = fullMetadata.ContentRating;
|
||||
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
foreach (Genre genre in existingMetadata.Genres
|
||||
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Remove(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
existingMetadata.ContentRating = fullMetadata.ContentRating;
|
||||
await _metadataRepository.SetContentRating(existingMetadata, fullMetadata.ContentRating);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
foreach (Genre genre in existingMetadata.Genres
|
||||
.Filter(g => fullMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Remove(genre);
|
||||
if (await _metadataRepository.RemoveGenre(genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Genre genre in fullMetadata.Genres
|
||||
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Genres.Add(genre);
|
||||
if (await _movieRepository.AddGenre(existingMetadata, genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in existingMetadata.Studios
|
||||
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Remove(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Studio studio in fullMetadata.Studios
|
||||
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Add(studio);
|
||||
if (await _movieRepository.AddStudio(existingMetadata, studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Actor actor in existingMetadata.Actors
|
||||
.Filter(
|
||||
a => fullMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Actors.Remove(actor);
|
||||
if (await _metadataRepository.RemoveActor(actor))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Actor actor in fullMetadata.Actors
|
||||
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Actors.Add(actor);
|
||||
if (await _movieRepository.AddActor(existingMetadata, actor))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in existingMetadata.Directors
|
||||
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Directors.Remove(director);
|
||||
if (await _metadataRepository.RemoveDirector(director))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in fullMetadata.Directors
|
||||
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Directors.Add(director);
|
||||
if (await _movieRepository.AddDirector(existingMetadata, director))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in existingMetadata.Writers
|
||||
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Writers.Remove(writer);
|
||||
if (await _metadataRepository.RemoveWriter(writer))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in fullMetadata.Writers
|
||||
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Writers.Add(writer);
|
||||
if (await _movieRepository.AddWriter(existingMetadata, writer))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Remove(guid);
|
||||
if (await _metadataRepository.RemoveGuid(guid))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in fullMetadata.Guids
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Add(guid);
|
||||
if (await _metadataRepository.AddGuid(existingMetadata, guid))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in existingMetadata.Tags
|
||||
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Tags.Remove(tag);
|
||||
if (await _metadataRepository.RemoveTag(tag))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in fullMetadata.Tags
|
||||
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Tags.Add(tag);
|
||||
if (await _movieRepository.AddTag(existingMetadata, tag))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
|
||||
{
|
||||
existingMetadata.SortTitle = fullMetadata.SortTitle;
|
||||
if (await _movieRepository.UpdateSortTitle(existingMetadata))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.IsUpdated)
|
||||
{
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: update other metadata?
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateSubtitles(
|
||||
List<PlexPathReplacement> pathReplacements,
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming)
|
||||
{
|
||||
try
|
||||
foreach (Genre genre in fullMetadata.Genres
|
||||
.Filter(g => existingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
string localPath = _plexPathReplacementService.GetReplacementPlexPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
await _localSubtitlesProvider.UpdateSubtitles(result.Item, localPath, false);
|
||||
|
||||
return result;
|
||||
existingMetadata.Genres.Add(genre);
|
||||
if (await _movieRepository.AddGenre(existingMetadata, genre))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
foreach (Studio studio in existingMetadata.Studios
|
||||
.Filter(s => fullMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
existingMetadata.Studios.Remove(studio);
|
||||
if (await _metadataRepository.RemoveStudio(studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> UpdateArtwork(
|
||||
MediaItemScanResult<PlexMovie> result,
|
||||
PlexMovie incoming)
|
||||
{
|
||||
PlexMovie existing = result.Item;
|
||||
MovieMetadata existingMetadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||
foreach (Studio studio in fullMetadata.Studios
|
||||
.Filter(s => existingMetadata.Studios.All(s2 => s2.Name != s.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Studios.Add(studio);
|
||||
if (await _movieRepository.AddStudio(existingMetadata, studio))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool poster = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.Poster);
|
||||
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, incomingMetadata, ArtworkKind.FanArt);
|
||||
foreach (Actor actor in existingMetadata.Actors
|
||||
.Filter(
|
||||
a => fullMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Actors.Remove(actor);
|
||||
if (await _metadataRepository.RemoveActor(actor))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Actor actor in fullMetadata.Actors
|
||||
.Filter(a => existingMetadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Actors.Add(actor);
|
||||
if (await _movieRepository.AddActor(existingMetadata, actor))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in existingMetadata.Directors
|
||||
.Filter(g => fullMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Directors.Remove(director);
|
||||
if (await _metadataRepository.RemoveDirector(director))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Director director in fullMetadata.Directors
|
||||
.Filter(g => existingMetadata.Directors.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Directors.Add(director);
|
||||
if (await _movieRepository.AddDirector(existingMetadata, director))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in existingMetadata.Writers
|
||||
.Filter(g => fullMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Writers.Remove(writer);
|
||||
if (await _metadataRepository.RemoveWriter(writer))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Writer writer in fullMetadata.Writers
|
||||
.Filter(g => existingMetadata.Writers.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Writers.Add(writer);
|
||||
if (await _movieRepository.AddWriter(existingMetadata, writer))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in existingMetadata.Guids
|
||||
.Filter(g => fullMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Remove(guid);
|
||||
if (await _metadataRepository.RemoveGuid(guid))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in fullMetadata.Guids
|
||||
.Filter(g => existingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Guids.Add(guid);
|
||||
if (await _metadataRepository.AddGuid(existingMetadata, guid))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in existingMetadata.Tags
|
||||
.Filter(g => fullMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Tags.Remove(tag);
|
||||
if (await _metadataRepository.RemoveTag(tag))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (Tag tag in fullMetadata.Tags
|
||||
.Filter(g => existingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
existingMetadata.Tags.Add(tag);
|
||||
if (await _movieRepository.AddTag(existingMetadata, tag))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullMetadata.SortTitle != existingMetadata.SortTitle)
|
||||
{
|
||||
existingMetadata.SortTitle = fullMetadata.SortTitle;
|
||||
if (await _movieRepository.UpdateSortTitle(existingMetadata))
|
||||
{
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
bool poster = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.Poster);
|
||||
bool fanArt = await UpdateArtworkIfNeeded(existingMetadata, fullMetadata, ArtworkKind.FanArt);
|
||||
if (poster || fanArt)
|
||||
{
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, incomingMetadata.DateUpdated);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
if (result.IsUpdated)
|
||||
{
|
||||
await _metadataRepository.MarkAsUpdated(existingMetadata, fullMetadata.DateUpdated);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<bool> UpdateArtworkIfNeeded(
|
||||
Domain.Metadata existingMetadata,
|
||||
Domain.Metadata incomingMetadata,
|
||||
ArtworkKind artworkKind)
|
||||
{
|
||||
if (incomingMetadata.DateUpdated > existingMetadata.DateUpdated)
|
||||
{
|
||||
Option<Artwork> maybeIncomingArtwork = Optional(incomingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
if (maybeIncomingArtwork.IsNone)
|
||||
{
|
||||
existingMetadata.Artwork ??= new List<Artwork>();
|
||||
existingMetadata.Artwork.RemoveAll(a => a.ArtworkKind == artworkKind);
|
||||
await _metadataRepository.RemoveArtwork(existingMetadata, artworkKind);
|
||||
}
|
||||
|
||||
foreach (Artwork incomingArtwork in maybeIncomingArtwork)
|
||||
{
|
||||
_logger.LogDebug("Refreshing Plex {Attribute} from {Path}", artworkKind, incomingArtwork.Path);
|
||||
|
||||
Option<Artwork> maybeExistingArtwork = Optional(existingMetadata.Artwork).Flatten()
|
||||
.Find(a => a.ArtworkKind == artworkKind);
|
||||
|
||||
if (maybeExistingArtwork.IsNone)
|
||||
{
|
||||
existingMetadata.Artwork ??= new List<Artwork>();
|
||||
existingMetadata.Artwork.Add(incomingArtwork);
|
||||
await _metadataRepository.AddArtwork(existingMetadata, incomingArtwork);
|
||||
}
|
||||
|
||||
foreach (Artwork existingArtwork in maybeExistingArtwork)
|
||||
{
|
||||
existingArtwork.Path = incomingArtwork.Path;
|
||||
existingArtwork.DateUpdated = incomingArtwork.DateUpdated;
|
||||
await _metadataRepository.UpdateArtworkPath(existingArtwork);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" Version="3.4.3" />
|
||||
<PackageReference Include="CliWrap" Version="3.4.4" />
|
||||
<PackageReference Include="LanguageExt.Core" Version="4.0.4" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class EmbyLibraryConfiguration : IEntityTypeConfiguration<EmbyLibrary>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<EmbyLibrary> builder) =>
|
||||
public void Configure(EntityTypeBuilder<EmbyLibrary> builder)
|
||||
{
|
||||
builder.ToTable("EmbyLibrary");
|
||||
|
||||
builder.HasMany(l => l.PathInfos)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ namespace ErsatzTV.Infrastructure.Data.Configurations;
|
||||
|
||||
public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLibrary>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder) =>
|
||||
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder)
|
||||
{
|
||||
builder.ToTable("JellyfinLibrary");
|
||||
|
||||
builder.HasMany(l => l.PathInfos)
|
||||
.WithOne()
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
|
||||
public class EmbyMovieRepository : IEmbyMovieRepository
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public EmbyMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<EmbyItemEtag>> GetExistingMovies(EmbyLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
|
||||
@"SELECT ItemId, Etag, MI.State FROM EmbyMovie
|
||||
INNER JOIN Movie M on EmbyMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(EmbyLibrary library, EmbyMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
movie.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT EmbyMovie.Id FROM EmbyMovie
|
||||
INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE EmbyMovie.ItemId = @ItemId)",
|
||||
new { LibraryId = library.Id, movie.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<Option<int>> FlagUnavailable(EmbyLibrary library, EmbyMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
movie.State = MediaItemState.Unavailable;
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT EmbyMovie.Id FROM EmbyMovie
|
||||
INNER JOIN MediaItem MI ON MI.Id = EmbyMovie.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE EmbyMovie.ItemId = @ItemId",
|
||||
new { LibraryId = library.Id, movie.ItemId });
|
||||
|
||||
foreach (int id in maybeId)
|
||||
{
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
|
||||
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFound(EmbyLibrary library, List<string> movieItemIds)
|
||||
{
|
||||
if (movieItemIds.Count == 0)
|
||||
{
|
||||
return new List<int>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM MediaItem M
|
||||
INNER JOIN EmbyMovie ON EmbyMovie.Id = M.Id
|
||||
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
WHERE EmbyMovie.ItemId IN @MovieItemIds",
|
||||
new { LibraryId = library.Id, MovieItemIds = movieItemIds })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> GetOrAdd(EmbyLibrary library, EmbyMovie item)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies
|
||||
.Include(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.SelectOneAsync(m => m.ItemId, m => m.ItemId == item.ItemId);
|
||||
|
||||
foreach (EmbyMovie embyMovie in maybeExisting)
|
||||
{
|
||||
var result = new MediaItemScanResult<EmbyMovie>(embyMovie) { IsAdded = false };
|
||||
if (embyMovie.Etag != item.Etag)
|
||||
{
|
||||
await UpdateMovie(dbContext, embyMovie, item);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return await AddMovie(dbContext, library, item);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetEtag(EmbyMovie movie, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE EmbyMovie SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<EmbyMovie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
EmbyLibrary library,
|
||||
EmbyMovie movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
// blank out etag for initial save in case other updates fail
|
||||
string etag = movie.Etag;
|
||||
movie.Etag = string.Empty;
|
||||
|
||||
movie.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await dbContext.AddAsync(movie);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// restore etag
|
||||
movie.Etag = etag;
|
||||
|
||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<EmbyMovie>(movie) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpdateMovie(TvContext dbContext, EmbyMovie existing, EmbyMovie incoming)
|
||||
{
|
||||
// library path is used for search indexing later
|
||||
incoming.LibraryPath = existing.LibraryPath;
|
||||
incoming.Id = existing.Id;
|
||||
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.Tagline = incomingMetadata.Tagline;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.Filter(g => g.ExternalCollectionId is null)
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Remove(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Add(studio);
|
||||
}
|
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors
|
||||
.Filter(
|
||||
a => incomingMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Remove(actor);
|
||||
}
|
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors
|
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
// directors
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Remove(director);
|
||||
}
|
||||
|
||||
foreach (Director director in incomingMetadata.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Add(director);
|
||||
}
|
||||
|
||||
// writers
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Remove(writer);
|
||||
}
|
||||
|
||||
foreach (Writer writer in incomingMetadata.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Add(writer);
|
||||
}
|
||||
|
||||
// guids
|
||||
foreach (MetadataGuid guid in metadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Remove(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Add(guid);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
version.Name = incomingVersion.Name;
|
||||
version.DateAdded = incomingVersion.DateAdded;
|
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
|
||||
file.Path = incomingFile.Path;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,357 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
|
||||
public class JellyfinMovieRepository : IJellyfinMovieRepository
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public JellyfinMovieRepository(IDbContextFactory<TvContext> dbContextFactory) =>
|
||||
_dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<JellyfinItemEtag>> GetExistingMovies(JellyfinLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
|
||||
@"SELECT ItemId, Etag, MI.State FROM JellyfinMovie
|
||||
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(JellyfinLibrary library, JellyfinMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
movie.State = MediaItemState.Normal;
|
||||
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 0 WHERE Id IN
|
||||
(SELECT JellyfinMovie.Id FROM JellyfinMovie
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE JellyfinMovie.ItemId = @ItemId)",
|
||||
new { LibraryId = library.Id, movie.ItemId }).Map(count => count > 0);
|
||||
}
|
||||
|
||||
public async Task<Option<int>> FlagUnavailable(JellyfinLibrary library, JellyfinMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
movie.State = MediaItemState.Unavailable;
|
||||
|
||||
Option<int> maybeId = await dbContext.Connection.ExecuteScalarAsync<int>(
|
||||
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
|
||||
INNER JOIN MediaItem MI ON MI.Id = JellyfinMovie.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id AND LibraryId = @LibraryId
|
||||
WHERE JellyfinMovie.ItemId = @ItemId",
|
||||
new { LibraryId = library.Id, movie.ItemId });
|
||||
|
||||
foreach (int id in maybeId)
|
||||
{
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 2 WHERE Id = @Id",
|
||||
new { Id = id }).Map(count => count > 0 ? Some(id) : None);
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
public async Task<List<int>> FlagFileNotFound(JellyfinLibrary library, List<string> movieItemIds)
|
||||
{
|
||||
if (movieItemIds.Count == 0)
|
||||
{
|
||||
return new List<int>();
|
||||
}
|
||||
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT M.Id
|
||||
FROM MediaItem M
|
||||
INNER JOIN JellyfinMovie ON JellyfinMovie.Id = M.Id
|
||||
INNER JOIN LibraryPath LP on M.LibraryPathId = LP.Id AND LP.LibraryId = @LibraryId
|
||||
WHERE JellyfinMovie.ItemId IN @MovieItemIds",
|
||||
new { LibraryId = library.Id, MovieItemIds = movieItemIds })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE MediaItem SET State = 1 WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> GetOrAdd(
|
||||
JellyfinLibrary library,
|
||||
JellyfinMovie item)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
|
||||
.Include(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.SelectOneAsync(m => m.ItemId, m => m.ItemId == item.ItemId);
|
||||
|
||||
foreach (JellyfinMovie jellyfinMovie in maybeExisting)
|
||||
{
|
||||
var result = new MediaItemScanResult<JellyfinMovie>(jellyfinMovie) { IsAdded = false };
|
||||
if (jellyfinMovie.Etag != item.Etag)
|
||||
{
|
||||
await UpdateMovie(dbContext, jellyfinMovie, item);
|
||||
result.IsUpdated = true;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return await AddMovie(dbContext, library, item);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetEtag(JellyfinMovie movie, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE JellyfinMovie SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
private async Task UpdateMovie(TvContext dbContext, JellyfinMovie existing, JellyfinMovie incoming)
|
||||
{
|
||||
// library path is used for search indexing later
|
||||
incoming.LibraryPath = existing.LibraryPath;
|
||||
incoming.Id = existing.Id;
|
||||
|
||||
existing.Etag = incoming.Etag;
|
||||
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = incoming.MovieMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.Tagline = incomingMetadata.Tagline;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.Filter(g => g.ExternalCollectionId is null)
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Remove(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Add(studio);
|
||||
}
|
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors
|
||||
.Filter(
|
||||
a => incomingMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Remove(actor);
|
||||
}
|
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors
|
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
// directors
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Remove(director);
|
||||
}
|
||||
|
||||
foreach (Director director in incomingMetadata.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Add(director);
|
||||
}
|
||||
|
||||
// writers
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Remove(writer);
|
||||
}
|
||||
|
||||
foreach (Writer writer in incomingMetadata.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Add(writer);
|
||||
}
|
||||
|
||||
// guids
|
||||
foreach (MetadataGuid guid in metadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Remove(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Add(guid);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = incoming.MediaVersions.Head();
|
||||
version.Name = incomingVersion.Name;
|
||||
version.DateAdded = incomingVersion.DateAdded;
|
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
|
||||
file.Path = incomingFile.Path;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<JellyfinMovie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
JellyfinLibrary library,
|
||||
JellyfinMovie movie)
|
||||
{
|
||||
try
|
||||
{
|
||||
// blank out etag for initial save in case other updates fail
|
||||
string etag = movie.Etag;
|
||||
movie.Etag = string.Empty;
|
||||
|
||||
movie.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await dbContext.AddAsync(movie);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// restore etag
|
||||
movie.Etag = etag;
|
||||
|
||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<JellyfinMovie>(movie) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Extensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
@@ -159,27 +163,51 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
public async Task<List<int>> UpdateLibraries(
|
||||
int jellyfinMediaSourceId,
|
||||
List<JellyfinLibrary> toAdd,
|
||||
List<JellyfinLibrary> toDelete)
|
||||
List<JellyfinLibrary> toDelete,
|
||||
List<JellyfinLibrary> toUpdate)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
foreach (JellyfinLibrary add in toAdd)
|
||||
{
|
||||
add.MediaSourceId = jellyfinMediaSourceId;
|
||||
dbContext.Entry(add).State = EntityState.Added;
|
||||
foreach (LibraryPath path in add.Paths)
|
||||
{
|
||||
dbContext.Entry(path).State = EntityState.Added;
|
||||
}
|
||||
dbContext.JellyfinLibraries.Add(add);
|
||||
}
|
||||
|
||||
foreach (JellyfinLibrary delete in toDelete)
|
||||
{
|
||||
dbContext.Entry(delete).State = EntityState.Deleted;
|
||||
}
|
||||
dbContext.JellyfinLibraries.RemoveRange(toDelete);
|
||||
|
||||
List<int> ids = await DisableJellyfinLibrarySync(toDelete.Map(l => l.Id).ToList());
|
||||
|
||||
foreach (JellyfinLibrary incoming in toUpdate)
|
||||
{
|
||||
Option<JellyfinLibrary> maybeExisting = await dbContext.JellyfinLibraries
|
||||
.Include(l => l.PathInfos)
|
||||
.SelectOneAsync(l => l.ItemId, l => l.ItemId == incoming.ItemId);
|
||||
|
||||
foreach (JellyfinLibrary existing in maybeExisting)
|
||||
{
|
||||
// remove paths that are not on the incoming version
|
||||
existing.PathInfos.RemoveAll(pi => incoming.PathInfos.All(upi => upi.Path != pi.Path));
|
||||
|
||||
// update all remaining paths
|
||||
foreach (JellyfinPathInfo existingPathInfo in existing.PathInfos)
|
||||
{
|
||||
Option<JellyfinPathInfo> maybeIncoming = incoming.PathInfos
|
||||
.Find(pi => pi.Path == existingPathInfo.Path);
|
||||
foreach (JellyfinPathInfo incomingPathInfo in maybeIncoming)
|
||||
{
|
||||
existingPathInfo.NetworkPath = incomingPathInfo.NetworkPath;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (JellyfinPathInfo incomingPathInfo in incoming.PathInfos
|
||||
.Filter(pi => existing.PathInfos.All(epi => epi.Path != pi.Path)))
|
||||
{
|
||||
existing.PathInfos.Add(incomingPathInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return ids;
|
||||
@@ -188,27 +216,51 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
public async Task<List<int>> UpdateLibraries(
|
||||
int embyMediaSourceId,
|
||||
List<EmbyLibrary> toAdd,
|
||||
List<EmbyLibrary> toDelete)
|
||||
List<EmbyLibrary> toDelete,
|
||||
List<EmbyLibrary> toUpdate)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
foreach (EmbyLibrary add in toAdd)
|
||||
{
|
||||
add.MediaSourceId = embyMediaSourceId;
|
||||
dbContext.Entry(add).State = EntityState.Added;
|
||||
foreach (LibraryPath path in add.Paths)
|
||||
{
|
||||
dbContext.Entry(path).State = EntityState.Added;
|
||||
}
|
||||
dbContext.EmbyLibraries.Add(add);
|
||||
}
|
||||
|
||||
foreach (EmbyLibrary delete in toDelete)
|
||||
{
|
||||
dbContext.Entry(delete).State = EntityState.Deleted;
|
||||
}
|
||||
dbContext.EmbyLibraries.RemoveRange(toDelete);
|
||||
|
||||
List<int> ids = await DisableEmbyLibrarySync(toDelete.Map(l => l.Id).ToList());
|
||||
|
||||
foreach (EmbyLibrary incoming in toUpdate)
|
||||
{
|
||||
Option<EmbyLibrary> maybeExisting = await dbContext.EmbyLibraries
|
||||
.Include(l => l.PathInfos)
|
||||
.SelectOneAsync(l => l.ItemId, l => l.ItemId == incoming.ItemId);
|
||||
|
||||
foreach (EmbyLibrary existing in maybeExisting)
|
||||
{
|
||||
// remove paths that are not on the incoming version
|
||||
existing.PathInfos.RemoveAll(pi => incoming.PathInfos.All(upi => upi.Path != pi.Path));
|
||||
|
||||
// update all remaining paths
|
||||
foreach (EmbyPathInfo existingPathInfo in existing.PathInfos)
|
||||
{
|
||||
Option<EmbyPathInfo> maybeIncoming = incoming.PathInfos
|
||||
.Find(pi => pi.Path == existingPathInfo.Path);
|
||||
foreach (EmbyPathInfo incomingPathInfo in maybeIncoming)
|
||||
{
|
||||
existingPathInfo.NetworkPath = incomingPathInfo.NetworkPath;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (EmbyPathInfo incomingPathInfo in incoming.PathInfos
|
||||
.Filter(pi => existing.PathInfos.All(epi => epi.Path != pi.Path)))
|
||||
{
|
||||
existing.PathInfos.Add(incomingPathInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return ids;
|
||||
@@ -259,14 +311,16 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
|
||||
List<PlexLibrary> allPlexLibraries = await dbContext.PlexLibraries.ToListAsync();
|
||||
dbContext.PlexLibraries.RemoveRange(allPlexLibraries);
|
||||
var libraryIds = allPlexLibraries.Map(l => l.Id).ToList();
|
||||
|
||||
List<int> movieIds = await dbContext.PlexMovies.Map(pm => pm.Id).ToListAsync();
|
||||
List<int> showIds = await dbContext.PlexShows.Map(ps => ps.Id).ToListAsync();
|
||||
List<int> episodeIds = await dbContext.PlexEpisodes.Map(pe => pe.Id).ToListAsync();
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return movieIds.Append(showIds).Append(episodeIds).ToList();
|
||||
return deletedMediaIds;
|
||||
}
|
||||
|
||||
public async Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource)
|
||||
@@ -292,83 +346,30 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
List<PlexLibrary> libraries = await dbContext.PlexLibraries
|
||||
.Include(l => l.Paths)
|
||||
.Filter(l => libraryIds.Contains(l.Id))
|
||||
.ToListAsync();
|
||||
|
||||
List<int> movieIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexMovie 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
dbContext.PlexLibraries.RemoveRange(libraries);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexMovie 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)",
|
||||
new { ids = libraryIds });
|
||||
foreach (PlexLibrary library in libraries)
|
||||
{
|
||||
library.Id = 0;
|
||||
library.ShouldSyncItems = false;
|
||||
library.LastScan = SystemTime.MinValueUtc;
|
||||
}
|
||||
|
||||
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexEpisode 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
await dbContext.PlexLibraries.AddRangeAsync(libraries);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexEpisode 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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexSeason 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexSeason 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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> showIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexShow 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN PlexShow 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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
|
||||
return deletedMediaIds;
|
||||
}
|
||||
|
||||
public async Task EnablePlexLibrarySync(IEnumerable<int> libraryIds)
|
||||
@@ -447,6 +448,7 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
return await context.JellyfinMediaSources
|
||||
.Include(p => p.Connections)
|
||||
.Include(p => p.Libraries)
|
||||
.ThenInclude(l => (l as JellyfinLibrary).PathInfos)
|
||||
.Include(p => p.PathReplacements)
|
||||
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
|
||||
.SingleOrDefaultAsync(p => p.Id == id)
|
||||
@@ -473,83 +475,31 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE JellyfinLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
List<JellyfinLibrary> libraries = await dbContext.JellyfinLibraries
|
||||
.Include(l => l.Paths)
|
||||
.Include(l => l.PathInfos)
|
||||
.Filter(l => libraryIds.Contains(l.Id))
|
||||
.ToListAsync();
|
||||
|
||||
List<int> movieIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinMovie 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
dbContext.JellyfinLibraries.RemoveRange(libraries);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinMovie 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)",
|
||||
new { ids = libraryIds });
|
||||
foreach (JellyfinLibrary library in libraries)
|
||||
{
|
||||
library.Id = 0;
|
||||
library.ShouldSyncItems = false;
|
||||
library.LastScan = SystemTime.MinValueUtc;
|
||||
}
|
||||
|
||||
List<int> episodeIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinEpisode 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
await dbContext.JellyfinLibraries.AddRangeAsync(libraries);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinEpisode 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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> seasonIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinSeason js ON js.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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinSeason 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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> showIds = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinShow 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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinShow 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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
|
||||
return deletedMediaIds;
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
|
||||
@@ -557,6 +507,8 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await context.JellyfinLibraries
|
||||
.Include(l => l.Paths)
|
||||
.Include(l => l.PathInfos)
|
||||
.Include(l => l.MediaSource)
|
||||
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
|
||||
.SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId)
|
||||
.Map(Optional);
|
||||
@@ -654,24 +606,14 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
var libraryIds = allJellyfinLibraries.Map(l => l.Id).ToList();
|
||||
dbContext.JellyfinLibraries.RemoveRange(allJellyfinLibraries);
|
||||
|
||||
List<int> movieIds = await dbContext.JellyfinMovies
|
||||
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
|
||||
.Map(pm => pm.Id)
|
||||
.ToListAsync();
|
||||
|
||||
List<int> showIds = await dbContext.JellyfinShows
|
||||
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
|
||||
.Map(ps => ps.Id)
|
||||
.ToListAsync();
|
||||
|
||||
List<int> episodeIds = await dbContext.JellyfinEpisodes
|
||||
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
|
||||
.Map(ps => ps.Id)
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return movieIds.Append(showIds).Append(episodeIds).ToList();
|
||||
return deletedMediaIds;
|
||||
}
|
||||
|
||||
public async Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem)
|
||||
@@ -742,10 +684,9 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
return await context.EmbyMediaSources
|
||||
.Include(p => p.Connections)
|
||||
.Include(p => p.Libraries)
|
||||
.ThenInclude(l => (l as EmbyLibrary).PathInfos)
|
||||
.Include(p => p.PathReplacements)
|
||||
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
|
||||
.SingleOrDefaultAsync(p => p.Id == id)
|
||||
.Map(Optional);
|
||||
.SelectOneAsync(s => s.Id, s => s.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Option<EmbyMediaSource>> GetEmbyByLibraryId(int embyLibraryId)
|
||||
@@ -771,9 +712,9 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.EmbyLibraries
|
||||
.Include(l => l.Paths)
|
||||
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
|
||||
.SingleOrDefaultAsync(l => l.Id == embyLibraryId)
|
||||
.Map(Optional);
|
||||
.Include(l => l.PathInfos)
|
||||
.Include(l => l.MediaSource)
|
||||
.SelectOneAsync(l => l.Id, l => l.Id == embyLibraryId);
|
||||
}
|
||||
|
||||
public async Task<List<EmbyLibrary>> GetEmbyLibraries(int embyMediaSourceId)
|
||||
@@ -858,24 +799,14 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
var libraryIds = allEmbyLibraries.Map(l => l.Id).ToList();
|
||||
dbContext.EmbyLibraries.RemoveRange(allEmbyLibraries);
|
||||
|
||||
List<int> movieIds = await dbContext.EmbyMovies
|
||||
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
|
||||
.Map(pm => pm.Id)
|
||||
.ToListAsync();
|
||||
|
||||
List<int> showIds = await dbContext.EmbyShows
|
||||
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
|
||||
.Map(ps => ps.Id)
|
||||
.ToListAsync();
|
||||
|
||||
List<int> episodeIds = await dbContext.EmbyEpisodes
|
||||
.Where(m => libraryIds.Contains(m.LibraryPath.LibraryId))
|
||||
.Map(ps => ps.Id)
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return movieIds.Append(showIds).Append(episodeIds).ToList();
|
||||
return deletedMediaIds;
|
||||
}
|
||||
|
||||
public async Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds)
|
||||
@@ -890,82 +821,30 @@ public class MediaSourceRepository : IMediaSourceRepository
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE EmbyLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
List<int> deletedMediaIds = await dbContext.MediaItems
|
||||
.Filter(mi => libraryIds.Contains(mi.LibraryPath.LibraryId))
|
||||
.Map(mi => mi.Id)
|
||||
.ToListAsync();
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
List<EmbyLibrary> libraries = await dbContext.EmbyLibraries
|
||||
.Include(l => l.Paths)
|
||||
.Include(l => l.PathInfos)
|
||||
.Filter(l => libraryIds.Contains(l.Id))
|
||||
.ToListAsync();
|
||||
|
||||
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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
dbContext.EmbyLibraries.RemoveRange(libraries);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
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)",
|
||||
new { ids = libraryIds });
|
||||
foreach (EmbyLibrary library in libraries)
|
||||
{
|
||||
library.Id = 0;
|
||||
library.ShouldSyncItems = false;
|
||||
library.LastScan = SystemTime.MinValueUtc;
|
||||
}
|
||||
|
||||
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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
await dbContext.EmbyLibraries.AddRangeAsync(libraries);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
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)",
|
||||
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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
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)",
|
||||
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",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
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)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).Append(seasonIds).Append(episodeIds).ToList();
|
||||
return deletedMediaIds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -513,30 +513,37 @@ public class MetadataRepository : IMetadataRepository
|
||||
.ToList();
|
||||
var toUpdate = subtitles.Except(toAdd).ToList();
|
||||
|
||||
// add
|
||||
existing.Subtitles.AddRange(toAdd);
|
||||
|
||||
// remove
|
||||
existing.Subtitles.RemoveAll(s => toRemove.Contains(s));
|
||||
|
||||
// update
|
||||
foreach (Subtitle incomingSubtitle in toUpdate)
|
||||
if (toAdd.Any() || toRemove.Any() || toUpdate.Any())
|
||||
{
|
||||
Subtitle existingSubtitle =
|
||||
existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex);
|
||||
// add
|
||||
existing.Subtitles.AddRange(toAdd);
|
||||
|
||||
existingSubtitle.Codec = incomingSubtitle.Codec;
|
||||
existingSubtitle.Default = incomingSubtitle.Default;
|
||||
existingSubtitle.Forced = incomingSubtitle.Forced;
|
||||
existingSubtitle.SDH = incomingSubtitle.SDH;
|
||||
existingSubtitle.Language = incomingSubtitle.Language;
|
||||
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
|
||||
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
|
||||
// remove
|
||||
existing.Subtitles.RemoveAll(s => toRemove.Contains(s));
|
||||
|
||||
// update
|
||||
foreach (Subtitle incomingSubtitle in toUpdate)
|
||||
{
|
||||
Subtitle existingSubtitle =
|
||||
existing.Subtitles.First(s => s.StreamIndex == incomingSubtitle.StreamIndex);
|
||||
|
||||
existingSubtitle.Codec = incomingSubtitle.Codec;
|
||||
existingSubtitle.Default = incomingSubtitle.Default;
|
||||
existingSubtitle.Forced = incomingSubtitle.Forced;
|
||||
existingSubtitle.SDH = incomingSubtitle.SDH;
|
||||
existingSubtitle.Language = incomingSubtitle.Language;
|
||||
existingSubtitle.SubtitleKind = incomingSubtitle.SubtitleKind;
|
||||
existingSubtitle.DateUpdated = incomingSubtitle.DateUpdated;
|
||||
}
|
||||
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
}
|
||||
|
||||
return await dbContext.SaveChangesAsync() > 0;
|
||||
// nothing to do
|
||||
return true;
|
||||
}
|
||||
|
||||
// no metadata
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories;
|
||||
@@ -95,48 +91,6 @@ public class MovieRepository : IMovieRepository
|
||||
async () => await AddMovie(dbContext, libraryPath.Id, path));
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(
|
||||
PlexLibrary library,
|
||||
PlexMovie item)
|
||||
{
|
||||
await using TvContext context = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<PlexMovie> maybeExisting = await context.PlexMovies
|
||||
.AsNoTracking()
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.ThenInclude(a => a.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(i => i.Key)
|
||||
.SingleOrDefaultAsync(i => i.Key == item.Key);
|
||||
|
||||
return await maybeExisting.Match(
|
||||
plexMovie =>
|
||||
Right<BaseError, MediaItemScanResult<PlexMovie>>(
|
||||
new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false }).AsTask(),
|
||||
async () => await AddPlexMovie(context, library, item));
|
||||
}
|
||||
|
||||
public async Task<List<MovieMetadata>> GetMoviesForCards(List<int> ids)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -241,19 +195,6 @@ public class MovieRepository : IMovieRepository
|
||||
.Map(result => result > 0);
|
||||
}
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingPlexMovies(PlexLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT Key, Etag, MI.State FROM PlexMovie
|
||||
INNER JOIN Movie M on PlexMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateSortTitle(MovieMetadata movieMetadata)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -262,530 +203,6 @@ public class MovieRepository : IMovieRepository
|
||||
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
|
||||
}
|
||||
|
||||
public async Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<JellyfinItemEtag>(
|
||||
@"SELECT ItemId, Etag FROM JellyfinMovie
|
||||
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
|
||||
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds",
|
||||
new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<bool> AddJellyfin(JellyfinMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await dbContext.AddAsync(movie);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
|
||||
.Include(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (maybeExisting.IsSome)
|
||||
{
|
||||
JellyfinMovie existing = maybeExisting.ValueUnsafe();
|
||||
|
||||
// library path is used for search indexing later
|
||||
movie.LibraryPath = existing.LibraryPath;
|
||||
movie.Id = existing.Id;
|
||||
|
||||
existing.Etag = movie.Etag;
|
||||
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.Tagline = incomingMetadata.Tagline;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.Filter(g => g.ExternalCollectionId is null)
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Remove(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Add(studio);
|
||||
}
|
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors
|
||||
.Filter(
|
||||
a => incomingMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Remove(actor);
|
||||
}
|
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors
|
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
// directors
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Remove(director);
|
||||
}
|
||||
|
||||
foreach (Director director in incomingMetadata.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Add(director);
|
||||
}
|
||||
|
||||
// writers
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Remove(writer);
|
||||
}
|
||||
|
||||
foreach (Writer writer in incomingMetadata.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Add(writer);
|
||||
}
|
||||
|
||||
// guids
|
||||
foreach (MetadataGuid guid in metadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Remove(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Add(guid);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = movie.MediaVersions.Head();
|
||||
version.Name = incomingVersion.Name;
|
||||
version.DateAdded = incomingVersion.DateAdded;
|
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
|
||||
file.Path = incomingFile.Path;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<List<EmbyItemEtag>> GetExistingEmbyMovies(EmbyLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<EmbyItemEtag>(
|
||||
@"SELECT ItemId, Etag FROM EmbyMovie
|
||||
INNER JOIN Movie M on EmbyMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
List<int> ids = await dbContext.Connection.QueryAsync<int>(
|
||||
@"SELECT EmbyMovie.Id FROM EmbyMovie
|
||||
INNER JOIN Movie M on EmbyMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds",
|
||||
new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList());
|
||||
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
"DELETE FROM MediaItem WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEmby(EmbyMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
await dbContext.AddAsync(movie);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<EmbyMovie> maybeExisting = await dbContext.EmbyMovies
|
||||
.Include(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.Include(m => m.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (maybeExisting.IsSome)
|
||||
{
|
||||
EmbyMovie existing = maybeExisting.ValueUnsafe();
|
||||
|
||||
// library path is used for search indexing later
|
||||
movie.LibraryPath = existing.LibraryPath;
|
||||
movie.Id = existing.Id;
|
||||
|
||||
existing.Etag = movie.Etag;
|
||||
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
|
||||
metadata.MetadataKind = incomingMetadata.MetadataKind;
|
||||
metadata.ContentRating = incomingMetadata.ContentRating;
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.Tagline = incomingMetadata.Tagline;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.Filter(g => g.ExternalCollectionId is null)
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Remove(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Add(studio);
|
||||
}
|
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors
|
||||
.Filter(
|
||||
a => incomingMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Remove(actor);
|
||||
}
|
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors
|
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
// directors
|
||||
foreach (Director director in metadata.Directors
|
||||
.Filter(d => incomingMetadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Remove(director);
|
||||
}
|
||||
|
||||
foreach (Director director in incomingMetadata.Directors
|
||||
.Filter(d => metadata.Directors.All(d2 => d2.Name != d.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Directors.Add(director);
|
||||
}
|
||||
|
||||
// writers
|
||||
foreach (Writer writer in metadata.Writers
|
||||
.Filter(w => incomingMetadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Remove(writer);
|
||||
}
|
||||
|
||||
foreach (Writer writer in incomingMetadata.Writers
|
||||
.Filter(w => metadata.Writers.All(w2 => w2.Name != w.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Writers.Add(writer);
|
||||
}
|
||||
|
||||
// guids
|
||||
foreach (MetadataGuid guid in metadata.Guids
|
||||
.Filter(g => incomingMetadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Remove(guid);
|
||||
}
|
||||
|
||||
foreach (MetadataGuid guid in incomingMetadata.Guids
|
||||
.Filter(g => metadata.Guids.All(g2 => g2.Guid != g.Guid))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Guids.Add(guid);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = movie.MediaVersions.Head();
|
||||
version.Name = incomingVersion.Name;
|
||||
version.DateAdded = incomingVersion.DateAdded;
|
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
|
||||
file.Path = incomingFile.Path;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddDirector(MovieMetadata metadata, Director director)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -810,14 +227,6 @@ public class MovieRepository : IMovieRepository
|
||||
new { Path = path, MediaFileId = mediaFileId }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetPlexEtag(PlexMovie movie, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
@@ -852,33 +261,4 @@ public class MovieRepository : IMovieRepository
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddPlexMovie(
|
||||
TvContext context,
|
||||
PlexLibrary library,
|
||||
PlexMovie item)
|
||||
{
|
||||
try
|
||||
{
|
||||
// blank out etag for initial save in case stats/metadata/etc updates fail
|
||||
string etag = item.Etag;
|
||||
item.Etag = string.Empty;
|
||||
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await context.PlexMovies.AddAsync(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// restore etag
|
||||
item.Etag = etag;
|
||||
|
||||
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await context.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +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;
|
||||
@@ -11,6 +15,19 @@ public class PlexMovieRepository : IPlexMovieRepository
|
||||
|
||||
public PlexMovieRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<PlexItemEtag>> GetExistingMovies(PlexLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<PlexItemEtag>(
|
||||
@"SELECT Key, Etag, MI.State FROM PlexMovie
|
||||
INNER JOIN Movie M on PlexMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<bool> FlagNormal(PlexLibrary library, PlexMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -73,4 +90,81 @@ public class PlexMovieRepository : IPlexMovieRepository
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> GetOrAdd(PlexLibrary library, PlexMovie item)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
Option<PlexMovie> maybeExisting = await dbContext.PlexMovies
|
||||
.AsNoTracking()
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.ThenInclude(a => a.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(i => i.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Guids)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(i => i.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(i => i.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(i => i.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.SelectOneAsync(i => i.Key, i => i.Key == item.Key);
|
||||
|
||||
foreach (PlexMovie plexMovie in maybeExisting)
|
||||
{
|
||||
return new MediaItemScanResult<PlexMovie>(plexMovie) { IsAdded = false };
|
||||
}
|
||||
|
||||
return await AddMovie(dbContext, library, item);
|
||||
}
|
||||
|
||||
public async Task<Unit> SetEtag(PlexMovie movie, string etag)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.ExecuteAsync(
|
||||
"UPDATE PlexMovie SET Etag = @Etag WHERE Id = @Id",
|
||||
new { Etag = etag, movie.Id }).Map(_ => Unit.Default);
|
||||
}
|
||||
|
||||
private async Task<Either<BaseError, MediaItemScanResult<PlexMovie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
PlexLibrary library,
|
||||
PlexMovie item)
|
||||
{
|
||||
try
|
||||
{
|
||||
// blank out etag for initial save in case stats/metadata/etc updates fail
|
||||
string etag = item.Etag;
|
||||
item.Etag = string.Empty;
|
||||
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await dbContext.PlexMovies.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// restore etag
|
||||
item.Etag = etag;
|
||||
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<PlexMovie>(item) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,105 @@ 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
|
||||
string etag = item.Etag;
|
||||
item.Etag = string.Empty;
|
||||
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await dbContext.PlexShows.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// restore etag
|
||||
item.Etag = etag;
|
||||
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return new MediaItemScanResult<PlexShow>(item) { IsAdded = true };
|
||||
}
|
||||
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
|
||||
string etag = item.Etag;
|
||||
item.Etag = string.Empty;
|
||||
|
||||
item.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await dbContext.PlexSeasons.AddAsync(item);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
// restore etag
|
||||
item.Etag = etag;
|
||||
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return 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
|
||||
string etag = item.Etag;
|
||||
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();
|
||||
|
||||
// restore etag
|
||||
item.Etag = etag;
|
||||
|
||||
await dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(item.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
await dbContext.Entry(item).Reference(e => e.Season).LoadAsync();
|
||||
return new MediaItemScanResult<PlexEpisode>(item) { IsAdded = true };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,6 @@ public class SearchRepository : ISearchRepository
|
||||
|
||||
public SearchRepository(IDbContextFactory<TvContext> dbContextFactory) => _dbContextFactory = dbContextFactory;
|
||||
|
||||
public async Task<List<int>> GetItemIdsToIndex()
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.Connection.QueryAsync<int>(@"SELECT Id FROM MediaItem")
|
||||
.Map(result => result.ToList());
|
||||
}
|
||||
|
||||
public async Task<Option<MediaItem>> GetItemToIndex(int id)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
@@ -57,6 +50,8 @@ public class SearchRepository : ISearchRepository
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(em => em.Streams)
|
||||
.Include(mi => (mi as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
@@ -103,9 +98,7 @@ public class SearchRepository : ISearchRepository
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.OrderBy(mi => mi.Id)
|
||||
.SingleOrDefaultAsync(mi => mi.Id == id)
|
||||
.Map(Optional);
|
||||
.SelectOneAsync(mi => mi.Id, mi => mi.Id == id);
|
||||
}
|
||||
|
||||
public async Task<List<string>> GetLanguagesForShow(Show show)
|
||||
@@ -151,4 +144,93 @@ public class SearchRepository : ISearchRepository
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
|
||||
return await dbContext.LanguageCodes.GetAllLanguageCodes(mediaCodes);
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<MediaItem> GetAllMediaItems()
|
||||
{
|
||||
TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
return dbContext.MediaItems
|
||||
.AsNoTracking()
|
||||
.Include(mi => mi.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Directors)
|
||||
.Include(mi => (mi as Movie).MovieMetadata)
|
||||
.ThenInclude(mm => mm.Writers)
|
||||
.Include(mi => (mi as Movie).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Genres)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Tags)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Studios)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Actors)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Directors)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Writers)
|
||||
.Include(mi => (mi as Episode).EpisodeMetadata)
|
||||
.ThenInclude(em => em.Guids)
|
||||
.Include(mi => (mi as Episode).MediaVersions)
|
||||
.ThenInclude(em => em.Streams)
|
||||
.Include(mi => (mi as Episode).Season)
|
||||
.ThenInclude(s => s.Show)
|
||||
.ThenInclude(s => s.ShowMetadata)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Genres)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Tags)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Studios)
|
||||
.Include(mi => (mi as Season).SeasonMetadata)
|
||||
.ThenInclude(sm => sm.Actors)
|
||||
.Include(mi => (mi as Season).Show)
|
||||
.ThenInclude(sm => sm.ShowMetadata)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as Show).ShowMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(mi => (mi as MusicVideo).Artist)
|
||||
.ThenInclude(mm => mm.ArtistMetadata)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as MusicVideo).MusicVideoMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(mi => (mi as MusicVideo).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Artist).ArtistMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Artist).ArtistMetadata)
|
||||
.ThenInclude(mm => mm.Styles)
|
||||
.Include(mi => (mi as Artist).ArtistMetadata)
|
||||
.ThenInclude(mm => mm.Moods)
|
||||
.Include(mi => (mi as OtherVideo).OtherVideoMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as OtherVideo).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => (mi as Song).SongMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(mi => (mi as Song).SongMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(mi => (mi as Song).MediaVersions)
|
||||
.ThenInclude(mm => mm.Streams)
|
||||
.Include(mi => mi.TraktListItems)
|
||||
.ThenInclude(tli => tli.TraktList)
|
||||
.AsAsyncEnumerable();
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user