Browse Source

optimize jellyfin collection scanning (#1453)

pull/1455/head
Jason Dove 2 years ago committed by GitHub
parent
commit
3ab8e5bc3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 83
      ErsatzTV.Application/Jellyfin/Commands/CallJellyfinCollectionScannerHandler.cs
  3. 6
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinCollections.cs
  4. 37
      ErsatzTV.Application/Libraries/Queries/GetExternalCollectionsHandler.cs
  5. 1
      ErsatzTV.Core/Domain/MediaSource/JellyfinMediaSource.cs
  6. 4
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  7. 3
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  8. 4432
      ErsatzTV.Infrastructure.MySql/Migrations/20230929142116_Add_JellyfinMediaSourceLastCollectionsScan.Designer.cs
  9. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20230929142116_Add_JellyfinMediaSourceLastCollectionsScan.cs
  10. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  11. 4430
      ErsatzTV.Infrastructure.Sqlite/Migrations/20230929142203_Add_JellyfinMediaSourceLastCollectionsScan.Designer.cs
  12. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20230929142203_Add_JellyfinMediaSourceLastCollectionsScan.cs
  13. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  14. 10
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  15. 28
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  16. 4
      ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyCollectionsHandler.cs
  17. 23
      ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs
  18. 2
      ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinCollections.cs
  19. 67
      ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinCollectionsHandler.cs
  20. 41
      ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs
  21. 23
      ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  22. 15
      ErsatzTV.Scanner/Core/Jellyfin/JellyfinCollectionScanner.cs
  23. 23
      ErsatzTV.Scanner/Worker.cs
  24. 10
      ErsatzTV/Pages/Libraries.razor
  25. 32
      ErsatzTV/Services/ScannerService.cs
  26. 11
      ErsatzTV/Services/SchedulerService.cs

5
CHANGELOG.md

@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -17,6 +17,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix playout bug that caused some schedule items with fixed start times to be pushed to the next day
- Fix VAAPI transcoding 8-bit source content to 10-bit
- Fix NVIDIA subtitle scaling when `scale_npp` filter is unavailable
- Remove ffmpeg and ffprobe as required dependencies for scanning media server libraries
- Note that ffmpeg is still *always* required for playback to work
### Changed
- Upgrade ffmpeg to 6.1, which is now *required* for all installs
@ -25,6 +27,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -25,6 +27,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `Off`: do not normalize loudness
- `loudnorm`: use `loudnorm` filter to normalize loudness (generally higher CPU use)
- `dynaudnorm`: use `dynaudnorm` filter to normalize loudness (generally lower CPU use)
- Jellyfin collection scanning will no longer happen after every (automatic or forced) library scan
- Automatic/periodic scans will check collections one time after all libraries have been scanned
- There is a new table in the `Media` > `Libraries` page with a button to manually re-scan Jellyfin collections as needed
## [0.8.2-beta] - 2023-09-14
### Added

83
ErsatzTV.Application/Jellyfin/Commands/CallJellyfinCollectionScannerHandler.cs

@ -0,0 +1,83 @@ @@ -0,0 +1,83 @@
using System.Globalization;
using System.Threading.Channels;
using ErsatzTV.Application.Libraries;
using ErsatzTV.Core;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.FFmpeg.Runtime;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Jellyfin;
public class CallJellyfinCollectionScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinCollections>,
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
{
public CallJellyfinCollectionScannerHandler(
IDbContextFactory<TvContext> dbContextFactory,
IConfigElementRepository configElementRepository,
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel,
IMediator mediator,
IRuntimeInfo runtimeInfo) : base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo)
{
}
public async Task<Either<BaseError, Unit>>
Handle(SynchronizeJellyfinCollections request, CancellationToken cancellationToken)
{
Validation<BaseError, string> validation = await Validate(request);
return await validation.Match(
scanner => PerformScan(scanner, request, cancellationToken),
error =>
{
foreach (ScanIsNotRequired scanIsNotRequired in error.OfType<ScanIsNotRequired>())
{
return Task.FromResult<Either<BaseError, Unit>>(scanIsNotRequired);
}
return Task.FromResult<Either<BaseError, Unit>>(error.Join());
});
}
protected override async Task<DateTimeOffset> GetLastScan(TvContext dbContext, SynchronizeJellyfinCollections request)
{
DateTime minDateTime = await dbContext.JellyfinMediaSources
.SelectOneAsync(l => l.Id, l => l.Id == request.JellyfinMediaSourceId)
.Match(l => l.LastCollectionsScan ?? SystemTime.MinValueUtc, () => SystemTime.MaxValueUtc);
return new DateTimeOffset(minDateTime, TimeSpan.Zero);
}
protected override bool ScanIsRequired(
DateTimeOffset lastScan,
int libraryRefreshInterval,
SynchronizeJellyfinCollections request)
{
if (lastScan == SystemTime.MaxValueUtc)
{
return false;
}
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
return request.ForceScan || libraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now;
}
private async Task<Either<BaseError, Unit>> PerformScan(
string scanner,
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
var arguments = new List<string>
{
"scan-jellyfin-collections", request.JellyfinMediaSourceId.ToString(CultureInfo.InvariantCulture)
};
if (request.ForceScan)
{
arguments.Add("--force");
}
return await base.PerformScan(scanner, arguments, cancellationToken).MapT(_ => Unit.Default);
}
}

