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/).
- Fix playout bug that caused some schedule items with fixed start times to be pushed to the next day - 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 VAAPI transcoding 8-bit source content to 10-bit
- Fix NVIDIA subtitle scaling when `scale_npp` filter is unavailable - 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 ### Changed
- Upgrade ffmpeg to 6.1, which is now *required* for all installs - 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 - `Off`: do not normalize loudness
- `loudnorm`: use `loudnorm` filter to normalize loudness (generally higher CPU use) - `loudnorm`: use `loudnorm` filter to normalize loudness (generally higher CPU use)
- `dynaudnorm`: use `dynaudnorm` filter to normalize loudness (generally lower 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 ## [0.8.2-beta] - 2023-09-14
### Added ### Added

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

@ -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 @@
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
GetExternalCollections request, GetExternalCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<LibraryViewModel> result = new();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); 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)) .Filter(ems => ems.Libraries.Any(l => ((EmbyLibrary)l).ShouldSyncItems))
.Map(ems => ems.Id) .Map(ems => ems.Id)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
return mediaSourceIds.Map( return embyMediaSourceIds.Map(id => new LibraryViewModel("Emby", 0, "Collections", 0, id, string.Empty));
id => new LibraryViewModel( }
"Emby",
0, private static async Task<IEnumerable<LibraryViewModel>> GetJellyfinExternalCollections(
"Collections", TvContext dbContext,
0, CancellationToken cancellationToken)
id, {
string.Empty)) List<int> jellyfinMediaSourceIds = await dbContext.JellyfinMediaSources
.ToList(); .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
public string OperatingSystem { get; set; } public string OperatingSystem { get; set; }
public List<JellyfinConnection> Connections { get; set; } public List<JellyfinConnection> Connections { get; set; }
public List<JellyfinPathReplacement> PathReplacements { 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
event EventHandler<Type> OnRemoteMediaSourceChanged; event EventHandler<Type> OnRemoteMediaSourceChanged;
event EventHandler OnTraktChanged; event EventHandler OnTraktChanged;
event EventHandler OnEmbyCollectionsChanged; event EventHandler OnEmbyCollectionsChanged;
event EventHandler OnJellyfinCollectionsChanged;
event EventHandler<int> OnPlayoutChanged; event EventHandler<int> OnPlayoutChanged;
bool LockLibrary(int libraryId); bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId); bool UnlockLibrary(int libraryId);
@ -23,6 +24,9 @@ public interface IEntityLocker
bool LockEmbyCollections(); bool LockEmbyCollections();
bool UnlockEmbyCollections(); bool UnlockEmbyCollections();
bool AreEmbyCollectionsLocked(); bool AreEmbyCollectionsLocked();
bool LockJellyfinCollections();
bool UnlockJellyfinCollections();
bool AreJellyfinCollectionsLocked();
bool LockPlayout(int playoutId); bool LockPlayout(int playoutId);
bool UnlockPlayout(int playoutId); bool UnlockPlayout(int playoutId);
bool IsPlayoutLocked(int playoutId); bool IsPlayoutLocked(int playoutId);

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

