mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* add ability to deep scan just a single tv show for Plex, Emby, and Jellyfin Including "/api/libraries/{id:int}/scan-show" REST API endpoint to trigger. * restrict plex search results to the intended library * restrict scanning to media server libraries that are marked to sync with etv * fix previous commit * also guard library scan api * add scan buttons to show ui * scan single plex show by id * scan jellyfin and emby single shows by id * update changelog --------- Co-authored-by: Jeff Slutter <MrMustard@gmail.com> Co-authored-by: Jason Dove <1695733+jasongdove@users.noreply.github.com>pull/2321/head
52 changed files with 1795 additions and 55 deletions
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
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 Microsoft.EntityFrameworkCore; |
||||
using System.Globalization; |
||||
using System.Threading.Channels; |
||||
|
||||
namespace ErsatzTV.Application.Emby; |
||||
|
||||
public class CallEmbyShowScannerHandler : CallLibraryScannerHandler<SynchronizeEmbyShowById>, |
||||
IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>> |
||||
{ |
||||
public CallEmbyShowScannerHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
IConfigElementRepository configElementRepository, |
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, |
||||
IMediator mediator, |
||||
IRuntimeInfo runtimeInfo) |
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) |
||||
{ |
||||
} |
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>>.Handle( |
||||
SynchronizeEmbyShowById request, |
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken); |
||||
|
||||
private async Task<Either<BaseError, string>> Handle( |
||||
SynchronizeEmbyShowById 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, string>>(scanIsNotRequired); |
||||
} |
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join()); |
||||
}); |
||||
} |
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan( |
||||
string scanner, |
||||
SynchronizeEmbyShowById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
var arguments = new List<string> |
||||
{ |
||||
"scan-emby-show", |
||||
request.EmbyLibraryId.ToString(CultureInfo.InvariantCulture), |
||||
request.ShowId.ToString(CultureInfo.InvariantCulture) |
||||
}; |
||||
|
||||
if (request.DeepScan) |
||||
{ |
||||
arguments.Add("--deep"); |
||||
} |
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken); |
||||
} |
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan( |
||||
TvContext dbContext, |
||||
SynchronizeEmbyShowById request) |
||||
{ |
||||
return Task.FromResult(DateTimeOffset.MinValue); |
||||
} |
||||
|
||||
protected override bool ScanIsRequired( |
||||
DateTimeOffset lastScan, |
||||
int libraryRefreshInterval, |
||||
SynchronizeEmbyShowById request) |
||||
{ |
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.Emby; |
||||
|
||||
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan) |
||||
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest; |
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
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 Microsoft.EntityFrameworkCore; |
||||
using System.Globalization; |
||||
using System.Threading.Channels; |
||||
|
||||
namespace ErsatzTV.Application.Jellyfin; |
||||
|
||||
public class CallJellyfinShowScannerHandler : CallLibraryScannerHandler<SynchronizeJellyfinShowById>, |
||||
IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>> |
||||
{ |
||||
public CallJellyfinShowScannerHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
IConfigElementRepository configElementRepository, |
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, |
||||
IMediator mediator, |
||||
IRuntimeInfo runtimeInfo) |
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) |
||||
{ |
||||
} |
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>>.Handle( |
||||
SynchronizeJellyfinShowById request, |
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken); |
||||
|
||||
private async Task<Either<BaseError, string>> Handle( |
||||
SynchronizeJellyfinShowById 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, string>>(scanIsNotRequired); |
||||
} |
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join()); |
||||
}); |
||||
} |
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan( |
||||
string scanner, |
||||
SynchronizeJellyfinShowById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
var arguments = new List<string> |
||||
{ |
||||
"scan-jellyfin-show", |
||||
request.JellyfinLibraryId.ToString(CultureInfo.InvariantCulture), |
||||
request.ShowId.ToString(CultureInfo.InvariantCulture) |
||||
}; |
||||
|
||||
if (request.DeepScan) |
||||
{ |
||||
arguments.Add("--deep"); |
||||
} |
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken); |
||||
} |
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan( |
||||
TvContext dbContext, |
||||
SynchronizeJellyfinShowById request) |
||||
{ |
||||
return Task.FromResult(DateTimeOffset.MinValue); |
||||
} |
||||
|
||||
protected override bool ScanIsRequired( |
||||
DateTimeOffset lastScan, |
||||
int libraryRefreshInterval, |
||||
SynchronizeJellyfinShowById request) |
||||
{ |
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.Jellyfin; |
||||
|
||||
public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan) |
||||
: IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest; |
@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Libraries; |
||||
|
||||
public record QueueShowScanByLibraryId(int LibraryId, int ShowId, string ShowTitle, bool DeepScan) : IRequest<bool>; |
@ -0,0 +1,90 @@
@@ -0,0 +1,90 @@
|
||||
using ErsatzTV.Application.Emby; |
||||
using ErsatzTV.Application.Jellyfin; |
||||
using ErsatzTV.Application.Plex; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Locking; |
||||
using ErsatzTV.Infrastructure.Data; |
||||
using ErsatzTV.Infrastructure.Extensions; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Application.Libraries; |
||||
|
||||
public class QueueShowScanByLibraryIdHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
IEntityLocker locker, |
||||
IMediator mediator, |
||||
ILogger<QueueShowScanByLibraryIdHandler> logger) |
||||
: IRequestHandler<QueueShowScanByLibraryId, bool> |
||||
{ |
||||
public async Task<bool> Handle(QueueShowScanByLibraryId request, CancellationToken cancellationToken) |
||||
{ |
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); |
||||
|
||||
Option<Library> maybeLibrary = await dbContext.Libraries |
||||
.AsNoTracking() |
||||
.SelectOneAsync(l => l.Id, l => l.Id == request.LibraryId); |
||||
|
||||
foreach (Library library in maybeLibrary) |
||||
{ |
||||
bool shouldSyncItems = library switch |
||||
{ |
||||
PlexLibrary plexLibrary => plexLibrary.ShouldSyncItems, |
||||
JellyfinLibrary jellyfinLibrary => jellyfinLibrary.ShouldSyncItems, |
||||
EmbyLibrary embyLibrary => embyLibrary.ShouldSyncItems, |
||||
_ => true |
||||
}; |
||||
|
||||
if (!shouldSyncItems) |
||||
{ |
||||
logger.LogWarning("Library sync is disabled for library id {Id}", library.Id); |
||||
return false; |
||||
} |
||||
|
||||
// Check if library is already being scanned - return false if locked
|
||||
if (!locker.LockLibrary(library.Id)) |
||||
{ |
||||
logger.LogWarning("Library {Id} is already being scanned, cannot scan individual show", library.Id); |
||||
return false; |
||||
} |
||||
|
||||
logger.LogDebug("Queued show scan for library id {Id}, show: {ShowTitle}, deepScan: {DeepScan}", |
||||
library.Id, request.ShowTitle, request.DeepScan); |
||||
|
||||
try |
||||
{ |
||||
switch (library) |
||||
{ |
||||
case PlexLibrary: |
||||
var plexResult = await mediator.Send( |
||||
new SynchronizePlexShowById(library.Id, request.ShowId, request.DeepScan), |
||||
cancellationToken); |
||||
return plexResult.IsRight; |
||||
case JellyfinLibrary: |
||||
var jellyfinResult = await mediator.Send( |
||||
new SynchronizeJellyfinShowById(library.Id, request.ShowId, request.DeepScan), |
||||
cancellationToken); |
||||
return jellyfinResult.IsRight; |
||||
case EmbyLibrary: |
||||
var embyResult = await mediator.Send( |
||||
new SynchronizeEmbyShowById(library.Id, request.ShowId, request.DeepScan), |
||||
cancellationToken); |
||||
return embyResult.IsRight; |
||||
case LocalLibrary: |
||||
logger.LogWarning("Single show scanning is not supported for local libraries"); |
||||
return false; |
||||
default: |
||||
logger.LogWarning("Unknown library type for library {Id}", library.Id); |
||||
return false; |
||||
} |
||||
} |
||||
finally |
||||
{ |
||||
// Always unlock the library when we're done
|
||||
locker.UnlockLibrary(library.Id); |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
} |
@ -0,0 +1,82 @@
@@ -0,0 +1,82 @@
|
||||
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 Microsoft.EntityFrameworkCore; |
||||
using System.Globalization; |
||||
using System.Threading.Channels; |
||||
|
||||
namespace ErsatzTV.Application.Plex; |
||||
|
||||
public class CallPlexShowScannerHandler : CallLibraryScannerHandler<SynchronizePlexShowById>, |
||||
IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>> |
||||
{ |
||||
public CallPlexShowScannerHandler( |
||||
IDbContextFactory<TvContext> dbContextFactory, |
||||
IConfigElementRepository configElementRepository, |
||||
ChannelWriter<ISearchIndexBackgroundServiceRequest> channel, |
||||
IMediator mediator, |
||||
IRuntimeInfo runtimeInfo) |
||||
: base(dbContextFactory, configElementRepository, channel, mediator, runtimeInfo) |
||||
{ |
||||
} |
||||
|
||||
Task<Either<BaseError, string>> IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>>.Handle( |
||||
SynchronizePlexShowById request, |
||||
CancellationToken cancellationToken) => Handle(request, cancellationToken); |
||||
|
||||
private async Task<Either<BaseError, string>> Handle( |
||||
SynchronizePlexShowById 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, string>>(scanIsNotRequired); |
||||
} |
||||
|
||||
return Task.FromResult<Either<BaseError, string>>(error.Join()); |
||||
}); |
||||
} |
||||
|
||||
private async Task<Either<BaseError, string>> PerformScan( |
||||
string scanner, |
||||
SynchronizePlexShowById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
var arguments = new List<string> |
||||
{ |
||||
"scan-plex-show", |
||||
request.PlexLibraryId.ToString(CultureInfo.InvariantCulture), |
||||
request.ShowId.ToString(CultureInfo.InvariantCulture) |
||||
}; |
||||
|
||||
if (request.DeepScan) |
||||
{ |
||||
arguments.Add("--deep"); |
||||
} |
||||
|
||||
return await base.PerformScan(scanner, arguments, cancellationToken); |
||||
} |
||||
|
||||
protected override Task<DateTimeOffset> GetLastScan( |
||||
TvContext dbContext, |
||||
SynchronizePlexShowById request) |
||||
{ |
||||
return Task.FromResult(DateTimeOffset.MinValue); |
||||
} |
||||
|
||||
protected override bool ScanIsRequired( |
||||
DateTimeOffset lastScan, |
||||
int libraryRefreshInterval, |
||||
SynchronizePlexShowById request) |
||||
{ |
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Application.Plex; |
||||
|
||||
public record SynchronizePlexShowById(int PlexLibraryId, int ShowId, bool DeepScan) : |
||||
IRequest<Either<BaseError, string>>, IScannerBackgroundServiceRequest; |
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Domain; |
||||
|
||||
public enum MediaSourceKind |
||||
{ |
||||
Local = 1, |
||||
Plex = 2, |
||||
Jellyfin = 3, |
||||
Emby = 4 |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models; |
||||
|
||||
public class EmbySearchHintsResponse |
||||
{ |
||||
public List<EmbySearchHintResponse> SearchHints { get; set; } = []; |
||||
public int TotalRecordCount { get; set; } |
||||
} |
||||
|
||||
public class EmbySearchHintResponse |
||||
{ |
||||
public string Id { get; set; } |
||||
public string Name { get; set; } |
||||
public string Type { get; set; } |
||||
public string MatchedTerm { get; set; } |
||||
public int? IndexNumber { get; set; } |
||||
public int? ProductionYear { get; set; } |
||||
public string Overview { get; set; } |
||||
public EmbyImageTagsResponse ImageTags { get; set; } = new(); |
||||
public List<string> BackdropImageTags { get; set; } = []; |
||||
} |
@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models; |
||||
|
||||
public class JellyfinSearchHintsResponse |
||||
{ |
||||
public List<JellyfinSearchHintResponse> SearchHints { get; set; } = []; |
||||
public int TotalRecordCount { get; set; } |
||||
} |
||||
|
||||
public class JellyfinSearchHintResponse |
||||
{ |
||||
public string Id { get; set; } |
||||
public string Name { get; set; } |
||||
public string Type { get; set; } |
||||
public string MatchedTerm { get; set; } |
||||
public int? IndexNumber { get; set; } |
||||
public int? ProductionYear { get; set; } |
||||
public string Overview { get; set; } |
||||
public JellyfinImageTagsResponse ImageTags { get; set; } = new(); |
||||
public List<string> BackdropImageTags { get; set; } = []; |
||||
} |
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
namespace ErsatzTV.Infrastructure.Plex.Models; |
||||
|
||||
public class PlexMediaContainerHubContent<T> |
||||
{ |
||||
public List<T> Hub { get; set; } = []; |
||||
} |
||||
|
||||
public class PlexHubResponse |
||||
{ |
||||
public string HubIdentifier { get; set; } |
||||
public string HubKey { get; set; } |
||||
public string Title { get; set; } |
||||
public string Type { get; set; } |
||||
public List<PlexMetadataResponse> Metadata { get; set; } = []; |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Scanner.Application.Emby; |
||||
|
||||
public record SynchronizeEmbyShowById(int EmbyLibraryId, int ShowId, bool DeepScan) |
||||
: IRequest<Either<BaseError, string>>; |
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Scanner.Application.Emby; |
||||
|
||||
public class SynchronizeEmbyShowByIdHandler : IRequestHandler<SynchronizeEmbyShowById, Either<BaseError, string>> |
||||
{ |
||||
private readonly IEmbySecretStore _embySecretStore; |
||||
private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner; |
||||
private readonly ILogger<SynchronizeEmbyShowByIdHandler> _logger; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly IEmbyTelevisionRepository _embyTelevisionRepository; |
||||
|
||||
public SynchronizeEmbyShowByIdHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IEmbyTelevisionRepository embyTelevisionRepository, |
||||
IEmbySecretStore embySecretStore, |
||||
IEmbyTelevisionLibraryScanner embyTelevisionLibraryScanner, |
||||
ILogger<SynchronizeEmbyShowByIdHandler> logger) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_embyTelevisionRepository = embyTelevisionRepository; |
||||
_embySecretStore = embySecretStore; |
||||
_embyTelevisionLibraryScanner = embyTelevisionLibraryScanner; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, string>> Handle( |
||||
SynchronizeEmbyShowById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
Validation<BaseError, RequestParameters> validation = await Validate(request); |
||||
return await validation.Match( |
||||
parameters => Synchronize(parameters, cancellationToken), |
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join())); |
||||
} |
||||
|
||||
private async Task<Either<BaseError, string>> Synchronize( |
||||
RequestParameters parameters, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
if (parameters.Library.MediaKind != LibraryMediaKind.Shows) |
||||
{ |
||||
return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); |
||||
} |
||||
|
||||
_logger.LogInformation( |
||||
"Starting targeted scan for show '{ShowTitle}' in Emby library {LibraryName}", |
||||
parameters.ShowTitle, |
||||
parameters.Library.Name); |
||||
|
||||
Either<BaseError, Unit> result = await _embyTelevisionLibraryScanner.ScanSingleShow( |
||||
parameters.ConnectionParameters.ActiveConnection.Address, |
||||
parameters.ConnectionParameters.ApiKey, |
||||
parameters.Library, |
||||
parameters.ItemId, |
||||
parameters.ShowTitle, |
||||
parameters.DeepScan, |
||||
cancellationToken); |
||||
|
||||
foreach (BaseError error in result.LeftToSeq()) |
||||
{ |
||||
_logger.LogError("Error synchronizing Emby show '{ShowTitle}': {Error}", parameters.ShowTitle, error); |
||||
} |
||||
|
||||
return result.Map(_ => $"Show '{parameters.ShowTitle}' in {parameters.Library.Name}"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizeEmbyShowById request) => |
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await EmbyShowMustExist(request)) |
||||
.Apply((connectionParameters, embyLibrary, showTitleItemId) => |
||||
new RequestParameters( |
||||
connectionParameters, |
||||
embyLibrary, |
||||
showTitleItemId.ItemId, |
||||
showTitleItemId.Title, |
||||
request.DeepScan |
||||
)); |
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection( |
||||
SynchronizeEmbyShowById request) => |
||||
EmbyMediaSourceMustExist(request) |
||||
.BindT(MediaSourceMustHaveActiveConnection) |
||||
.BindT(MediaSourceMustHaveApiKey); |
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist( |
||||
SynchronizeEmbyShowById request) => |
||||
_mediaSourceRepository.GetEmbyByLibraryId(request.EmbyLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>( |
||||
$"Emby media source for library {request.EmbyLibraryId} does not exist.")); |
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( |
||||
EmbyMediaSource embyMediaSource) |
||||
{ |
||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone(); |
||||
return maybeConnection.Map(connection => new ConnectionParameters(connection)) |
||||
.ToValidation<BaseError>("Emby media source requires an active connection"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey( |
||||
ConnectionParameters connectionParameters) |
||||
{ |
||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); |
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) |
||||
.Where(match => match) |
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) |
||||
.ToValidation<BaseError>("Emby media source requires an api key"); |
||||
} |
||||
|
||||
private Task<Validation<BaseError, EmbyLibrary>> EmbyLibraryMustExist( |
||||
SynchronizeEmbyShowById request) => |
||||
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist.")); |
||||
|
||||
private Task<Validation<BaseError, EmbyShowTitleItemIdResult>> EmbyShowMustExist( |
||||
SynchronizeEmbyShowById request) => |
||||
_embyTelevisionRepository.GetShowTitleItemId(request.EmbyLibraryId, request.ShowId) |
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin show {request.ShowId} does not exist in library {request.EmbyLibraryId}.")); |
||||
|
||||
private record RequestParameters( |
||||
ConnectionParameters ConnectionParameters, |
||||
EmbyLibrary Library, |
||||
string ItemId, |
||||
string ShowTitle, |
||||
bool DeepScan); |
||||
|
||||
private record ConnectionParameters(EmbyConnection ActiveConnection) |
||||
{ |
||||
public string? ApiKey { get; init; } |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Scanner.Application.Jellyfin; |
||||
|
||||
public record SynchronizeJellyfinShowById(int JellyfinLibraryId, int ShowId, bool DeepScan) |
||||
: IRequest<Either<BaseError, string>>; |
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Jellyfin; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Jellyfin; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Scanner.Application.Jellyfin; |
||||
|
||||
public class SynchronizeJellyfinShowByIdHandler : IRequestHandler<SynchronizeJellyfinShowById, Either<BaseError, string>> |
||||
{ |
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore; |
||||
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner; |
||||
private readonly ILogger<SynchronizeJellyfinShowByIdHandler> _logger; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly IJellyfinTelevisionRepository _jellyfinTelevisionRepository; |
||||
|
||||
public SynchronizeJellyfinShowByIdHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IJellyfinTelevisionRepository jellyfinTelevisionRepository, |
||||
IJellyfinSecretStore jellyfinSecretStore, |
||||
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner, |
||||
ILogger<SynchronizeJellyfinShowByIdHandler> logger) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_jellyfinTelevisionRepository = jellyfinTelevisionRepository; |
||||
_jellyfinSecretStore = jellyfinSecretStore; |
||||
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, string>> Handle( |
||||
SynchronizeJellyfinShowById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
Validation<BaseError, RequestParameters> validation = await Validate(request); |
||||
return await validation.Match( |
||||
parameters => Synchronize(parameters, cancellationToken), |
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join())); |
||||
} |
||||
|
||||
private async Task<Either<BaseError, string>> Synchronize( |
||||
RequestParameters parameters, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
if (parameters.Library.MediaKind != LibraryMediaKind.Shows) |
||||
{ |
||||
return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); |
||||
} |
||||
|
||||
_logger.LogInformation( |
||||
"Starting targeted scan for show '{ShowTitle}' in Jellyfin library {LibraryName}", |
||||
parameters.ShowTitle, |
||||
parameters.Library.Name); |
||||
|
||||
Either<BaseError, Unit> result = await _jellyfinTelevisionLibraryScanner.ScanSingleShow( |
||||
parameters.ConnectionParameters.ActiveConnection.Address, |
||||
parameters.ConnectionParameters.ApiKey, |
||||
parameters.Library, |
||||
parameters.ItemId, |
||||
parameters.ShowTitle, |
||||
parameters.DeepScan, |
||||
cancellationToken); |
||||
|
||||
foreach (BaseError error in result.LeftToSeq()) |
||||
{ |
||||
_logger.LogError("Error synchronizing Jellyfin show '{ShowTitle}': {Error}", parameters.ShowTitle, error); |
||||
} |
||||
|
||||
return result.Map(_ => $"Show '{parameters.ShowTitle}' in {parameters.Library.Name}"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizeJellyfinShowById request) => |
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await JellyfinShowMustExist(request)) |
||||
.Apply((connectionParameters, jellyfinLibrary, showTitleItemId) => |
||||
new RequestParameters( |
||||
connectionParameters, |
||||
jellyfinLibrary, |
||||
showTitleItemId.ItemId, |
||||
showTitleItemId.Title, |
||||
request.DeepScan |
||||
)); |
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection( |
||||
SynchronizeJellyfinShowById request) => |
||||
JellyfinMediaSourceMustExist(request) |
||||
.BindT(MediaSourceMustHaveActiveConnection) |
||||
.BindT(MediaSourceMustHaveApiKey); |
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist( |
||||
SynchronizeJellyfinShowById request) => |
||||
_mediaSourceRepository.GetJellyfinByLibraryId(request.JellyfinLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>( |
||||
$"Jellyfin media source for library {request.JellyfinLibraryId} does not exist.")); |
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( |
||||
JellyfinMediaSource jellyfinMediaSource) |
||||
{ |
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone(); |
||||
return maybeConnection.Map(connection => new ConnectionParameters(connection)) |
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey( |
||||
ConnectionParameters connectionParameters) |
||||
{ |
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets(); |
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) |
||||
.Where(match => match) |
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) |
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key"); |
||||
} |
||||
|
||||
private Task<Validation<BaseError, JellyfinLibrary>> JellyfinLibraryMustExist( |
||||
SynchronizeJellyfinShowById request) => |
||||
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist.")); |
||||
|
||||
private Task<Validation<BaseError, JellyfinShowTitleItemIdResult>> JellyfinShowMustExist( |
||||
SynchronizeJellyfinShowById request) => |
||||
_jellyfinTelevisionRepository.GetShowTitleItemId(request.JellyfinLibraryId, request.ShowId) |
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin show {request.ShowId} does not exist in library {request.JellyfinLibraryId}.")); |
||||
|
||||
private record RequestParameters( |
||||
ConnectionParameters ConnectionParameters, |
||||
JellyfinLibrary Library, |
||||
string ItemId, |
||||
string ShowTitle, |
||||
bool DeepScan); |
||||
|
||||
private record ConnectionParameters(JellyfinConnection ActiveConnection) |
||||
{ |
||||
public string? ApiKey { get; init; } |
||||
} |
||||
} |
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Core; |
||||
|
||||
namespace ErsatzTV.Scanner.Application.Plex; |
||||
|
||||
public record SynchronizePlexShowById(int PlexLibraryId, int ShowId, bool DeepScan) |
||||
: IRequest<Either<BaseError, string>>; |
@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
|
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Plex; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Plex; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Scanner.Application.Plex; |
||||
|
||||
public class SynchronizePlexShowByIdHandler : IRequestHandler<SynchronizePlexShowById, Either<BaseError, string>> |
||||
{ |
||||
private readonly ILogger<SynchronizePlexShowByIdHandler> _logger; |
||||
private readonly IPlexTelevisionRepository _plexTelevisionRepository; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly IPlexSecretStore _plexSecretStore; |
||||
private readonly IPlexTelevisionLibraryScanner _plexTelevisionLibraryScanner; |
||||
|
||||
public SynchronizePlexShowByIdHandler( |
||||
IPlexTelevisionRepository plexTelevisionRepository, |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IPlexSecretStore plexSecretStore, |
||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner, |
||||
ILogger<SynchronizePlexShowByIdHandler> logger) |
||||
{ |
||||
_plexTelevisionRepository = plexTelevisionRepository; |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_plexSecretStore = plexSecretStore; |
||||
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, string>> Handle( |
||||
SynchronizePlexShowById request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
Validation<BaseError, RequestParameters> validation = await Validate(request); |
||||
return await validation.Match( |
||||
parameters => Synchronize(parameters, cancellationToken), |
||||
error => Task.FromResult<Either<BaseError, string>>(error.Join())); |
||||
} |
||||
|
||||
private async Task<Either<BaseError, string>> Synchronize( |
||||
RequestParameters parameters, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
if (parameters.Library.MediaKind != LibraryMediaKind.Shows) |
||||
{ |
||||
return BaseError.New($"Library {parameters.Library.Name} is not a TV show library"); |
||||
} |
||||
|
||||
_logger.LogInformation( |
||||
"Starting targeted scan for show '{ShowTitle}' in Plex library {LibraryName}", |
||||
parameters.ShowTitle, |
||||
parameters.Library.Name); |
||||
|
||||
Either<BaseError, Unit> result = await _plexTelevisionLibraryScanner.ScanSingleShow( |
||||
parameters.ConnectionParameters.ActiveConnection, |
||||
parameters.ConnectionParameters.PlexServerAuthToken, |
||||
parameters.Library, |
||||
parameters.ShowKey, |
||||
parameters.ShowTitle, |
||||
parameters.DeepScan, |
||||
cancellationToken); |
||||
|
||||
foreach (BaseError error in result.LeftToSeq()) |
||||
{ |
||||
_logger.LogError("Error synchronizing Plex show '{ShowTitle}': {Error}", parameters.ShowTitle, error); |
||||
} |
||||
|
||||
return result.Map(_ => $"Show '{parameters.ShowTitle}' in {parameters.Library.Name}"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(SynchronizePlexShowById request) => |
||||
(await ValidateConnection(request), await PlexLibraryMustExist(request), await PlexShowMustExist(request)) |
||||
.Apply((connectionParameters, plexLibrary, titleKey) => |
||||
new RequestParameters( |
||||
connectionParameters, |
||||
plexLibrary, |
||||
titleKey.Key, |
||||
titleKey.Title, |
||||
request.DeepScan |
||||
)); |
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection( |
||||
SynchronizePlexShowById request) => |
||||
PlexMediaSourceMustExist(request) |
||||
.BindT(MediaSourceMustHaveActiveConnection) |
||||
.BindT(MediaSourceMustHaveToken); |
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist( |
||||
SynchronizePlexShowById request) => |
||||
_mediaSourceRepository.GetPlexByLibraryId(request.PlexLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>( |
||||
$"Plex media source for library {request.PlexLibraryId} does not exist.")); |
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( |
||||
PlexMediaSource plexMediaSource) |
||||
{ |
||||
Option<PlexConnection> maybeConnection = |
||||
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive); |
||||
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection)) |
||||
.ToValidation<BaseError>("Plex media source requires an active connection"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken( |
||||
ConnectionParameters connectionParameters) |
||||
{ |
||||
Option<PlexServerAuthToken> maybeToken = await |
||||
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier); |
||||
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token }) |
||||
.ToValidation<BaseError>("Plex media source requires a token"); |
||||
} |
||||
|
||||
private Task<Validation<BaseError, PlexLibrary>> PlexLibraryMustExist( |
||||
SynchronizePlexShowById request) => |
||||
_mediaSourceRepository.GetPlexLibrary(request.PlexLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>($"Plex library {request.PlexLibraryId} does not exist.")); |
||||
|
||||
private Task<Validation<BaseError, PlexShowTitleKeyResult>> PlexShowMustExist( |
||||
SynchronizePlexShowById request) => |
||||
_plexTelevisionRepository.GetShowTitleKey(request.PlexLibraryId, request.ShowId) |
||||
.Map(v => v.ToValidation<BaseError>($"Plex show {request.ShowId} does not exist in library {request.PlexLibraryId}.")); |
||||
|
||||
private record RequestParameters( |
||||
ConnectionParameters ConnectionParameters, |
||||
PlexLibrary Library, |
||||
string ShowKey, |
||||
string ShowTitle, |
||||
bool DeepScan); |
||||
|
||||
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection) |
||||
{ |
||||
public PlexServerAuthToken? PlexServerAuthToken { get; set; } |
||||
} |
||||
} |
@ -1,15 +1,40 @@
@@ -1,15 +1,40 @@
|
||||
using ErsatzTV.Application.Libraries; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using MediatR; |
||||
using Microsoft.AspNetCore.Mvc; |
||||
|
||||
namespace ErsatzTV.Controllers.Api; |
||||
|
||||
[ApiController] |
||||
public class LibrariesController(IMediator mediator) |
||||
public class LibrariesController(ITelevisionRepository televisionRepository, IMediator mediator) |
||||
{ |
||||
[HttpPost("/api/libraries/{id:int}/scan")] |
||||
public async Task<IActionResult> ResetPlayout(int id) => |
||||
await mediator.Send(new QueueLibraryScanByLibraryId(id)) |
||||
? new OkResult() |
||||
: new NotFoundResult(); |
||||
|
||||
[HttpPost("/api/libraries/{id:int}/scan-show")] |
||||
public async Task<IActionResult> ScanShow(int id, [FromBody] ScanShowRequest request) |
||||
{ |
||||
if (string.IsNullOrWhiteSpace(request.ShowTitle)) |
||||
{ |
||||
return new BadRequestObjectResult(new { error = "ShowTitle is required" }); |
||||
} |
||||
|
||||
var trimmedTitle = request.ShowTitle.Trim(); |
||||
var maybeShowId = await televisionRepository.GetShowIdByTitle(id, trimmedTitle); |
||||
foreach (var showId in maybeShowId) |
||||
{ |
||||
bool result = await mediator.Send(new QueueShowScanByLibraryId(id, showId, trimmedTitle, request.DeepScan)); |
||||
|
||||
return result |
||||
? new OkResult() |
||||
: new BadRequestObjectResult(new { error = "Unable to queue show scan. Library may not exist, may not support single show scanning, or may already be scanning." }); |
||||
} |
||||
|
||||
return new BadRequestObjectResult(new { error = $"Unable to locate show with title {request.ShowTitle} in library {id}" }); |
||||
} |
||||
} |
||||
|
||||
public record ScanShowRequest(string ShowTitle, bool DeepScan = false); |
||||
|
Loading…
Reference in new issue