6
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinCollections.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
IScannerBackgroundServiceRequest;

37
ErsatzTV.Application/Libraries/Queries/GetExternalCollectionsHandler.cs

@ -15,20 +15,37 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti @@ -15,20 +15,37 @@ public class GetExternalCollectionsHandler : IRequestHandler<GetExternalCollecti
GetExternalCollections request,
CancellationToken cancellationToken)
{
List<LibraryViewModel> result = new();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
List<int> mediaSourceIds = await dbContext.EmbyMediaSources
result.AddRange(await GetEmbyExternalCollections(dbContext, cancellationToken));
result.AddRange(await GetJellyfinExternalCollections(dbContext, cancellationToken));
return result;
}
private static async Task<IEnumerable<LibraryViewModel>> GetEmbyExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> embyMediaSourceIds = await dbContext.EmbyMediaSources
.Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
.Map(ems => ems.Id)
.ToListAsync(cancellationToken);
return mediaSourceIds.Map(
id => new LibraryViewModel(
"Emby",
0,
"Collections",
0,
id,
string.Empty))
.ToList();
return embyMediaSourceIds.Map(id => new LibraryViewModel("Emby", 0, "Collections", 0, id, string.Empty));
}
private static async Task<IEnumerable<LibraryViewModel>> GetJellyfinExternalCollections(
TvContext dbContext,
CancellationToken cancellationToken)
{
List<int> jellyfinMediaSourceIds = await dbContext.JellyfinMediaSources
.Filter(jms => jms.Libraries.Any(l => ((JellyfinLibrary)l).ShouldSyncItems))
.Map(jms => jms.Id)
.ToListAsync(cancellationToken);
return jellyfinMediaSourceIds.Map(
id => new LibraryViewModel("Jellyfin", 0, "Collections", 0, id, string.Empty));
}
}

1
ErsatzTV.Core/Domain/MediaSource/JellyfinMediaSource.cs

@ -6,4 +6,5 @@ public class JellyfinMediaSource : MediaSource @@ -6,4 +6,5 @@ public class JellyfinMediaSource : MediaSource
public string OperatingSystem { get; set; }
public List<JellyfinConnection> Connections { get; set; }
public List<JellyfinPathReplacement> PathReplacements { get; set; }
public DateTime? LastCollectionsScan { get; set; }
}

4
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -7,6 +7,7 @@ public interface IEntityLocker @@ -7,6 +7,7 @@ public interface IEntityLocker
event EventHandler<Type> OnRemoteMediaSourceChanged;
event EventHandler OnTraktChanged;
event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
@ -23,6 +24,9 @@ public interface IEntityLocker @@ -23,6 +24,9 @@ public interface IEntityLocker
bool LockEmbyCollections();
bool UnlockEmbyCollections();
bool AreEmbyCollectionsLocked();
bool LockJellyfinCollections();
bool UnlockJellyfinCollections();
bool AreJellyfinCollectionsLocked();
bool LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId);

3
ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs

@ -82,5 +82,6 @@ public interface IMediaSourceRepository @@ -82,5 +82,6 @@ public interface IMediaSourceRepository
Task<List<int>> DeleteAllEmby();
Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds);
Task<List<int>> DisableEmbyLibrarySync(List<int> libraryIds);
Task<Unit> UpdateLastScan(EmbyMediaSource embyMediaSource);
Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource);
Task<Unit> UpdateLastCollectionScan(JellyfinMediaSource jellyfinMediaSource);
}