@ -82,5 +82,6 @@ public interface IMediaSourceRepository
Task<List<int>> DeleteAllEmby(); Task<List<int>> DeleteAllEmby();
Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds); Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds);
Task<List<int>> DisableEmbyLibrarySync(List<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 @@
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
{ {
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("datetime(6)");
b.Property<string>("OperatingSystem") b.Property<string>("OperatingSystem")
.HasColumnType("longtext"); .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 @@
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
{ {
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource"); b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<DateTime?>("LastCollectionsScan")
.HasColumnType("TEXT");
b.Property<string>("OperatingSystem") b.Property<string>("OperatingSystem")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

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

@ -858,11 +858,19 @@ public class MediaSourceRepository : IMediaSourceRepository
return deletedMediaIds; return deletedMediaIds;
} }
public async Task<Unit> UpdateLastScan(EmbyMediaSource embyMediaSource) public async Task<Unit> UpdateLastCollectionScan(EmbyMediaSource embyMediaSource)
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync( return await dbContext.Connection.ExecuteAsync(
"UPDATE EmbyMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id", "UPDATE EmbyMediaSource SET LastCollectionsScan = @LastCollectionsScan WHERE Id = @Id",
new { embyMediaSource.LastCollectionsScan, embyMediaSource.Id }).ToUnit(); 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
private readonly ConcurrentDictionary<int, byte> _lockedPlayouts; private readonly ConcurrentDictionary<int, byte> _lockedPlayouts;
private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes; private readonly ConcurrentDictionary<Type, byte> _lockedRemoteMediaSourceTypes;
private bool _embyCollections; private bool _embyCollections;
private bool _jellyfinCollections;
private bool _plex; private bool _plex;
private bool _trakt; private bool _trakt;
@ -24,6 +25,7 @@ public class EntityLocker : IEntityLocker
public event EventHandler<Type> OnRemoteMediaSourceChanged; public event EventHandler<Type> OnRemoteMediaSourceChanged;
public event EventHandler OnTraktChanged; public event EventHandler OnTraktChanged;
public event EventHandler OnEmbyCollectionsChanged; public event EventHandler OnEmbyCollectionsChanged;
public event EventHandler OnJellyfinCollectionsChanged;
public event EventHandler<int> OnPlayoutChanged; public event EventHandler<int> OnPlayoutChanged;
public bool LockLibrary(int libraryId) public bool LockLibrary(int libraryId)
@ -159,6 +161,32 @@ public class EntityLocker : IEntityLocker
public bool AreEmbyCollectionsLocked() => _embyCollections; 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) public bool LockPlayout(int playoutId)
{ {
if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0)) if (!_lockedPlayouts.ContainsKey(playoutId) && _lockedPlayouts.TryAdd(playoutId, 0))

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

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

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

@ -108,17 +108,14 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
private async Task<Validation<BaseError, RequestParameters>> Validate( private async Task<Validation<BaseError, RequestParameters>> Validate(
SynchronizeEmbyLibraryById request) => SynchronizeEmbyLibraryById request) =>
(await ValidateConnection(request), await EmbyLibraryMustExist(request), (await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateLibraryRefreshInterval())
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
.Apply( .Apply(
(connectionParameters, embyLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) => (connectionParameters, embyLibrary, libraryRefreshInterval) =>
new RequestParameters( new RequestParameters(
connectionParameters, connectionParameters,
embyLibrary, embyLibrary,
request.ForceScan, request.ForceScan,
libraryRefreshInterval, libraryRefreshInterval,
ffmpegPath,
ffprobePath,
request.DeepScan request.DeepScan
)); ));
@ -163,27 +160,11 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
.FilterT(lri => lri is >= 0 and < 1_000_000) .FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid")); .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( private record RequestParameters(
ConnectionParameters ConnectionParameters, ConnectionParameters ConnectionParameters,
EmbyLibrary Library, EmbyLibrary Library,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval, int LibraryRefreshInterval,
string FFmpegPath,
string FFprobePath,
bool DeepScan); bool DeepScan);
private record ConnectionParameters(EmbyConnection ActiveConnection) private record ConnectionParameters(EmbyConnection ActiveConnection)

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

@ -2,4 +2,4 @@
namespace ErsatzTV.Scanner.Application.Jellyfin; 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
private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository; private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IJellyfinCollectionScanner _scanner; private readonly IJellyfinCollectionScanner _scanner;
private readonly IConfigElementRepository _configElementRepository;
public SynchronizeJellyfinCollectionsHandler( public SynchronizeJellyfinCollectionsHandler(
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore, IJellyfinSecretStore jellyfinSecretStore,
IJellyfinCollectionScanner scanner) IJellyfinCollectionScanner scanner,
IConfigElementRepository configElementRepository)
{ {
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore; _jellyfinSecretStore = jellyfinSecretStore;
_scanner = scanner; _scanner = scanner;
_configElementRepository = configElementRepository;
} }
@ -28,23 +31,38 @@ public class
SynchronizeJellyfinCollections request, SynchronizeJellyfinCollections request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
Validation<BaseError, ConnectionParameters> validation = await Validate(request); Validation<BaseError, RequestParameters> validation = await Validate(request);
return await validation.Match( return await validation.Match(
SynchronizeCollections, SynchronizeCollections,
error => Task.FromResult<Either<BaseError, Unit>>(error.Join())); error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
} }
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinCollections request) => private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizeJellyfinCollections request)
MediaSourceMustExist(request) {
Task<Validation<BaseError, ConnectionParameters>> mediaSource = MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection) .BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey); .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( private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
SynchronizeJellyfinCollections request) => SynchronizeJellyfinCollections request) =>
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId) _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist.")); .Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( private static Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
JellyfinMediaSource jellyfinMediaSource) JellyfinMediaSource jellyfinMediaSource)
{ {
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone(); Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
@ -62,15 +80,38 @@ public class
.ToValidation<BaseError>("Jellyfin media source requires an api key"); .ToValidation<BaseError>("Jellyfin media source requires an api key");
} }
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) => private async Task<Either<BaseError, Unit>> SynchronizeCollections(RequestParameters parameters)
await _scanner.ScanCollections( {
connectionParameters.ActiveConnection.Address, var lastScan = new DateTimeOffset(
connectionParameters.ApiKey, parameters.MediaSource.LastCollectionsScan ?? SystemTime.MinValueUtc,
connectionParameters.JellyfinMediaSource.Id); 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( private record ConnectionParameters(JellyfinMediaSource MediaSource, JellyfinConnection ActiveConnection)
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{ {
public string? ApiKey { get; init; } public string? ApiKey { get; init; }
} }

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

@ -13,7 +13,6 @@ public class
{ {
private readonly IConfigElementRepository _configElementRepository; private readonly IConfigElementRepository _configElementRepository;
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner; private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
private readonly IJellyfinSecretStore _jellyfinSecretStore; private readonly IJellyfinSecretStore _jellyfinSecretStore;
@ -24,7 +23,6 @@ public class
private readonly IMediator _mediator; private readonly IMediator _mediator;
public SynchronizeJellyfinLibraryByIdHandler( public SynchronizeJellyfinLibraryByIdHandler(
IJellyfinApiClient jellyfinApiClient,
IMediator mediator, IMediator mediator,
IMediaSourceRepository mediaSourceRepository, IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore, IJellyfinSecretStore jellyfinSecretStore,
@ -34,7 +32,6 @@ public class
IConfigElementRepository configElementRepository, IConfigElementRepository configElementRepository,
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger) ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
{ {
_jellyfinApiClient = jellyfinApiClient;
_mediator = mediator; _mediator = mediator;
_mediaSourceRepository = mediaSourceRepository; _mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore; _jellyfinSecretStore = jellyfinSecretStore;
@ -96,22 +93,6 @@ public class
{ {
parameters.Library.LastScan = DateTime.UtcNow; parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library); 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()) foreach (BaseError error in result.LeftToSeq())
@ -140,16 +121,14 @@ public class
private async Task<Validation<BaseError, RequestParameters>> Validate( private async Task<Validation<BaseError, RequestParameters>> Validate(
SynchronizeJellyfinLibraryById request) => SynchronizeJellyfinLibraryById request) =>
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), (await ValidateConnection(request), await JellyfinLibraryMustExist(request),
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath()) await ValidateLibraryRefreshInterval())
.Apply( .Apply(
(connectionParameters, jellyfinLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) => (connectionParameters, jellyfinLibrary, libraryRefreshInterval) =>
new RequestParameters( new RequestParameters(
connectionParameters, connectionParameters,
jellyfinLibrary, jellyfinLibrary,
request.ForceScan, request.ForceScan,
libraryRefreshInterval, libraryRefreshInterval,
ffmpegPath,
ffprobePath,
request.DeepScan request.DeepScan
)); ));
@ -194,27 +173,11 @@ public class
.FilterT(lri => lri is >= 0 and < 1_000_000) .FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid")); .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( private record RequestParameters(
ConnectionParameters ConnectionParameters, ConnectionParameters ConnectionParameters,
JellyfinLibrary Library, JellyfinLibrary Library,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval, int LibraryRefreshInterval,
string FFmpegPath,
string FFprobePath,
bool DeepScan); bool DeepScan);
private record ConnectionParameters(JellyfinConnection ActiveConnection) private record ConnectionParameters(JellyfinConnection ActiveConnection)

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

@ -108,17 +108,14 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
} }
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizePlexLibraryById request) => private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizePlexLibraryById request) =>
(await ValidateConnection(request), await PlexLibraryMustExist(request), (await ValidateConnection(request), await PlexLibraryMustExist(request), await ValidateLibraryRefreshInterval())
await ValidateLibraryRefreshInterval(), await ValidateFFmpegPath(), await ValidateFFprobePath())
.Apply( .Apply(
(connectionParameters, plexLibrary, libraryRefreshInterval, ffmpegPath, ffprobePath) => (connectionParameters, plexLibrary, libraryRefreshInterval) =>
new RequestParameters( new RequestParameters(
connectionParameters, connectionParameters,
plexLibrary, plexLibrary,
request.ForceScan, request.ForceScan,
libraryRefreshInterval, libraryRefreshInterval,
ffmpegPath,
ffprobePath,
request.DeepScan request.DeepScan
)); ));
@ -163,27 +160,11 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
.FilterT(lri => lri is >= 0 and < 1_000_000) .FilterT(lri => lri is >= 0 and < 1_000_000)
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid")); .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( private record RequestParameters(
ConnectionParameters ConnectionParameters, ConnectionParameters ConnectionParameters,
PlexLibrary Library, PlexLibrary Library,
bool ForceScan, bool ForceScan,
int LibraryRefreshInterval, int LibraryRefreshInterval,
string FFmpegPath,
string FFprobePath,
bool DeepScan); bool DeepScan);
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection) private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)

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

