Compare commits

..
Author SHA1 Message Date
Jason Dove 87aa69f4cc update changelog for release v0.5.4-beta [no ci] 2022-04-29 17:59:12 -05:00
Jason DoveandGitHub 404ea49e35 jellyfin and emby path infos (#771)
* start adding jellyfin path info; fix some scanning bugs

* sync jellyfin libraries before scanning to ensure latest path infos

* code cleanup

* support emby path infos

* fix periodic scanning of emby and jellyfin libraries

* bug fixes
2022-04-29 15:07:17 -05:00
Jason DoveandGitHub 4ed40acfbe rebuild corrupt search index (#770) 2022-04-28 13:49:06 -05:00
Jason DoveandGitHub 17f540dc99 add more search fields (#769) 2022-04-28 10:40:27 -05:00
Jason DoveandGitHub 780ebc01ee add v2 ffmpeg profile page (#768)
* wip

* remove transcode property; use i18n

* add api

* use computed table headers for i18n
2022-04-28 06:56:01 -05:00
Jason DoveandGitHub 0a0fb71b94 refactor plex, emby and jellyfin television scanners (#767)
* refactor plex television scanner

* refactor emby television scanner

* refactor jellyfin television scanner

* update changelog
2022-04-27 22:34:25 -05:00
Jason Dove 53d6ecae8d fix windows build 2022-04-27 14:20:14 -05:00
Jason DoveandGitHub 837f311ec0 add more search fields (#766)
* properly index show and season state

* add height, width, season_number, episode_number to search index

* update docs
2022-04-27 13:58:33 -05:00
Jason DoveandGitHub a9a89d04ea optimize search-index rebuilding (#765)
* update dependencies

* optimize search-index rebuilding

* cleanup logging
2022-04-27 12:23:37 -05:00
Rafael VieiraandGitHub 2e1073eb53 Add support to internationalization (#764)
* client-app: Improve development documentation

* client-app: add basic support to translation

* client-app: fix i18n and create lang state

* client-app: add language selector

* client-app: add translation EN and PT-BR
2022-04-27 10:58:27 -05:00
Jason DoveandGitHub 7687278b80 health check fixes (#763) 2022-04-26 09:38:03 -05:00
Jason DoveandGitHub 392aebd46f refactor movie library scanners (#761)
* catch health check cancellation

* local library scanner cleanup

* emby and jf library scanner cleanup

* rework emby movie library scanner

* refactor emby movie library scanner

* refactor jellyfin movie library scanner

* clear etag until after new media has been processed

* refactor plex movie library scanner

* update changelog
2022-04-25 20:31:12 -05:00
174 changed files with 24350 additions and 5604 deletions
+27 -1
View File
@@ -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
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+9 -4
View File
@@ -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);
+1 -1
View File
@@ -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);
+2 -1
View File
@@ -5,4 +5,5 @@ public enum ChannelSubtitleMode
None = 0,
Forced = 1,
Default = 2,
Any = 3}
Any = 3
}
+4 -1
View File
@@ -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;
+7 -3
View File
@@ -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; }
}
+60 -209
View File
@@ -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);
}
+8
View File
@@ -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);
}
+2 -2
View File
@@ -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>
+1
View File
@@ -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;
+7 -3
View File
@@ -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());
}
}
}
+105 -94
View File
@@ -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)
{
+143 -119
View File
@@ -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(
+114 -97
View File
@@ -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;
}
+130 -95
View File
@@ -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;
+4 -3
View File
@@ -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; }
}
+24 -24
View File
@@ -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;
}
+276 -472
View File
@@ -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
+1 -1
View File
@@ -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