4432
ErsatzTV.Infrastructure.MySql/Migrations/20230929142116_Add_JellyfinMediaSourceLastCollectionsScan.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20230929142116_Add_JellyfinMediaSourceLastCollectionsScan.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_JellyfinMediaSourceLastCollectionsScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastCollectionsScan",
table: "JellyfinMediaSource",
type: "datetime(6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastCollectionsScan",
table: "JellyfinMediaSource");
}
}
}

3
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -2471,6 +2471,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2471,6 +2471,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("datetime(6)");
b.Property<string>("OperatingSystem")
.HasColumnType("longtext");

4430
ErsatzTV.Infrastructure.Sqlite/Migrations/20230929142203_Add_JellyfinMediaSourceLastCollectionsScan.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20230929142203_Add_JellyfinMediaSourceLastCollectionsScan.cs

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_JellyfinMediaSourceLastCollectionsScan : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "LastCollectionsScan",
table: "JellyfinMediaSource",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastCollectionsScan",
table: "JellyfinMediaSource");
}
}
}

3
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -2469,6 +2469,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2469,6 +2469,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("TEXT");
b.Property<string>("OperatingSystem")
.HasColumnType("TEXT");

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

@ -858,11 +858,19 @@ public class MediaSourceRepository : IMediaSourceRepository @@ -858,11 +858,19 @@ public class MediaSourceRepository : IMediaSourceRepository
return deletedMediaIds;
}
public async Task<Unit> UpdateLastScan(EmbyMediaSource embyMediaSource)
public async Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { embyMediaSource.LastCollectionsScan, embyMediaSource.Id }).ToUnit();
}
public async Task<Unit> UpdateLastCollectionScan(JellyfinMediaSource jellyfinMediaSource)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"UPDATE JellyfinMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { jellyfinMediaSource.LastCollectionsScan, jellyfinMediaSource.Id }).ToUnit();
}
}