@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin; using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.MediaSources; using ErsatzTV.Core.MediaSources;
using ErsatzTV.Scanner.Application.Jellyfin;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
namespace ErsatzTV.Scanner.Core.Jellyfin; namespace ErsatzTV.Scanner.Core.Jellyfin;
@ -30,6 +31,20 @@ public class JellyfinCollectionScanner : IJellyfinCollectionScanner
{ {
try 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>(); var incomingItemIds = new List<string>();
// get all collections from db (item id, etag) // get all collections from db (item id, etag)

23
ErsatzTV.Scanner/Worker.cs

@ -84,6 +84,10 @@ public class Worker : BackgroundService
scanJellyfinCommand.AddOption(forceOption); scanJellyfinCommand.AddOption(forceOption);
scanJellyfinCommand.AddOption(deepOption); scanJellyfinCommand.AddOption(deepOption);
var scanJellyfinCollectionsCommand = new Command("scan-jellyfin-collections", "Scan Jellyfin collections");
scanJellyfinCollectionsCommand.AddArgument(mediaSourceIdArgument);
scanJellyfinCollectionsCommand.AddOption(forceOption);
scanLocalCommand.SetHandler( scanLocalCommand.SetHandler(
async context => async context =>
{ {
@ -177,12 +181,31 @@ public class Worker : BackgroundService
} }
}); });
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(); var rootCommand = new RootCommand();
rootCommand.AddCommand(scanLocalCommand); rootCommand.AddCommand(scanLocalCommand);
rootCommand.AddCommand(scanPlexCommand); rootCommand.AddCommand(scanPlexCommand);
rootCommand.AddCommand(scanEmbyCommand); rootCommand.AddCommand(scanEmbyCommand);
rootCommand.AddCommand(scanEmbyCollectionsCommand); rootCommand.AddCommand(scanEmbyCollectionsCommand);
rootCommand.AddCommand(scanJellyfinCommand); rootCommand.AddCommand(scanJellyfinCommand);
rootCommand.AddCommand(scanJellyfinCollectionsCommand);
return rootCommand; return rootCommand;
} }

10
ErsatzTV/Pages/Libraries.razor

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

32
ErsatzTV/Services/ScannerService.cs

@ -55,6 +55,9 @@ public class ScannerService : BackgroundService
case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById: case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById:
requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, stoppingToken); requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, stoppingToken);
break; break;
case SynchronizeJellyfinCollections synchronizeJellyfinCollections:
requestTask = SynchronizeJellyfinCollections(synchronizeJellyfinCollections, stoppingToken);
break;
case SynchronizeEmbyLibraries synchronizeEmbyLibraries: case SynchronizeEmbyLibraries synchronizeEmbyLibraries:
requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, stoppingToken); requestTask = SynchronizeLibraries(synchronizeEmbyLibraries, stoppingToken);
break; break;
@ -232,6 +235,35 @@ public class ScannerService : BackgroundService
} }
} }
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) private async Task SynchronizeLibraries(SynchronizeEmbyLibraries request, CancellationToken cancellationToken)
{ {
using IServiceScope scope = _serviceScopeFactory.CreateScope(); using IServiceScope scope = _serviceScopeFactory.CreateScope();

11
ErsatzTV/Services/SchedulerService.cs

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

Loading…
Cancel
Save