optimize jellyfin collection scanning (#1453)
This commit is contained in:
@@ -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/).
|
||||
- `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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin;
|
||||
|
||||
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId, bool ForceScan) : IRequest<Either<BaseError, Unit>>,
|
||||
IScannerBackgroundServiceRequest;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
bool LockEmbyCollections();
|
||||
bool UnlockEmbyCollections();
|
||||
bool AreEmbyCollectionsLocked();
|
||||
bool LockJellyfinCollections();
|
||||
bool UnlockJellyfinCollections();
|
||||
bool AreJellyfinCollectionsLocked();
|
||||
bool LockPlayout(int playoutId);
|
||||
bool UnlockPlayout(int playoutId);
|
||||
bool IsPlayoutLocked(int playoutId);
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
if (result.IsRight)
|
||||
{
|
||||
parameters.MediaSource.LastCollectionsScan = DateTime.UtcNow;
|
||||
await _mediaSourceRepository.UpdateLastScan(parameters.MediaSource);
|
||||
await _mediaSourceRepository.UpdateLastCollectionScan(parameters.MediaSource);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -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
|
||||
.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,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>>;
|
||||
|
||||
+54
-13
@@ -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
|
||||
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
|
||||
.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);
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
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 MediaSource, JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string? ApiKey { get; init; }
|
||||
}
|
||||
|
||||
+2
-39
@@ -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
|
||||
private readonly IMediator _mediator;
|
||||
|
||||
public SynchronizeJellyfinLibraryByIdHandler(
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IMediator mediator,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
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
|
||||
.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)
|
||||
|
||||
@@ -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
|
||||
.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)
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
rootCommand.AddCommand(scanEmbyCommand);
|
||||
rootCommand.AddCommand(scanEmbyCollectionsCommand);
|
||||
rootCommand.AddCommand(scanJellyfinCommand);
|
||||
rootCommand.AddCommand(scanJellyfinCollectionsCommand);
|
||||
|
||||
return rootCommand;
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
{
|
||||
_locker.OnLibraryChanged += LockChanged;
|
||||
_locker.OnEmbyCollectionsChanged += LockChanged;
|
||||
_locker.OnJellyfinCollectionsChanged += LockChanged;
|
||||
_courier.Subscribe<LibraryScanProgress>(HandleScanProgress);
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
{
|
||||
case "emby":
|
||||
return _locker.AreEmbyCollectionsLocked();
|
||||
case "jellyfin":
|
||||
return _locker.AreJellyfinCollectionsLocked();
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -238,6 +247,7 @@
|
||||
{
|
||||
_locker.OnLibraryChanged -= LockChanged;
|
||||
_locker.OnEmbyCollectionsChanged -= LockChanged;
|
||||
_locker.OnJellyfinCollectionsChanged -= LockChanged;
|
||||
_courier.UnSubscribe<LibraryScanProgress>(HandleScanProgress);
|
||||
|
||||
_cts.Cancel();
|
||||
|
||||
@@ -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
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (int mediaSourceId in mediaSourceIds)
|
||||
{
|
||||
await _scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinCollections(mediaSourceId, false),
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanEmbyMediaSources(CancellationToken cancellationToken)
|
||||
|
||||
Reference in New Issue
Block a user