28
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -9,6 +9,7 @@ public class EntityLocker : IEntityLocker @@ -9,6 +9,7 @@ public class EntityLocker : IEntityLocker
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts;
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes;
private bool _embyCollections;
private bool _jellyfinCollections;
private bool _plex;
private bool _trakt;
@ -24,6 +25,7 @@ public class EntityLocker : IEntityLocker @@ -24,6 +25,7 @@ public class EntityLocker : IEntityLocker
public event EventHandler<Type> OnRemoteMediaSourceChanged;
public event EventHandler OnTraktChanged;
public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler OnJellyfinCollectionsChanged;
public event EventHandler<int> OnPlayoutChanged;
public bool LockLibrary(int libraryId)
@ -158,6 +160,32 @@ public class EntityLocker : IEntityLocker @@ -158,6 +160,32 @@ public class EntityLocker : IEntityLocker
}
public bool AreEmbyCollectionsLocked() => _embyCollections;
public bool LockJellyfinCollections()
{
if (!_jellyfinCollections)
{
_jellyfinCollections = true;
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockJellyfinCollections()
{
if (_jellyfinCollections)
{
_jellyfinCollections = false;
OnJellyfinCollectionsChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool AreJellyfinCollectionsLocked() => _jellyfinCollections;
public bool LockPlayout(int playoutId)
{

4
ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyCollectionsHandler.cs

@ -60,7 +60,7 @@ public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmby @@ -60,7 +60,7 @@ public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmby
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
private static Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
EmbyMediaSource embyMediaSource)
{
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
@ -93,7 +93,7 @@ public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmby @@ -93,7 +93,7 @@ public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmby
if (result.IsRight)
{
parameters.MediaSource.LastCollectionsScan = DateTime.UtcNow;
await _mediaSourceRepository.UpdateLastScan(parameters.MediaSource);
await _mediaSourceRepository.UpdateLastCollectionScan(parameters.MediaSource);
}
return result;

23
ErsatzTV.Scanner/Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs

@ -108,17 +108,14 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby @@ -108,17 +108,14 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
private async Task<Validation<BaseError, RequestParameters>> Validate(
SynchronizeEmbyLibraryById request) =>
(await ValidateConnection(request), await EmbyLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateLibraryRefreshInterval())
.Apply(
(connectionParameters, embyLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
(connectionParameters, embyLibrary, libraryRefreshInterval) =>
new RequestParameters(
connectionParameters,
embyLibrary,
request.ForceScan,
libraryRefreshInterval,
ffmpegPath,
ffprobePath,
request.DeepScan
));
@ -163,27 +160,11 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby @@ -163,27 +160,11 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
.FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(
ffmpegPath =>
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
EmbyLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFmpegPath,
string FFprobePath,
bool DeepScan);
private record ConnectionParameters(EmbyConnection ActiveConnection)

2
ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinCollections.cs

@ -2,4 +2,4 @@ @@ -2,4 +2,4 @@
namespace ErsatzTV.Scanner.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>;

67
ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinCollectionsHandler.cs

@ -12,15 +12,18 @@ public class @@ -12,15 +12,18 @@ public class
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IJellyfinCollectionScanner _scanner;
private readonly IConfigElementRepository _configElementRepository;
public SynchronizeJellyfinCollectionsHandler(
IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinCollectionScanner scanner)
IJellyfinCollectionScanner scanner,
IConfigElementRepository configElementRepository)
{
_mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore;
_scanner = scanner;
_configElementRepository = configElementRepository;
}
@ -28,23 +31,38 @@ public class @@ -28,23 +31,38 @@ public class
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
Validation<BaseError, ConnectionParameters> validation = await Validate(request);
Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match(
SynchronizeCollections,
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinCollections request) =>
MediaSourceMustExist(request)
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizeJellyfinCollections request)
{
Task<Validation<BaseError, ConnectionParameters>> mediaSource = MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
return (await mediaSource, await ValidateLibraryRefreshInterval())
.Apply(
(connectionParameters, libraryRefreshInterval) => new RequestParameters(
connectionParameters,
connectionParameters.MediaSource,
request.ForceScan,
libraryRefreshInterval));
}
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
.FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
SynchronizeJellyfinCollections request) =>
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
private static Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource)
{
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
@ -62,15 +80,38 @@ public class @@ -62,15 +80,38 @@ public class
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) =>
await _scanner.ScanCollections(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey,
connectionParameters.JellyfinMediaSource.Id);
private async Task<Either<BaseError, Unit>> SynchronizeCollections(RequestParameters parameters)
{
var lastScan = new DateTimeOffset(
parameters.MediaSource.LastCollectionsScan ?? SystemTime.MinValueUtc,
TimeSpan.Zero);
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || parameters.LibraryRefreshInterval > 0 && nextScan < DateTimeOffset.Now)
{
Either<BaseError, Unit> result = await _scanner.ScanCollections(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.MediaSource.Id);
if (result.IsRight)
{
parameters.MediaSource.LastCollectionsScan = DateTime.UtcNow;
await _mediaSourceRepository.UpdateLastCollectionScan(parameters.MediaSource);
}
return result;
}
return Unit.Default;
}
private record RequestParameters(
ConnectionParameters ConnectionParameters,
JellyfinMediaSource MediaSource,
bool ForceScan,
int LibraryRefreshInterval);
private record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
private record ConnectionParameters(JellyfinMediaSource MediaSource, JellyfinConnection ActiveConnection)
{
public string? ApiKey { get; init; }
}

41
ErsatzTV.Scanner/Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs

@ -13,7 +13,6 @@ public class @@ -13,7 +13,6 @@ public class
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
private readonly IJellyfinSecretStore _jellyfinSecretStore;
@ -24,7 +23,6 @@ public class @@ -24,7 +23,6 @@ public class
private readonly IMediator _mediator;
public SynchronizeJellyfinLibraryByIdHandler(
IJellyfinApiClient jellyfinApiClient,
IMediator mediator,
IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore,
@ -34,7 +32,6 @@ public class @@ -34,7 +32,6 @@ public class
IConfigElementRepository configElementRepository,
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
{
_jellyfinApiClient = jellyfinApiClient;
_mediator = mediator;
_mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore;
@ -96,22 +93,6 @@ public class @@ -96,22 +93,6 @@ public class
{
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
// need to call get libraries to find library that contains collections (box sets)
await _jellyfinApiClient.GetLibraries(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey);
Either<BaseError, Unit> collectionResult = await _mediator.Send(
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId),
cancellationToken);
collectionResult.BiIter(
_ => _logger.LogDebug("Done synchronizing jellyfin collections"),
error => _logger.LogWarning(
"Unable to synchronize jellyfin collections for source {MediaSourceId}: {Error}",
parameters.Library.MediaSourceId,
error.Value));
}
foreach (BaseError error in result.LeftToSeq())
@ -140,16 +121,14 @@ public class @@ -140,16 +121,14 @@ public class
private async Task<Validation<BaseError, RequestParameters>> Validate(
SynchronizeJellyfinLibraryById request) =>
(await ValidateConnection(request), await JellyfinLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
await ValidateLibraryRefreshInterval())
.Apply(
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
(connectionParameters, jellyfinLibrary, libraryRefreshInterval) =>
new RequestParameters(
connectionParameters,
jellyfinLibrary,
request.ForceScan,
libraryRefreshInterval,
ffmpegPath,
ffprobePath,
request.DeepScan
));
@ -194,27 +173,11 @@ public class @@ -194,27 +173,11 @@ public class
.FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(
ffmpegPath =>
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
JellyfinLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFmpegPath,
string FFprobePath,
bool DeepScan);
private record ConnectionParameters(JellyfinConnection ActiveConnection)

23
ErsatzTV.Scanner/Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs

@ -108,17 +108,14 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex @@ -108,17 +108,14 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
}
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizePlexLibraryById request) =>
(await ValidateConnection(request), await PlexLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
(await ValidateConnection(request), await PlexLibraryMustExist(request), await ValidateLibraryRefreshInterval())
.Apply(
(connectionParameters, plexLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) =>
(connectionParameters, plexLibrary, libraryRefreshInterval) =>
new RequestParameters(
connectionParameters,
plexLibrary,
request.ForceScan,
libraryRefreshInterval,
ffmpegPath,
ffprobePath,
request.DeepScan
));
@ -163,27 +160,11 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex @@ -163,27 +160,11 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
.FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
private Task<Validation<BaseError, string>> ValidateFFmpegPath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFmpegPath)
.FilterT(File.Exists)
.Map(
ffmpegPath =>
ffmpegPath.ToValidation<BaseError>("FFmpeg path does not exist on the file system"));
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(
ConnectionParameters ConnectionParameters,
PlexLibrary Library,
bool ForceScan,
int LibraryRefreshInterval,
string FFmpegPath,
string FFprobePath,
bool DeepScan);
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)

15
ErsatzTV.Scanner/Core/Jellyfin/JellyfinCollectionScanner.cs

@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain; @@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources;
using ErsatzTV.Scanner.Application.Jellyfin;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin;
@ -30,6 +31,20 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner @@ -30,6 +31,20 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
{
try
{
// need the jellyfin admin user id for now
Either<BaseError, Unit> syncAdminResult = await _mediator.Send(
new SynchronizeJellyfinAdminUserId(mediaSourceId),
CancellationToken.None);
foreach (BaseError error in syncAdminResult.LeftToSeq())
{
_logger.LogError("Error synchronizing jellyfin admin user id: {Error}", error);
return error;
}
// need to call get libraries to find library that contains collections (box sets)
await _jellyfinApiClient.GetLibraries(address, apiKey);
var incomingItemIds = new List<string>();
// get all collections from db (item id, etag)

23
ErsatzTV.Scanner/Worker.cs

@ -84,6 +84,10 @@ public class Worker : BackgroundService @@ -84,6 +84,10 @@ public class Worker : BackgroundService
scanJellyfinCommand.AddOption(forceOption);
scanJellyfinCommand.AddOption(deepOption);
var scanJellyfinCollectionsCommand = new Command("scan-jellyfin-collections", "Scan Jellyfin collections");
scanJellyfinCollectionsCommand.AddArgument(mediaSourceIdArgument);
scanJellyfinCollectionsCommand.AddOption(forceOption);
scanLocalCommand.SetHandler(
async context =>
{
@ -176,6 +180,24 @@ public class Worker : BackgroundService @@ -176,6 +180,24 @@ public class Worker : BackgroundService
await mediator.Send(scan, context.GetCancellationToken());
}
});
scanJellyfinCollectionsCommand.SetHandler(
async context =>
{
if (IsScanningEnabled())
{
bool force = context.ParseResult.GetValueForOption(forceOption);
SetProcessPriority(force);
int mediaSourceId = context.ParseResult.GetValueForArgument(mediaSourceIdArgument);
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
var scan = new SynchronizeJellyfinCollections(mediaSourceId, force);
await mediator.Send(scan, context.GetCancellationToken());
}
});
var rootCommand = new RootCommand();
rootCommand.AddCommand(scanLocalCommand);
@ -183,6 +205,7 @@ public class Worker : BackgroundService @@ -183,6 +205,7 @@ public class Worker : BackgroundService
rootCommand.AddCommand(scanEmbyCommand);
rootCommand.AddCommand(scanEmbyCollectionsCommand);
rootCommand.AddCommand(scanJellyfinCommand);
rootCommand.AddCommand(scanJellyfinCollectionsCommand);
return rootCommand;
}

10
ErsatzTV/Pages/Libraries.razor

@ -151,6 +151,7 @@ @@ -151,6 +151,7 @@
{
_locker.OnLibraryChanged += LockChanged;
_locker.OnEmbyCollectionsChanged += LockChanged;
_locker.OnJellyfinCollectionsChanged += LockChanged;
_courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
}
@ -201,6 +202,12 @@ @@ -201,6 +202,12 @@
await _scannerWorkerChannel.WriteAsync(new SynchronizeEmbyCollections(library.MediaSourceId, true));
}
break;
case "jellyfin":
if (_locker.LockJellyfinCollections())
{
await _scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinCollections(library.MediaSourceId, true));
}
break;
}
}
@ -213,6 +220,8 @@ @@ -213,6 +220,8 @@
{
case "emby":
return _locker.AreEmbyCollectionsLocked();
case "jellyfin":
return _locker.AreJellyfinCollectionsLocked();
}
return false;
@ -238,6 +247,7 @@ @@ -238,6 +247,7 @@
{
_locker.OnLibraryChanged -= LockChanged;
_locker.OnEmbyCollectionsChanged -= LockChanged;
_locker.OnJellyfinCollectionsChanged -= LockChanged;
_courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
_cts.Cancel();

32
ErsatzTV/Services/ScannerService.cs

@ -55,6 +55,9 @@ public class ScannerService : BackgroundService @@ -55,6 +55,9 @@ public class ScannerService : BackgroundService
case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById:
requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, stoppingToken);
break;
case SynchronizeJellyfinCollections synchronizeJellyfinCollections:
requestTask = SynchronizeJellyfinCollections(synchronizeJellyfinCollections, stoppingToken);
break;
case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, stoppingToken);
break;
@ -231,6 +234,35 @@ public class ScannerService : BackgroundService @@ -231,6 +234,35 @@ public class ScannerService : BackgroundService
entityLocker.UnlockLibrary(request.JellyfinLibraryId);
}
}
private async Task SynchronizeJellyfinCollections(
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
IEntityLocker entityLocker = scope.ServiceProvider.GetRequiredService<IEntityLocker>();
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
result.BiIter(
_ => _logger.LogDebug("Done synchronizing jellyfin collections"),
error =>
{
if (error is ScanIsNotRequired)
{
_logger.LogDebug("Scan is not required for jellyfin collections at this time");
}
else
{
_logger.LogWarning("Unable to synchronize jellyfin collections: {Error}", error.Value);
}
});
if (entityLocker.AreJellyfinCollectionsLocked())
{
entityLocker.UnlockJellyfinCollections();
}
}
private async Task SynchronizeLibraries(SynchronizeEmbyLibraries request, CancellationToken cancellationToken)
{

11
ErsatzTV/Services/SchedulerService.cs

@ -250,8 +250,12 @@ public class SchedulerService : BackgroundService @@ -250,8 +250,12 @@ public class SchedulerService : BackgroundService
using IServiceScope scope = _serviceScopeFactory.CreateScope();
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
var mediaSourceIds = new System.Collections.Generic.HashSet<int>();
foreach (JellyfinLibrary library in dbContext.JellyfinLibraries.Filter(l => l.ShouldSyncItems))
{
mediaSourceIds.Add(library.MediaSourceId);
if (_entityLocker.LockLibrary(library.Id))
{
await _scannerWorkerChannel.WriteAsync(
@ -259,6 +263,13 @@ public class SchedulerService : BackgroundService @@ -259,6 +263,13 @@ public class SchedulerService : BackgroundService
cancellationToken);
}
}
foreach (int mediaSourceId in mediaSourceIds)
{
await _scannerWorkerChannel.WriteAsync(
new SynchronizeJellyfinCollections(mediaSourceId, false),
cancellationToken);
}
}
private async Task ScanEmbyMediaSources(CancellationToken cancellationToken)

Loading…
Cancel
Save