feat(media-sources): SPA management screens + write REST API (#202) #262
@@ -30,12 +30,21 @@ public class DisconnectEmbyHandler : IRequestHandler<DisconnectEmby, Either<Base
|
||||
DisconnectEmby request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
|
||||
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
|
||||
// commit, secret store) can't wedge the Emby lock until restart (design #202 finding 7).
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _embySecretStore.DeleteAll();
|
||||
|
||||
return Unit.Default;
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -12,12 +12,29 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateEmbyPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request, cancellationToken)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken);
|
||||
|
||||
return await maybeSource.Match(
|
||||
Some: async embyMediaSource =>
|
||||
{
|
||||
Option<BaseError> maybeError = ValidateItems(request, embyMediaSource);
|
||||
return await maybeError.Match(
|
||||
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
|
||||
None: async () =>
|
||||
{
|
||||
await MergePathReplacements(request, embyMediaSource);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
});
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(
|
||||
BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist."))));
|
||||
}
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateEmbyPathReplacements request,
|
||||
@@ -37,12 +54,38 @@ public class UpdateEmbyPathReplacementsHandler : IRequestHandler<UpdateEmbyPathR
|
||||
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
EmbyMediaSourceMustExist(request, cancellationToken);
|
||||
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by
|
||||
// EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
|
||||
// mutation is all-or-nothing.
|
||||
private static Option<BaseError> ValidateItems(
|
||||
UpdateEmbyPathReplacements request,
|
||||
EmbyMediaSource embyMediaSource)
|
||||
{
|
||||
List<EmbyPathReplacementItem> items = request.PathReplacements ?? [];
|
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist(
|
||||
UpdateEmbyPathReplacements request, CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist."));
|
||||
if (items.Any(item => item is null))
|
||||
{
|
||||
return BaseError.New("Path replacement items must not be null.");
|
||||
}
|
||||
|
||||
if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
|
||||
{
|
||||
return BaseError.New("Each path replacement requires a non-blank Emby path and local path.");
|
||||
}
|
||||
|
||||
var existingIds = (embyMediaSource.PathReplacements ?? new List<EmbyPathReplacement>())
|
||||
.Map(pr => pr.Id)
|
||||
.ToList();
|
||||
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
|
||||
.Map(item => item.Id)
|
||||
.ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " +
|
||||
$"{request.EmbyMediaSourceId}.");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,21 @@ public class DisconnectJellyfinHandler : IRequestHandler<DisconnectJellyfin, Eit
|
||||
DisconnectJellyfin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
// This is a terminal handler with no lock handoff (unlike the Plex pin-flow handlers) —
|
||||
// release unconditionally so a throw from any awaited dependency (repo delete, search-index
|
||||
// commit, secret store) can't wedge the Jellyfin lock until restart (design #202 finding 7).
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
|
||||
return Unit.Default;
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
@@ -12,12 +12,29 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
|
||||
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId);
|
||||
|
||||
return await maybeSource.Match(
|
||||
Some: async jellyfinMediaSource =>
|
||||
{
|
||||
Option<BaseError> maybeError = ValidateItems(request, jellyfinMediaSource);
|
||||
return await maybeError.Match(
|
||||
Some: error => Task.FromResult(Left<BaseError, Unit>(error)),
|
||||
None: async () =>
|
||||
{
|
||||
await MergePathReplacements(request, jellyfinMediaSource);
|
||||
return Right<BaseError, Unit>(Unit.Default);
|
||||
});
|
||||
},
|
||||
None: () => Task.FromResult(
|
||||
Left<BaseError, Unit>(
|
||||
BaseError.New($"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."))));
|
||||
}
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
@@ -37,12 +54,38 @@ public class UpdateJellyfinPathReplacementsHandler : IRequestHandler<UpdateJelly
|
||||
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
|
||||
JellyfinMediaSourceMustExist(request);
|
||||
// Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by
|
||||
// JellyfinMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the
|
||||
// mutation is all-or-nothing.
|
||||
private static Option<BaseError> ValidateItems(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
List<JellyfinPathReplacementItem> items = request.PathReplacements ?? [];
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
UpdateJellyfinPathReplacements request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
|
||||
if (items.Any(item => item is null))
|
||||
{
|
||||
return BaseError.New("Path replacement items must not be null.");
|
||||
}
|
||||
|
||||
if (items.Any(item => string.IsNullOrWhiteSpace(item.JellyfinPath) || string.IsNullOrWhiteSpace(item.LocalPath)))
|
||||
{
|
||||
return BaseError.New("Each path replacement requires a non-blank Jellyfin path and local path.");
|
||||
}
|
||||
|
||||
var existingIds = (jellyfinMediaSource.PathReplacements ?? new List<JellyfinPathReplacement>())
|
||||
.Map(pr => pr.Id)
|
||||
.ToList();
|
||||
var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id))
|
||||
.Map(item => item.Id)
|
||||
.ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Jellyfin media source " +
|
||||
$"{request.JellyfinMediaSourceId}.");
|
||||
}
|
||||
|
||||
return Option<BaseError>.None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -14,15 +15,18 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
|
||||
public CreateLocalLibraryHandler(
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_fileSystem = fileSystem;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
@@ -31,7 +35,7 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(dbContext, request);
|
||||
Validation<BaseError, LocalLibrary> validation = await Validate(_fileSystem, dbContext, request);
|
||||
return await validation.Apply(localLibrary => PersistLocalLibrary(dbContext, localLibrary));
|
||||
}
|
||||
|
||||
@@ -61,11 +65,13 @@ public class CreateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> Validate(
|
||||
IFileSystem fileSystem,
|
||||
TvContext dbContext,
|
||||
CreateLocalLibrary request) =>
|
||||
MediaSourceMustExist(dbContext, request)
|
||||
.BindT(localLibrary => NameMustBeValid(request, localLibrary))
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary));
|
||||
.BindT(localLibrary => PathsMustBeValid(dbContext, localLibrary))
|
||||
.BindT(localLibrary => NewPathsMustExist(fileSystem, localLibrary));
|
||||
|
||||
private static Task<Validation<BaseError, LocalLibrary>> MediaSourceMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,6 +15,28 @@ public abstract class LocalLibraryHandlerBase
|
||||
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
|
||||
.Map(_ => localLibrary).AsTask();
|
||||
|
||||
/// <summary>
|
||||
/// Validates that every NEW path (<c>Id < 1</c> — see design #202 §C2) exists on the
|
||||
/// filesystem. Existing rows are exempt: an unmounted share must not block saving a rename,
|
||||
/// matching Blazor's behavior of only checking existence when a path is added.
|
||||
/// </summary>
|
||||
protected static Task<Validation<BaseError, LocalLibrary>> NewPathsMustExist(
|
||||
IFileSystem fileSystem,
|
||||
LocalLibrary localLibrary)
|
||||
{
|
||||
List<string> missing = localLibrary.Paths
|
||||
.Filter(p => p.Id < 1)
|
||||
.Filter(p => !fileSystem.Directory.Exists(p.Path))
|
||||
.Map(p => p.Path)
|
||||
.ToList();
|
||||
|
||||
Validation<BaseError, LocalLibrary> result = missing.Count == 0
|
||||
? Success<BaseError, LocalLibrary>(localLibrary)
|
||||
: Fail<BaseError, LocalLibrary>($"Path(s) do not exist on the filesystem: {string.Join(", ", missing)}");
|
||||
|
||||
return result.AsTask();
|
||||
}
|
||||
|
||||
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
|
||||
TvContext dbContext,
|
||||
LocalLibrary localLibrary,
|
||||
|
||||
@@ -79,10 +79,31 @@ public class MoveLocalLibraryPathHandler : IRequestHandler<MoveLocalLibraryPath,
|
||||
private static async Task<Validation<BaseError, Parameters>> Validate(
|
||||
TvContext dbContext,
|
||||
MoveLocalLibraryPath request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Parameters> parameters =
|
||||
(await LibraryPathMustExist(dbContext, request, cancellationToken),
|
||||
await LocalLibraryMustExist(dbContext, request, cancellationToken))
|
||||
.Apply((libraryPath, localLibrary) => new Parameters(libraryPath, localLibrary));
|
||||
|
||||
return parameters
|
||||
.Bind(TargetLibraryMustDiffer)
|
||||
.Bind(TargetLibraryMustMatchMediaKind);
|
||||
}
|
||||
|
||||
// Blazor's move dialog filters the target-library picker to same-kind, source-excluded
|
||||
// libraries only (MoveLocalLibraryPathDialog.razor:82); an API/MCP client bypasses that
|
||||
// client-side filter today, so #202 moves both invariants into the handler (design #202 §C5,
|
||||
// finding 3).
|
||||
private static Validation<BaseError, Parameters> TargetLibraryMustDiffer(Parameters parameters) =>
|
||||
parameters.LibraryPath.LibraryId == parameters.Library.Id
|
||||
? Fail<BaseError, Parameters>("Target library must be different from the source path's current library")
|
||||
: Success<BaseError, Parameters>(parameters);
|
||||
|
||||
private static Validation<BaseError, Parameters> TargetLibraryMustMatchMediaKind(Parameters parameters) =>
|
||||
parameters.LibraryPath.Library.MediaKind != parameters.Library.MediaKind
|
||||
? Fail<BaseError, Parameters>("Target library must have the same media kind as the source path's library")
|
||||
: Success<BaseError, Parameters>(parameters);
|
||||
|
||||
private static Task<Validation<BaseError, LibraryPath>> LibraryPathMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Channels;
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using Dapper;
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core;
|
||||
@@ -17,17 +18,20 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
{
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IFileSystem _fileSystem;
|
||||
private readonly ChannelWriter<IScannerBackgroundServiceRequest> _scannerWorkerChannel;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdateLocalLibraryHandler(
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
ISearchIndex searchIndex,
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_scannerWorkerChannel = scannerWorkerChannel;
|
||||
_entityLocker = entityLocker;
|
||||
_fileSystem = fileSystem;
|
||||
_searchIndex = searchIndex;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
@@ -37,7 +41,8 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Parameters> validation = await Validate(dbContext, request, cancellationToken);
|
||||
Validation<BaseError, Parameters> validation =
|
||||
await Validate(_fileSystem, dbContext, request, cancellationToken);
|
||||
return await validation.Apply(parameters => UpdateLocalLibrary(dbContext, parameters));
|
||||
}
|
||||
|
||||
@@ -114,13 +119,15 @@ public class UpdateLocalLibraryHandler : LocalLibraryHandlerBase,
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> Validate(
|
||||
IFileSystem fileSystem,
|
||||
TvContext dbContext,
|
||||
UpdateLocalLibrary request,
|
||||
CancellationToken cancellationToken) =>
|
||||
LocalLibraryMustExist(dbContext, request, cancellationToken)
|
||||
.BindT(parameters => NameMustBeValid(request, parameters.Incoming).MapT(_ => parameters))
|
||||
.BindT(parameters => PathsMustBeValid(dbContext, parameters.Incoming, parameters.Existing.Id)
|
||||
.MapT(_ => parameters));
|
||||
.MapT(_ => parameters))
|
||||
.BindT(parameters => NewPathsMustExist(fileSystem, parameters.Incoming).MapT(_ => parameters));
|
||||
|
||||
private static Task<Validation<BaseError, Parameters>> LocalLibraryMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -27,12 +27,22 @@ public class SignOutOfPlexHandler : IRequestHandler<SignOutOfPlex, Either<BaseEr
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(SignOutOfPlex request, CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockPlex();
|
||||
// Terminal handler (no lock handoff): release the Plex lock on EVERY exit via finally so a throw
|
||||
// from any awaited dependency (repo delete, search-index commit, secret store) cannot wedge Plex
|
||||
// locked at 409 until restart (#202 §C6 / finding 7). This is the UNCONDITIONAL-finally case —
|
||||
// contrast the pin-flow handlers, which hand the lock off and must NOT use a blanket finally.
|
||||
try
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllPlex();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
_searchIndex.Commit();
|
||||
await _plexSecretStore.DeleteAll();
|
||||
|
||||
return Unit.Default;
|
||||
return Unit.Default;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_entityLocker.UnlockPlex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
|
||||
namespace ErsatzTV.Application.Plex;
|
||||
@@ -7,34 +8,59 @@ namespace ErsatzTV.Application.Plex;
|
||||
public class TryCompletePlexPinFlowHandler : IRequestHandler<TryCompletePlexPinFlow, Either<BaseError, bool>>
|
||||
{
|
||||
private readonly ChannelWriter<IPlexBackgroundServiceRequest> _channel;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IPlexTvApiClient _plexTvApiClient;
|
||||
|
||||
public TryCompletePlexPinFlowHandler(
|
||||
IPlexTvApiClient plexTvApiClient,
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel)
|
||||
ChannelWriter<IPlexBackgroundServiceRequest> channel,
|
||||
IEntityLocker entityLocker)
|
||||
{
|
||||
_plexTvApiClient = plexTvApiClient;
|
||||
_channel = channel;
|
||||
_entityLocker = entityLocker;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>>
|
||||
Handle(TryCompletePlexPinFlow request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Lock-release discipline (#202 §C1.6 / §C6): the Plex lock this pin flow holds is released
|
||||
// ONLY on non-handoff exits — the 2-minute timeout (Task.Delay throws
|
||||
// OperationCanceledException), a poll exception, a failed enqueue, or the (effectively dead)
|
||||
// return-false at loop entry. On SUCCESS the lock is HANDED OFF to SynchronizePlexMediaSources,
|
||||
// whose handler is the sole releaser after server discovery (SynchronizePlexMediaSourcesHandler).
|
||||
// This is deliberately NOT an unconditional finally: a blanket release here would double-release
|
||||
// AND release before discovery, re-opening the finding-5 race (an empty server list reading as
|
||||
// success). Contrast the terminal SignOutOfPlexHandler, which DOES use finally (it has no handoff).
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(2));
|
||||
using var linkedTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
|
||||
CancellationToken token = linkedTokenSource.Token;
|
||||
while (!token.IsCancellationRequested)
|
||||
try
|
||||
{
|
||||
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
|
||||
if (result)
|
||||
while (!token.IsCancellationRequested)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
|
||||
return true;
|
||||
bool result = await _plexTvApiClient.TryCompletePinFlow(request.AuthPin);
|
||||
if (result)
|
||||
{
|
||||
// hand the lock off to the sync handler — do NOT release on this success path
|
||||
await _channel.WriteAsync(new SynchronizePlexMediaSources(), token);
|
||||
return true;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), token);
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(1), token);
|
||||
// effectively unreachable (Task.Delay throws on cancellation before the loop condition is
|
||||
// re-evaluated) but if the flow ever ends here it abandoned without auth → release
|
||||
_entityLocker.UnlockPlex();
|
||||
return false;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// non-handoff exit: timeout-throw, poll exception, or failed enqueue — release the lock so an
|
||||
// abandoned flow does not wedge Plex locked, then rethrow (PlexService logs it as before)
|
||||
_entityLocker.UnlockPlex();
|
||||
throw;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,14 +35,56 @@ public class
|
||||
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> Validate(
|
||||
private async Task<Validation<BaseError, PlexMediaSource>> Validate(
|
||||
UpdatePlexPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlexMediaSourceMustExist(request, cancellationToken);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<PlexMediaSource> maybeSource =
|
||||
await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken);
|
||||
|
||||
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
|
||||
foreach (PlexMediaSource plexMediaSource in maybeSource)
|
||||
{
|
||||
return ValidatePathReplacements(request, plexMediaSource);
|
||||
}
|
||||
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist."));
|
||||
}
|
||||
|
||||
// Programmatic clients now reach this handler directly (#202), so validate what the Blazor form
|
||||
// enforced plus the cross-source ownership hole (finding 2c/8): reject a null list, null items,
|
||||
// blank RemotePath/LocalPath, and any positive Id NOT owned by this source — all before any write,
|
||||
// so there is no partial mutation.
|
||||
private static Validation<BaseError, PlexMediaSource> ValidatePathReplacements(
|
||||
UpdatePlexPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken)
|
||||
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
|
||||
PlexMediaSource plexMediaSource)
|
||||
{
|
||||
List<PlexPathReplacementItem> items = request.PathReplacements;
|
||||
if (items is null)
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] is required"));
|
||||
}
|
||||
|
||||
if (items.Any(i => i is null))
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(BaseError.New("[PathReplacements] must not contain null items"));
|
||||
}
|
||||
|
||||
if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath)))
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath"));
|
||||
}
|
||||
|
||||
var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet();
|
||||
List<int> foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return Fail<BaseError, PlexMediaSource>(
|
||||
BaseError.New(
|
||||
$"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}"));
|
||||
}
|
||||
|
||||
return Success<BaseError, PlexMediaSource>(plexMediaSource);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalLibraryDetailResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
LibraryMediaKind MediaKind,
|
||||
bool IsLocked,
|
||||
int MediaItemCount,
|
||||
List<LocalLibraryPathResponseModel> Paths);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalLibraryPathResponseModel(int Id, string Path, int MediaItemCount);
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool IsLocked);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record LocalPathCheckResponseModel(bool Exists);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record PathReplacementResponseModel(int Id, string RemotePath, string LocalPath);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record PlexPinFlowResponseModel(string AuthUrl);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
// NEVER include the raw API key in this DTO — the secure connection contract (design #202 finding 1)
|
||||
// requires the key never leave the server; callers only learn whether one is configured.
|
||||
public record RemoteConnectionResponseModel(string Address, bool HasApiKey);
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record RemoteLibraryResponseModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record RemoteMediaSourceItemResponseModel(int Id, string Name, string Address);
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Core.Api.MediaSources;
|
||||
|
||||
public record RemoteMediaSourceStateResponseModel(
|
||||
bool IsAuthorized,
|
||||
bool IsLocked,
|
||||
List<RemoteMediaSourceItemResponseModel> Servers);
|
||||
@@ -7,6 +7,14 @@ public interface ILibraryRepository
|
||||
Task<LibraryPath> Add(LibraryPath libraryPath);
|
||||
Task<Option<Library>> GetLibrary(int libraryId);
|
||||
Task<Option<LocalLibrary>> GetLocal(int libraryId);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the owning library id for a library path, without loading the full entity graph.
|
||||
/// Used by the local-libraries API to pre-check a path's existence/ownership before a move
|
||||
/// (see design #202 §C6 — the 404/lock-check for <c>POST .../paths/{pathId}/move</c>).
|
||||
/// </summary>
|
||||
Task<Option<int>> GetLibraryIdForPath(int libraryPathId);
|
||||
|
||||
Task<List<Library>> GetAll();
|
||||
Task<Unit> UpdateLastScan(Library library);
|
||||
Task<Unit> UpdateLastScan(LibraryPath libraryPath);
|
||||
|
||||
@@ -39,6 +39,15 @@ public class LibraryRepository(IFileSystem fileSystem, IDbContextFactory<TvConte
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public async Task<Option<int>> GetLibraryIdForPath(int libraryPathId)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
int? libraryId = await dbContext.Connection.QuerySingleOrDefaultAsync<int?>(
|
||||
"SELECT LibraryId FROM LibraryPath WHERE Id = @LibraryPathId",
|
||||
new { LibraryPathId = libraryPathId });
|
||||
return Optional(libraryId);
|
||||
}
|
||||
|
||||
public async Task<List<Library>> GetAll()
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync();
|
||||
|
||||
@@ -396,11 +396,14 @@ public class MediaSourceRepository(IDbContextFactory<TvContext> dbContextFactory
|
||||
|
||||
foreach (PlexPathReplacement update in toUpdate)
|
||||
{
|
||||
// Scope the UPDATE to the owning source (#202 finding 2c): without the PlexMediaSourceId
|
||||
// predicate a PUT to source A could overwrite source B's row by id (defense-in-depth behind
|
||||
// the handler's ownership guard).
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE PlexPathReplacement
|
||||
SET PlexPath = @PlexPath, LocalPath = @LocalPath
|
||||
WHERE Id = @Id",
|
||||
new { update.PlexPath, update.LocalPath, update.Id });
|
||||
WHERE Id = @Id AND PlexMediaSourceId = @PlexMediaSourceId",
|
||||
new { update.PlexPath, update.LocalPath, update.Id, PlexMediaSourceId = plexMediaSourceId });
|
||||
}
|
||||
|
||||
foreach (PlexPathReplacement delete in toDelete)
|
||||
@@ -692,8 +695,14 @@ public class MediaSourceRepository(IDbContextFactory<TvContext> dbContextFactory
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE JellyfinPathReplacement
|
||||
SET JellyfinPath = @JellyfinPath, LocalPath = @LocalPath
|
||||
WHERE Id = @Id",
|
||||
new { update.JellyfinPath, update.LocalPath, update.Id });
|
||||
WHERE Id = @Id AND JellyfinMediaSourceId = @JellyfinMediaSourceId",
|
||||
new
|
||||
{
|
||||
update.JellyfinPath,
|
||||
update.LocalPath,
|
||||
update.Id,
|
||||
JellyfinMediaSourceId = jellyfinMediaSourceId
|
||||
});
|
||||
}
|
||||
|
||||
foreach (JellyfinPathReplacement delete in toDelete)
|
||||
@@ -890,8 +899,14 @@ public class MediaSourceRepository(IDbContextFactory<TvContext> dbContextFactory
|
||||
await dbContext.Connection.ExecuteAsync(
|
||||
@"UPDATE EmbyPathReplacement
|
||||
SET EmbyPath = @EmbyPath, LocalPath = @LocalPath
|
||||
WHERE Id = @Id",
|
||||
new { update.EmbyPath, update.LocalPath, update.Id });
|
||||
WHERE Id = @Id AND EmbyMediaSourceId = @EmbyMediaSourceId",
|
||||
new
|
||||
{
|
||||
update.EmbyPath,
|
||||
update.LocalPath,
|
||||
update.Id,
|
||||
EmbyMediaSourceId = embyMediaSourceId
|
||||
});
|
||||
}
|
||||
|
||||
foreach (EmbyPathReplacement delete in toDelete)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Emby;
|
||||
|
||||
[TestFixture]
|
||||
public class DisconnectEmbyHandlerTests
|
||||
{
|
||||
private IMediaSourceRepository _mediaSourceRepository = null!;
|
||||
private IEmbySecretStore _embySecretStore = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private ISearchIndex _searchIndex = null!;
|
||||
private DisconnectEmbyHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
_embySecretStore = Substitute.For<IEmbySecretStore>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_searchIndex = Substitute.For<ISearchIndex>();
|
||||
_handler = new DisconnectEmbyHandler(
|
||||
_mediaSourceRepository,
|
||||
_embySecretStore,
|
||||
_entityLocker,
|
||||
_searchIndex);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_On_Success()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllEmby().Returns(new List<int>());
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(new DisconnectEmby(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_When_Repository_Delete_Throws()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllEmby().ThrowsAsync(new InvalidOperationException("db exploded"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_When_SearchIndex_RemoveItems_Throws()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllEmby().Returns([1, 2]);
|
||||
_searchIndex.RemoveItems(Arg.Any<IEnumerable<int>>()).ThrowsAsync(new InvalidOperationException("index down"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_When_SecretStore_DeleteAll_Throws()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllEmby().Returns(new List<int>());
|
||||
_embySecretStore.DeleteAll().ThrowsAsync(new InvalidOperationException("secret store down"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await _handler.Handle(new DisconnectEmby(), CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<EmbyMediaSource>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Emby;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateEmbyPathReplacementsHandlerTests
|
||||
{
|
||||
private IMediaSourceRepository _mediaSourceRepository = null!;
|
||||
private UpdateEmbyPathReplacementsHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
_handler = new UpdateEmbyPathReplacementsHandler(_mediaSourceRepository);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_Source_Does_Not_Exist()
|
||||
{
|
||||
_mediaSourceRepository.GetEmby(99, Arg.Any<CancellationToken>()).Returns(Option<EmbyMediaSource>.None);
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateEmbyPathReplacements(99, [new EmbyPathReplacementItem(0, "/emby", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_For_Cross_Source_Id_And_Perform_No_Mutation()
|
||||
{
|
||||
var source = new EmbyMediaSource
|
||||
{
|
||||
Id = 1,
|
||||
PathReplacements = [new EmbyPathReplacement { Id = 1, EmbyPath = "/emby1", LocalPath = "/l1" }]
|
||||
};
|
||||
_mediaSourceRepository.GetEmby(1, Arg.Any<CancellationToken>()).Returns(Option<EmbyMediaSource>.Some(source));
|
||||
|
||||
// id 999 belongs to some other source, not this one
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateEmbyPathReplacements(1, [new EmbyPathReplacementItem(999, "/emby", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>());
|
||||
}
|
||||
|
||||
[TestCase("", "/local")]
|
||||
[TestCase("/emby", "")]
|
||||
[TestCase(" ", " ")]
|
||||
public async Task Handle_Should_Return_422_For_Blank_Paths(string embyPath, string localPath)
|
||||
{
|
||||
var source = new EmbyMediaSource { Id = 1, PathReplacements = [] };
|
||||
_mediaSourceRepository.GetEmby(1, Arg.Any<CancellationToken>()).Returns(Option<EmbyMediaSource>.Some(source));
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateEmbyPathReplacements(1, [new EmbyPathReplacementItem(0, embyPath, localPath)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_For_Null_Items_List_Defense()
|
||||
{
|
||||
var source = new EmbyMediaSource { Id = 1, PathReplacements = [] };
|
||||
_mediaSourceRepository.GetEmby(1, Arg.Any<CancellationToken>()).Returns(Option<EmbyMediaSource>.Some(source));
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateEmbyPathReplacements(1, [null!]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Merge_Add_Update_Delete_On_Valid_Request()
|
||||
{
|
||||
var source = new EmbyMediaSource
|
||||
{
|
||||
Id = 1,
|
||||
PathReplacements =
|
||||
[
|
||||
new EmbyPathReplacement { Id = 1, EmbyPath = "/old", LocalPath = "/old-local" },
|
||||
new EmbyPathReplacement { Id = 2, EmbyPath = "/gone", LocalPath = "/gone-local" }
|
||||
]
|
||||
};
|
||||
_mediaSourceRepository.GetEmby(1, Arg.Any<CancellationToken>()).Returns(Option<EmbyMediaSource>.Some(source));
|
||||
_mediaSourceRepository.UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>(),
|
||||
Arg.Any<List<EmbyPathReplacement>>())
|
||||
.Returns(Unit.Default);
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateEmbyPathReplacements(
|
||||
1,
|
||||
[
|
||||
new EmbyPathReplacementItem(1, "/updated", "/updated-local"), // update
|
||||
new EmbyPathReplacementItem(0, "/new", "/new-local") // add
|
||||
// id 2 is absent -> delete
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await _mediaSourceRepository.Received(1).UpdatePathReplacements(
|
||||
1,
|
||||
Arg.Is<List<EmbyPathReplacement>>(l => l.Count == 1 && l[0].EmbyPath == "/new"),
|
||||
Arg.Is<List<EmbyPathReplacement>>(l => l.Count == 1 && l[0].Id == 1 && l[0].EmbyPath == "/updated"),
|
||||
Arg.Is<List<EmbyPathReplacement>>(l => l.Count == 1 && l[0].Id == 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NSubstitute.ExceptionExtensions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Jellyfin;
|
||||
|
||||
[TestFixture]
|
||||
public class DisconnectJellyfinHandlerTests
|
||||
{
|
||||
private IMediaSourceRepository _mediaSourceRepository = null!;
|
||||
private IJellyfinSecretStore _jellyfinSecretStore = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private ISearchIndex _searchIndex = null!;
|
||||
private DisconnectJellyfinHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
_jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_searchIndex = Substitute.For<ISearchIndex>();
|
||||
_handler = new DisconnectJellyfinHandler(
|
||||
_mediaSourceRepository,
|
||||
_jellyfinSecretStore,
|
||||
_entityLocker,
|
||||
_searchIndex);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_On_Success()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllJellyfin().Returns(new List<int>());
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_When_Repository_Delete_Throws()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllJellyfin().ThrowsAsync(new InvalidOperationException("db exploded"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_When_SearchIndex_RemoveItems_Throws()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllJellyfin().Returns([1, 2]);
|
||||
_searchIndex.RemoveItems(Arg.Any<IEnumerable<int>>()).ThrowsAsync(new InvalidOperationException("index down"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Release_Lock_When_SecretStore_DeleteAll_Throws()
|
||||
{
|
||||
_mediaSourceRepository.DeleteAllJellyfin().Returns(new List<int>());
|
||||
_jellyfinSecretStore.DeleteAll().ThrowsAsync(new InvalidOperationException("secret store down"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(
|
||||
async () => await _handler.Handle(new DisconnectJellyfin(), CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockRemoteMediaSource<JellyfinMediaSource>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Jellyfin;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateJellyfinPathReplacementsHandlerTests
|
||||
{
|
||||
private IMediaSourceRepository _mediaSourceRepository = null!;
|
||||
private UpdateJellyfinPathReplacementsHandler _handler = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
_handler = new UpdateJellyfinPathReplacementsHandler(_mediaSourceRepository);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_Source_Does_Not_Exist()
|
||||
{
|
||||
_mediaSourceRepository.GetJellyfin(99).Returns(Option<JellyfinMediaSource>.None);
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateJellyfinPathReplacements(99, [new JellyfinPathReplacementItem(0, "/jf", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_For_Cross_Source_Id_And_Perform_No_Mutation()
|
||||
{
|
||||
var source = new JellyfinMediaSource
|
||||
{
|
||||
Id = 1,
|
||||
PathReplacements = [new JellyfinPathReplacement { Id = 1, JellyfinPath = "/jf1", LocalPath = "/l1" }]
|
||||
};
|
||||
_mediaSourceRepository.GetJellyfin(1).Returns(Option<JellyfinMediaSource>.Some(source));
|
||||
|
||||
// id 999 belongs to some other source, not this one
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateJellyfinPathReplacements(1, [new JellyfinPathReplacementItem(999, "/jf", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>());
|
||||
}
|
||||
|
||||
[TestCase("", "/local")]
|
||||
[TestCase("/jf", "")]
|
||||
[TestCase(" ", " ")]
|
||||
public async Task Handle_Should_Return_422_For_Blank_Paths(string jellyfinPath, string localPath)
|
||||
{
|
||||
var source = new JellyfinMediaSource { Id = 1, PathReplacements = [] };
|
||||
_mediaSourceRepository.GetJellyfin(1).Returns(Option<JellyfinMediaSource>.Some(source));
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateJellyfinPathReplacements(1, [new JellyfinPathReplacementItem(0, jellyfinPath, localPath)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_For_Null_Items_List_Defense()
|
||||
{
|
||||
var source = new JellyfinMediaSource { Id = 1, PathReplacements = [] };
|
||||
_mediaSourceRepository.GetJellyfin(1).Returns(Option<JellyfinMediaSource>.Some(source));
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateJellyfinPathReplacements(1, [null!]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Merge_Add_Update_Delete_On_Valid_Request()
|
||||
{
|
||||
var source = new JellyfinMediaSource
|
||||
{
|
||||
Id = 1,
|
||||
PathReplacements =
|
||||
[
|
||||
new JellyfinPathReplacement { Id = 1, JellyfinPath = "/old", LocalPath = "/old-local" },
|
||||
new JellyfinPathReplacement { Id = 2, JellyfinPath = "/gone", LocalPath = "/gone-local" }
|
||||
]
|
||||
};
|
||||
_mediaSourceRepository.GetJellyfin(1).Returns(Option<JellyfinMediaSource>.Some(source));
|
||||
_mediaSourceRepository.UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>(),
|
||||
Arg.Any<List<JellyfinPathReplacement>>())
|
||||
.Returns(Unit.Default);
|
||||
|
||||
Either<BaseError, Unit> result = await _handler.Handle(
|
||||
new UpdateJellyfinPathReplacements(
|
||||
1,
|
||||
[
|
||||
new JellyfinPathReplacementItem(1, "/updated", "/updated-local"), // update
|
||||
new JellyfinPathReplacementItem(0, "/new", "/new-local") // add
|
||||
// id 2 is absent -> delete
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await _mediaSourceRepository.Received(1).UpdatePathReplacements(
|
||||
1,
|
||||
Arg.Is<List<JellyfinPathReplacement>>(l => l.Count == 1 && l[0].JellyfinPath == "/new"),
|
||||
Arg.Is<List<JellyfinPathReplacement>>(l => l.Count == 1 && l[0].Id == 1 && l[0].JellyfinPath == "/updated"),
|
||||
Arg.Is<List<JellyfinPathReplacement>>(l => l.Count == 1 && l[0].Id == 2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using ThreadingChannel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class CreateLocalLibraryHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_New_Path_Does_Not_Exist()
|
||||
{
|
||||
await SeedLocalMediaSource();
|
||||
|
||||
CreateLocalLibraryHandler handler = CreateHandler(new MockFileSystem());
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(error => error.Value.ShouldContain("/media/movies"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_List_Only_The_Missing_Paths_When_Mixed()
|
||||
{
|
||||
await SeedLocalMediaSource();
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.CreateDirectory("/media/movies");
|
||||
|
||||
CreateLocalLibraryHandler handler = CreateHandler(fileSystem);
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies", "/media/missing"]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(error =>
|
||||
{
|
||||
error.Value.ShouldContain("/media/missing");
|
||||
error.Value.ShouldNotContain("/media/movies");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Create_When_All_New_Paths_Exist()
|
||||
{
|
||||
await SeedLocalMediaSource();
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.CreateDirectory("/media/movies");
|
||||
|
||||
CreateLocalLibraryHandler handler = CreateHandler(fileSystem);
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new CreateLocalLibrary("Movies", LibraryMediaKind.Movies, ["/media/movies"]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
result.IfRight(vm =>
|
||||
{
|
||||
vm.Name.ShouldBe("Movies");
|
||||
vm.MediaKind.ShouldBe(LibraryMediaKind.Movies);
|
||||
});
|
||||
}
|
||||
|
||||
private CreateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) =>
|
||||
new(
|
||||
ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>().Writer,
|
||||
Substitute.For<IEntityLocker>(),
|
||||
fileSystem,
|
||||
_db.Factory);
|
||||
|
||||
private async Task SeedLocalMediaSource()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
await context.LocalMediaSources.AddAsync(new LocalMediaSource());
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class MoveLocalLibraryPathHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_Target_Library_Is_Same_As_Source()
|
||||
{
|
||||
(int libraryId, int pathId) = await SeedLibraryWithPath("Movies", LibraryMediaKind.Movies);
|
||||
|
||||
MoveLocalLibraryPathHandler handler = CreateHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new MoveLocalLibraryPath(pathId, libraryId), CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(error => error.Value.ShouldContain("different"));
|
||||
|
||||
await using TvContext dbContext = _db.CreateContext();
|
||||
(await dbContext.LibraryPaths.FindAsync(pathId))!.LibraryId.ShouldBe(libraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_Target_Library_Has_Different_MediaKind()
|
||||
{
|
||||
(int sourceLibraryId, int pathId) = await SeedLibraryWithPath("Movies", LibraryMediaKind.Movies);
|
||||
int targetLibraryId = await SeedLibrary("TV Shows", LibraryMediaKind.Shows);
|
||||
|
||||
MoveLocalLibraryPathHandler handler = CreateHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new MoveLocalLibraryPath(pathId, targetLibraryId), CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(error => error.Value.ShouldContain("media kind"));
|
||||
|
||||
await using TvContext dbContext = _db.CreateContext();
|
||||
(await dbContext.LibraryPaths.FindAsync(pathId))!.LibraryId.ShouldBe(sourceLibraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Move_Path_When_Target_Library_Is_Different_And_Same_MediaKind()
|
||||
{
|
||||
(_, int pathId) = await SeedLibraryWithPath("Movies", LibraryMediaKind.Movies);
|
||||
int targetLibraryId = await SeedLibrary("More Movies", LibraryMediaKind.Movies);
|
||||
|
||||
MoveLocalLibraryPathHandler handler = CreateHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new MoveLocalLibraryPath(pathId, targetLibraryId), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext dbContext = _db.CreateContext();
|
||||
(await dbContext.LibraryPaths.FindAsync(pathId))!.LibraryId.ShouldBe(targetLibraryId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_Path_Does_Not_Exist()
|
||||
{
|
||||
int targetLibraryId = await SeedLibrary("Movies", LibraryMediaKind.Movies);
|
||||
|
||||
MoveLocalLibraryPathHandler handler = CreateHandler();
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await handler.Handle(new MoveLocalLibraryPath(9999, targetLibraryId), CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
}
|
||||
|
||||
private MoveLocalLibraryPathHandler CreateHandler() =>
|
||||
new(
|
||||
Substitute.For<ISearchIndex>(),
|
||||
Substitute.For<ISearchRepository>(),
|
||||
Substitute.For<IFallbackMetadataProvider>(),
|
||||
Substitute.For<ILanguageCodeService>(),
|
||||
_db.Factory,
|
||||
NullLogger<MoveLocalLibraryPathHandler>.Instance);
|
||||
|
||||
private async Task<int> SeedLibrary(string name, LibraryMediaKind mediaKind)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary { Name = name, MediaKind = mediaKind, Paths = [] }
|
||||
]
|
||||
};
|
||||
await context.LocalMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return source.Libraries[0].Id;
|
||||
}
|
||||
|
||||
private async Task<(int LibraryId, int PathId)> SeedLibraryWithPath(string name, LibraryMediaKind mediaKind)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = name,
|
||||
MediaKind = mediaKind,
|
||||
Paths = [new LibraryPath { Path = "/media/one" }]
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.LocalMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return (source.Libraries[0].Id, source.Libraries[0].Paths[0].Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
using System.IO.Abstractions;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using ThreadingChannel = System.Threading.Channels.Channel;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Libraries;
|
||||
|
||||
[TestFixture]
|
||||
public class UpdateLocalLibraryHandlerTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_422_When_New_Path_Does_Not_Exist()
|
||||
{
|
||||
(int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/movies");
|
||||
|
||||
UpdateLocalLibraryHandler handler = CreateHandler(new MockFileSystem());
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new UpdateLocalLibrary(
|
||||
libraryId,
|
||||
"Movies",
|
||||
[
|
||||
new UpdateLocalLibraryPath(pathId, "/media/movies"),
|
||||
new UpdateLocalLibraryPath(0, "/media/missing")
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.IfLeft(error => error.Value.ShouldContain("/media/missing"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Exempt_Existing_Paths_From_The_Existence_Check()
|
||||
{
|
||||
// the existing row's directory is NOT in the mock filesystem (simulates an unmounted
|
||||
// share) -- design #202 §C2: existing rows are exempt so a rename doesn't fail on a
|
||||
// temporarily-missing mount.
|
||||
(int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/movies");
|
||||
|
||||
UpdateLocalLibraryHandler handler = CreateHandler(new MockFileSystem());
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new UpdateLocalLibrary(
|
||||
libraryId,
|
||||
"Movies Renamed",
|
||||
[new UpdateLocalLibraryPath(pathId, "/media/movies")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Identify_Paths_By_Normalized_String_Not_Id()
|
||||
{
|
||||
(int libraryId, int pathId) = await SeedLibraryWithPath("Movies", "/media/Movies/");
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.CreateDirectory("/media/movies");
|
||||
|
||||
UpdateLocalLibraryHandler handler = CreateHandler(fileSystem);
|
||||
|
||||
// same path, different case + no trailing slash, Id omitted (0): the handler's merge is by
|
||||
// normalized path string (design #202 §C4c), so this must NOT be treated as delete-old +
|
||||
// add-new -- the existing LibraryPath row (and its id) is preserved.
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new UpdateLocalLibrary(libraryId, "Movies", [new UpdateLocalLibraryPath(0, "/media/movies")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext dbContext = _db.CreateContext();
|
||||
List<LibraryPath> paths = await dbContext.LibraryPaths
|
||||
.Where(lp => lp.LibraryId == libraryId)
|
||||
.ToListAsync();
|
||||
|
||||
paths.Count.ShouldBe(1);
|
||||
paths[0].Id.ShouldBe(pathId);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Add_And_Remove_Paths_By_Normalized_String_Diff()
|
||||
{
|
||||
(int libraryId, int keptPathId) = await SeedLibraryWithPath("Movies", "/media/keep");
|
||||
|
||||
var fileSystem = new MockFileSystem();
|
||||
fileSystem.Directory.CreateDirectory("/media/new");
|
||||
|
||||
UpdateLocalLibraryHandler handler = CreateHandler(fileSystem);
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result = await handler.Handle(
|
||||
new UpdateLocalLibrary(
|
||||
libraryId,
|
||||
"Movies",
|
||||
[
|
||||
new UpdateLocalLibraryPath(keptPathId, "/media/keep"),
|
||||
new UpdateLocalLibraryPath(0, "/media/new")
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext dbContext = _db.CreateContext();
|
||||
List<string> paths = await dbContext.LibraryPaths
|
||||
.Where(lp => lp.LibraryId == libraryId)
|
||||
.Select(lp => lp.Path)
|
||||
.ToListAsync();
|
||||
|
||||
paths.Count.ShouldBe(2);
|
||||
paths.ShouldContain("/media/keep");
|
||||
paths.ShouldContain("/media/new");
|
||||
}
|
||||
|
||||
private UpdateLocalLibraryHandler CreateHandler(IFileSystem fileSystem) =>
|
||||
new(
|
||||
ThreadingChannel.CreateUnbounded<IScannerBackgroundServiceRequest>().Writer,
|
||||
Substitute.For<IEntityLocker>(),
|
||||
fileSystem,
|
||||
Substitute.For<ISearchIndex>(),
|
||||
_db.Factory);
|
||||
|
||||
private async Task<(int LibraryId, int PathId)> SeedLibraryWithPath(string name, string path)
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = name,
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = [new LibraryPath { Path = path }]
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.LocalMediaSources.AddAsync(source);
|
||||
await context.SaveChangesAsync();
|
||||
return (source.Libraries[0].Id, source.Libraries[0].Paths[0].Id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Plex;
|
||||
|
||||
[TestFixture]
|
||||
public class PlexHandlerTests
|
||||
{
|
||||
// ----- TryCompletePlexPinFlowHandler: release ONLY on non-handoff exits (§C1.6 / finding 5) -----
|
||||
|
||||
[Test]
|
||||
public async Task PinFlow_Should_Release_Lock_On_Thrown_Cancellation()
|
||||
{
|
||||
IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
using var externalCts = new CancellationTokenSource();
|
||||
// poll returns false, then cancels the flow's token so Task.Delay THROWS (the real timeout path)
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>())
|
||||
.Returns(_ =>
|
||||
{
|
||||
externalCts.Cancel();
|
||||
return false;
|
||||
});
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() =>
|
||||
handler.Handle(new TryCompletePlexPinFlow(Pin()), externalCts.Token));
|
||||
|
||||
entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PinFlow_Should_Release_Lock_On_Poll_Exception()
|
||||
{
|
||||
IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>())
|
||||
.Returns<bool>(_ => throw new InvalidOperationException("poll boom"));
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None));
|
||||
|
||||
entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PinFlow_Should_Release_Lock_On_Enqueue_Exception()
|
||||
{
|
||||
IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>()).Returns(true);
|
||||
channel.WriteAsync(Arg.Any<IPlexBackgroundServiceRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns<ValueTask>(_ => throw new InvalidOperationException("enqueue boom"));
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None));
|
||||
|
||||
entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PinFlow_Success_Should_Not_Release_Lock_In_Handler()
|
||||
{
|
||||
IPlexTvApiClient plexTvApiClient = Substitute.For<IPlexTvApiClient>();
|
||||
var channel = Substitute.For<ChannelWriter<IPlexBackgroundServiceRequest>>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
|
||||
plexTvApiClient.TryCompletePinFlow(Arg.Any<PlexAuthPin>()).Returns(true);
|
||||
|
||||
var handler = new TryCompletePlexPinFlowHandler(plexTvApiClient, channel, entityLocker);
|
||||
|
||||
Either<BaseError, bool> result =
|
||||
await handler.Handle(new TryCompletePlexPinFlow(Pin()), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
// the lock is handed off to SynchronizePlexMediaSources — the handler must NOT release it
|
||||
entityLocker.DidNotReceive().UnlockPlex();
|
||||
await channel.Received(1).WriteAsync(
|
||||
Arg.Any<SynchronizePlexMediaSources>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- SignOutOfPlexHandler: unconditional finally-release (§C6 / finding 7) -----
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Release_Lock_When_Dependency_Throws()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
IPlexSecretStore plexSecretStore = Substitute.For<IPlexSecretStore>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
||||
|
||||
mediaSourceRepository.DeleteAllPlex()
|
||||
.Returns<List<int>>(_ => throw new InvalidOperationException("delete boom"));
|
||||
|
||||
var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex);
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() =>
|
||||
handler.Handle(new SignOutOfPlex(), CancellationToken.None));
|
||||
|
||||
entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Release_Lock_On_Success()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
IPlexSecretStore plexSecretStore = Substitute.For<IPlexSecretStore>();
|
||||
IEntityLocker entityLocker = Substitute.For<IEntityLocker>();
|
||||
ISearchIndex searchIndex = Substitute.For<ISearchIndex>();
|
||||
|
||||
mediaSourceRepository.DeleteAllPlex().Returns(new List<int> { 1, 2 });
|
||||
|
||||
var handler = new SignOutOfPlexHandler(mediaSourceRepository, plexSecretStore, entityLocker, searchIndex);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(new SignOutOfPlex(), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
// ----- UpdatePlexPathReplacementsHandler: ownership + nonblank + null validation (§C4b / findings 2c/8) -----
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Reject_CrossSource_Id_Without_Mutating()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
// id 99 is not owned by source 1 (its only replacement is id 5) → must not touch the repo
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(99, "/remote", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Reject_Blank_Rows_Without_Mutating()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(0, " ", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Reject_Null_Items_Without_Mutating()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [null]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await mediaSourceRepository.DidNotReceive().UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task PathReplacements_Should_Accept_Owned_Rows_And_Mutate()
|
||||
{
|
||||
IMediaSourceRepository mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
mediaSourceRepository.GetPlex(1, Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlexMediaSource>.Some(SourceWithReplacement(1, replacementId: 5)));
|
||||
mediaSourceRepository.UpdatePathReplacements(
|
||||
Arg.Any<int>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>())
|
||||
.Returns(Unit.Default);
|
||||
|
||||
var handler = new UpdatePlexPathReplacementsHandler(mediaSourceRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdatePlexPathReplacements(1, [new PlexPathReplacementItem(5, "/remote", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await mediaSourceRepository.Received(1).UpdatePathReplacements(
|
||||
1,
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>(),
|
||||
Arg.Any<List<PlexPathReplacement>>());
|
||||
}
|
||||
|
||||
private static PlexAuthPin Pin() => new(1, "code", "client-id");
|
||||
|
||||
private static PlexMediaSource SourceWithReplacement(int sourceId, int replacementId) =>
|
||||
new()
|
||||
{
|
||||
Id = sourceId,
|
||||
PathReplacements =
|
||||
[
|
||||
new PlexPathReplacement
|
||||
{
|
||||
Id = replacementId,
|
||||
PlexPath = "/existing-remote",
|
||||
LocalPath = "/existing-local",
|
||||
PlexMediaSourceId = sourceId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class EmbyMediaSourcesControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private Channel<IScannerBackgroundServiceRequest> _scannerChannel = null!;
|
||||
private EmbyMediaSourcesController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_scannerChannel = System.Threading.Channels.Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
_controller = new EmbyMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(EmbyMediaSourcesController.GetState), "GET", "/api/media-sources/emby");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.GetConnection),
|
||||
"GET",
|
||||
"/api/media-sources/emby/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.SaveConnection),
|
||||
"PUT",
|
||||
"/api/media-sources/emby/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.Disconnect),
|
||||
"POST",
|
||||
"/api/media-sources/emby/disconnect");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.GetLibraries),
|
||||
"GET",
|
||||
"/api/media-sources/emby/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.ReplaceLibraryPreferences),
|
||||
"PUT",
|
||||
"/api/media-sources/emby/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.GetPathReplacements),
|
||||
"GET",
|
||||
"/api/media-sources/emby/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.ReplacePathReplacements),
|
||||
"PUT",
|
||||
"/api/media-sources/emby/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(EmbyMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/emby/{id:int}/refresh-libraries");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Report_Authorized_And_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllEmbyMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns([new EmbyMediaSourceViewModel(1, "My Server", "http://emby.local")]);
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "secret" });
|
||||
_entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>().Returns(true);
|
||||
|
||||
RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.IsAuthorized.ShouldBeTrue();
|
||||
result.IsLocked.ShouldBeTrue();
|
||||
result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://emby.local")]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllEmbyMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<EmbyMediaSourceViewModel>());
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "" });
|
||||
|
||||
RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.IsAuthorized.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetConnection_Should_Never_Return_The_Api_Key()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new EmbySecrets { Address = "http://emby.local", ApiKey = "super-secret" });
|
||||
|
||||
RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None);
|
||||
|
||||
result.Address.ShouldBe("http://emby.local");
|
||||
result.HasApiKey.ShouldBeTrue();
|
||||
result.ToString().ShouldNotContain("super-secret");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetConnection_Should_Report_No_Key_When_Not_Configured()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new EmbySecrets { Address = "", ApiKey = "" });
|
||||
|
||||
RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None);
|
||||
|
||||
result.HasApiKey.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Return_409_When_Locked()
|
||||
{
|
||||
_entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://emby.local", "key"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SaveEmbySecrets>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address()
|
||||
{
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("not-a-uri", "key"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SaveEmbySecrets>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new EmbySecrets { Address = "", ApiKey = "" });
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://emby.local", ""),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SaveEmbySecrets>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" },
|
||||
new EmbySecrets { Address = "http://emby.local", ApiKey = "existing-key" });
|
||||
_mediator.Send(Arg.Any<SaveEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://emby.local", " "),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<RemoteConnectionResponseModel>()
|
||||
.HasApiKey.ShouldBeTrue();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<SaveEmbySecrets>(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://emby.local"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Set_New_Key_When_NonBlank()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new EmbySecrets { Address = "http://old.local", ApiKey = "existing-key" },
|
||||
new EmbySecrets { Address = "http://emby.local", ApiKey = "new-key" });
|
||||
_mediator.Send(Arg.Any<SaveEmbySecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://emby.local", "new-key"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<SaveEmbySecrets>(c => c.Secrets.ApiKey == "new-key"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Disconnect_Should_Return_409_When_Lock_Fails()
|
||||
{
|
||||
_entityLocker.LockRemoteMediaSource<EmbyMediaSource>().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.Disconnect(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DisconnectEmby>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Disconnect_Should_Return_204_On_Success()
|
||||
{
|
||||
_entityLocker.LockRemoteMediaSource<EmbyMediaSource>().Returns(true);
|
||||
_mediator.Send(Arg.Any<DisconnectEmby>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Disconnect(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetLibraries(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetEmbyLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns([new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateEmbyLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetEmbyLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
[
|
||||
new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1),
|
||||
new EmbyLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1)
|
||||
]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetEmbyLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
[new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)],
|
||||
[new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]);
|
||||
_mediator.Send(Arg.Any<UpdateEmbyLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(1).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<RemoteLibraryResponseModel>>()
|
||||
.Single().Id.ShouldBe(1);
|
||||
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue();
|
||||
first.ShouldBeOfType<SynchronizeEmbyLibraries>().EmbyMediaSourceId.ShouldBe(1);
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue();
|
||||
second.ShouldBeOfType<SynchronizeEmbyLibraryByIdIfNeeded>().EmbyLibraryId.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetEmbyLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
[new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)],
|
||||
[new EmbyLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]);
|
||||
_mediator.Send(Arg.Any<UpdateEmbyLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(1).Returns(false);
|
||||
|
||||
await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
_scannerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPathReplacements_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<UpdateEmbyPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetEmbyPathReplacementsBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns([new EmbyPathReplacementViewModel(1, "/emby", "/local")]);
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
1,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/emby", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
List<PathReplacementResponseModel> body = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PathReplacementResponseModel>>();
|
||||
body.Single().ShouldBe(new PathReplacementResponseModel(1, "/emby", "/local"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<UpdateEmbyPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad row")));
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
1,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_409_When_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Enqueue_And_Return_202()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetEmbyMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<EmbyMediaSourceViewModel>.Some(new EmbyMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<SynchronizeEmbyLibraries>().EmbyMediaSourceId.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(EmbyMediaSourcesController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class JellyfinMediaSourcesControllerTests
|
||||
{
|
||||
private IMediator _mediator = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private Channel<IScannerBackgroundServiceRequest> _scannerChannel = null!;
|
||||
private JellyfinMediaSourcesController _controller = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_scannerChannel = System.Threading.Channels.Channel.CreateUnbounded<IScannerBackgroundServiceRequest>();
|
||||
_controller = new JellyfinMediaSourcesController(_mediator, _entityLocker, _scannerChannel.Writer);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(JellyfinMediaSourcesController.GetState), "GET", "/api/media-sources/jellyfin");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.GetConnection),
|
||||
"GET",
|
||||
"/api/media-sources/jellyfin/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.SaveConnection),
|
||||
"PUT",
|
||||
"/api/media-sources/jellyfin/connection");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.Disconnect),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/disconnect");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.GetLibraries),
|
||||
"GET",
|
||||
"/api/media-sources/jellyfin/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.ReplaceLibraryPreferences),
|
||||
"PUT",
|
||||
"/api/media-sources/jellyfin/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.GetPathReplacements),
|
||||
"GET",
|
||||
"/api/media-sources/jellyfin/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.ReplacePathReplacements),
|
||||
"PUT",
|
||||
"/api/media-sources/jellyfin/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(JellyfinMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/jellyfin/{id:int}/refresh-libraries");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Report_Authorized_And_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllJellyfinMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns([new JellyfinMediaSourceViewModel(1, "My Server", "http://jf.local")]);
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "secret" });
|
||||
_entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>().Returns(true);
|
||||
|
||||
RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.IsAuthorized.ShouldBeTrue();
|
||||
result.IsLocked.ShouldBeTrue();
|
||||
result.Servers.ShouldBe([new RemoteMediaSourceItemResponseModel(1, "My Server", "http://jf.local")]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Report_Unauthorized_When_ApiKey_Blank()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllJellyfinMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<JellyfinMediaSourceViewModel>());
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "" });
|
||||
|
||||
RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.IsAuthorized.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetConnection_Should_Never_Return_The_Api_Key()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new JellyfinSecrets { Address = "http://jf.local", ApiKey = "super-secret" });
|
||||
|
||||
RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None);
|
||||
|
||||
result.Address.ShouldBe("http://jf.local");
|
||||
result.HasApiKey.ShouldBeTrue();
|
||||
result.ToString().ShouldNotContain("super-secret");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetConnection_Should_Report_No_Key_When_Not_Configured()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new JellyfinSecrets { Address = "", ApiKey = "" });
|
||||
|
||||
RemoteConnectionResponseModel result = await _controller.GetConnection(CancellationToken.None);
|
||||
|
||||
result.HasApiKey.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Return_409_When_Locked()
|
||||
{
|
||||
_entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://jf.local", "key"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SaveJellyfinSecrets>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Return_422_For_Non_Absolute_Address()
|
||||
{
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("not-a-uri", "key"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SaveJellyfinSecrets>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Return_422_When_Key_Blank_On_First_Connect()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new JellyfinSecrets { Address = "", ApiKey = "" });
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://jf.local", ""),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SaveJellyfinSecrets>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Retain_Existing_Key_When_Blank()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new JellyfinSecrets { Address = "http://old.local", ApiKey = "existing-key" },
|
||||
new JellyfinSecrets { Address = "http://jf.local", ApiKey = "existing-key" });
|
||||
_mediator.Send(Arg.Any<SaveJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://jf.local", " "),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<RemoteConnectionResponseModel>()
|
||||
.HasApiKey.ShouldBeTrue();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<SaveJellyfinSecrets>(c => c.Secrets.ApiKey == "existing-key" && c.Secrets.Address == "http://jf.local"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SaveConnection_Should_Set_New_Key_When_NonBlank()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
new JellyfinSecrets { Address = "http://old.local", ApiKey = "existing-key" },
|
||||
new JellyfinSecrets { Address = "http://jf.local", ApiKey = "new-key" });
|
||||
_mediator.Send(Arg.Any<SaveJellyfinSecrets>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.SaveConnection(
|
||||
new SaveRemoteConnectionRequest("http://jf.local", "new-key"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<SaveJellyfinSecrets>(c => c.Secrets.ApiKey == "new-key"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Disconnect_Should_Return_409_When_Lock_Fails()
|
||||
{
|
||||
_entityLocker.LockRemoteMediaSource<JellyfinMediaSource>().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.Disconnect(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DisconnectJellyfin>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Disconnect_Should_Return_204_On_Success()
|
||||
{
|
||||
_entityLocker.LockRemoteMediaSource<JellyfinMediaSource>().Returns(true);
|
||||
_mediator.Send(Arg.Any<DisconnectJellyfin>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Disconnect(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetLibraries(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Return_422_For_Foreign_Id()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetJellyfinLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns([new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateJellyfinLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Return_422_When_Not_Covering_All_Libraries()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetJellyfinLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
[
|
||||
new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1),
|
||||
new JellyfinLibraryViewModel(2, "Shows", LibraryMediaKind.Shows, true, 1)
|
||||
]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Enqueue_Sync_Pair_For_Enabled_Libraries_And_Return_Reloaded()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetJellyfinLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
[new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)],
|
||||
[new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]);
|
||||
_mediator.Send(Arg.Any<UpdateJellyfinLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(1).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<RemoteLibraryResponseModel>>()
|
||||
.Single().Id.ShouldBe(1);
|
||||
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? first).ShouldBeTrue();
|
||||
first.ShouldBeOfType<SynchronizeJellyfinLibraries>().JellyfinMediaSourceId.ShouldBe(1);
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? second).ShouldBeTrue();
|
||||
second.ShouldBeOfType<SynchronizeJellyfinLibraryByIdIfNeeded>().JellyfinLibraryId.ShouldBe(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraryPreferences_Should_Skip_Enqueue_When_Library_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<GetJellyfinLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
[new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)],
|
||||
[new JellyfinLibraryViewModel(1, "Movies", LibraryMediaKind.Movies, true, 1)]);
|
||||
_mediator.Send(Arg.Any<UpdateJellyfinLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(1).Returns(false);
|
||||
|
||||
await _controller.ReplaceLibraryPreferences(
|
||||
1,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(1, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
_scannerChannel.Reader.TryRead(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPathReplacements_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetPathReplacements(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_200_With_Reloaded_List()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<UpdateJellyfinPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetJellyfinPathReplacementsBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns([new JellyfinPathReplacementViewModel(1, "/jellyfin", "/local")]);
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
1,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(1, "/jellyfin", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
List<PathReplacementResponseModel> body = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PathReplacementResponseModel>>();
|
||||
body.Single().ShouldBe(new PathReplacementResponseModel(1, "/jellyfin", "/local"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Map_Handler_Error_To_422()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_mediator.Send(Arg.Any<UpdateJellyfinPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad row")));
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
1,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(999, "", "")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>().StatusCode.ShouldBe(422);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(99, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_409_When_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().StatusCode.ShouldBe(409);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Enqueue_And_Return_202()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetJellyfinMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<JellyfinMediaSourceViewModel>.Some(new JellyfinMediaSourceViewModel(1, "s", "a")));
|
||||
_entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(1, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
_scannerChannel.Reader.TryRead(out IScannerBackgroundServiceRequest? request).ShouldBeTrue();
|
||||
request.ShouldBeOfType<SynchronizeJellyfinLibraries>().JellyfinMediaSourceId.ShouldBe(1);
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(JellyfinMediaSourcesController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
using System.Reflection;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Testably.Abstractions.Testing;
|
||||
using Unit = LanguageExt.Unit;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class LocalLibrariesControllerTests
|
||||
{
|
||||
private LocalLibrariesController _controller = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private MockFileSystem _fileSystem = null!;
|
||||
private ILibraryRepository _libraryRepository = null!;
|
||||
private IMediator _mediator = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_fileSystem = new MockFileSystem();
|
||||
_libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
_controller = new LocalLibrariesController(_mediator, _entityLocker, _fileSystem, _libraryRepository);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.GetAll), "GET", "/api/libraries/local");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.GetById), "GET", "/api/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Create), "POST", "/api/libraries/local");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Update), "PUT", "/api/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(nameof(LocalLibrariesController.Delete), "DELETE", "/api/libraries/local/{id:int}");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(LocalLibrariesController.MovePath),
|
||||
"POST",
|
||||
"/api/libraries/local/paths/{pathId:int}/move");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(LocalLibrariesController.CheckPathExists),
|
||||
"POST",
|
||||
"/api/libraries/local/path-exists");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetAll_Should_Project_Libraries_And_Lock_State()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllLocalLibraries>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<LocalLibraryViewModel>
|
||||
{
|
||||
new(1, "Movies", LibraryMediaKind.Movies, 0),
|
||||
new(2, "TV Shows", LibraryMediaKind.Shows, 0)
|
||||
});
|
||||
_entityLocker.IsLibraryLocked(1).Returns(true);
|
||||
_entityLocker.IsLibraryLocked(2).Returns(false);
|
||||
|
||||
List<LocalLibraryResponseModel> result = await _controller.GetAll(CancellationToken.None);
|
||||
|
||||
result.Count.ShouldBe(2);
|
||||
result[0].ShouldBe(new LocalLibraryResponseModel(1, "Movies", LibraryMediaKind.Movies, true));
|
||||
result[1].ShouldBe(new LocalLibraryResponseModel(2, "TV Shows", LibraryMediaKind.Shows, false));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<LocalLibraryViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.GetById(99, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetById_Should_Return_Detail_With_Paths_And_Counts()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)));
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryPaths>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<LocalLibraryPathViewModel> { new(10, 3, "/media/movies") });
|
||||
_mediator.Send(Arg.Any<CountMediaItemsByLibrary>(), Arg.Any<CancellationToken>()).Returns(5);
|
||||
_mediator.Send(Arg.Any<CountMediaItemsByLibraryPath>(), Arg.Any<CancellationToken>()).Returns(5);
|
||||
_entityLocker.IsLibraryLocked(3).Returns(false);
|
||||
|
||||
IActionResult result = await _controller.GetById(3, CancellationToken.None);
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
var detail = ok.Value.ShouldBeOfType<LocalLibraryDetailResponseModel>();
|
||||
detail.Id.ShouldBe(3);
|
||||
detail.MediaItemCount.ShouldBe(5);
|
||||
detail.Paths.Count.ShouldBe(1);
|
||||
detail.Paths[0].ShouldBe(new LocalLibraryPathResponseModel(10, "/media/movies", 5));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_With_Location_And_Body()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateLocalLibrary>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, LocalLibraryViewModel>(
|
||||
new LocalLibraryViewModel(5, "Movies", LibraryMediaKind.Movies, 0)));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateLocalLibraryRequest("Movies", LibraryMediaKind.Movies, ["/media/movies"]),
|
||||
CancellationToken.None);
|
||||
|
||||
var created = result.ShouldBeOfType<CreatedResult>();
|
||||
created.Location.ShouldBe("/api/libraries/local/5");
|
||||
created.Value.ShouldBeOfType<LocalLibraryResponseModel>().Name.ShouldBe("Movies");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<CreateLocalLibrary>(c => c.Name == "Movies" && c.MediaKind == LibraryMediaKind.Movies),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreateLocalLibrary>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, LocalLibraryViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreateLocalLibraryRequest(string.Empty, LibraryMediaKind.Movies, []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<LocalLibraryViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
99,
|
||||
new UpdateLocalLibraryRequest("New Name", []),
|
||||
CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateLocalLibrary>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_409_When_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)));
|
||||
_entityLocker.IsLibraryLocked(3).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
3,
|
||||
new UpdateLocalLibraryRequest("New Name", []),
|
||||
CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdateLocalLibrary>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)));
|
||||
_entityLocker.IsLibraryLocked(3).Returns(false);
|
||||
_mediator.Send(Arg.Any<UpdateLocalLibrary>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, LocalLibraryViewModel>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
3,
|
||||
new UpdateLocalLibraryRequest("New Name", []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Reload_And_Return_Detail_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(
|
||||
Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)),
|
||||
Some(new LocalLibraryViewModel(3, "New Name", LibraryMediaKind.Movies, 0)));
|
||||
_entityLocker.IsLibraryLocked(3).Returns(false);
|
||||
_mediator.Send(Arg.Any<UpdateLocalLibrary>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, LocalLibraryViewModel>(
|
||||
new LocalLibraryViewModel(3, "New Name", LibraryMediaKind.Movies, 0)));
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryPaths>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<LocalLibraryPathViewModel>());
|
||||
_mediator.Send(Arg.Any<CountMediaItemsByLibrary>(), Arg.Any<CancellationToken>()).Returns(0);
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
3,
|
||||
new UpdateLocalLibraryRequest("New Name", []),
|
||||
CancellationToken.None);
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<LocalLibraryDetailResponseModel>().Name.ShouldBe("New Name");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateLocalLibrary>(c => c.Id == 3 && c.Name == "New Name"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_404_When_Missing()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<LocalLibraryViewModel>.None);
|
||||
|
||||
IActionResult result = await _controller.Delete(99, CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeleteLocalLibrary>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_409_When_Locked()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)));
|
||||
_entityLocker.IsLibraryLocked(3).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.Delete(3, CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<DeleteLocalLibrary>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetLocalLibraryById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Some(new LocalLibraryViewModel(3, "Movies", LibraryMediaKind.Movies, 0)));
|
||||
_entityLocker.IsLibraryLocked(3).Returns(false);
|
||||
_mediator.Send(Arg.Any<DeleteLocalLibrary>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.Delete(3, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<DeleteLocalLibrary>(c => c.LocalLibraryId == 3),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MovePath_Should_Return_404_When_Path_Unknown()
|
||||
{
|
||||
_libraryRepository.GetLibraryIdForPath(999).Returns(Option<int>.None);
|
||||
|
||||
IActionResult result = await _controller.MovePath(
|
||||
999,
|
||||
new MoveLocalLibraryPathRequest(2),
|
||||
CancellationToken.None);
|
||||
|
||||
var notFound = result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
notFound.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status404NotFound);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<MoveLocalLibraryPath>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MovePath_Should_Return_409_When_Source_Library_Locked()
|
||||
{
|
||||
_libraryRepository.GetLibraryIdForPath(10).Returns(Some(1));
|
||||
_entityLocker.IsLibraryLocked(1).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.MovePath(
|
||||
10,
|
||||
new MoveLocalLibraryPathRequest(2),
|
||||
CancellationToken.None);
|
||||
|
||||
var conflict = result.ShouldBeOfType<ConflictObjectResult>();
|
||||
conflict.Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(StatusCodes.Status409Conflict);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<MoveLocalLibraryPath>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MovePath_Should_Return_204_On_Success()
|
||||
{
|
||||
_libraryRepository.GetLibraryIdForPath(10).Returns(Some(1));
|
||||
_entityLocker.IsLibraryLocked(1).Returns(false);
|
||||
_mediator.Send(Arg.Any<MoveLocalLibraryPath>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.MovePath(
|
||||
10,
|
||||
new MoveLocalLibraryPathRequest(2),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<MoveLocalLibraryPath>(c => c.LibraryPathId == 10 && c.TargetLibraryId == 2),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task MovePath_Should_Return_422_When_Handler_Rejects_Move_After_PreCheck_Passes()
|
||||
{
|
||||
// documents the residual check-then-act race (design #202 §C5): the pre-check passed, but
|
||||
// the handler's own validation (e.g. same-kind/different-library) still fails 422, not 404.
|
||||
_libraryRepository.GetLibraryIdForPath(10).Returns(Some(1));
|
||||
_entityLocker.IsLibraryLocked(1).Returns(false);
|
||||
_mediator.Send(Arg.Any<MoveLocalLibraryPath>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("Target library must be different from the source path's current library")));
|
||||
|
||||
IActionResult result = await _controller.MovePath(
|
||||
10,
|
||||
new MoveLocalLibraryPathRequest(1),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckPathExists_Should_Return_True_When_Directory_Exists()
|
||||
{
|
||||
_fileSystem.Directory.CreateDirectory("/media/movies");
|
||||
|
||||
IActionResult result = _controller.CheckPathExists(new LocalPathCheckRequest("/media/movies"));
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<LocalPathCheckResponseModel>().Exists.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CheckPathExists_Should_Return_False_When_Directory_Missing()
|
||||
{
|
||||
IActionResult result = _controller.CheckPathExists(new LocalPathCheckRequest("/media/does-not-exist"));
|
||||
|
||||
var ok = result.ShouldBeOfType<OkObjectResult>();
|
||||
ok.Value.ShouldBeOfType<LocalPathCheckResponseModel>().Exists.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(LocalLibrariesController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
using System.Reflection;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Plex;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Controllers;
|
||||
|
||||
[TestFixture]
|
||||
public class PlexMediaSourcesControllerTests
|
||||
{
|
||||
private ChannelWriter<IScannerBackgroundServiceRequest> _channel = null!;
|
||||
private PlexMediaSourcesController _controller = null!;
|
||||
private IEntityLocker _entityLocker = null!;
|
||||
private IMediator _mediator = null!;
|
||||
private IPlexSecretStore _plexSecretStore = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_entityLocker = Substitute.For<IEntityLocker>();
|
||||
_plexSecretStore = Substitute.For<IPlexSecretStore>();
|
||||
_channel = Substitute.For<ChannelWriter<IScannerBackgroundServiceRequest>>();
|
||||
_controller = new PlexMediaSourcesController(_mediator, _entityLocker, _plexSecretStore, _channel);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Controller_Should_Expose_Idiomatic_Rest_Routes()
|
||||
{
|
||||
ShouldHaveActionRoute(nameof(PlexMediaSourcesController.GetState), "GET", "/api/media-sources/plex");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.StartPinFlow),
|
||||
"POST",
|
||||
"/api/media-sources/plex/pin-flow");
|
||||
ShouldHaveActionRoute(nameof(PlexMediaSourcesController.SignOutOfPlex), "POST", "/api/media-sources/plex/sign-out");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.GetLibraries),
|
||||
"GET",
|
||||
"/api/media-sources/plex/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.ReplaceLibraryPreferences),
|
||||
"PUT",
|
||||
"/api/media-sources/plex/{id:int}/libraries");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.GetPathReplacements),
|
||||
"GET",
|
||||
"/api/media-sources/plex/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.ReplacePathReplacements),
|
||||
"PUT",
|
||||
"/api/media-sources/plex/{id:int}/path-replacements");
|
||||
ShouldHaveActionRoute(
|
||||
nameof(PlexMediaSourcesController.RefreshLibraries),
|
||||
"POST",
|
||||
"/api/media-sources/plex/{id:int}/refresh-libraries");
|
||||
}
|
||||
|
||||
// ----- P1 GetState -----
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Stamp_Authorized_Locked_And_Servers()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlexMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexMediaSourceViewModel> { new(3, "Server", "http://plex:32400") });
|
||||
_plexSecretStore.GetUserAuthTokens()
|
||||
.Returns(new List<PlexUserAuthToken> { new("me@example.com", "token") });
|
||||
_entityLocker.IsPlexLocked().Returns(true);
|
||||
|
||||
RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.IsAuthorized.ShouldBeTrue();
|
||||
result.IsLocked.ShouldBeTrue();
|
||||
result.Servers.Single().Id.ShouldBe(3);
|
||||
result.Servers.Single().Address.ShouldBe("http://plex:32400");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetState_Should_Report_Unauthorized_With_No_Tokens()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetAllPlexMediaSources>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexMediaSourceViewModel>());
|
||||
_plexSecretStore.GetUserAuthTokens().Returns(new List<PlexUserAuthToken>());
|
||||
|
||||
RemoteMediaSourceStateResponseModel result = await _controller.GetState(CancellationToken.None);
|
||||
|
||||
result.IsAuthorized.ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ----- P2 StartPinFlow -----
|
||||
|
||||
[Test]
|
||||
public async Task StartPinFlow_Should_Return_409_When_Lock_Held()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.StartPinFlow(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartPinFlow_Should_Return_200_And_Hold_Lock_On_Success()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, string>("https://app.plex.tv/auth#?code=abc"));
|
||||
|
||||
IActionResult result = await _controller.StartPinFlow(CancellationToken.None);
|
||||
|
||||
var model = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<PlexPinFlowResponseModel>();
|
||||
model.AuthUrl.ShouldBe("https://app.plex.tv/auth#?code=abc");
|
||||
// the Right/200 path keeps the lock held for the background flow
|
||||
_entityLocker.DidNotReceive().UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartPinFlow_Should_Compensate_Unlock_On_Left()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, string>(BaseError.New("plex.tv error")));
|
||||
|
||||
IActionResult result = await _controller.StartPinFlow(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
_entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task StartPinFlow_Should_Compensate_Unlock_On_Thrown_Dispatch()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<StartPlexPinFlow>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Either<BaseError, string>>(_ => throw new InvalidOperationException("channel closed"));
|
||||
|
||||
await Should.ThrowAsync<InvalidOperationException>(() => _controller.StartPinFlow(CancellationToken.None));
|
||||
|
||||
_entityLocker.Received(1).UnlockPlex();
|
||||
}
|
||||
|
||||
// ----- P3 SignOut -----
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Return_409_When_Lock_Held()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<SignOutOfPlex>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SignOut_Should_Return_204_And_Not_Release_In_Controller()
|
||||
{
|
||||
_entityLocker.LockPlex().Returns(true);
|
||||
_mediator.Send(Arg.Any<SignOutOfPlex>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
IActionResult result = await _controller.SignOutOfPlex(CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NoContentResult>();
|
||||
await _mediator.Received(1).Send(Arg.Any<SignOutOfPlex>(), Arg.Any<CancellationToken>());
|
||||
// the handler is the designated releaser (finally) — the controller must not double-release
|
||||
_entityLocker.DidNotReceive().UnlockPlex();
|
||||
}
|
||||
|
||||
// ----- P4 GetLibraries -----
|
||||
|
||||
[Test]
|
||||
public async Task GetLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
SourceExists(false);
|
||||
|
||||
IActionResult result = await _controller.GetLibraries(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetLibraries_Should_Return_200_List()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Movies", LibraryMediaKind.Movies, true) });
|
||||
|
||||
IActionResult result = await _controller.GetLibraries(3, CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<RemoteLibraryResponseModel>>();
|
||||
list.Single().Id.ShouldBe(5);
|
||||
list.Single().ShouldSyncItems.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ----- P5 ReplaceLibraryPreferences -----
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
SourceExists(false);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
9,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Return_422_When_Id_Not_Owned()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Movies", LibraryMediaKind.Movies, true) });
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(999, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Return_422_For_Zero_Id_Row()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Movies", LibraryMediaKind.Movies, true) });
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(0, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Return_422_When_An_Owned_Library_Is_Missing()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel>
|
||||
{
|
||||
new(5, "Movies", LibraryMediaKind.Movies, true),
|
||||
new(6, "Shows", LibraryMediaKind.Shows, true)
|
||||
});
|
||||
|
||||
// request omits owned id 6 -> 422: the PUT is a complete flag document (§C4a), matching Jellyfin/Emby
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Save_Enqueue_Ordered_Pair_And_Return_Reloaded()
|
||||
{
|
||||
SourceExists(true);
|
||||
// owned set for validation, then the reloaded set (drives the sync loop + the response)
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Shows", LibraryMediaKind.Shows, true) });
|
||||
_mediator.Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(5).Returns(true);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<RemoteLibraryResponseModel>>();
|
||||
list.Single().Id.ShouldBe(5);
|
||||
|
||||
// library message carries Unlock:false; the networks message carries the single release Unlock:true
|
||||
Received.InOrder(() =>
|
||||
{
|
||||
_channel.WriteAsync(
|
||||
Arg.Is<SynchronizePlexLibraryByIdIfNeeded>(s => s.PlexLibraryId == 5 && !s.Unlock),
|
||||
Arg.Any<CancellationToken>());
|
||||
_channel.WriteAsync(
|
||||
Arg.Is<SynchronizePlexNetworks>(n => n.PlexLibraryId == 5 && n.Unlock),
|
||||
Arg.Any<CancellationToken>());
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceLibraries_Should_Skip_Enqueue_For_Locked_Library()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexLibrariesBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexLibraryViewModel> { new(5, "Shows", LibraryMediaKind.Shows, true) });
|
||||
_mediator.Send(Arg.Any<UpdatePlexLibraryPreferences>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_entityLocker.LockLibrary(5).Returns(false);
|
||||
|
||||
IActionResult result = await _controller.ReplaceLibraryPreferences(
|
||||
3,
|
||||
new ReplaceRemoteLibraryPreferencesRequest([new RemoteLibraryPreferenceRequest(5, true)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ----- P6 GetPathReplacements -----
|
||||
|
||||
[Test]
|
||||
public async Task GetPathReplacements_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
SourceExists(false);
|
||||
|
||||
IActionResult result = await _controller.GetPathReplacements(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetPathReplacements_Should_Map_Plex_Path_To_Remote_Path()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<GetPlexPathReplacementsBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexPathReplacementViewModel> { new(7, "/plex", "/local") });
|
||||
|
||||
IActionResult result = await _controller.GetPathReplacements(3, CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PathReplacementResponseModel>>();
|
||||
list.Single().RemotePath.ShouldBe("/plex");
|
||||
list.Single().LocalPath.ShouldBe("/local");
|
||||
}
|
||||
|
||||
// ----- P7 ReplacePathReplacements -----
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
SourceExists(false);
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
9,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(0, "/remote", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<UpdatePlexPathReplacements>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_422_On_Handler_Left()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<UpdatePlexPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("cross-source")));
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
3,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(99, "/remote", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplacePathReplacements_Should_Return_200_Reloaded_On_Success()
|
||||
{
|
||||
SourceExists(true);
|
||||
_mediator.Send(Arg.Any<UpdatePlexPathReplacements>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetPlexPathReplacementsBySourceId>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlexPathReplacementViewModel> { new(7, "/plex", "/local") });
|
||||
|
||||
IActionResult result = await _controller.ReplacePathReplacements(
|
||||
3,
|
||||
new ReplacePathReplacementsRequest([new PathReplacementItemRequest(7, "/plex", "/local")]),
|
||||
CancellationToken.None);
|
||||
|
||||
var list = result.ShouldBeOfType<OkObjectResult>().Value
|
||||
.ShouldBeOfType<List<PathReplacementResponseModel>>();
|
||||
list.Single().Id.ShouldBe(7);
|
||||
}
|
||||
|
||||
// ----- P8 RefreshLibraries -----
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_404_When_Source_Missing()
|
||||
{
|
||||
SourceExists(false);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(9, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<NotFoundObjectResult>();
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_409_When_Plex_Locked()
|
||||
{
|
||||
SourceExists(true);
|
||||
_entityLocker.IsPlexLocked().Returns(true);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<ConflictObjectResult>().Value.ShouldBeOfType<ProblemDetails>().Status.ShouldBe(409);
|
||||
await _channel.DidNotReceive().WriteAsync(
|
||||
Arg.Any<IScannerBackgroundServiceRequest>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task RefreshLibraries_Should_Return_202_And_Enqueue()
|
||||
{
|
||||
SourceExists(true);
|
||||
_entityLocker.IsPlexLocked().Returns(false);
|
||||
|
||||
IActionResult result = await _controller.RefreshLibraries(3, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<AcceptedResult>();
|
||||
await _channel.Received(1).WriteAsync(
|
||||
Arg.Is<SynchronizePlexLibraries>(s => s.PlexMediaSourceId == 3),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private void SourceExists(bool exists) =>
|
||||
_mediator.Send(Arg.Any<GetPlexMediaSourceById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(exists
|
||||
? Option<PlexMediaSourceViewModel>.Some(new PlexMediaSourceViewModel(3, "Server", "http://plex:32400"))
|
||||
: Option<PlexMediaSourceViewModel>.None);
|
||||
|
||||
private static void ShouldHaveActionRoute(string actionName, string httpMethod, string route)
|
||||
{
|
||||
MethodInfo action = typeof(PlexMediaSourcesController).GetMethod(actionName)
|
||||
?? throw new AssertionException($"Missing action {actionName}");
|
||||
|
||||
HttpMethodAttribute attribute = action.GetCustomAttributes<HttpMethodAttribute>(inherit: true).Single();
|
||||
attribute.HttpMethods.ShouldContain(httpMethod);
|
||||
attribute.Template.ShouldBe(route);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
// Design #202 findings 2c/8 — repo-level defense-in-depth: the UPDATE for path replacements must be
|
||||
// scoped to the owning media source so a PUT against source A can never silently overwrite a row
|
||||
// that belongs to source B, even if a caller bypasses the handler-level ownership guard.
|
||||
[TestFixture]
|
||||
public class MediaSourceRepositoryPathReplacementScopeTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private MediaSourceRepository _repository = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_repository = new MediaSourceRepository(_db.Factory, NullLogger<MediaSourceRepository>.Instance);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Jellyfin_Source()
|
||||
{
|
||||
var sourceA = new JellyfinMediaSource
|
||||
{
|
||||
ServerName = "A",
|
||||
OperatingSystem = "Linux",
|
||||
Connections = [],
|
||||
PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/a", LocalPath = "/a-local" }]
|
||||
};
|
||||
var sourceB = new JellyfinMediaSource
|
||||
{
|
||||
ServerName = "B",
|
||||
OperatingSystem = "Linux",
|
||||
Connections = [],
|
||||
PathReplacements = [new JellyfinPathReplacement { JellyfinPath = "/b", LocalPath = "/b-local" }]
|
||||
};
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.MediaSources.AddRange(sourceA, sourceB);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
int bRowId = sourceB.PathReplacements.Single().Id;
|
||||
|
||||
// attack: source A's PUT carries source B's row id as an "update"
|
||||
var maliciousUpdate = new JellyfinPathReplacement
|
||||
{
|
||||
Id = bRowId,
|
||||
JellyfinPath = "/hijacked",
|
||||
LocalPath = "/hijacked-local"
|
||||
};
|
||||
|
||||
await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []);
|
||||
|
||||
await using TvContext verifyContext = _db.CreateContext();
|
||||
JellyfinPathReplacement bRowAfter = await verifyContext.JellyfinPathReplacements.FindAsync(bRowId);
|
||||
bRowAfter.JellyfinPath.ShouldBe("/b");
|
||||
bRowAfter.LocalPath.ShouldBe("/b-local");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdatePathReplacements_Should_Not_Update_A_Row_Owned_By_Another_Emby_Source()
|
||||
{
|
||||
var sourceA = new EmbyMediaSource
|
||||
{
|
||||
ServerName = "A",
|
||||
OperatingSystem = "Linux",
|
||||
Connections = [],
|
||||
PathReplacements = [new EmbyPathReplacement { EmbyPath = "/a", LocalPath = "/a-local" }]
|
||||
};
|
||||
var sourceB = new EmbyMediaSource
|
||||
{
|
||||
ServerName = "B",
|
||||
OperatingSystem = "Linux",
|
||||
Connections = [],
|
||||
PathReplacements = [new EmbyPathReplacement { EmbyPath = "/b", LocalPath = "/b-local" }]
|
||||
};
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
context.MediaSources.AddRange(sourceA, sourceB);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
int bRowId = sourceB.PathReplacements.Single().Id;
|
||||
|
||||
var maliciousUpdate = new EmbyPathReplacement
|
||||
{
|
||||
Id = bRowId,
|
||||
EmbyPath = "/hijacked",
|
||||
LocalPath = "/hijacked-local"
|
||||
};
|
||||
|
||||
await _repository.UpdatePathReplacements(sourceA.Id, [], [maliciousUpdate], []);
|
||||
|
||||
await using TvContext verifyContext = _db.CreateContext();
|
||||
EmbyPathReplacement bRowAfter = await verifyContext.EmbyPathReplacements.FindAsync(bRowId);
|
||||
bRowAfter.EmbyPath.ShouldBe("/b");
|
||||
bRowAfter.LocalPath.ShouldBe("/b-local");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
// Design #202 §A.4 (E1-E9). Copy-symmetric with JellyfinMediaSourcesController.
|
||||
[ApiController]
|
||||
public class EmbyMediaSourcesController(
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources/emby", Name = "GetEmbyState")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get Emby connection state and discovered servers")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<RemoteMediaSourceStateResponseModel> GetState(CancellationToken cancellationToken)
|
||||
{
|
||||
List<EmbyMediaSourceViewModel> sources =
|
||||
await mediator.Send(new GetAllEmbyMediaSources(), cancellationToken);
|
||||
EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken);
|
||||
|
||||
bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey);
|
||||
bool isLocked = entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>();
|
||||
|
||||
return new RemoteMediaSourceStateResponseModel(
|
||||
isAuthorized,
|
||||
isLocked,
|
||||
sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/emby/connection", Name = "GetEmbyConnection")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get the Emby connection address")]
|
||||
[EndpointDescription(
|
||||
"Never returns the API key — only whether one is currently configured (design #202 secure connection " +
|
||||
"contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<RemoteConnectionResponseModel> GetConnection(CancellationToken cancellationToken)
|
||||
{
|
||||
EmbySecrets secrets = await mediator.Send(new GetEmbySecrets(), cancellationToken);
|
||||
return new RemoteConnectionResponseModel(
|
||||
secrets.Address ?? string.Empty,
|
||||
!string.IsNullOrWhiteSpace(secrets.ApiKey));
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/emby/connection", Name = "SaveEmbyConnection")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Connect, reconnect, or edit the Emby connection")]
|
||||
[EndpointDescription(
|
||||
"A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " +
|
||||
"on first connect (no existing secret).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> SaveConnection(
|
||||
[Required] [FromBody] SaveRemoteConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Emby operation in progress",
|
||||
"An Emby sign-in or sync is already in progress.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _))
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(
|
||||
CreateProblemDetails("Address must be an absolute URI."));
|
||||
}
|
||||
|
||||
EmbySecrets existingSecrets = await mediator.Send(new GetEmbySecrets(), cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey))
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required."));
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(
|
||||
request.ToEmbyCommand(existingSecrets.ApiKey),
|
||||
cancellationToken);
|
||||
|
||||
return await result.Match<Task<IActionResult>>(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
EmbySecrets saved = await mediator.Send(new GetEmbySecrets(), cancellationToken);
|
||||
return new OkObjectResult(
|
||||
new RemoteConnectionResponseModel(
|
||||
saved.Address ?? string.Empty,
|
||||
!string.IsNullOrWhiteSpace(saved.ApiKey)));
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/disconnect", Name = "DisconnectEmby")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Disconnect Emby")]
|
||||
[EndpointDescription("Purges the Emby connection, discovered servers, and all synced Emby content.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entityLocker.LockRemoteMediaSource<EmbyMediaSource>())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Emby operation in progress",
|
||||
"An Emby sign-in or sync is already in progress.");
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DisconnectEmby(), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: _ => (IActionResult)new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/emby/{id:int}/libraries", Name = "GetEmbyLibraries")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get an Emby source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<EmbyLibraryViewModel> libraries =
|
||||
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/emby/{id:int}/libraries", Name = "ReplaceEmbyLibraryPreferences")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Replace an Emby source's library sync preferences")]
|
||||
[EndpointDescription(
|
||||
"The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " +
|
||||
"the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " +
|
||||
"response rather than the request body.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<EmbyLibraryViewModel> existingLibraries =
|
||||
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
|
||||
|
||||
UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToEmbyCommand(), cancellationToken);
|
||||
|
||||
return await result.Match<Task<IActionResult>>(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems))
|
||||
{
|
||||
await EnqueueLibrarySync(id, library.Id, cancellationToken);
|
||||
}
|
||||
|
||||
List<EmbyLibraryViewModel> reloaded =
|
||||
await mediator.Send(new GetEmbyLibrariesBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/emby/{id:int}/path-replacements", Name = "GetEmbyPathReplacements")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Get an Emby source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPathReplacements(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<EmbyPathReplacementViewModel> replacements =
|
||||
await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/emby/{id:int}/path-replacements", Name = "ReplaceEmbyPathReplacements")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Replace an Emby source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplacePathReplacements(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePathReplacementsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToEmbyCommand(id), cancellationToken);
|
||||
|
||||
return await result.Match<Task<IActionResult>>(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
List<EmbyPathReplacementViewModel> reloaded =
|
||||
await mediator.Send(new GetEmbyPathReplacementsBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/emby/{id:int}/refresh-libraries", Name = "RefreshEmbyLibraries")]
|
||||
[Tags("Emby")]
|
||||
[EndpointSummary("Refresh an Emby source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> RefreshLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<EmbyMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetEmbyMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
if (entityLocker.IsRemoteMediaSourceLocked<EmbyMediaSource>())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Emby operation in progress",
|
||||
"An Emby sign-in or sync is already in progress.");
|
||||
}
|
||||
|
||||
await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(id), cancellationToken);
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
|
||||
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
|
||||
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entityLocker.LockLibrary(libraryId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(new SynchronizeEmbyLibraries(sourceId), cancellationToken);
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeEmbyLibraryByIdIfNeeded(libraryId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
entityLocker.UnlockLibrary(libraryId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static UnprocessableEntityObjectResult ValidateLibraryPreferences(
|
||||
ReplaceRemoteLibraryPreferencesRequest request,
|
||||
List<EmbyLibraryViewModel> existingLibraries)
|
||||
{
|
||||
List<RemoteLibraryPreferenceRequest> libraries = request.Libraries ?? [];
|
||||
|
||||
if (libraries.Any(l => l.Id < 1))
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required."));
|
||||
}
|
||||
|
||||
var existingIds = existingLibraries.Map(l => l.Id).ToList();
|
||||
var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(
|
||||
CreateProblemDetails(
|
||||
$"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Emby source."));
|
||||
}
|
||||
|
||||
var incomingIds = libraries.Map(l => l.Id).ToList();
|
||||
var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList();
|
||||
if (missingIds.Count > 0)
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(
|
||||
CreateProblemDetails(
|
||||
"The request must include every library for this source " +
|
||||
$"(missing id(s) {string.Join(", ", missingIds)})."));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblemDetails(string detail) =>
|
||||
new()
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Validation failed",
|
||||
Detail = detail
|
||||
};
|
||||
|
||||
private static RemoteLibraryResponseModel ProjectToResponseModel(EmbyLibraryViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems);
|
||||
|
||||
private static PathReplacementResponseModel ProjectToResponseModel(EmbyPathReplacementViewModel vm) =>
|
||||
new(vm.Id, vm.EmbyPath, vm.LocalPath);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
// Design #202 §A.3 (J1-J9). Jellyfin and Emby are copy-symmetric — see EmbyMediaSourcesController.
|
||||
[ApiController]
|
||||
public class JellyfinMediaSourcesController(
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/media-sources/jellyfin", Name = "GetJellyfinState")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get Jellyfin connection state and discovered servers")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<RemoteMediaSourceStateResponseModel> GetState(CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinMediaSourceViewModel> sources =
|
||||
await mediator.Send(new GetAllJellyfinMediaSources(), cancellationToken);
|
||||
JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
||||
|
||||
bool isAuthorized = !string.IsNullOrWhiteSpace(secrets.Address) && !string.IsNullOrWhiteSpace(secrets.ApiKey);
|
||||
bool isLocked = entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>();
|
||||
|
||||
return new RemoteMediaSourceStateResponseModel(
|
||||
isAuthorized,
|
||||
isLocked,
|
||||
sources.Map(s => new RemoteMediaSourceItemResponseModel(s.Id, s.Name, s.Address)).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/jellyfin/connection", Name = "GetJellyfinConnection")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get the Jellyfin connection address")]
|
||||
[EndpointDescription(
|
||||
"Never returns the API key — only whether one is currently configured (design #202 secure connection " +
|
||||
"contract). Use the PUT to (re)connect; a blank apiKey there retains the existing key.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<RemoteConnectionResponseModel> GetConnection(CancellationToken cancellationToken)
|
||||
{
|
||||
JellyfinSecrets secrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
||||
return new RemoteConnectionResponseModel(
|
||||
secrets.Address ?? string.Empty,
|
||||
!string.IsNullOrWhiteSpace(secrets.ApiKey));
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/jellyfin/connection", Name = "SaveJellyfinConnection")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Connect, reconnect, or edit the Jellyfin connection")]
|
||||
[EndpointDescription(
|
||||
"A blank/omitted apiKey retains the existing key; a non-blank value sets a new one. The key is required " +
|
||||
"on first connect (no existing secret).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteConnectionResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> SaveConnection(
|
||||
[Required] [FromBody] SaveRemoteConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Jellyfin operation in progress",
|
||||
"A Jellyfin sign-in or sync is already in progress.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(request.Address, UriKind.Absolute, out _))
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(
|
||||
CreateProblemDetails("Address must be an absolute URI."));
|
||||
}
|
||||
|
||||
JellyfinSecrets existingSecrets = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(request.ApiKey) && string.IsNullOrWhiteSpace(existingSecrets.ApiKey))
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(CreateProblemDetails("API key is required."));
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(
|
||||
request.ToJellyfinCommand(existingSecrets.ApiKey),
|
||||
cancellationToken);
|
||||
|
||||
return await result.Match<Task<IActionResult>>(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
JellyfinSecrets saved = await mediator.Send(new GetJellyfinSecrets(), cancellationToken);
|
||||
return new OkObjectResult(
|
||||
new RemoteConnectionResponseModel(
|
||||
saved.Address ?? string.Empty,
|
||||
!string.IsNullOrWhiteSpace(saved.ApiKey)));
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/disconnect", Name = "DisconnectJellyfin")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Disconnect Jellyfin")]
|
||||
[EndpointDescription("Purges the Jellyfin connection, discovered servers, and all synced Jellyfin content.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entityLocker.LockRemoteMediaSource<JellyfinMediaSource>())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Jellyfin operation in progress",
|
||||
"A Jellyfin sign-in or sync is already in progress.");
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DisconnectJellyfin(), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: _ => (IActionResult)new NoContentResult());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/jellyfin/{id:int}/libraries", Name = "GetJellyfinLibraries")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get a Jellyfin source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<JellyfinLibraryViewModel> libraries =
|
||||
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(libraries.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/jellyfin/{id:int}/libraries", Name = "ReplaceJellyfinLibraryPreferences")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Replace a Jellyfin source's library sync preferences")]
|
||||
[EndpointDescription(
|
||||
"The body must be the complete set of the source's libraries (design #202 §C4a) — a row absent from " +
|
||||
"the request is rejected, not silently ignored. Ids are not stable across a disable, so re-fetch this " +
|
||||
"response rather than the request body.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<JellyfinLibraryViewModel> existingLibraries =
|
||||
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
|
||||
|
||||
UnprocessableEntityObjectResult validationError = ValidateLibraryPreferences(request, existingLibraries);
|
||||
if (validationError is not null)
|
||||
{
|
||||
return validationError;
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToJellyfinCommand(), cancellationToken);
|
||||
|
||||
return await result.Match<Task<IActionResult>>(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
foreach (RemoteLibraryPreferenceRequest library in request.Libraries.Where(l => l.ShouldSyncItems))
|
||||
{
|
||||
await EnqueueLibrarySync(id, library.Id, cancellationToken);
|
||||
}
|
||||
|
||||
List<JellyfinLibraryViewModel> reloaded =
|
||||
await mediator.Send(new GetJellyfinLibrariesBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "GetJellyfinPathReplacements")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Get a Jellyfin source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPathReplacements(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<JellyfinPathReplacementViewModel> replacements =
|
||||
await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(replacements.Map(ProjectToResponseModel).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/jellyfin/{id:int}/path-replacements", Name = "ReplaceJellyfinPathReplacements")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Replace a Jellyfin source's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplacePathReplacements(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePathReplacementsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToJellyfinCommand(id), cancellationToken);
|
||||
|
||||
return await result.Match<Task<IActionResult>>(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
List<JellyfinPathReplacementViewModel> reloaded =
|
||||
await mediator.Send(new GetJellyfinPathReplacementsBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(reloaded.Map(ProjectToResponseModel).ToList());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/jellyfin/{id:int}/refresh-libraries", Name = "RefreshJellyfinLibraries")]
|
||||
[Tags("Jellyfin")]
|
||||
[EndpointSummary("Refresh a Jellyfin source's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> RefreshLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSourceViewModel> maybeSource =
|
||||
await mediator.Send(new GetJellyfinMediaSourceById(id), cancellationToken);
|
||||
if (maybeSource.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
if (entityLocker.IsRemoteMediaSourceLocked<JellyfinMediaSource>())
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Jellyfin operation in progress",
|
||||
"A Jellyfin sign-in or sync is already in progress.");
|
||||
}
|
||||
|
||||
await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(id), cancellationToken);
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
// §C7: LockLibrary then enqueue the sync pair; a throw from the enqueue compensates by unlocking
|
||||
// (EnqueueWithTraktLock pattern) — a locked library is silently skipped (Blazor parity).
|
||||
private async Task EnqueueLibrarySync(int sourceId, int libraryId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entityLocker.LockLibrary(libraryId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(new SynchronizeJellyfinLibraries(sourceId), cancellationToken);
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizeJellyfinLibraryByIdIfNeeded(libraryId),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
entityLocker.UnlockLibrary(libraryId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static UnprocessableEntityObjectResult ValidateLibraryPreferences(
|
||||
ReplaceRemoteLibraryPreferencesRequest request,
|
||||
List<JellyfinLibraryViewModel> existingLibraries)
|
||||
{
|
||||
List<RemoteLibraryPreferenceRequest> libraries = request.Libraries ?? [];
|
||||
|
||||
if (libraries.Any(l => l.Id < 1))
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(CreateProblemDetails("Every library id is required."));
|
||||
}
|
||||
|
||||
var existingIds = existingLibraries.Map(l => l.Id).ToList();
|
||||
var foreignIds = libraries.Filter(l => !existingIds.Contains(l.Id)).Map(l => l.Id).ToList();
|
||||
if (foreignIds.Count > 0)
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(
|
||||
CreateProblemDetails(
|
||||
$"Library id(s) {string.Join(", ", foreignIds)} do not belong to this Jellyfin source."));
|
||||
}
|
||||
|
||||
var incomingIds = libraries.Map(l => l.Id).ToList();
|
||||
var missingIds = existingIds.Filter(existingId => !incomingIds.Contains(existingId)).ToList();
|
||||
if (missingIds.Count > 0)
|
||||
{
|
||||
return new UnprocessableEntityObjectResult(
|
||||
CreateProblemDetails(
|
||||
"The request must include every library for this source " +
|
||||
$"(missing id(s) {string.Join(", ", missingIds)})."));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ProblemDetails CreateProblemDetails(string detail) =>
|
||||
new()
|
||||
{
|
||||
Status = StatusCodes.Status422UnprocessableEntity,
|
||||
Title = "Validation failed",
|
||||
Detail = detail
|
||||
};
|
||||
|
||||
private static RemoteLibraryResponseModel ProjectToResponseModel(JellyfinLibraryViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems);
|
||||
|
||||
private static PathReplacementResponseModel ProjectToResponseModel(JellyfinPathReplacementViewModel vm) =>
|
||||
new(vm.Id, vm.JellyfinPath, vm.LocalPath);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO.Abstractions;
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
// Local library CRUD (design #202 §A.1, endpoints L1-L7). Every id-keyed mutation (L4/L5/L6) gets
|
||||
// its real 404 from a controller pre-check, NOT from the wrapped handler: `.Apply`/`ToEitherAsync`
|
||||
// both `.Join()` a `NotFoundError` into a plain `BaseError`, which maps to 422
|
||||
// (see `ErsatzTV.Core.LanguageExtensions`) — so relying on the handler for 404 here would be dead
|
||||
// code (design #202 §A.1 finding 9). This is check-then-act: a delete racing the pre-check falls
|
||||
// through to the handler's own 422, which is accepted and documented, not silently hidden.
|
||||
[ApiController]
|
||||
[EndpointGroupName("general")]
|
||||
public class LocalLibrariesController(
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
IFileSystem fileSystem,
|
||||
ILibraryRepository libraryRepository) : ControllerBase
|
||||
{
|
||||
[HttpGet("/api/libraries/local", Name = "GetLocalLibraries")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get all local libraries")]
|
||||
[ProducesResponseType(typeof(List<LocalLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
public async Task<List<LocalLibraryResponseModel>> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
List<LocalLibraryViewModel> libraries = await mediator.Send(new GetAllLocalLibraries(), cancellationToken);
|
||||
return libraries.Map(ProjectToResponseModel).ToList();
|
||||
}
|
||||
|
||||
[HttpGet("/api/libraries/local/{id:int}", Name = "GetLocalLibrary")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get a local library by id")]
|
||||
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LocalLibraryViewModel> maybeLibrary = await mediator.Send(new GetLocalLibraryById(id), cancellationToken);
|
||||
return await maybeLibrary.Match(
|
||||
Some: async vm => (IActionResult)new OkObjectResult(await ProjectToDetailResponseModel(vm, cancellationToken)),
|
||||
None: () => Task.FromResult(ApiResults.NotFoundProblem($"Local library {id} does not exist.")));
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/local")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Create a local library")]
|
||||
[ProducesResponseType(typeof(LocalLibraryResponseModel), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Create(
|
||||
[Required] [FromBody] CreateLocalLibraryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, LocalLibraryViewModel> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return result.ToCreatedResult(
|
||||
vm => $"/api/libraries/local/{vm.Id}",
|
||||
ProjectToResponseModel);
|
||||
}
|
||||
|
||||
[HttpPut("/api/libraries/local/{id:int}")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Update a local library")]
|
||||
[EndpointDescription(
|
||||
"Replaces the library's name and full path list. MediaKind is immutable after create and is not " +
|
||||
"part of this request. Paths are identified by normalized path string, not id — a renamed path is a " +
|
||||
"delete-old + add-new (its id changes).")]
|
||||
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] UpdateLocalLibraryRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LocalLibraryViewModel> existing = await mediator.Send(new GetLocalLibraryById(id), cancellationToken);
|
||||
if (existing.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem($"Local library {id} does not exist.");
|
||||
}
|
||||
|
||||
if (entityLocker.IsLibraryLocked(id))
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"Local library {id} is locked by an in-progress scan.");
|
||||
}
|
||||
|
||||
Either<BaseError, LocalLibraryViewModel> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// reload via the same query GetById uses (api-conventions §7 write-path projection);
|
||||
// paths.Id may have changed for renamed/added paths.
|
||||
Option<LocalLibraryViewModel> refreshed =
|
||||
await mediator.Send(new GetLocalLibraryById(id), cancellationToken);
|
||||
return await refreshed.Match(
|
||||
Some: async vm => (IActionResult)new OkObjectResult(
|
||||
await ProjectToDetailResponseModel(vm, cancellationToken)),
|
||||
None: () => Task.FromResult(ApiResults.NotFoundProblem($"Local library {id} does not exist.")));
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/libraries/local/{id:int}")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Delete a local library")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<LocalLibraryViewModel> existing = await mediator.Send(new GetLocalLibraryById(id), cancellationToken);
|
||||
if (existing.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem($"Local library {id} does not exist.");
|
||||
}
|
||||
|
||||
if (entityLocker.IsLibraryLocked(id))
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"Local library {id} is locked by an in-progress scan.");
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result = await mediator.Send(new DeleteLocalLibrary(id), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/local/paths/{pathId:int}/move")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Move a local library path to another local library")]
|
||||
[EndpointDescription(
|
||||
"Moves a path (and its scanned media) from its current library to a different local library of the " +
|
||||
"same media kind. Blazor's move dialog enforces same-kind/different-library only client-side; this " +
|
||||
"endpoint enforces both server-side so API/MCP clients cannot bypass them.")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> MovePath(
|
||||
int pathId,
|
||||
[Required] [FromBody] MoveLocalLibraryPathRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<int> maybeSourceLibraryId = await libraryRepository.GetLibraryIdForPath(pathId);
|
||||
if (maybeSourceLibraryId.IsNone)
|
||||
{
|
||||
return ApiResults.NotFoundProblem($"Library path {pathId} does not exist.");
|
||||
}
|
||||
|
||||
int sourceLibraryId = maybeSourceLibraryId.IfNone(0);
|
||||
if (entityLocker.IsLibraryLocked(sourceLibraryId))
|
||||
{
|
||||
return ApiResults.ConflictProblem(
|
||||
"Library scan in progress",
|
||||
$"Local library {sourceLibraryId} is locked by an in-progress scan.");
|
||||
}
|
||||
|
||||
// residual check-then-act race (design #202 §C5): a concurrent delete of this path between
|
||||
// the pre-check above and this Send falls through to the handler's own 422, not a 404.
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(pathId), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpPost("/api/libraries/local/path-exists")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Check whether a filesystem path exists")]
|
||||
[EndpointDescription(
|
||||
"Server-side existence check for the SPA's add-path UX (a browser cannot call Directory.Exists). " +
|
||||
"This is a check-then-act convenience only — Create/Update still validate new paths at save time " +
|
||||
"(design #202 §C2).")]
|
||||
[ProducesResponseType(typeof(LocalPathCheckResponseModel), StatusCodes.Status200OK)]
|
||||
public IActionResult CheckPathExists([Required] [FromBody] LocalPathCheckRequest request)
|
||||
{
|
||||
bool exists = !string.IsNullOrWhiteSpace(request.Path) && fileSystem.Directory.Exists(request.Path);
|
||||
return new OkObjectResult(new LocalPathCheckResponseModel(exists));
|
||||
}
|
||||
|
||||
private LocalLibraryResponseModel ProjectToResponseModel(LocalLibraryViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.MediaKind, entityLocker.IsLibraryLocked(vm.Id));
|
||||
|
||||
private async Task<LocalLibraryDetailResponseModel> ProjectToDetailResponseModel(
|
||||
LocalLibraryViewModel vm,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<LocalLibraryPathViewModel> paths =
|
||||
await mediator.Send(new GetLocalLibraryPaths(vm.Id), cancellationToken);
|
||||
int mediaItemCount = await mediator.Send(new CountMediaItemsByLibrary(vm.Id), cancellationToken);
|
||||
|
||||
var pathModels = new List<LocalLibraryPathResponseModel>();
|
||||
foreach (LocalLibraryPathViewModel path in paths)
|
||||
{
|
||||
int pathCount = await mediator.Send(new CountMediaItemsByLibraryPath(path.Id), cancellationToken);
|
||||
pathModels.Add(new LocalLibraryPathResponseModel(path.Id, path.Path, pathCount));
|
||||
}
|
||||
|
||||
return new LocalLibraryDetailResponseModel(
|
||||
vm.Id,
|
||||
vm.Name,
|
||||
vm.MediaKind,
|
||||
entityLocker.IsLibraryLocked(vm.Id),
|
||||
mediaItemCount,
|
||||
pathModels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Plex;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Extensions;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api;
|
||||
|
||||
[ApiController]
|
||||
public class PlexMediaSourcesController(
|
||||
IMediator mediator,
|
||||
IEntityLocker entityLocker,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
ChannelWriter<IScannerBackgroundServiceRequest> scannerWorkerChannel) : ControllerBase
|
||||
{
|
||||
private const string PlexBusyTitle = "Plex sign-in or sync in progress";
|
||||
|
||||
private const string PlexBusyDetail =
|
||||
"A Plex sign-in or synchronization is currently in progress; try again once it completes.";
|
||||
|
||||
private static IActionResult PlexLockedProblem() =>
|
||||
ApiResults.ConflictProblem(PlexBusyTitle, PlexBusyDetail);
|
||||
|
||||
[HttpGet("/api/media-sources/plex", Name = "GetPlexState")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Get Plex connection state")]
|
||||
[EndpointDescription(
|
||||
"Returns whether ErsatzTV is authorized with plex.tv, whether a Plex sign-in/sync lock is held, " +
|
||||
"and the list of discovered Plex servers. Poll this during the pin flow: authorized && !locked = done.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(RemoteMediaSourceStateResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<RemoteMediaSourceStateResponseModel> GetState(CancellationToken cancellationToken)
|
||||
{
|
||||
List<PlexMediaSourceViewModel> servers =
|
||||
await mediator.Send(new GetAllPlexMediaSources(), cancellationToken);
|
||||
bool isAuthorized = (await plexSecretStore.GetUserAuthTokens()).Count > 0;
|
||||
return new RemoteMediaSourceStateResponseModel(
|
||||
isAuthorized,
|
||||
entityLocker.IsPlexLocked(),
|
||||
servers.Map(ToItemResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/pin-flow", Name = "StartPlexPinFlow")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Start the Plex sign-in pin flow")]
|
||||
[EndpointDescription(
|
||||
"Acquires the Plex lock and starts the OAuth pin flow, returning the plex.tv authorization URL to " +
|
||||
"open in a new tab. The lock stays held for the background flow; poll GET /api/media-sources/plex " +
|
||||
"until authorized && !locked. Also used to fix credentials for an existing but unauthorized server.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(PlexPinFlowResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> StartPinFlow(CancellationToken cancellationToken)
|
||||
{
|
||||
// The lock IS the running sign-in flow (§3b): fail to acquire = a flow/sync is already active → 409.
|
||||
if (!entityLocker.LockPlex())
|
||||
{
|
||||
return PlexLockedProblem();
|
||||
}
|
||||
|
||||
// Only the Right/200 path keeps the lock held (handed off to the background pin flow). Release the
|
||||
// controller-acquired lock on the Left branch AND on any thrown exception from the dispatch/enqueue
|
||||
// (re-review finding 2 — StartPlexPinFlowHandler awaits a channel WriteAsync that can throw).
|
||||
try
|
||||
{
|
||||
Either<BaseError, string> result = await mediator.Send(new StartPlexPinFlow(), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error =>
|
||||
{
|
||||
entityLocker.UnlockPlex();
|
||||
return error.ToErrorResult();
|
||||
},
|
||||
Right: url => (IActionResult)new OkObjectResult(new PlexPinFlowResponseModel(url)));
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
entityLocker.UnlockPlex();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/sign-out", Name = "SignOutOfPlex")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Sign out of Plex")]
|
||||
[EndpointDescription(
|
||||
"Purges all Plex servers, synced Plex content, and stored credentials. Acquires the Plex lock; the " +
|
||||
"handler always releases it (even on failure).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> SignOutOfPlex(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!entityLocker.LockPlex())
|
||||
{
|
||||
return PlexLockedProblem();
|
||||
}
|
||||
|
||||
// SignOutOfPlexHandler is the designated releaser (unconditional finally) — the controller acquires
|
||||
// and hands off; it does NOT release here (that would double-release on the handler's success path).
|
||||
Either<BaseError, Unit> result = await mediator.Send(new SignOutOfPlex(), cancellationToken);
|
||||
return result.ToDeletedResult();
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/plex/{id:int}/libraries", Name = "GetPlexLibraries")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Get a Plex server's libraries")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<PlexLibraryViewModel> libraries =
|
||||
await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(libraries.Map(ToLibraryResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/plex/{id:int}/libraries", Name = "ReplacePlexLibraryPreferences")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Replace a Plex server's library sync preferences")]
|
||||
[EndpointDescription(
|
||||
"The body is the complete set of the source's libraries with each shouldSyncItems flag. A row absent " +
|
||||
"from the request is left untouched. Every id must belong to this source (no Id=0 rows). Returns the " +
|
||||
"reloaded list — library ids change when sync is disabled, so re-key any client draft off the response. " +
|
||||
"Enabled libraries are queued for sync (locked libraries are skipped).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<RemoteLibraryResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceLibraryPreferences(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceRemoteLibraryPreferencesRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<RemoteLibraryPreferenceRequest> libraries = request.Libraries ?? [];
|
||||
List<PlexLibraryViewModel> owned =
|
||||
await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken);
|
||||
var ownedIds = owned.Map(l => l.Id).ToHashSet();
|
||||
|
||||
// Source-scope the id set (§C4a / finding 2a): reject Id=0 rows and any id not owned by this source
|
||||
// before dispatch — UpdatePlexLibraryPreferences carries no source id, so this is the only guard.
|
||||
var invalidIds = libraries.Filter(l => l.Id < 1 || !ownedIds.Contains(l.Id)).Map(l => l.Id).ToList();
|
||||
if (invalidIds.Count > 0)
|
||||
{
|
||||
return BaseError.New($"Library {invalidIds[0]} does not belong to Plex media source {id}")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
// Require the complete set of this source's libraries (§C4a; matches Jellyfin/Emby): the PUT is a
|
||||
// full flag document, so an omitted owned id would silently keep its old sync state.
|
||||
var incomingIds = libraries.Map(l => l.Id).ToList();
|
||||
var missingIds = ownedIds.Where(ownedId => !incomingIds.Contains(ownedId)).ToList();
|
||||
if (missingIds.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"The request must include every library for Plex media source {id} " +
|
||||
$"(missing id(s) {string.Join(", ", missingIds)})")
|
||||
.ToErrorResult();
|
||||
}
|
||||
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToPlexCommand(), cancellationToken);
|
||||
if (result.IsLeft)
|
||||
{
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
// Reload BEFORE enqueueing — ids change on disable (§C4a); the reloaded list carries the fresh ids
|
||||
// for both the sync loop and the response (§7 write-path projection: reload via the GET's query).
|
||||
List<PlexLibraryViewModel> reloaded =
|
||||
await mediator.Send(new GetPlexLibrariesBySourceId(id), cancellationToken);
|
||||
|
||||
await EnqueuePostSaveSync(reloaded, cancellationToken);
|
||||
|
||||
return new OkObjectResult(reloaded.Map(ToLibraryResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("/api/media-sources/plex/{id:int}/path-replacements", Name = "GetPlexPathReplacements")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Get a Plex server's path replacements")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetPathReplacements(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
List<PlexPathReplacementViewModel> replacements =
|
||||
await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(replacements.Map(ToPathReplacementResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("/api/media-sources/plex/{id:int}/path-replacements", Name = "ReplacePlexPathReplacements")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Replace a Plex server's path replacements")]
|
||||
[EndpointDescription(
|
||||
"Replaces the source's path replacements: an existing id updates, id<1 adds, an existing id absent from " +
|
||||
"the body is deleted. Every positive id must belong to this source and every row needs a non-empty " +
|
||||
"RemotePath and LocalPath (else 422, no partial mutation). Returns the reloaded list.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PathReplacementResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplacePathReplacements(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePathReplacementsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The handler owns the cross-source ownership guard + nonblank/null validation (→ 422, no mutation).
|
||||
Either<BaseError, Unit> result =
|
||||
await mediator.Send(request.ToPlexCommand(id), cancellationToken);
|
||||
if (result.IsLeft)
|
||||
{
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
return error.ToErrorResult();
|
||||
}
|
||||
}
|
||||
|
||||
List<PlexPathReplacementViewModel> reloaded =
|
||||
await mediator.Send(new GetPlexPathReplacementsBySourceId(id), cancellationToken);
|
||||
return new OkObjectResult(reloaded.Map(ToPathReplacementResponse).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("/api/media-sources/plex/{id:int}/refresh-libraries", Name = "RefreshPlexLibraries")]
|
||||
[Tags("Plex")]
|
||||
[EndpointSummary("Refresh a Plex server's libraries")]
|
||||
[EndpointDescription(
|
||||
"Queues a synchronization of the server's libraries (fire-and-forget). Returns 409 while a Plex " +
|
||||
"sign-in/sync lock is held; duplicate refreshes are accepted (the scan is idempotent).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status409Conflict)]
|
||||
public async Task<IActionResult> RefreshLibraries(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await PlexSourceExists(id, cancellationToken))
|
||||
{
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
if (entityLocker.IsPlexLocked())
|
||||
{
|
||||
return PlexLockedProblem();
|
||||
}
|
||||
|
||||
await scannerWorkerChannel.WriteAsync(new SynchronizePlexLibraries(id), cancellationToken);
|
||||
return new AcceptedResult();
|
||||
}
|
||||
|
||||
private async Task<bool> PlexSourceExists(int id, CancellationToken cancellationToken) =>
|
||||
(await mediator.Send(new GetPlexMediaSourceById(id), cancellationToken)).IsSome;
|
||||
|
||||
// Post-save re-sync (§C7 / finding 6): per enabled library, one LockLibrary ⇄ one release carried by
|
||||
// the LAST message. Correcting the Blazor bug: the library message runs Unlock:false so the release
|
||||
// rides SynchronizePlexNetworks(Unlock:true). Compensating-unlock if the networks enqueue throws.
|
||||
private async Task EnqueuePostSaveSync(
|
||||
IEnumerable<PlexLibraryViewModel> libraries,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (PlexLibraryViewModel library in libraries.Filter(l => l.ShouldSyncItems))
|
||||
{
|
||||
if (!entityLocker.LockLibrary(library.Id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexLibraryByIdIfNeeded(library.Id, Unlock: false),
|
||||
cancellationToken);
|
||||
await scannerWorkerChannel.WriteAsync(
|
||||
new SynchronizePlexNetworks(library.Id, false, Unlock: true),
|
||||
cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// the library message carries Unlock:false, so the not-yet-enqueued networks message was
|
||||
// the sole releaser — release here since it never ran
|
||||
entityLocker.UnlockLibrary(library.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RemoteMediaSourceItemResponseModel ToItemResponse(PlexMediaSourceViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.Address);
|
||||
|
||||
private static RemoteLibraryResponseModel ToLibraryResponse(PlexLibraryViewModel vm) =>
|
||||
new(vm.Id, vm.Name, vm.MediaKind, vm.ShouldSyncItems);
|
||||
|
||||
private static PathReplacementResponseModel ToPathReplacementResponse(PlexPathReplacementViewModel vm) =>
|
||||
new(vm.Id, vm.PlexPath, vm.LocalPath);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record CreateLocalLibraryRequest(string Name, LibraryMediaKind MediaKind, List<string> Paths)
|
||||
{
|
||||
public CreateLocalLibrary ToCommand() => new(Name, MediaKind, Paths ?? []);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// No command mapping — the controller checks filesystem existence directly (see design #202 §C2).
|
||||
public record LocalPathCheckRequest(string Path);
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record MoveLocalLibraryPathRequest(int TargetLibraryId)
|
||||
{
|
||||
public MoveLocalLibraryPath ToCommand(int pathId) => new(pathId, TargetLibraryId);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// Id 0/absent = new; existing Id = update; existing row whose Id is absent from the request = delete
|
||||
// (design #202 §C4b) — plus a per-row ownership guard and nonblank validation enforced by the handler.
|
||||
public record PathReplacementItemRequest(int Id, string RemotePath, string LocalPath);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record RemoteLibraryPreferenceRequest(int Id, bool ShouldSyncItems);
|
||||
@@ -0,0 +1,29 @@
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.Plex;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplacePathReplacementsRequest(List<PathReplacementItemRequest> Items)
|
||||
{
|
||||
public UpdatePlexPathReplacements ToPlexCommand(int sourceId) =>
|
||||
new(
|
||||
sourceId,
|
||||
(Items ?? [])
|
||||
.Select(item => new PlexPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
|
||||
.ToList());
|
||||
|
||||
public UpdateJellyfinPathReplacements ToJellyfinCommand(int sourceId) =>
|
||||
new(
|
||||
sourceId,
|
||||
(Items ?? [])
|
||||
.Select(item => new JellyfinPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
|
||||
.ToList());
|
||||
|
||||
public UpdateEmbyPathReplacements ToEmbyCommand(int sourceId) =>
|
||||
new(
|
||||
sourceId,
|
||||
(Items ?? [])
|
||||
.Select(item => new EmbyPathReplacementItem(item.Id, item.RemotePath, item.LocalPath))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Application.Plex;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// Shared by Plex/Jellyfin/Emby (design #202 §C4a) — the PUT body is the complete set of the
|
||||
// source's libraries; a row absent from the request is left untouched, not deleted. The controller
|
||||
// validates the id set against that source's known libraries before dispatch.
|
||||
public record ReplaceRemoteLibraryPreferencesRequest(List<RemoteLibraryPreferenceRequest> Libraries)
|
||||
{
|
||||
public UpdatePlexLibraryPreferences ToPlexCommand() =>
|
||||
new(
|
||||
(Libraries ?? [])
|
||||
.Select(item => new PlexLibraryPreference(item.Id, item.ShouldSyncItems))
|
||||
.ToList());
|
||||
|
||||
public UpdateJellyfinLibraryPreferences ToJellyfinCommand() =>
|
||||
new(
|
||||
(Libraries ?? [])
|
||||
.Select(item => new JellyfinLibraryPreference(item.Id, item.ShouldSyncItems))
|
||||
.ToList());
|
||||
|
||||
public UpdateEmbyLibraryPreferences ToEmbyCommand() =>
|
||||
new(
|
||||
(Libraries ?? [])
|
||||
.Select(item => new EmbyLibraryPreference(item.Id, item.ShouldSyncItems))
|
||||
.ToList());
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using ErsatzTV.Application.Emby;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// ApiKey blank/omitted = retain the existing key (design #202 secure connection contract, finding 1)
|
||||
// — the caller passes the source's current key so ToCommand can substitute it when the incoming
|
||||
// value is blank. A non-blank value sets a new key. Required on first connect (no existing key),
|
||||
// which the controller/handler enforces before dispatch.
|
||||
public record SaveRemoteConnectionRequest(string Address, string ApiKey)
|
||||
{
|
||||
public SaveJellyfinSecrets ToJellyfinCommand(string existingApiKey) =>
|
||||
new(
|
||||
new JellyfinSecrets
|
||||
{
|
||||
Address = Address,
|
||||
ApiKey = string.IsNullOrWhiteSpace(ApiKey) ? existingApiKey : ApiKey
|
||||
});
|
||||
|
||||
public SaveEmbySecrets ToEmbyCommand(string existingApiKey) =>
|
||||
new(
|
||||
new EmbySecrets
|
||||
{
|
||||
Address = Address,
|
||||
ApiKey = string.IsNullOrWhiteSpace(ApiKey) ? existingApiKey : ApiKey
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// Id 0/absent = new path (matches UpdateLocalLibraryHandler's normalized-path-string identity merge;
|
||||
// the Id is advisory only — see design #202 §C4c).
|
||||
public record UpdateLocalLibraryPathRequest(int Id, string Path)
|
||||
{
|
||||
public UpdateLocalLibraryPath ToCommand() => new(Id, Path);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
// MediaKind is immutable after create, so it is deliberately absent here (structural immutability
|
||||
// instead of a 422 — see design #202 §C3).
|
||||
public record UpdateLocalLibraryRequest(string Name, List<UpdateLocalLibraryPathRequest> Paths)
|
||||
{
|
||||
public UpdateLocalLibrary ToCommand(int id) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
(Paths ?? [])
|
||||
.Select(path => path.ToCommand())
|
||||
.ToList());
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,37 @@ handler's validation when a lookup fails, so the controller-side mapping falls o
|
||||
precedent from the schedules endpoints (see issue #172) and intentional to avoid N+1 validation
|
||||
queries; don't "fix" this without discussing it first.
|
||||
|
||||
### 3c. A durable-save PUT that also triggers a background sync as a side effect
|
||||
|
||||
Some PUT-replace endpoints (issue #202: `PUT /api/media-sources/{plex|jellyfin|emby}/{id}/libraries`)
|
||||
have a synchronous durable write as their primary purpose — the response must reflect that write —
|
||||
but Blazor's editor also fired off a background sync per newly-enabled library after the save. This
|
||||
is a **different shape from §3b**: §3b is for an endpoint whose entire job *is* starting a
|
||||
background operation (so the outcome enum drives the status code); here the enqueue is a fire-and-
|
||||
forget side effect of an otherwise-ordinary write, and the response must still be **200 with the
|
||||
durably-saved data**, not 202.
|
||||
|
||||
Pattern (see `PlexMediaSourcesController.ReplaceLibraryPreferences` / `EnqueuePostSaveSync`):
|
||||
1. Dispatch the write command; on `Left`, return the error — nothing is enqueued.
|
||||
2. **Reload** the saved rows through the same query the GET uses (per §7) — needed both for the
|
||||
response and because ids can change as a result of the save (e.g. a disable-then-re-add).
|
||||
3. Iterate the reloaded rows and, per row that needs a sync, `LockLibrary(id)` — **skip rows whose
|
||||
lock is already held** (mirrors Blazor's `if (Locker.LockLibrary(id))` loop; no 409 for the save
|
||||
itself, only a silent skip for that one row's sync).
|
||||
4. Enqueue the background message(s) for that row; if a multi-message enqueue can throw partway
|
||||
through, wrap it in `try/catch` and release the lock in the `catch` (compensating unlock) before
|
||||
rethrowing — the standard `EnqueueWithTraktLock` one-lock-⇄-one-release discipline (§3b), just
|
||||
invoked from inside a write endpoint instead of a dedicated "start background job" endpoint.
|
||||
5. Return the reloaded data with `200 OK` — never 202 — since the durable save already happened;
|
||||
the enqueue is best-effort and its failure (after a caught/compensated exception) surfaces as a
|
||||
500 on this same request rather than silently dropping the sync.
|
||||
|
||||
This is server-side (not SPA-orchestrated) because the SPA has no access to the scanner channel,
|
||||
and having the SPA fire a second request after the save would open a crash window between "saved"
|
||||
and "synced". See `docs/decisions.md` 2026-07-11 (#202) for the fuller rationale and the specific
|
||||
bug this pattern corrected (Blazor's Plex `Unlock` ordering released the library lock before a
|
||||
dependent second message ran).
|
||||
|
||||
## 4. Artwork contract
|
||||
|
||||
API response DTOs return **rooted, directly-usable artwork URLs** — e.g. `/artwork/posters/...`,
|
||||
|
||||
+48
-21
@@ -122,17 +122,35 @@ target query and harmlessly ignored). The incoming query string is merged into `
|
||||
> | Media browse/detail (read paths + image browser) | PARITY-OK | — |
|
||||
> | Media browse/detail (mutations, per-show scan, episode info/troubleshoot) | PARITY-OK (shared Add-to layer + scan/info/troubleshoot wired, 2026-07-10; mutation controls gated on kind/query/page refetch — #221) | — |
|
||||
> | Schedules editors | PARITY-OK (full item + schedule CRUD rebuild, draft/explicit-Save, all Blazor fields/gates/resets; 2026-07-11) | **#207 DONE** |
|
||||
> | Media sources | **disproven — moved to Section 3** | **#202** |
|
||||
> | Media sources | **PARITY-OK** (#202 DONE 2026-07-11 — moved back from Section 3; new SPA screens under `/app/libraries/*` over a new REST write API) | — |
|
||||
>
|
||||
> Bold issues + #202 are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11.
|
||||
> Bold issues are MUST-FIX gates for #91 phase (b); #212 DONE 2026-07-11; #213 closed 2026-07-11; #202 DONE 2026-07-11.
|
||||
|
||||
**As of ersatztv#204, this section is empty** — every route the mutation-depth sweep marked
|
||||
PARITY-OK now has a redirect entry (exact `Map` or a `PatternRule`) and has moved to **Section 1**.
|
||||
The SPA screens were verified against `web/src/App.tsx`'s route table and `web/src/screens/` when
|
||||
each was built (scheduling parity #144/#162; troubleshooting/YAML #145; media detail + image browser
|
||||
#141/#183); #204 only added the redirects. The one route still deliberately un-redirected is
|
||||
`/media/sources/*` — disproven and tracked in **Section 3** (#202) — plus the `/system/health`
|
||||
escape hatch in **Section 4**.
|
||||
As of ersatztv#204, every route the mutation-depth sweep marked PARITY-OK received a redirect entry
|
||||
(an exact `Map` entry or a Tier-2 `PatternRule`) and moved to **Section 1** — EXCEPT the 14
|
||||
`/media/sources/*` routes. Those became **SPA-READY as of #202 (DONE 2026-07-11)**: new screens under
|
||||
`/app/libraries/*` (`LibrariesScreen` hub + `LocalLibraryEditScreen`, `PlexSourceScreen`,
|
||||
`RemoteSourceScreen`, `RemoteConnectionEditScreen`, `RemoteLibrariesEditScreen`,
|
||||
`PathReplacementsEditScreen`) over a new REST write API. They are listed below; their redirect entries
|
||||
are **deferred to the #91b removal PR** (matching the pre-removal spot-check convention). Once those
|
||||
redirects land, this section is empty.
|
||||
|
||||
| Blazor route | Blazor file | SPA route | Notes |
|
||||
|---|---|---|---|
|
||||
| `/media/sources/local` | `LocalLibraries.razor` | `/app/libraries` | local-library rows on the hub (list, scan, gear → editor); **#202** |
|
||||
| `/media/sources/local/add` | `LocalLibraryEditor.razor` (add mode) | `/app/libraries/local/new` | `LocalLibraryEditScreen` create mode: name/media-kind/paths (`path-exists` pre-check); **#202** |
|
||||
| `/media/sources/local/{Id:int}/edit` | `LocalLibraryEditor.razor` (edit mode) | `/app/libraries/local/{id}` | `LocalLibraryEditScreen` edit mode: name + path add/delete (count-confirm)/move (incl. "(New Library)"), media-kind immutable; **#202** |
|
||||
| `/media/sources/plex` | `PlexMediaSources.razor` | `/app/libraries/plex` | `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out, server table, per-server Refresh; **#202** |
|
||||
| `/media/sources/plex/{Id:int}/libraries` | `PlexLibrariesEditor.razor` | `/app/libraries/plex/{id}/sync` | `RemoteLibrariesEditScreen` (family=plex): sortable Name/MediaKind, per-library sync toggle + Save; **#202** |
|
||||
| `/media/sources/plex/{Id:int}/paths` | `PlexPathReplacementsEditor.razor` | `/app/libraries/plex/{id}/path-replacements` | `PathReplacementsEditScreen` (family=plex); **#202** |
|
||||
| `/media/sources/jellyfin` | `JellyfinMediaSources.razor` | `/app/libraries/jellyfin` | `RemoteSourceScreen` (family=jellyfin): connect/edit-connection/disconnect + server table; **#202** |
|
||||
| `/media/sources/jellyfin/edit` | `JellyfinMediaSourceEditor.razor` | `/app/libraries/jellyfin/connection` | `RemoteConnectionEditScreen` (family=jellyfin): address prefilled, api-key set-affordance (never displayed once set); **#202** |
|
||||
| `/media/sources/jellyfin/{Id:int}/libraries` | `JellyfinLibrariesEditor.razor` | `/app/libraries/jellyfin/{id}/sync` | `RemoteLibrariesEditScreen` (family=jellyfin); **#202** |
|
||||
| `/media/sources/jellyfin/{Id:int}/paths` | `JellyfinPathReplacementsEditor.razor` | `/app/libraries/jellyfin/{id}/path-replacements` | `PathReplacementsEditScreen` (family=jellyfin); **#202** |
|
||||
| `/media/sources/emby` | `EmbyMediaSources.razor` | `/app/libraries/emby` | `RemoteSourceScreen` (family=emby); **#202** |
|
||||
| `/media/sources/emby/edit` | `EmbyMediaSourceEditor.razor` | `/app/libraries/emby/connection` | `RemoteConnectionEditScreen` (family=emby); **#202** |
|
||||
| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (family=emby); **#202** |
|
||||
| `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** |
|
||||
|
||||
## Section 3 — BLAZOR-ONLY (blocking issues)
|
||||
|
||||
@@ -164,17 +182,26 @@ search preview. The `POST /api/collections/{id}/items` bogus-id case already ret
|
||||
Nothing remains here. The playback troubleshooting screen (`/app/troubleshooting/playback`,
|
||||
`PlaybackTroubleshootingScreen.tsx`) is built and now lives in Section 2.
|
||||
|
||||
### Media sources — #202 (found by the #91 cold review, 2026-07-09)
|
||||
### Media sources — #202 DONE (2026-07-11)
|
||||
|
||||
| Blazor route | Blazor file | Blocking issue |
|
||||
|---|---|---|
|
||||
| `/media/sources/{local,plex,jellyfin,emby}/...` | `LocalLibraries.razor`, `PlexMediaSources.razor`, `JellyfinMediaSources.razor`, `EmbyMediaSources.razor` + editors | **#202** |
|
||||
Nothing remains here. Found by the #91 cold review (2026-07-09): the SPA `/app/libraries` screen
|
||||
listed sources/libraries and could trigger scans, but source add/edit, connection parameters,
|
||||
path replacements, and library enable/disable had **no SPA UI and no write REST API**
|
||||
(`MediaSourcesController` was GET-only; "Add Source" was a hardcoded disabled stub) — a fresh
|
||||
install could not connect any library without Blazor.
|
||||
|
||||
Previously (wrongly) listed in Section 2 as "merged into libraries screen": the SPA
|
||||
`/app/libraries` screen lists sources/libraries and can trigger scans, but source add/edit,
|
||||
connection parameters, path replacements, and library enable/disable have **no SPA UI and no
|
||||
write REST API** (`MediaSourcesController` is GET-only; the "Add Source" button is a hardcoded
|
||||
disabled stub). A fresh install could not connect any library without Blazor.
|
||||
**Resolved by #202**: new write controllers (`LocalLibrariesController` under
|
||||
`/api/libraries/local/*`; `PlexMediaSourcesController`/`JellyfinMediaSourcesController`/
|
||||
`EmbyMediaSourcesController` under `/api/media-sources/{plex|jellyfin|emby}/*`) wrap the existing
|
||||
MediatR commands (no new commands, no DB migration), plus new SPA screens
|
||||
(`LocalLibraryEditScreen`, `PlexSourceScreen`, `RemoteSourceScreen`, `RemoteConnectionEditScreen`,
|
||||
`RemoteLibrariesEditScreen`, `PathReplacementsEditScreen`) under the now-`allowSubPaths`
|
||||
`/app/libraries/*` route. All 14 Blazor routes now have SPA equivalents — see the per-route table
|
||||
in Section 2 (moved there from this section). Fixed along the way (pre-existing bugs #202 chose to
|
||||
own, not just parity-copy): the Plex pin-flow lock leak on an abandoned OAuth flow, cross-source
|
||||
overwrite in the path-replacement repo SQL, non-`finally` lock release in sign-out/disconnect
|
||||
handlers, and the secure `apiKey` contract (never served over GET). Full design rationale in
|
||||
`docs/decisions.md` (2026-07-11 entries) and the capability matrix posted on issue #202.
|
||||
|
||||
### Schedule editors — #207 DONE (2026-07-11)
|
||||
|
||||
@@ -242,9 +269,9 @@ intentional exit ramp until phase (b) removes Blazor entirely.
|
||||
|
||||
## Section 5 — Removal execution runbook (#91 phase b, Step 2/3)
|
||||
|
||||
The removal PR is **gated** — it starts only after these clear: #202 (media-source write API + SPA),
|
||||
#235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor` can't be deleted
|
||||
yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern
|
||||
The removal PR is **gated** — it starts only after these clear: ~~#202 (media-source write API + SPA)~~
|
||||
**DONE 2026-07-11**, #235 F9 (deep-scan + external-collections-scan API — no API today, so `Libraries.razor`
|
||||
can't be deleted yet), and the **mandatory cold adversarial pass** (#91 comment 2026-07-09). (#204's id-carrying pattern
|
||||
redirects landed 2026-07-11; its catch-all fallback that replaces `MapFallbackToPage("/_Host")` is folded
|
||||
into Step 2 below, since it can only ship when `_Host` is deleted.) When those close, the removal-PR routine
|
||||
executes, in order:
|
||||
|
||||
@@ -460,6 +460,92 @@ into the moved `progressFromChannelState` (behavior-identical — `ChannelState.
|
||||
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
|
||||
is deferred to #247 (epic phase 4).
|
||||
|
||||
## 2026-07-11 — Media-source management REST write API + SPA (#202)
|
||||
|
||||
Replaced the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages (14 routes) with SPA
|
||||
screens under `/app/libraries/*` over new write controllers (`LocalLibrariesController`,
|
||||
`Plex|Jellyfin|EmbyMediaSourcesController`), wrapping existing MediatR commands 1:1 (no new
|
||||
commands, no DB migration). Full design + adversarial-review reconciliation:
|
||||
`docs/handoffs/` session record and issue #202. The design surfaced and fixed several pre-existing
|
||||
Application/Infrastructure bugs newly reachable from a programmatic client; each is recorded here
|
||||
because it changes documented behavior, not just adds a route.
|
||||
|
||||
**Secure `apiKey` contract (Jellyfin/Emby connection).** The connection GET
|
||||
(`RemoteConnectionResponseModel`) returns `{ address, hasApiKey }` — the stored key **never**
|
||||
leaves the server, closing a leak where the old design would have served the raw key from an
|
||||
unauthenticated GET under any-origin CORS. On the connection PUT, a blank/omitted `apiKey` means
|
||||
*retain the existing key*; a non-blank value sets a new one; the key is **required on first
|
||||
connect** (no existing secret) → 422. Rationale: GETs aren't behind `X-Api-Key`
|
||||
(`ApiKeyAuthorizationFilter` only guards mutating verbs), so a secret-bearing GET is a real
|
||||
exposure regardless of how obscure the route is. **Stated explicitly as an input to #197** (the
|
||||
planned read-side-auth review for secret-bearing GETs) — #197 should treat "does any GET return a
|
||||
credential" as one of its checks, not just this one instance.
|
||||
|
||||
**Three list-replace identity contracts, not one uniform one.** An earlier draft assumed a single
|
||||
"`Id<1`=add / missing=delete / id-preserved" contract across all three PUT-replace families; source
|
||||
inspection proved that false for two of them:
|
||||
- *Remote library sync preferences* (`PUT .../{id}/libraries`) — the command carries no source id
|
||||
and the handler toggles only the ids present in the body; a row **absent** from the request is
|
||||
left untouched, not deleted (libraries are sync-discovered, never created via this PUT, so there
|
||||
are no `Id=0` adds either). The controller validates the submitted id set against
|
||||
`Get{Family}LibrariesBySourceId(id)` (422 on any id not owned by the route's source — closes a
|
||||
cross-source hole). Identity is **not stable across a disable**: `Disable{Family}LibrarySync`
|
||||
removes and re-adds the row with a fresh id, so the SPA keys its draft to `(name, mediaKind)`,
|
||||
never to `Id`, and refetches after every save (the PUT returns the reloaded list).
|
||||
- *Path replacements* (`PUT .../{id}/path-replacements`) — id-based (existing `Id`=update,
|
||||
`Id<1`=add, absent=delete) as documented, **but** the repo UPDATE SQL had no source-id predicate
|
||||
(`WHERE Id = @id`, no `AND {Family}MediaSourceId = @id`), so a PUT to source A could silently
|
||||
overwrite source B's row with the same numeric id. Fixed with a handler-level ownership guard
|
||||
(reject any incoming positive id not in this source's current set → 422, no partial mutation)
|
||||
**and** the repo SQL predicate itself (defense-in-depth for any other caller of that repo method).
|
||||
- *Local library paths* (`PUT /api/libraries/local/{id}`) — identity is the **normalized path
|
||||
string** (full path, trailing-separator/case-insensitive), not `Id`; `Id` in the request is
|
||||
advisory. Renaming a path is delete-old+add-new under the hood (its `LibraryPath.Id` changes).
|
||||
Kept as-is (matches the entrenched, tested Blazor behavior and how the SPA edits by value); not
|
||||
rewritten to id-based identity, which would be a bigger, riskier change out of #202's scope.
|
||||
|
||||
**Plex pin-flow as REST: poll until the lock releases, exception-safe non-handoff unlock.** The
|
||||
SPA polls `GET /api/media-sources/plex` rather than a per-pin status resource (no pin-addressable
|
||||
server state exists to expose; SSE/push was already rejected, 2026-07-09). Polling contract is
|
||||
`isLocked && !isAuthorized` = waiting on the user; `isLocked && isAuthorized` = finalizing
|
||||
(discovering servers — **do not** stop here, the server list is still empty);
|
||||
`!isLocked && isAuthorized` = success; `!isLocked && !isAuthorized` = timed out/abandoned. This
|
||||
required fixing a **latent lock-leak bug**: `TryCompletePlexPinFlowHandler` threw
|
||||
`OperationCanceledException` on its 2-minute timeout instead of returning `false`, and nothing
|
||||
unlocked on that path — an abandoned sign-in wedged the Plex lock until restart or manual sign-out.
|
||||
Fix releases `UnlockPlex()` on the timeout-throw, a poll-exception, and an enqueue-exception — but
|
||||
**deliberately not** in an unconditional `finally`: on success the lock is handed off to
|
||||
`SynchronizePlexMediaSources`, the sole releaser after server discovery; a blanket `finally` would
|
||||
double-release and release *before* discovery completes, re-opening the same race the fix closes.
|
||||
This is the same non-owner-token discipline as the #231 `EntityLocker` model (2026-07-11 entry
|
||||
above), applied to the pin-flow's handoff-vs-terminal distinction specifically.
|
||||
|
||||
**404 comes from the controller pre-check, not the handler.** `Apply`/`ToEitherAsync` both
|
||||
`.Join()` errors, which flattens any `NotFoundError` inside a joined `Validation` down to a plain
|
||||
422. So every id-taking endpoint's real 404 is a controller-side pre-check
|
||||
(`Get...ById(id)`-is-`None` → `ApiResults.NotFoundProblem`, the `TemplateController.DeleteGroup`
|
||||
pattern), not a handler-level conversion — converting the joined validators to `NotFoundError`
|
||||
would be dead code, since the join discards the distinction anyway. This is check-then-act (a
|
||||
delete racing between the pre-check and the command falls through to the handler's own 422, not a
|
||||
404); accepted and tested against the actual runtime error type rather than a hoped-for handler 404.
|
||||
|
||||
**"Scan All" dropped, not implemented.** The disabled SPA header button on the libraries hub was
|
||||
speculative UI with **no Blazor equivalent** (`Libraries.razor` only ever supported per-library
|
||||
scan). Removed rather than backed with a new bulk-scan endpoint; per-library scan (#232) and the
|
||||
new per-source refresh-libraries endpoints (P8/J9/E9) cover the real capability set.
|
||||
|
||||
**App-owned popstate for guarded sub-path routes.** `LocalLibraryEditScreen` and the other
|
||||
`/app/libraries/*` editors are the first screens to both register a dirty-navigation guard *and*
|
||||
track their own sub-path pathname — the combination `spa-conventions.md` §8 had flagged as
|
||||
unvalidated. React commits child passive effects before parent ones, so a sub-path wrapper that
|
||||
self-registers `popstate` would fire (and switch sub-screen) *before* App's guard-restore listener
|
||||
could veto. Resolution: App owns `popstate` centrally for the `libraries` route and only pushes an
|
||||
approved sub-path down to the wrapper (which no longer self-registers `popstate`); on a vetoed pop
|
||||
App re-pushes the pre-pop URL and the wrapper never sees the rejected path. This is scoped to the
|
||||
`libraries` route only (gated on `activeRoute === 'libraries'`) so unguarded sub-path routes
|
||||
(Playouts, Media) stay byte-identical. See `spa-conventions.md` §2/§8 for the updated exemplar
|
||||
list and the resolved caveat text.
|
||||
|
||||
## 2026-07-11 — Legacy→SPA redirect matcher: exact map + ordered segment-template patterns (#204)
|
||||
|
||||
`LegacyUiRedirects.TryGetRedirect` grew from a single exact-path dictionary to a **two-tier matcher**
|
||||
|
||||
+19
-3
@@ -63,8 +63,8 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
|
||||
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
|
||||
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |
|
||||
| **Library / LibraryPath / LibraryFolder** | `Library` (abstract; Local/Plex/Jellyfin/Emby subclasses) owns one or more `LibraryPath`s (scan roots); each path has a `LibraryFolder` tree used for browsing and image-folder duration metadata. | `Library`, `LibraryPath`, `LibraryFolder` | `/app/libraries` |
|
||||
| **Media source kind** | `MediaSourceKind`: Local/Plex/Jellyfin/Emby — the origin server type for a `Library`. | `MediaSourceKind` | `/app/libraries` |
|
||||
| **Library / LibraryPath / LibraryFolder** | `Library` (abstract; Local/Plex/Jellyfin/Emby subclasses) owns one or more `LibraryPath`s (scan roots); each path has a `LibraryFolder` tree used for browsing and image-folder duration metadata. | `Library`, `LibraryPath`, `LibraryFolder` | `/app/libraries` (hub: list + scan); `/app/libraries/local/{id}` (`/new`) for local library name/media-kind/path CRUD + move-path (#202) |
|
||||
| **Media source kind** | `MediaSourceKind`: Local/Plex/Jellyfin/Emby — the origin server type for a `Library`. | `MediaSourceKind` | `/app/libraries` (hub); connect/edit/disconnect and per-source library-sync + path-replacements now live at `/app/libraries/{plex\|jellyfin\|emby}` (+ `/connection`, `/{id}/sync`, `/{id}/path-replacements`) — #202, previously Blazor-only |
|
||||
| **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` |
|
||||
| **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) |
|
||||
| **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` |
|
||||
@@ -82,7 +82,23 @@ Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/guid
|
||||
Media nav: `/app/media` (generic kind-filtered browse, `?kind=movies|shows|artists|music-videos|
|
||||
other-videos|remote-streams|images`), `/app/search`, `/app/trash`, `/app/collections` (manual +
|
||||
smart), `/app/multi-collections` (#151), `/app/rerun-collections` (#152),
|
||||
`/app/filler-presets`, `/app/libraries`, `/app/trakt-lists`.
|
||||
`/app/filler-presets`, `/app/libraries` (+ sub-paths, see below), `/app/trakt-lists`.
|
||||
|
||||
**Media sources / libraries (#202, 2026-07-11)**: `/app/libraries` is an `allowSubPaths` route
|
||||
(`LibrariesRouteScreen`, App-owned popstate — see `spa-conventions.md` §2/§8). The hub itself lists
|
||||
sources/libraries and triggers scans; connecting/editing/removing a source and its libraries now
|
||||
happens at:
|
||||
- `/app/libraries/local/new`, `/app/libraries/local/{id}` — `LocalLibraryEditScreen`: name,
|
||||
media-kind (create-only), path add/move/delete.
|
||||
- `/app/libraries/plex` — `PlexSourceScreen`: OAuth pin-flow sign-in/fix-credentials/sign-out.
|
||||
- `/app/libraries/{jellyfin|emby}` — `RemoteSourceScreen`; `/app/libraries/{jellyfin|emby}/connection`
|
||||
— `RemoteConnectionEditScreen` (address + api-key, key never displayed once set).
|
||||
- `/app/libraries/{plex|jellyfin|emby}/{id}/sync` — `RemoteLibrariesEditScreen` (per-library sync
|
||||
toggle); `/app/libraries/{plex|jellyfin|emby}/{id}/path-replacements` — `PathReplacementsEditScreen`.
|
||||
|
||||
These replace the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages over the new
|
||||
`/api/libraries/local/*` and `/api/media-sources/{plex|jellyfin|emby}/*` write endpoints — see
|
||||
`docs/blazor-route-parity.md` Section 2 for the full per-route mapping.
|
||||
|
||||
System nav: `/app/settings` (sub-tabs: streaming/system/logging/playout/scanner/general/xmltv —
|
||||
all mapped 1:1 from legacy `/settings/*` Blazor routes), `/app/logs`, `/app/troubleshooting` (+
|
||||
|
||||
+49
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
133 endpoints, 207 operations.
|
||||
155 endpoints, 240 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -109,6 +109,20 @@
|
||||
| GET | `/api/decos/{id}` | GetDecoById | Get a deco by id |
|
||||
| PUT | `/api/decos/{id}` | | Replace a deco |
|
||||
|
||||
## Emby
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/media-sources/emby` | GetEmbyState | Get Emby connection state and discovered servers |
|
||||
| GET | `/api/media-sources/emby/connection` | GetEmbyConnection | Get the Emby connection address |
|
||||
| PUT | `/api/media-sources/emby/connection` | SaveEmbyConnection | Connect, reconnect, or edit the Emby connection |
|
||||
| POST | `/api/media-sources/emby/disconnect` | DisconnectEmby | Disconnect Emby |
|
||||
| GET | `/api/media-sources/emby/{id}/libraries` | GetEmbyLibraries | Get an Emby source's libraries |
|
||||
| PUT | `/api/media-sources/emby/{id}/libraries` | ReplaceEmbyLibraryPreferences | Replace an Emby source's library sync preferences |
|
||||
| GET | `/api/media-sources/emby/{id}/path-replacements` | GetEmbyPathReplacements | Get an Emby source's path replacements |
|
||||
| PUT | `/api/media-sources/emby/{id}/path-replacements` | ReplaceEmbyPathReplacements | Replace an Emby source's path replacements |
|
||||
| POST | `/api/media-sources/emby/{id}/refresh-libraries` | RefreshEmbyLibraries | Refresh an Emby source's libraries |
|
||||
|
||||
## FFmpeg Profiles
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
@@ -149,6 +163,20 @@
|
||||
| GET | `/api/images/folders` | GetImageFolders | List image library folders |
|
||||
| PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration |
|
||||
|
||||
## Jellyfin
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/media-sources/jellyfin` | GetJellyfinState | Get Jellyfin connection state and discovered servers |
|
||||
| GET | `/api/media-sources/jellyfin/connection` | GetJellyfinConnection | Get the Jellyfin connection address |
|
||||
| PUT | `/api/media-sources/jellyfin/connection` | SaveJellyfinConnection | Connect, reconnect, or edit the Jellyfin connection |
|
||||
| POST | `/api/media-sources/jellyfin/disconnect` | DisconnectJellyfin | Disconnect Jellyfin |
|
||||
| GET | `/api/media-sources/jellyfin/{id}/libraries` | GetJellyfinLibraries | Get a Jellyfin source's libraries |
|
||||
| PUT | `/api/media-sources/jellyfin/{id}/libraries` | ReplaceJellyfinLibraryPreferences | Replace a Jellyfin source's library sync preferences |
|
||||
| GET | `/api/media-sources/jellyfin/{id}/path-replacements` | GetJellyfinPathReplacements | Get a Jellyfin source's path replacements |
|
||||
| PUT | `/api/media-sources/jellyfin/{id}/path-replacements` | ReplaceJellyfinPathReplacements | Replace a Jellyfin source's path replacements |
|
||||
| POST | `/api/media-sources/jellyfin/{id}/refresh-libraries` | RefreshJellyfinLibraries | Refresh a Jellyfin source's libraries |
|
||||
|
||||
## Languages
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
@@ -159,6 +187,13 @@
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/libraries/local` | GetLocalLibraries | Get all local libraries |
|
||||
| POST | `/api/libraries/local` | | Create a local library |
|
||||
| POST | `/api/libraries/local/path-exists` | | Check whether a filesystem path exists |
|
||||
| POST | `/api/libraries/local/paths/{pathId}/move` | | Move a local library path to another local library |
|
||||
| DELETE | `/api/libraries/local/{id}` | | Delete a local library |
|
||||
| GET | `/api/libraries/local/{id}` | GetLocalLibrary | Get a local library by id |
|
||||
| PUT | `/api/libraries/local/{id}` | | Update a local library |
|
||||
| GET | `/api/libraries/scan-status` | GetLibraryScanStatus | Get active library scan status |
|
||||
| POST | `/api/libraries/{id}/scan` | | Scan library |
|
||||
| POST | `/api/libraries/{id}/scan-show` | | Scan show |
|
||||
@@ -248,6 +283,19 @@
|
||||
| GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
|
||||
| PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates |
|
||||
|
||||
## Plex
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/media-sources/plex` | GetPlexState | Get Plex connection state |
|
||||
| POST | `/api/media-sources/plex/pin-flow` | StartPlexPinFlow | Start the Plex sign-in pin flow |
|
||||
| POST | `/api/media-sources/plex/sign-out` | SignOutOfPlex | Sign out of Plex |
|
||||
| GET | `/api/media-sources/plex/{id}/libraries` | GetPlexLibraries | Get a Plex server's libraries |
|
||||
| PUT | `/api/media-sources/plex/{id}/libraries` | ReplacePlexLibraryPreferences | Replace a Plex server's library sync preferences |
|
||||
| GET | `/api/media-sources/plex/{id}/path-replacements` | GetPlexPathReplacements | Get a Plex server's path replacements |
|
||||
| PUT | `/api/media-sources/plex/{id}/path-replacements` | ReplacePlexPathReplacements | Replace a Plex server's path replacements |
|
||||
| POST | `/api/media-sources/plex/{id}/refresh-libraries` | RefreshPlexLibraries | Refresh a Plex server's libraries |
|
||||
|
||||
## Rerun Collections
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|
||||
+26
-4
@@ -42,6 +42,13 @@ Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `Templa
|
||||
(which owns two sibling sub-paths, `/playouts/{id}/alternate-schedules` and
|
||||
`/playouts/{id}/templates`, dispatching internally via `parsePlayoutSubRoute`).
|
||||
|
||||
**Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's
|
||||
screens ALSO register a dirty guard (§8), the wrapper must **not** self-listen for `popstate`; App
|
||||
owns the pathname and passes the approved sub-path down as a prop. The `LibrariesRouteScreen` wrapper
|
||||
in `App.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched via a flat
|
||||
`switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent
|
||||
effect order makes the self-listening pattern unsafe here.
|
||||
|
||||
## 3. Data loading pattern
|
||||
|
||||
Reference implementation: `web/src/screens/LogsScreen.tsx`. Structure to copy for any screen that
|
||||
@@ -229,10 +236,25 @@ draft to navigation. The shared module `web/src/navigationGuard.ts` is the seam:
|
||||
`activeRoute` untouched, effectively undoing the browser's URL change. This same handler also runs
|
||||
for the **synthetic** pop `navigateToPath()` dispatches, so programmatic in-app navigation is
|
||||
guarded as well. The re-push does **not** re-fire `popstate`; that's safe because only one screen
|
||||
is mounted at a time and the guard-registering screen (the schedules editor) owns no internal
|
||||
`popstate`/sub-path listener — so a blocked pop cannot desync a sub-path screen's own pathname
|
||||
state (§2). A sub-path screen that both registers a dirty guard *and* tracks its own pathname would
|
||||
need to re-validate this; none does today.
|
||||
is mounted at a time.
|
||||
|
||||
- **Guarded sub-path routes: App owns pathname/popstate (resolves the old §2 caveat).** A sub-path
|
||||
wrapper that BOTH tracks its own pathname (§2) AND whose sub-screens register a dirty guard cannot
|
||||
self-listen for `popstate`: React commits **child** passive effects before the **parent**, so a
|
||||
wrapper-owned `popstate` listener fires *before* App's guard-restore handler and would switch
|
||||
sub-screen before App could veto and re-push — desyncing the two. **Resolution (design #202 §D.2,
|
||||
finding 4):** App is the single `popstate` owner. On a pop it consults `canLeaveCurrentScreen()`,
|
||||
and **only on approval** does it update a `currentSubPath` state value (`librariesSubPath`) that it
|
||||
passes **down** into the wrapper; on a veto it re-pushes the pre-pop path and updates nothing, so
|
||||
the wrapper never sees a vetoed path. The wrapper (`LibrariesRouteScreen`) derives its sub-path
|
||||
purely from that prop — never from the raw event — so effect-commit order is irrelevant. **Scope
|
||||
the state write to the guarded route only:** the `setLibrariesSubPath` call is gated on the arrived
|
||||
route being `libraries`, so unguarded sub-path routes (Playouts/Media) skip it entirely and stay
|
||||
byte-identical (they still bail App's `setActiveRoute` via `Object.is` and self-own their pathname
|
||||
per §2). The regression test is the App-owned-popstate case in `App.test.tsx` (dirty editor at
|
||||
`/app/libraries/local/3`: `confirm→false` keeps URL + mounted sub-screen; `confirm→true`
|
||||
navigates). Unguarded sub-path screens (`PlayoutsRouteScreen`, `MediaRouteScreen`) keep
|
||||
self-owning their pathname — only screens that register a dirty guard defer to App.
|
||||
- The screen **also** guards the paths the module still can't see — an in-screen action that would
|
||||
replace the draft (schedule switch, opening the properties editor) uses its own
|
||||
`window.confirm(...)`, and a `beforeunload` listener (installed while `dirty`) covers full-page
|
||||
|
||||
+67
-479
@@ -1,4 +1,4 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { App } from './App';
|
||||
import { Button, Checkbox, Input, ProgressBar, Switch, Tabs, Toast, Tooltip } from './components';
|
||||
@@ -514,6 +514,57 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
// Design §D.2 (finding 4): the libraries route is the first that BOTH tracks its own sub-path AND
|
||||
// registers a dirty guard. App owns pathname/popstate for it (the wrapper never self-listens), so a
|
||||
// dirty-editor Back is vetoed by App BEFORE the wrapper can switch sub-screen. S6a landed the real
|
||||
// LocalLibraryEditScreen, so this exercises its own dirty guard (typing into Name) rather than a
|
||||
// manually-armed stand-in guard.
|
||||
it('App-owned popstate: a dirty libraries editor vetoes Back, then navigates when confirmed', async () => {
|
||||
mockDashboardApi({
|
||||
mediaSources: [mediaSource({ id: 30, kind: 'Local', libraries: [], name: 'Local' })],
|
||||
localLibraryDetail: {
|
||||
id: 3,
|
||||
name: 'Feature Films',
|
||||
mediaKind: 'Movies',
|
||||
isLocked: false,
|
||||
mediaItemCount: 0,
|
||||
paths: []
|
||||
}
|
||||
});
|
||||
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
render(<App />);
|
||||
|
||||
// The S6a editor is mounted for the local-edit sub-path.
|
||||
const nameInput = await screen.findByDisplayValue('Feature Films');
|
||||
|
||||
// Make the editor dirty for real (not a manually-armed stand-in guard).
|
||||
fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } });
|
||||
expect(await screen.findByText('Unsaved changes')).toBeInTheDocument();
|
||||
|
||||
// Browser Back to the hub, vetoed: URL is restored and the editor stays put (App decided BEFORE
|
||||
// the wrapper could react — the wrapper only ever sees an approved sub-path).
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
window.history.pushState(null, '', '/app/libraries');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalled();
|
||||
expect(window.location.pathname).toBe('/app/libraries/local/3');
|
||||
expect(screen.getByDisplayValue('Feature Films (renamed)')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('heading', { name: 'Media Libraries' })).not.toBeInTheDocument();
|
||||
|
||||
// Back again, this time confirmed: the wrapper switches to the hub and the editor unmounts.
|
||||
confirmSpy.mockReturnValue(true);
|
||||
window.history.pushState(null, '', '/app/libraries');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/libraries');
|
||||
expect(screen.queryByDisplayValue('Feature Films (renamed)')).not.toBeInTheDocument();
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opens the create-schedule dialog from the TopBar Add Schedule button', async () => {
|
||||
mockDashboardApi({ schedules: [schedule({ id: 5, name: 'Prime Time Cartoons' })] });
|
||||
|
||||
@@ -590,44 +641,15 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/channels/state', expect.any(Object));
|
||||
});
|
||||
|
||||
it('renders the Libraries screen from live media source and scan status APIs', async () => {
|
||||
it('renders the extracted Libraries screen from live media source and scan status APIs (#202)', async () => {
|
||||
// Thin route/composition smoke test: the full Libraries behavioral suite lives colocated in
|
||||
// web/src/screens/LibrariesScreen.test.tsx. This only asserts the nav wires the extracted screen
|
||||
// into the shell and it mounts + hits its own endpoints.
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }],
|
||||
libraryScanStatuses: [{ libraryId: 31, percent: 0.5 }],
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
connectionAddress: null,
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 1250, lastScan: '2026-07-05T14:30:00Z', mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 14, lastScan: null, mediaKind: 'OtherVideos', name: 'Station IDs' })
|
||||
],
|
||||
name: 'Local'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://plex.example.test',
|
||||
id: 40,
|
||||
kind: 'Plex',
|
||||
libraries: [
|
||||
library({ id: 41, itemCount: 80, lastScan: '2026-07-04T08:00:00Z', mediaKind: 'Shows', name: 'TV Shows' })
|
||||
],
|
||||
name: 'Plex Server'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://jellyfin.example.test',
|
||||
id: 50,
|
||||
kind: 'Jellyfin',
|
||||
libraries: [],
|
||||
name: 'Jellyfin Home'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://emby.example.test',
|
||||
id: 60,
|
||||
kind: 'Emby',
|
||||
libraries: [
|
||||
library({ id: 61, itemCount: 22, lastScan: '2026-07-01T00:00:00Z', mediaKind: 'MusicVideos', name: 'Music Videos' })
|
||||
],
|
||||
name: 'Emby Archive'
|
||||
libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })]
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -637,436 +659,11 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
||||
expect(screen.getByText('4 sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,366 items')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Local media source' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Local connection')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Plex Server media source' })).toBeInTheDocument();
|
||||
expect(screen.getByText('https://plex.example.test')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Movies').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Station IDs')).toBeInTheDocument();
|
||||
expect(screen.getByText('TV Shows')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Other Videos')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
|
||||
// "Scanning" now appears twice: once as the source-header status label (paired with
|
||||
// the StatusDot, not color alone) and once as the per-library scanning Badge.
|
||||
expect(screen.getAllByText('Scanning').length).toBeGreaterThan(0);
|
||||
// Wire contract: percent is a 0-1 fraction (0.625) - the UI must render it as 63%, not 0.625%.
|
||||
expect(screen.getByText('63%')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Synced').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Never scanned').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: 'Add Source' })).toBeDisabled();
|
||||
expect(screen.getByText('Adding sources is deferred to the existing server setup screens.')).toBeInTheDocument();
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object));
|
||||
});
|
||||
|
||||
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [
|
||||
{ libraryId: 31, percent: 0 },
|
||||
{ libraryId: 32, percent: 1 }
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
|
||||
expect(await screen.findByText('0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches Libraries data as a route delta and does not poll sources', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [{ libraryId: 31, percent: 0.1 }],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/media-sources')).toBeGreaterThan(0);
|
||||
});
|
||||
const sourceFetchesBeforeNavigation = fetchCount('/api/media-sources');
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
||||
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeNavigation + 1);
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(1);
|
||||
|
||||
const sourceFetchesBeforePoll = fetchCount('/api/media-sources');
|
||||
await runPollTick(intervalHandlers);
|
||||
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBeGreaterThan(1);
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforePoll);
|
||||
});
|
||||
|
||||
it('stops Libraries scan polling and refreshes sources once when scans complete', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
let clearCount = 0;
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
|
||||
clearCount += 1;
|
||||
});
|
||||
mockDashboardApi({
|
||||
libraryScanStatusSequence: [
|
||||
[{ libraryId: 31, percent: 0.75 }],
|
||||
[]
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect(await screen.findByText('75%')).toBeInTheDocument();
|
||||
|
||||
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
|
||||
await runPollTick(intervalHandlers);
|
||||
|
||||
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('triggers a library scan, disables that library while in flight, and starts polling scan status', async () => {
|
||||
const scanStatusesAfterMutation = [{ libraryId: 31, percent: 0.05 }];
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [],
|
||||
libraryScanStatusesAfterMutation: scanStatusesAfterMutation,
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
expect(await screen.findByText('5%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps the scan button disabled and polling armed through the queue-to-start race, then clears once the scan finishes', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
let clearCount = 0;
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => {
|
||||
clearCount += 1;
|
||||
});
|
||||
|
||||
mockDashboardApi({
|
||||
libraryScanStatusSequence: [
|
||||
[], // initial screen load
|
||||
[], // trigger's immediate post-POST fetch: scanner hasn't started yet
|
||||
[{ libraryId: 31, percent: 0.3 }], // first poll tick: now active
|
||||
[] // second poll tick: scan finished
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
// The immediate post-trigger status fetch returned [] - without pending-id tracking
|
||||
// the button would re-enable here and polling would never start. Both must hold.
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
|
||||
const sourceFetchesBeforeCompletion = fetchCount('/api/media-sources');
|
||||
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByText('30%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.queryByText('30%')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
expect(fetchCount('/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
expect(clearCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
|
||||
// POST /scan now returns 409 when a scan is already in progress. That is benign - the SPA
|
||||
// must NOT show an error; it reconciles against scan-status (which reports the active scan)
|
||||
// and keeps the button disabled while that scan runs.
|
||||
mockDashboardApi({
|
||||
libraryScanStatusSequence: [
|
||||
[], // initial screen load: nothing active yet
|
||||
[{ libraryId: 31, percent: 0.3 }] // post-409 reconcile fetch: the in-progress scan
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { status: 409 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
// The reconcile fetch surfaces the already-running scan; the button stays disabled...
|
||||
expect(await screen.findByText('30%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...and no error toast is shown for the benign conflict.
|
||||
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a 422 "sync disabled" scan rejection and leaves the button enabled (#232)', async () => {
|
||||
// POST /scan now returns 422 when item sync is disabled for the library. Nothing was queued,
|
||||
// so the button must not be stuck disabled and the error must be surfaced.
|
||||
mockDashboardApi({
|
||||
libraryScanStatuses: [],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { detail: 'Item sync is disabled for library 31.', status: 422 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
expect(await screen.findByText('Item sync is disabled for library 31.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('re-enables the scan button after the grace window if a 202 scan never appears active (#232)', async () => {
|
||||
// A 202 genuinely queues a (forced) scan, but a short/empty library can finish before the next
|
||||
// 10s poll ever observes it in scan-status - or the scanner may never register it. The optimistic
|
||||
// "pending" flag must not wedge the button disabled forever: it expires after PENDING_GRACE_TICKS
|
||||
// polls that never see the library active, then the button re-enables.
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => 0);
|
||||
|
||||
mockDashboardApi({
|
||||
// initial load, immediate post-POST fetch, then every poll: the scan never shows up
|
||||
libraryScanStatusSequence: [[], [], [], [], []],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
|
||||
// Optimistically disabled; the immediate post-POST fetch ([]) burned one grace tick but keeps it pending.
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// Grace is PENDING_GRACE_TICKS (3): the immediate fetch used one, two more unseen polls expire it.
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
await runPollTick(intervalHandlers);
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('keeps the scan button disabled on a 409 even when scan-status has not yet caught up (#232)', async () => {
|
||||
// 409 means the library is already scanning (a lock is held). The SPA must NOT re-enable the
|
||||
// button just because scan-status has not yet listed the in-progress scan (the queue->active
|
||||
// lag) - otherwise the user can fire repeated 409s. The pending flag is retained (button stays
|
||||
// disabled, no error toast) and is reconciled/expired by the normal polling path.
|
||||
mockDashboardApi({
|
||||
libraryScanStatusSequence: [
|
||||
[], // initial screen load
|
||||
[] // post-409 reconcile fetch: the in-progress scan is not visible in scan-status yet
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { status: 409 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
// Reconcile fetch happened and returned nothing active, yet the button stays disabled...
|
||||
await waitFor(() => {
|
||||
expect(fetchCount('/api/libraries/scan-status')).toBe(2);
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
|
||||
// ...and no error toast is shown for the benign conflict.
|
||||
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Libraries API errors and retries', async () => {
|
||||
// The backend has no exception middleware, so a real failure is a bare 500 with no
|
||||
// ProblemDetails body - do not invent one here (mirrors the scan-trigger-404 test below).
|
||||
mockDashboardApi({
|
||||
mediaSourcesFailuresBeforeSuccess: 2,
|
||||
mediaSources: [mediaSource({ libraries: [] })]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
|
||||
expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows scan trigger failures without inventing application ProblemDetails', async () => {
|
||||
mockDashboardApi({
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': {
|
||||
status: 404
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('scans every library in a source when its Scan-all button is clicked', async () => {
|
||||
mockDashboardApi({
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
id: 30,
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
|
||||
],
|
||||
name: 'Local'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan all libraries in Local' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/libraries/32/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
expect(fetchCount('/api/libraries/31/scan')).toBe(1);
|
||||
expect(fetchCount('/api/libraries/32/scan')).toBe(1);
|
||||
});
|
||||
|
||||
it('shows an empty Libraries state when no media sources exist', async () => {
|
||||
mockDashboardApi({ mediaSources: [] });
|
||||
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Libraries' }));
|
||||
|
||||
expect(await screen.findByText('No media sources returned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles Show filler to refetch playout items with showFiller=true and badge filler rows', async () => {
|
||||
mockDashboardApi({
|
||||
playoutItems: [
|
||||
@@ -3216,6 +2813,7 @@ function mockDashboardApi({
|
||||
health = [],
|
||||
libraryScanStatusFailAfterTrigger = false,
|
||||
libraryScanStatuses = [],
|
||||
localLibraryDetail = null,
|
||||
libraryScanStatusesAfterMutation = null,
|
||||
libraryScanStatusSequence = null,
|
||||
mediaSources = [],
|
||||
@@ -3285,6 +2883,7 @@ function mockDashboardApi({
|
||||
health?: unknown[];
|
||||
libraryScanStatusFailAfterTrigger?: boolean;
|
||||
libraryScanStatuses?: unknown[];
|
||||
localLibraryDetail?: unknown;
|
||||
libraryScanStatusesAfterMutation?: unknown[] | null;
|
||||
libraryScanStatusSequence?: unknown[][] | null;
|
||||
mediaSources?: unknown[];
|
||||
@@ -3514,6 +3113,13 @@ function mockDashboardApi({
|
||||
return Promise.resolve(jsonResponse(mediaSources));
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/libraries/local/')) {
|
||||
if (localLibraryDetail) {
|
||||
return Promise.resolve(jsonResponse(localLibraryDetail));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 404 }));
|
||||
}
|
||||
|
||||
if (path === '/api/libraries/scan-status') {
|
||||
if (libraryScanStatusFailAfterTrigger && scanStatusShouldFail) {
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
@@ -3810,24 +3416,6 @@ function mockDashboardApi({
|
||||
});
|
||||
}
|
||||
|
||||
// Deterministically fires one captured poll tick for the mocked-setInterval polling tests.
|
||||
//
|
||||
// The Libraries poll interval is registered in a passive useEffect that only runs after
|
||||
// an out-of-act state commit (the async fetch that flips hasActiveScans), so `findBy*`
|
||||
// queries - which resolve on the DOM mutation, via MutationObserver - can win the race
|
||||
// against React's effect flush and observe an empty handler list (the #157 flake).
|
||||
// The leading empty act() flushes those pending effects, the length assertion turns a
|
||||
// still-missing registration into a clear failure instead of a silent no-op forEach,
|
||||
// and the act() around the handlers applies the fetch microtasks + state updates before
|
||||
// the caller asserts, so no assertion depends on wall-clock scheduling.
|
||||
async function runPollTick(intervalHandlers: Array<() => void>): Promise<void> {
|
||||
await act(async () => {});
|
||||
expect(intervalHandlers.length).toBeGreaterThan(0);
|
||||
await act(async () => {
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
});
|
||||
}
|
||||
|
||||
function fetchCount(path: string): number {
|
||||
return vi.mocked(window.fetch).mock.calls.filter(([input]) => input.toString() === path).length;
|
||||
}
|
||||
|
||||
+81
-275
@@ -23,11 +23,8 @@ import {
|
||||
ClipboardCopy,
|
||||
Crosshair,
|
||||
Film,
|
||||
FileImage,
|
||||
Eraser,
|
||||
Folder,
|
||||
FolderTree,
|
||||
HardDrive,
|
||||
Info,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
@@ -37,8 +34,6 @@ import {
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
ListVideo,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
Plus,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -47,7 +42,6 @@ import {
|
||||
Repeat,
|
||||
ScrollText,
|
||||
Search,
|
||||
Server,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
@@ -69,6 +63,13 @@ import { MultiCollectionsScreen } from './screens/MultiCollectionsScreen';
|
||||
import { PlaylistsScreen } from './screens/PlaylistsScreen';
|
||||
import { RerunCollectionsScreen } from './screens/RerunCollectionsScreen';
|
||||
import { ChannelsScreen } from './screens/ChannelsScreen';
|
||||
import { LibrariesScreen } from './screens/LibrariesScreen';
|
||||
import { LocalLibraryEditScreen } from './screens/LocalLibraryEditScreen';
|
||||
import { PlexSourceScreen } from './screens/PlexSourceScreen';
|
||||
import { RemoteSourceScreen } from './screens/RemoteSourceScreen';
|
||||
import { RemoteConnectionEditScreen } from './screens/RemoteConnectionEditScreen';
|
||||
import { RemoteLibrariesEditScreen } from './screens/RemoteLibrariesEditScreen';
|
||||
import { PathReplacementsEditScreen } from './screens/PathReplacementsEditScreen';
|
||||
import { SchedulesScreen } from './screens/SchedulesScreen';
|
||||
import { canLeaveCurrentScreen } from './navigationGuard';
|
||||
import {
|
||||
@@ -95,7 +96,12 @@ import {
|
||||
PlayoutAlternateSchedulesScreen,
|
||||
PlayoutTemplatesEditorScreen
|
||||
} from './screens/PlayoutScheduleEditors';
|
||||
import { navigateToPath, parseMediaSubRoute, parsePlayoutSubRoute } from './routing';
|
||||
import {
|
||||
navigateToPath,
|
||||
parseLibrariesSubRoute,
|
||||
parseMediaSubRoute,
|
||||
parsePlayoutSubRoute
|
||||
} from './routing';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -135,7 +141,6 @@ import {
|
||||
defaultGuideWindowStart,
|
||||
GUIDE_WINDOW_MS,
|
||||
useGuideScreenQuery,
|
||||
useLibrariesScreenQuery,
|
||||
type ChannelState,
|
||||
type ChannelGuideChannel,
|
||||
type ChannelGuideProgramme,
|
||||
@@ -144,9 +149,6 @@ import {
|
||||
type DecoListItem,
|
||||
type DashboardChannelState,
|
||||
type DashboardHealthQueryState,
|
||||
type LibraryScanStatus,
|
||||
type MediaSource,
|
||||
type MediaSourceLibrary,
|
||||
type PlayoutDetail,
|
||||
type PlayoutScheduleKind,
|
||||
type ProgramSchedule,
|
||||
@@ -436,6 +438,11 @@ const routes: ScreenRoute[] = [
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The media-source editors live at sub-paths (/app/libraries/local/{id}, /app/libraries/plex,
|
||||
// /app/libraries/{jellyfin|emby}/connection, .../{id}/sync, .../{id}/path-replacements).
|
||||
// Unlike other allowSubPaths screens, the libraries wrapper does NOT self-listen for popstate:
|
||||
// its editors register a dirty guard, so App owns pathname/popstate and passes the approved
|
||||
// sub-path down (see LibrariesRouteScreen + spa-conventions §8, design §D.2).
|
||||
id: 'libraries',
|
||||
path: '/app/libraries',
|
||||
label: 'Libraries',
|
||||
@@ -444,7 +451,8 @@ const routes: ScreenRoute[] = [
|
||||
description: 'Local, Plex, Jellyfin, and Emby media source monitoring.',
|
||||
icon: <Library aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Scan',
|
||||
placeholder: 'Libraries workspace'
|
||||
placeholder: 'Libraries workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the
|
||||
@@ -1450,266 +1458,6 @@ function programmeMatchesNowPlaying(programme: ClippedProgramme, nowPlaying: Cha
|
||||
programme.stop.getTime() === nowPlayingFinish.getTime();
|
||||
}
|
||||
|
||||
function LibrariesLoadingState() {
|
||||
return (
|
||||
<Card title={<h2>Loading libraries</h2>} subtitle="Fetching media sources and active scan status.">
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner tone="muted" />
|
||||
<span>Loading libraries</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrariesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Libraries unavailable</h2>}
|
||||
subtitle="The API returned an error while loading media sources."
|
||||
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
|
||||
>
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrariesEmptyState() {
|
||||
return (
|
||||
<Card title={<h2>No media sources</h2>} subtitle="Configure a source in the server setup screens before monitoring scans here.">
|
||||
<div className="ctv-schedule-empty">No media sources returned</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrariesScreen() {
|
||||
const query = useLibrariesScreenQuery();
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <LibrariesLoadingState />;
|
||||
}
|
||||
|
||||
if (query.status === 'error') {
|
||||
return <LibrariesErrorState error={query.error} refresh={query.refresh} />;
|
||||
}
|
||||
|
||||
if (query.data.sources.length === 0) {
|
||||
return <LibrariesEmptyState />;
|
||||
}
|
||||
|
||||
const totalItems = query.data.sources.reduce(
|
||||
(sum, source) => sum + source.libraries.reduce((librarySum, library) => librarySum + library.itemCount, 0),
|
||||
0
|
||||
);
|
||||
const scanStatusesByLibraryId = new Map(query.data.scanStatuses.map((status) => [status.libraryId, status]));
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">Sources</span>
|
||||
<h2>Media Libraries</h2>
|
||||
<p>Monitor connected media sources and trigger per-library scans.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<Button disabled startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Scan All</Button>
|
||||
<Button disabled startIcon={<Plus aria-hidden="true" size={15} />}>Add Source</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{query.error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{query.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-libraries-summary">
|
||||
<span>{query.data.sources.length} source{query.data.sources.length === 1 ? '' : 's'}</span>
|
||||
<span>{totalItems.toLocaleString()} items</span>
|
||||
<span><StatusDot status={query.data.scanStatuses.length > 0 ? 'live' : 'ok'} size={7} />{query.data.scanStatuses.length} active scan{query.data.scanStatuses.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
|
||||
<div className="ctv-libraries-grid">
|
||||
{query.data.sources.map((source) => (
|
||||
<MediaSourceCard
|
||||
key={source.id}
|
||||
onScanLibrary={query.scanLibrary}
|
||||
scanStatusesByLibraryId={scanStatusesByLibraryId}
|
||||
scanningLibraryIds={query.scanningLibraryIds}
|
||||
source={source}
|
||||
/>
|
||||
))}
|
||||
<section className="ctv-library-add-card" aria-label="Add media source">
|
||||
<span className="ctv-library-kind-icon ctv-library-kind-local"><Plus aria-hidden="true" size={18} /></span>
|
||||
<div>
|
||||
<h3>Connect a media source</h3>
|
||||
<p>Adding sources is deferred to the existing server setup screens.</p>
|
||||
</div>
|
||||
<div className="ctv-library-add-options" aria-hidden="true">
|
||||
<span><HardDrive size={13} />Local folder</span>
|
||||
<span><Server size={13} />Plex</span>
|
||||
<span><Server size={13} />Jellyfin</span>
|
||||
<span><Server size={13} />Emby</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaSourceCard({
|
||||
onScanLibrary,
|
||||
scanStatusesByLibraryId,
|
||||
scanningLibraryIds,
|
||||
source
|
||||
}: {
|
||||
onScanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanStatusesByLibraryId: Map<number, LibraryScanStatus>;
|
||||
scanningLibraryIds: Set<number>;
|
||||
source: MediaSource;
|
||||
}) {
|
||||
const totalItems = source.libraries.reduce((sum, library) => sum + library.itemCount, 0);
|
||||
const scanning = source.libraries.some((library) => scanningLibraryIds.has(library.id));
|
||||
|
||||
const scanSource = () => {
|
||||
source.libraries.forEach((library) => {
|
||||
void onScanLibrary(library.id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ctv-library-source-card" aria-label={`${source.name} media source`}>
|
||||
<div className="ctv-library-source-head">
|
||||
<span className={`ctv-library-kind-icon ctv-library-kind-${source.kind.toLowerCase()}`}>{sourceKindIcon(source.kind)}</span>
|
||||
<div>
|
||||
<div className="ctv-library-source-title">
|
||||
<h3>{source.name}</h3>
|
||||
<StatusDot status={scanning ? 'live' : 'ok'} label={scanning ? 'Scanning' : 'Synced'} size={7} />
|
||||
</div>
|
||||
<span>{source.connectionAddress ?? 'Local connection'}</span>
|
||||
</div>
|
||||
<div className="ctv-library-source-actions">
|
||||
<IconButton disabled={scanning} onClick={scanSource} size="sm" title={`Scan all libraries in ${source.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<IconButton disabled size="sm" title={`Settings unavailable for ${source.name}`}>
|
||||
<Settings aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctv-library-list" role="list" aria-label={`${source.name} libraries`}>
|
||||
{source.libraries.length === 0 ? (
|
||||
<div className="ctv-library-empty">No synced libraries returned</div>
|
||||
) : source.libraries.map((library) => (
|
||||
<LibraryRow
|
||||
key={library.id}
|
||||
library={library}
|
||||
onScanLibrary={onScanLibrary}
|
||||
scanStatus={scanStatusesByLibraryId.get(library.id) ?? null}
|
||||
scanning={scanningLibraryIds.has(library.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ctv-library-source-foot">
|
||||
<span><Clock aria-hidden="true" size={13} />{scanning ? 'Scanning now' : sourceLastScanLabel(source)}</span>
|
||||
<span><code>{totalItems.toLocaleString()}</code> items · {source.libraries.length} librar{source.libraries.length === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryRow({
|
||||
library,
|
||||
onScanLibrary,
|
||||
scanStatus,
|
||||
scanning
|
||||
}: {
|
||||
library: MediaSourceLibrary;
|
||||
onScanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanStatus: LibraryScanStatus | null;
|
||||
scanning: boolean;
|
||||
}) {
|
||||
const scanActive = scanStatus !== null;
|
||||
const disabled = scanning || scanActive;
|
||||
|
||||
return (
|
||||
<div className="ctv-library-row" role="listitem">
|
||||
<span className="ctv-library-media-icon">{libraryMediaIcon(library.mediaKind)}</span>
|
||||
<div className="ctv-library-row-main">
|
||||
<strong>{library.name}</strong>
|
||||
<span><span>{formatLibraryMediaKind(library.mediaKind)}</span> · <code>{library.itemCount.toLocaleString()}</code> items</span>
|
||||
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
|
||||
</div>
|
||||
<div className="ctv-library-row-status">
|
||||
{scanStatus ? (
|
||||
<div className="ctv-library-progress">
|
||||
<ProgressBar value={scanStatus.percent} showLabel />
|
||||
<Badge tone="accent" dot>Scanning</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge tone="ok" dot>Synced</Badge>
|
||||
)}
|
||||
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceKindIcon(kind: string): ReactNode {
|
||||
return kind === 'Local'
|
||||
? <HardDrive aria-hidden="true" size={18} />
|
||||
: <Server aria-hidden="true" size={18} />;
|
||||
}
|
||||
|
||||
function libraryMediaIcon(kind: string): ReactNode {
|
||||
switch (kind) {
|
||||
case 'Movies':
|
||||
return <Clapperboard aria-hidden="true" size={14} />;
|
||||
case 'Shows':
|
||||
return <MonitorPlay aria-hidden="true" size={14} />;
|
||||
case 'MusicVideos':
|
||||
case 'Songs':
|
||||
return <Music aria-hidden="true" size={14} />;
|
||||
case 'Images':
|
||||
return <FileImage aria-hidden="true" size={14} />;
|
||||
case 'RemoteStreams':
|
||||
return <Radio aria-hidden="true" size={14} />;
|
||||
case 'OtherVideos':
|
||||
return <Film aria-hidden="true" size={14} />;
|
||||
default:
|
||||
return <Folder aria-hidden="true" size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLibraryMediaKind(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'MusicVideos':
|
||||
return 'Music Videos';
|
||||
case 'OtherVideos':
|
||||
return 'Other Videos';
|
||||
case 'RemoteStreams':
|
||||
return 'Remote Streams';
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLastScanLabel(source: MediaSource): string {
|
||||
const scans = source.libraries
|
||||
.map((library) => library.lastScan)
|
||||
.filter((scan): scan is string => Boolean(scan))
|
||||
.sort();
|
||||
|
||||
return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned';
|
||||
}
|
||||
|
||||
function PlayoutsLoadingState() {
|
||||
return (
|
||||
<Card title={<h2>Loading playouts</h2>} subtitle="Fetching playouts, channel state, and upcoming items.">
|
||||
@@ -2813,11 +2561,50 @@ function MediaRouteScreen() {
|
||||
return <MediaBrowseScreen key={search} />;
|
||||
}
|
||||
|
||||
// The Libraries nav entry owns the media-source editor sub-paths. CRITICAL (design §D.2, finding 4):
|
||||
// unlike PlayoutsRouteScreen/MediaRouteScreen this wrapper does NOT register its own `popstate`
|
||||
// listener. Its editor sub-screens (S6) register a dirty guard, and React commits child effects
|
||||
// before the parent — so a wrapper-owned popstate listener would switch sub-screen BEFORE App's
|
||||
// guard could veto and re-push, desyncing the two. Instead App is the single popstate owner: it
|
||||
// consults canLeaveCurrentScreen() on a pop and only on approval updates `librariesSubPath`, which it
|
||||
// passes DOWN here. This wrapper derives its sub-path purely from that prop, never from the raw
|
||||
// event, so effect-commit order is irrelevant. A null parse means "render the hub".
|
||||
//
|
||||
// The dispatch is a flat switch keyed by the parsed sub-route: S6a (local-*) → LocalLibraryEditScreen,
|
||||
// S6b (remote-*) → the Plex/remote source + editor screens. All branches are live.
|
||||
function LibrariesRouteScreen({ subPath }: { subPath: string }) {
|
||||
const sub = parseLibrariesSubRoute(subPath);
|
||||
|
||||
if (sub === null) {
|
||||
return <LibrariesScreen />;
|
||||
}
|
||||
|
||||
switch (sub.kind) {
|
||||
// --- Local (slice S6a) ---
|
||||
case 'local-new':
|
||||
case 'local-edit':
|
||||
return <LocalLibraryEditScreen key={subPath} />;
|
||||
// --- Remote (slice S6b) — Plex uses the pin flow, Jellyfin/Emby share the remote screens ---
|
||||
case 'remote-source':
|
||||
return sub.family === 'plex'
|
||||
? <PlexSourceScreen key={subPath} />
|
||||
: <RemoteSourceScreen key={subPath} family={sub.family} />;
|
||||
case 'remote-connection':
|
||||
return <RemoteConnectionEditScreen key={subPath} family={sub.family} />;
|
||||
case 'remote-libraries':
|
||||
return <RemoteLibrariesEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
|
||||
case 'remote-path-replacements':
|
||||
return <PathReplacementsEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
|
||||
}
|
||||
}
|
||||
|
||||
function ScreenContent({
|
||||
healthState,
|
||||
librariesSubPath,
|
||||
route
|
||||
}: {
|
||||
healthState: DashboardHealthQueryState;
|
||||
librariesSubPath: string;
|
||||
route: ScreenRoute | null;
|
||||
}) {
|
||||
if (route === null) {
|
||||
@@ -2869,7 +2656,7 @@ function ScreenContent({
|
||||
}
|
||||
|
||||
if (route.id === 'libraries') {
|
||||
return <LibrariesScreen />;
|
||||
return <LibrariesRouteScreen subPath={librariesSubPath} />;
|
||||
}
|
||||
|
||||
if (route.id === 'settings') {
|
||||
@@ -2948,6 +2735,13 @@ export function App() {
|
||||
const [activeRoute, setActiveRoute] = useState<ScreenRoute | null>(() => routeFromLocation());
|
||||
const healthState = useDashboardHealthQuery();
|
||||
|
||||
// The guarded `libraries` route's approved sub-path. App owns this (rather than the wrapper
|
||||
// self-listening for popstate) so the dirty guard is consulted BEFORE the wrapper switches
|
||||
// sub-screen (design §D.2, finding 4). Scoped to the libraries route only: the write is gated on
|
||||
// the arrived route being 'libraries' so Playouts/Media sub-path pops stay byte-identical — they
|
||||
// still bail App's setActiveRoute via Object.is and own their own pathname (re-review nit 3).
|
||||
const [librariesSubPath, setLibrariesSubPath] = useState<string>(() => window.location.pathname);
|
||||
|
||||
// The last path the guard has approved us being on. Browser Back/Forward (popstate) can't be
|
||||
// cancelled, so when the dirty guard vetoes a pop we re-push THIS path to undo the browser's URL
|
||||
// change. Kept in a ref (not state) so the popstate handler reads it synchronously at event time.
|
||||
@@ -2970,7 +2764,14 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
currentPathRef.current = window.location.pathname;
|
||||
setActiveRoute(routeFromLocation());
|
||||
const nextRoute = routeFromLocation();
|
||||
setActiveRoute(nextRoute);
|
||||
// Guarded sub-path route: App owns the sub-path so the wrapper reacts to an APPROVED
|
||||
// navigation, never the raw event (design §D.2). Gated on 'libraries' so unguarded routes
|
||||
// (Playouts/Media) skip this state write entirely and stay byte-identical.
|
||||
if (nextRoute?.id === 'libraries') {
|
||||
setLibrariesSubPath(window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', onPopState);
|
||||
@@ -3000,6 +2801,11 @@ export function App() {
|
||||
window.history.pushState(null, '', routeHref(nextRoute));
|
||||
currentPathRef.current = window.location.pathname;
|
||||
setActiveRoute(nextRoute);
|
||||
// Sidebar click into Libraries lands on the hub base path; keep the owned sub-path in sync so
|
||||
// the wrapper renders the hub (not a stale editor sub-path from a prior visit).
|
||||
if (nextRoute.id === 'libraries') {
|
||||
setLibrariesSubPath(window.location.pathname);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -3009,7 +2815,7 @@ export function App() {
|
||||
<TopBar route={route} />
|
||||
<main className="ctv-main">
|
||||
<ThemeSwitcher theme={theme} onThemeChange={setTheme} />
|
||||
<ScreenContent healthState={healthState} route={route} />
|
||||
<ScreenContent healthState={healthState} librariesSubPath={librariesSubPath} route={route} />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Vendored
+88
@@ -444,6 +444,11 @@ export interface components {
|
||||
"playlistId": null | number;
|
||||
"expression": null | string;
|
||||
"useChaptersAsMediaItems": boolean;
|
||||
};
|
||||
"CreateLocalLibraryRequest": {
|
||||
"name": null | string;
|
||||
"mediaKind": components["schemas"]["LibraryMediaKind"];
|
||||
"paths": null | Array<string>;
|
||||
};
|
||||
"CreateMultiCollectionRequest": {
|
||||
"name": null | string;
|
||||
@@ -750,6 +755,31 @@ export interface components {
|
||||
"LibraryScanStatusResponseModel": {
|
||||
"libraryId": number;
|
||||
"percent": number;
|
||||
};
|
||||
"LocalLibraryDetailResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"mediaKind": components["schemas"]["LibraryMediaKind"];
|
||||
"isLocked": boolean;
|
||||
"mediaItemCount": number;
|
||||
"paths": null | Array<components["schemas"]["LocalLibraryPathResponseModel"]>;
|
||||
};
|
||||
"LocalLibraryPathResponseModel": {
|
||||
"id": number;
|
||||
"path": null | string;
|
||||
"mediaItemCount": number;
|
||||
};
|
||||
"LocalLibraryResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"mediaKind": components["schemas"]["LibraryMediaKind"];
|
||||
"isLocked": boolean;
|
||||
};
|
||||
"LocalPathCheckRequest": {
|
||||
"path": null | string;
|
||||
};
|
||||
"LocalPathCheckResponseModel": {
|
||||
"exists": boolean;
|
||||
};
|
||||
"LogEntryResponseModel": {
|
||||
"timestamp": string;
|
||||
@@ -841,6 +871,9 @@ export interface components {
|
||||
"libraries": Array<components["schemas"]["MediaSourceLibraryResponseModel"]>;
|
||||
};
|
||||
"MediaStreamKind": "Video" | "Audio" | "Subtitle" | "Attachment" | "ExternalSubtitle";
|
||||
"MoveLocalLibraryPathRequest": {
|
||||
"targetLibraryId": number;
|
||||
};
|
||||
"MovieDetailResponseModel": {
|
||||
"id": number;
|
||||
"title": string;
|
||||
@@ -916,6 +949,16 @@ export interface components {
|
||||
"PagedTraktListsResponseModel": {
|
||||
"totalCount": number;
|
||||
"page": Array<components["schemas"]["TraktListResponseModel"]>;
|
||||
};
|
||||
"PathReplacementItemRequest": {
|
||||
"id": number;
|
||||
"remotePath": null | string;
|
||||
"localPath": null | string;
|
||||
};
|
||||
"PathReplacementResponseModel": {
|
||||
"id": number;
|
||||
"remotePath": null | string;
|
||||
"localPath": null | string;
|
||||
};
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||
"PlaylistGroupResponseModel": {
|
||||
@@ -1091,6 +1134,9 @@ export interface components {
|
||||
"endMonth": number;
|
||||
"endDay": number;
|
||||
"endYear": null | number;
|
||||
};
|
||||
"PlexPinFlowResponseModel": {
|
||||
"authUrl": null | string;
|
||||
};
|
||||
"ProblemDetails": {
|
||||
"type"?: null | string;
|
||||
@@ -1107,6 +1153,30 @@ export interface components {
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
};
|
||||
"RemoteConnectionResponseModel": {
|
||||
"address": null | string;
|
||||
"hasApiKey": boolean;
|
||||
};
|
||||
"RemoteLibraryPreferenceRequest": {
|
||||
"id": number;
|
||||
"shouldSyncItems": boolean;
|
||||
};
|
||||
"RemoteLibraryResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"mediaKind": components["schemas"]["LibraryMediaKind"];
|
||||
"shouldSyncItems": boolean;
|
||||
};
|
||||
"RemoteMediaSourceItemResponseModel": {
|
||||
"id": number;
|
||||
"name": null | string;
|
||||
"address": null | string;
|
||||
};
|
||||
"RemoteMediaSourceStateResponseModel": {
|
||||
"isAuthorized": boolean;
|
||||
"isLocked": boolean;
|
||||
"servers": null | Array<components["schemas"]["RemoteMediaSourceItemResponseModel"]>;
|
||||
};
|
||||
"ReplaceBlockRequest": {
|
||||
"name": null | string;
|
||||
@@ -1141,6 +1211,9 @@ export interface components {
|
||||
"ReplaceDecoTemplateRequest": {
|
||||
"name": null | string;
|
||||
"items": null | Array<components["schemas"]["DecoTemplateItemRequest"]>;
|
||||
};
|
||||
"ReplacePathReplacementsRequest": {
|
||||
"items": null | Array<components["schemas"]["PathReplacementItemRequest"]>;
|
||||
};
|
||||
"ReplacePlaylistRequest": {
|
||||
"name": null | string;
|
||||
@@ -1151,6 +1224,9 @@ export interface components {
|
||||
};
|
||||
"ReplacePlayoutTemplatesRequest": {
|
||||
"items": null | Array<components["schemas"]["PlayoutTemplateItemRequest"]>;
|
||||
};
|
||||
"ReplaceRemoteLibraryPreferencesRequest": {
|
||||
"libraries": null | Array<components["schemas"]["RemoteLibraryPreferenceRequest"]>;
|
||||
};
|
||||
"ReplaceScheduleItemsRequest": {
|
||||
"items": null | Array<components["schemas"]["ScheduleItemRequest"]>;
|
||||
@@ -1181,6 +1257,10 @@ export interface components {
|
||||
"width": number;
|
||||
"height": number;
|
||||
"isCustom": boolean;
|
||||
};
|
||||
"SaveRemoteConnectionRequest": {
|
||||
"address": null | string;
|
||||
"apiKey": null | string;
|
||||
};
|
||||
"ScalingBehavior": "ScaleAndPad" | "Stretch" | "Crop";
|
||||
"ScannerSettingsResponseModel": {
|
||||
@@ -1565,6 +1645,14 @@ export interface components {
|
||||
};
|
||||
"UpdateImageFolderDurationResponseModel": {
|
||||
"durationSeconds": null | number;
|
||||
};
|
||||
"UpdateLocalLibraryPathRequest": {
|
||||
"id": number;
|
||||
"path": null | string;
|
||||
};
|
||||
"UpdateLocalLibraryRequest": {
|
||||
"name": null | string;
|
||||
"paths": null | Array<components["schemas"]["UpdateLocalLibraryPathRequest"]>;
|
||||
};
|
||||
"UpdateLoggingSettingsRequest": {
|
||||
"defaultMinimumLogLevel": components["schemas"]["LogEventLevel"];
|
||||
|
||||
@@ -19,6 +19,7 @@ export * from './logs';
|
||||
export * from './maintenance';
|
||||
export * from './mediaDetail';
|
||||
export * from './mediaItems';
|
||||
export * from './mediaSources';
|
||||
export * from './multiCollections';
|
||||
export * from './pickers';
|
||||
export * from './playlists';
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ApiError } from './client';
|
||||
import {
|
||||
checkLocalPathExists,
|
||||
createLocalLibrary,
|
||||
deleteLocalLibrary,
|
||||
disconnectRemote,
|
||||
getLocalLibraries,
|
||||
getLocalLibrary,
|
||||
getPathReplacements,
|
||||
getPlexState,
|
||||
getRemoteConnection,
|
||||
getRemoteLibraries,
|
||||
getRemoteState,
|
||||
messageFromMediaSourcesError,
|
||||
moveLocalLibraryPath,
|
||||
refreshRemoteLibraries,
|
||||
replacePathReplacements,
|
||||
replaceRemoteLibraryPreferences,
|
||||
saveRemoteConnection,
|
||||
signOutOfPlex,
|
||||
startPlexPinFlow,
|
||||
updateLocalLibrary
|
||||
} from './mediaSources';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
function noContent(): Response {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
function lastCall(fetchSpy: ReturnType<typeof vi.spyOn>) {
|
||||
const [url, init] = fetchSpy.mock.calls[fetchSpy.mock.calls.length - 1] as [string, RequestInit | undefined];
|
||||
return {
|
||||
url,
|
||||
method: (init?.method ?? 'GET').toUpperCase(),
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
describe('mediaSources API client', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('local library reads and mutations hit /api/libraries/local with the right verbs', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
|
||||
|
||||
await getLocalLibraries();
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/libraries/local', method: 'GET' });
|
||||
|
||||
await getLocalLibrary(7);
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/libraries/local/7', method: 'GET' });
|
||||
|
||||
await createLocalLibrary({ name: 'Movies', mediaKind: 'Movies', paths: ['/media/movies'] });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/libraries/local',
|
||||
method: 'POST',
|
||||
body: { name: 'Movies', mediaKind: 'Movies', paths: ['/media/movies'] }
|
||||
});
|
||||
|
||||
await updateLocalLibrary(7, { name: 'Films', paths: [{ id: 1, path: '/media/movies' }] });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/libraries/local/7',
|
||||
method: 'PUT',
|
||||
body: { name: 'Films', paths: [{ id: 1, path: '/media/movies' }] }
|
||||
});
|
||||
|
||||
await deleteLocalLibrary(7);
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/libraries/local/7', method: 'DELETE' });
|
||||
|
||||
await moveLocalLibraryPath(42, { targetLibraryId: 9 });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/libraries/local/paths/42/move',
|
||||
method: 'POST',
|
||||
body: { targetLibraryId: 9 }
|
||||
});
|
||||
|
||||
await checkLocalPathExists({ path: '/media/movies' });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/libraries/local/path-exists',
|
||||
method: 'POST',
|
||||
body: { path: '/media/movies' }
|
||||
});
|
||||
});
|
||||
|
||||
it('Plex helpers map to /api/media-sources/plex', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
|
||||
|
||||
await getPlexState();
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex', method: 'GET' });
|
||||
|
||||
await startPlexPinFlow();
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex/pin-flow', method: 'POST' });
|
||||
|
||||
await signOutOfPlex();
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex/sign-out', method: 'POST' });
|
||||
});
|
||||
|
||||
it('the shared remote surface varies only by the family segment', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
|
||||
|
||||
await getRemoteState('jellyfin');
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin', method: 'GET' });
|
||||
|
||||
await getRemoteState('emby');
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/emby', method: 'GET' });
|
||||
|
||||
await getRemoteLibraries('plex', 3);
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/plex/3/libraries', method: 'GET' });
|
||||
|
||||
await replaceRemoteLibraryPreferences('emby', 3, { libraries: [{ id: 5, shouldSyncItems: true }] });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/media-sources/emby/3/libraries',
|
||||
method: 'PUT',
|
||||
body: { libraries: [{ id: 5, shouldSyncItems: true }] }
|
||||
});
|
||||
|
||||
await getPathReplacements('jellyfin', 3);
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin/3/path-replacements', method: 'GET' });
|
||||
|
||||
await replacePathReplacements('plex', 3, { items: [{ id: 0, remotePath: '/r', localPath: '/l' }] });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/media-sources/plex/3/path-replacements',
|
||||
method: 'PUT',
|
||||
body: { items: [{ id: 0, remotePath: '/r', localPath: '/l' }] }
|
||||
});
|
||||
|
||||
await refreshRemoteLibraries('emby', 3);
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/emby/3/refresh-libraries', method: 'POST' });
|
||||
});
|
||||
|
||||
it('connection endpoints (Jellyfin/Emby) round-trip address + apiKey', async () => {
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse({})));
|
||||
|
||||
await getRemoteConnection('jellyfin');
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin/connection', method: 'GET' });
|
||||
|
||||
await saveRemoteConnection('emby', { address: 'http://emby:8096', apiKey: '' });
|
||||
expect(lastCall(fetchSpy)).toMatchObject({
|
||||
url: '/api/media-sources/emby/connection',
|
||||
method: 'PUT',
|
||||
body: { address: 'http://emby:8096', apiKey: '' }
|
||||
});
|
||||
|
||||
await disconnectRemote('jellyfin');
|
||||
expect(lastCall(fetchSpy)).toMatchObject({ url: '/api/media-sources/jellyfin/disconnect', method: 'POST' });
|
||||
});
|
||||
|
||||
it('204 responses resolve to undefined', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(noContent());
|
||||
await expect(deleteLocalLibrary(1)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('messageFromMediaSourcesError', () => {
|
||||
it('prefers ProblemDetails.detail, then message', () => {
|
||||
const withDetail = new ApiError(422, { detail: 'Path must exist', title: 'Unprocessable' });
|
||||
expect(messageFromMediaSourcesError(withDetail)).toBe('Path must exist');
|
||||
|
||||
const titleOnly = new ApiError(409, { title: 'Already in progress' });
|
||||
expect(messageFromMediaSourcesError(titleOnly)).toBe('Already in progress');
|
||||
});
|
||||
|
||||
it('falls back for plain Errors and unknown throwables', () => {
|
||||
expect(messageFromMediaSourcesError(new Error('boom'))).toBe('boom');
|
||||
expect(messageFromMediaSourcesError('nope', 'fallback text')).toBe('fallback text');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { ApiError, request } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
import type { RemoteFamily } from '../mediaSources/familyMeta';
|
||||
|
||||
// --- Response DTOs (re-exported from the generated spec) ---
|
||||
export type LocalLibrary = components['schemas']['LocalLibraryResponseModel'];
|
||||
export type LocalLibraryPath = components['schemas']['LocalLibraryPathResponseModel'];
|
||||
export type LocalLibraryDetail = components['schemas']['LocalLibraryDetailResponseModel'];
|
||||
export type LocalPathCheckResult = components['schemas']['LocalPathCheckResponseModel'];
|
||||
export type RemoteMediaSourceItem = components['schemas']['RemoteMediaSourceItemResponseModel'];
|
||||
export type RemoteMediaSourceState = components['schemas']['RemoteMediaSourceStateResponseModel'];
|
||||
export type RemoteLibrary = components['schemas']['RemoteLibraryResponseModel'];
|
||||
export type PathReplacement = components['schemas']['PathReplacementResponseModel'];
|
||||
export type RemoteConnection = components['schemas']['RemoteConnectionResponseModel'];
|
||||
export type PlexPinFlow = components['schemas']['PlexPinFlowResponseModel'];
|
||||
|
||||
// --- Request DTOs (re-exported from the generated spec) ---
|
||||
export type CreateLocalLibraryRequest = components['schemas']['CreateLocalLibraryRequest'];
|
||||
export type UpdateLocalLibraryRequest = components['schemas']['UpdateLocalLibraryRequest'];
|
||||
export type UpdateLocalLibraryPathRequest = components['schemas']['UpdateLocalLibraryPathRequest'];
|
||||
export type MoveLocalLibraryPathRequest = components['schemas']['MoveLocalLibraryPathRequest'];
|
||||
export type LocalPathCheckRequest = components['schemas']['LocalPathCheckRequest'];
|
||||
export type ReplaceRemoteLibraryPreferencesRequest = components['schemas']['ReplaceRemoteLibraryPreferencesRequest'];
|
||||
export type RemoteLibraryPreferenceRequest = components['schemas']['RemoteLibraryPreferenceRequest'];
|
||||
export type ReplacePathReplacementsRequest = components['schemas']['ReplacePathReplacementsRequest'];
|
||||
export type PathReplacementItemRequest = components['schemas']['PathReplacementItemRequest'];
|
||||
export type SaveRemoteConnectionRequest = components['schemas']['SaveRemoteConnectionRequest'];
|
||||
|
||||
export type { RemoteFamily } from '../mediaSources/familyMeta';
|
||||
|
||||
// --- Local libraries (/api/libraries/local) ---
|
||||
|
||||
export function getLocalLibraries(): Promise<LocalLibrary[]> {
|
||||
return request<LocalLibrary[]>('/api/libraries/local');
|
||||
}
|
||||
|
||||
export function getLocalLibrary(id: number): Promise<LocalLibraryDetail> {
|
||||
return request<LocalLibraryDetail>(`/api/libraries/local/${id}`);
|
||||
}
|
||||
|
||||
export function createLocalLibrary(body: CreateLocalLibraryRequest): Promise<LocalLibrary> {
|
||||
return request<LocalLibrary>('/api/libraries/local', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function updateLocalLibrary(id: number, body: UpdateLocalLibraryRequest): Promise<LocalLibraryDetail> {
|
||||
return request<LocalLibraryDetail>(`/api/libraries/local/${id}`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function deleteLocalLibrary(id: number): Promise<void> {
|
||||
return request<void>(`/api/libraries/local/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export function moveLocalLibraryPath(pathId: number, body: MoveLocalLibraryPathRequest): Promise<void> {
|
||||
return request<void>(`/api/libraries/local/paths/${pathId}/move`, { body, method: 'POST' });
|
||||
}
|
||||
|
||||
export function checkLocalPathExists(body: LocalPathCheckRequest): Promise<LocalPathCheckResult> {
|
||||
return request<LocalPathCheckResult>('/api/libraries/local/path-exists', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
// --- Plex-specific convenience over the shared remote surface (/api/media-sources/plex) ---
|
||||
|
||||
export function getPlexState(): Promise<RemoteMediaSourceState> {
|
||||
return getRemoteState('plex');
|
||||
}
|
||||
|
||||
export function startPlexPinFlow(): Promise<PlexPinFlow> {
|
||||
return request<PlexPinFlow>('/api/media-sources/plex/pin-flow', { method: 'POST' });
|
||||
}
|
||||
|
||||
export function signOutOfPlex(): Promise<void> {
|
||||
return request<void>('/api/media-sources/plex/sign-out', { method: 'POST' });
|
||||
}
|
||||
|
||||
// --- Shared remote surface (/api/media-sources/{family}); family is the only URL variance ---
|
||||
|
||||
export function getRemoteState(family: RemoteFamily): Promise<RemoteMediaSourceState> {
|
||||
return request<RemoteMediaSourceState>(`/api/media-sources/${family}`);
|
||||
}
|
||||
|
||||
// Connection endpoints exist only for the api-key families (Jellyfin / Emby); Plex uses the pin flow.
|
||||
export function getRemoteConnection(family: 'jellyfin' | 'emby'): Promise<RemoteConnection> {
|
||||
return request<RemoteConnection>(`/api/media-sources/${family}/connection`);
|
||||
}
|
||||
|
||||
export function saveRemoteConnection(
|
||||
family: 'jellyfin' | 'emby',
|
||||
body: SaveRemoteConnectionRequest
|
||||
): Promise<RemoteConnection> {
|
||||
return request<RemoteConnection>(`/api/media-sources/${family}/connection`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function disconnectRemote(family: 'jellyfin' | 'emby'): Promise<void> {
|
||||
return request<void>(`/api/media-sources/${family}/disconnect`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function getRemoteLibraries(family: RemoteFamily, sourceId: number): Promise<RemoteLibrary[]> {
|
||||
return request<RemoteLibrary[]>(`/api/media-sources/${family}/${sourceId}/libraries`);
|
||||
}
|
||||
|
||||
export function replaceRemoteLibraryPreferences(
|
||||
family: RemoteFamily,
|
||||
sourceId: number,
|
||||
body: ReplaceRemoteLibraryPreferencesRequest
|
||||
): Promise<RemoteLibrary[]> {
|
||||
return request<RemoteLibrary[]>(`/api/media-sources/${family}/${sourceId}/libraries`, { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function getPathReplacements(family: RemoteFamily, sourceId: number): Promise<PathReplacement[]> {
|
||||
return request<PathReplacement[]>(`/api/media-sources/${family}/${sourceId}/path-replacements`);
|
||||
}
|
||||
|
||||
export function replacePathReplacements(
|
||||
family: RemoteFamily,
|
||||
sourceId: number,
|
||||
body: ReplacePathReplacementsRequest
|
||||
): Promise<PathReplacement[]> {
|
||||
return request<PathReplacement[]>(`/api/media-sources/${family}/${sourceId}/path-replacements`, {
|
||||
body,
|
||||
method: 'PUT'
|
||||
});
|
||||
}
|
||||
|
||||
export function refreshRemoteLibraries(family: RemoteFamily, sourceId: number): Promise<void> {
|
||||
return request<void>(`/api/media-sources/${family}/${sourceId}/refresh-libraries`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function messageFromMediaSourcesError(error: unknown, fallback = 'Unable to load media sources'): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.detail ?? error.message;
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
REMOTE_FAMILIES,
|
||||
familyMeta,
|
||||
familyRoute,
|
||||
localLibraryEditRoute,
|
||||
localLibraryNewRoute,
|
||||
parseRemoteFamily,
|
||||
pathReplacementsRoute,
|
||||
remoteConnectionRoute,
|
||||
remoteLibrariesRoute
|
||||
} from './familyMeta';
|
||||
|
||||
describe('familyMeta', () => {
|
||||
it('exposes exactly the three remote families', () => {
|
||||
expect([...REMOTE_FAMILIES]).toEqual(['plex', 'jellyfin', 'emby']);
|
||||
});
|
||||
|
||||
it('labels and remote-path columns are family-specific', () => {
|
||||
expect(familyMeta('plex').label).toBe('Plex');
|
||||
expect(familyMeta('plex').remotePathColumnLabel).toBe('Plex Path');
|
||||
expect(familyMeta('jellyfin').remotePathColumnLabel).toBe('Jellyfin Path');
|
||||
expect(familyMeta('emby').remotePathColumnLabel).toBe('Emby Path');
|
||||
});
|
||||
|
||||
it('only Jellyfin/Emby support the connection editor (Plex uses the pin flow)', () => {
|
||||
expect(familyMeta('plex').supportsConnectionEditor).toBe(false);
|
||||
expect(familyMeta('jellyfin').supportsConnectionEditor).toBe(true);
|
||||
expect(familyMeta('emby').supportsConnectionEditor).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRemoteFamily', () => {
|
||||
it('narrows known families case-insensitively', () => {
|
||||
expect(parseRemoteFamily('plex')).toBe('plex');
|
||||
expect(parseRemoteFamily('Jellyfin')).toBe('jellyfin');
|
||||
expect(parseRemoteFamily('EMBY')).toBe('emby');
|
||||
});
|
||||
|
||||
it('returns null for local, unknown, or empty values', () => {
|
||||
expect(parseRemoteFamily('local')).toBeNull();
|
||||
expect(parseRemoteFamily('Local')).toBeNull();
|
||||
expect(parseRemoteFamily('nope')).toBeNull();
|
||||
expect(parseRemoteFamily('')).toBeNull();
|
||||
expect(parseRemoteFamily(null)).toBeNull();
|
||||
expect(parseRemoteFamily(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('route builders', () => {
|
||||
it('builds family + sub-resource routes', () => {
|
||||
expect(familyRoute('plex')).toBe('/app/libraries/plex');
|
||||
expect(familyRoute('jellyfin')).toBe('/app/libraries/jellyfin');
|
||||
expect(remoteLibrariesRoute('emby', 7)).toBe('/app/libraries/emby/7/sync');
|
||||
expect(pathReplacementsRoute('jellyfin', 12)).toBe('/app/libraries/jellyfin/12/path-replacements');
|
||||
expect(remoteConnectionRoute('jellyfin')).toBe('/app/libraries/jellyfin/connection');
|
||||
expect(remoteConnectionRoute('emby')).toBe('/app/libraries/emby/connection');
|
||||
});
|
||||
|
||||
it('builds local library routes', () => {
|
||||
expect(localLibraryNewRoute).toBe('/app/libraries/local/new');
|
||||
expect(localLibraryEditRoute(3)).toBe('/app/libraries/local/3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Family metadata for the remote media-source screens (Plex / Jellyfin / Emby). Single source of
|
||||
// truth for the family string, its human label, its SPA routes, and the family-specific
|
||||
// remote-path column label used by the path-replacements editor ("Plex Path" / "Jellyfin Path" /
|
||||
// "Emby Path"). Owned here (the mediaSources domain) and re-imported by routing.ts + the API client
|
||||
// so the family union has exactly one definition.
|
||||
|
||||
export type RemoteFamily = 'plex' | 'jellyfin' | 'emby';
|
||||
|
||||
export const REMOTE_FAMILIES: readonly RemoteFamily[] = ['plex', 'jellyfin', 'emby'];
|
||||
|
||||
export interface FamilyMeta {
|
||||
family: RemoteFamily;
|
||||
/** Human label, e.g. shown in menus and headings. */
|
||||
label: string;
|
||||
/** The `MediaSource.kind` string the hub reports for this family. */
|
||||
sourceKind: 'Plex' | 'Jellyfin' | 'Emby';
|
||||
/** The remote-path column label for the path-replacements editor. */
|
||||
remotePathColumnLabel: string;
|
||||
/**
|
||||
* Plex authenticates via the OAuth pin flow (no address/api-key connection editor); Jellyfin and
|
||||
* Emby connect with an address + api key. The connection editor route only exists for the latter.
|
||||
*/
|
||||
supportsConnectionEditor: boolean;
|
||||
}
|
||||
|
||||
const META: Record<RemoteFamily, FamilyMeta> = {
|
||||
plex: {
|
||||
family: 'plex',
|
||||
label: 'Plex',
|
||||
sourceKind: 'Plex',
|
||||
remotePathColumnLabel: 'Plex Path',
|
||||
supportsConnectionEditor: false
|
||||
},
|
||||
jellyfin: {
|
||||
family: 'jellyfin',
|
||||
label: 'Jellyfin',
|
||||
sourceKind: 'Jellyfin',
|
||||
remotePathColumnLabel: 'Jellyfin Path',
|
||||
supportsConnectionEditor: true
|
||||
},
|
||||
emby: {
|
||||
family: 'emby',
|
||||
label: 'Emby',
|
||||
sourceKind: 'Emby',
|
||||
remotePathColumnLabel: 'Emby Path',
|
||||
supportsConnectionEditor: true
|
||||
}
|
||||
};
|
||||
|
||||
export function familyMeta(family: RemoteFamily): FamilyMeta {
|
||||
return META[family];
|
||||
}
|
||||
|
||||
/** Narrow an arbitrary string (path segment or `MediaSource.kind`) to a `RemoteFamily`, or null. */
|
||||
export function parseRemoteFamily(value: string | null | undefined): RemoteFamily | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const lower = value.toLowerCase();
|
||||
return (REMOTE_FAMILIES as readonly string[]).includes(lower) ? (lower as RemoteFamily) : null;
|
||||
}
|
||||
|
||||
// --- SPA route builders (kept next to the metadata so callers never hand-assemble these) ---
|
||||
|
||||
/** The family screen, e.g. `/app/libraries/plex`. */
|
||||
export function familyRoute(family: RemoteFamily): string {
|
||||
return `/app/libraries/${family}`;
|
||||
}
|
||||
|
||||
/** The library-preferences ("sync") editor, e.g. `/app/libraries/plex/12/sync`. */
|
||||
export function remoteLibrariesRoute(family: RemoteFamily, sourceId: number): string {
|
||||
return `/app/libraries/${family}/${sourceId}/sync`;
|
||||
}
|
||||
|
||||
/** The path-replacements editor, e.g. `/app/libraries/jellyfin/12/path-replacements`. */
|
||||
export function pathReplacementsRoute(family: RemoteFamily, sourceId: number): string {
|
||||
return `/app/libraries/${family}/${sourceId}/path-replacements`;
|
||||
}
|
||||
|
||||
/** The connection editor (Jellyfin/Emby only), e.g. `/app/libraries/jellyfin/connection`. */
|
||||
export function remoteConnectionRoute(family: 'jellyfin' | 'emby'): string {
|
||||
return `/app/libraries/${family}/connection`;
|
||||
}
|
||||
|
||||
// --- Local library route builders ---
|
||||
|
||||
export const localLibraryNewRoute = '/app/libraries/local/new';
|
||||
|
||||
/** The local library editor, e.g. `/app/libraries/local/7`. */
|
||||
export function localLibraryEditRoute(id: number): string {
|
||||
return `/app/libraries/local/${id}`;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isDuplicatePath, normalizePath, pathsEqual } from './paths';
|
||||
|
||||
describe('normalizePath', () => {
|
||||
it('trims trailing slashes and backslashes', () => {
|
||||
expect(normalizePath('/media/movies/')).toBe('/MEDIA/MOVIES');
|
||||
expect(normalizePath('/media/movies///')).toBe('/MEDIA/MOVIES');
|
||||
expect(normalizePath('C:\\Media\\Movies\\')).toBe('C:\\MEDIA\\MOVIES');
|
||||
});
|
||||
|
||||
it('upper-cases for case-insensitive comparison', () => {
|
||||
expect(normalizePath('/Media/Movies')).toBe('/MEDIA/MOVIES');
|
||||
});
|
||||
|
||||
it('leaves a bare separator-free path (minus case) alone', () => {
|
||||
expect(normalizePath('movies')).toBe('MOVIES');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathsEqual', () => {
|
||||
it('treats trailing-separator and case variants as equal', () => {
|
||||
expect(pathsEqual('/media/movies', '/media/movies/')).toBe(true);
|
||||
expect(pathsEqual('/Media/Movies', '/media/movies')).toBe(true);
|
||||
expect(pathsEqual('/media/movies', '/media/shows')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDuplicatePath', () => {
|
||||
const existing = ['/media/movies', '/media/shows/'];
|
||||
|
||||
it('detects a duplicate ignoring case + trailing separators', () => {
|
||||
expect(isDuplicatePath(existing, '/media/movies/')).toBe(true);
|
||||
expect(isDuplicatePath(existing, '/MEDIA/SHOWS')).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a genuinely new path', () => {
|
||||
expect(isDuplicatePath(existing, '/media/music')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles an empty existing list', () => {
|
||||
expect(isDuplicatePath([], '/media/movies')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
// Client-side path normalization for in-draft duplicate detection in the local-library editor.
|
||||
//
|
||||
// This mirrors the *comparison-relevant* part of the Blazor `UpdateLocalLibraryHandler.NormalizePath`
|
||||
// (`Path.GetFullPath(new Uri(path).LocalPath).TrimEnd(sep, altSep).ToUpperInvariant()`), which the
|
||||
// server uses to merge/dedupe library paths by normalized string (design §C4c). A browser cannot
|
||||
// reproduce `Path.GetFullPath` (it resolves relative paths against the server CWD and collapses
|
||||
// `.`/`..`) — that resolution stays authoritative on the server. What we CAN and must mirror for
|
||||
// catching an obvious in-draft duplicate before a round-trip is the trailing-separator trim and the
|
||||
// case-insensitive (upper-invariant) compare. Two already-absolute paths that differ only by a
|
||||
// trailing slash or letter case are treated as the same path here, exactly as the server would.
|
||||
|
||||
/** Normalize a path for case/trailing-separator-insensitive comparison (browser-side subset). */
|
||||
export function normalizePath(path: string): string {
|
||||
return path.replace(/[\\/]+$/, '').toUpperCase();
|
||||
}
|
||||
|
||||
/** True when `a` and `b` name the same path under {@link normalizePath}. */
|
||||
export function pathsEqual(a: string, b: string): boolean {
|
||||
return normalizePath(a) === normalizePath(b);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `candidate` already exists (by normalized string) in `existing`. Used to reject an
|
||||
* Add-Path that duplicates a path already in the draft.
|
||||
*/
|
||||
export function isDuplicatePath(existing: readonly string[], candidate: string): boolean {
|
||||
const normalizedCandidate = normalizePath(candidate);
|
||||
return existing.some((path) => normalizePath(path) === normalizedCandidate);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
PIN_FLOW_POLL_BUDGET_MS,
|
||||
PIN_FLOW_POLL_INTERVAL_MS,
|
||||
evaluatePinFlow,
|
||||
initialPinFlowState,
|
||||
isPinFlowBudgetExhausted
|
||||
} from './pinFlowPoll';
|
||||
|
||||
describe('pinFlowPoll constants', () => {
|
||||
it('polls every 2s for up to 150s (design §C1)', () => {
|
||||
expect(PIN_FLOW_POLL_INTERVAL_MS).toBe(2000);
|
||||
expect(PIN_FLOW_POLL_BUDGET_MS).toBe(150_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialPinFlowState', () => {
|
||||
it('starts in the non-terminal waiting state', () => {
|
||||
const state = initialPinFlowState();
|
||||
expect(state.status).toBe('waiting');
|
||||
expect(state.done).toBe(false);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('evaluatePinFlow — all transitions with injected state', () => {
|
||||
it('locked & !authorized => waiting (keep polling)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: false });
|
||||
expect(state.status).toBe('waiting');
|
||||
expect(state.done).toBe(false);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('locked & authorized => finalizing (keep polling, discovering servers)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true });
|
||||
expect(state.status).toBe('finalizing');
|
||||
expect(state.done).toBe(false);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('!locked & authorized => success (terminal)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true });
|
||||
expect(state.status).toBe('success');
|
||||
expect(state.done).toBe(true);
|
||||
expect(state.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('!locked & !authorized => timeout (terminal, ended without auth)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false });
|
||||
expect(state.status).toBe('timeout');
|
||||
expect(state.done).toBe(true);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('locked & budget exhausted => budget-exhausted (terminal, not a failure)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('budget-exhausted');
|
||||
expect(state.done).toBe(true);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('budget exhaustion is ignored once the lock has released (success still wins)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('success');
|
||||
expect(state.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('budget exhaustion is ignored once the lock has released (timeout still wins)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPinFlowBudgetExhausted', () => {
|
||||
it('is false until the budget is reached and true at/after it', () => {
|
||||
expect(isPinFlowBudgetExhausted(0)).toBe(false);
|
||||
expect(isPinFlowBudgetExhausted(PIN_FLOW_POLL_BUDGET_MS - 1)).toBe(false);
|
||||
expect(isPinFlowBudgetExhausted(PIN_FLOW_POLL_BUDGET_MS)).toBe(true);
|
||||
expect(isPinFlowBudgetExhausted(PIN_FLOW_POLL_BUDGET_MS + 5000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// The Plex OAuth pin-flow poll STATE MACHINE (design §C1 step 5), as a pure, timer-free module so
|
||||
// the transitions can be unit-tested with injected state. The Plex screen (slice S6b) drives it:
|
||||
// after `POST /pin-flow` it opens the auth URL and polls `GET /api/media-sources/plex` every 2s for
|
||||
// up to 150s, feeding each observation ({ isLocked, isAuthorized }) here to decide what to show and
|
||||
// whether to keep polling.
|
||||
//
|
||||
// KEY INSIGHT (finding 5): `isAuthorized` flips true the moment the token is saved, which is BEFORE
|
||||
// SynchronizePlexMediaSources discovers the servers and releases the Plex lock. So authorization
|
||||
// alone is NOT success — the terminal success signal is the lock RELEASING while authorized. Poll
|
||||
// until the lock releases, not until authorized.
|
||||
//
|
||||
// isLocked & !isAuthorized -> waiting (keep polling; user hasn't authorized yet)
|
||||
// isLocked & isAuthorized -> finalizing (keep polling; discovering servers)
|
||||
// !isLocked & isAuthorized -> success (terminal)
|
||||
// !isLocked & !isAuthorized -> timeout (terminal; flow ended without auth)
|
||||
// isLocked (budget spent) -> budget-exhausted (terminal; a large first sync legitimately
|
||||
// still holds the lock — not a failure)
|
||||
|
||||
export type PinFlowStatus = 'waiting' | 'finalizing' | 'success' | 'timeout' | 'budget-exhausted';
|
||||
|
||||
export interface PinFlowObservation {
|
||||
isLocked: boolean;
|
||||
isAuthorized: boolean;
|
||||
}
|
||||
|
||||
export interface PinFlowState {
|
||||
status: PinFlowStatus;
|
||||
/** True once the machine has settled and polling should stop. */
|
||||
done: boolean;
|
||||
/** True only on the successful terminal state. */
|
||||
ok: boolean;
|
||||
/** User-facing message for the current state. */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** Poll cadence and total budget (design §C1: every 2s for up to 150s). */
|
||||
export const PIN_FLOW_POLL_INTERVAL_MS = 2000;
|
||||
export const PIN_FLOW_POLL_BUDGET_MS = 150_000;
|
||||
|
||||
const MESSAGES: Record<PinFlowStatus, string> = {
|
||||
waiting: 'Waiting for you to authorize in the Plex tab…',
|
||||
finalizing: 'Authorized — discovering your Plex servers…',
|
||||
success: 'Connected to Plex.',
|
||||
timeout: 'Plex sign-in timed out — try again.',
|
||||
'budget-exhausted': 'Still working — this can take a while on a large first sync. Use Refresh to check again.'
|
||||
};
|
||||
|
||||
function stateFor(status: PinFlowStatus): PinFlowState {
|
||||
return {
|
||||
status,
|
||||
done: status === 'success' || status === 'timeout' || status === 'budget-exhausted',
|
||||
ok: status === 'success',
|
||||
message: MESSAGES[status]
|
||||
};
|
||||
}
|
||||
|
||||
/** The state shown before the first poll observation arrives. */
|
||||
export function initialPinFlowState(): PinFlowState {
|
||||
return stateFor('waiting');
|
||||
}
|
||||
|
||||
/** True when the elapsed poll time has consumed the whole budget. */
|
||||
export function isPinFlowBudgetExhausted(elapsedMs: number): boolean {
|
||||
return elapsedMs >= PIN_FLOW_POLL_BUDGET_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single poll observation. `budgetExhausted` reflects whether the poll budget is spent;
|
||||
* it only matters while the lock is still held (a released lock is always terminal, success or
|
||||
* timeout, regardless of budget).
|
||||
*/
|
||||
export function evaluatePinFlow(
|
||||
observation: PinFlowObservation,
|
||||
options: { budgetExhausted?: boolean } = {}
|
||||
): PinFlowState {
|
||||
const { isLocked, isAuthorized } = observation;
|
||||
|
||||
// Lock released: terminal either way. Success iff authorized.
|
||||
if (!isLocked) {
|
||||
return stateFor(isAuthorized ? 'success' : 'timeout');
|
||||
}
|
||||
|
||||
// Still locked but out of budget: not a failure, just "still working".
|
||||
if (options.budgetExhausted) {
|
||||
return stateFor('budget-exhausted');
|
||||
}
|
||||
|
||||
// Still locked, budget remaining: waiting for auth, or finalizing after auth.
|
||||
return stateFor(isAuthorized ? 'finalizing' : 'waiting');
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Shared dirty-guard wiring for the media-source editor screens (design §D.2). Mirrors the
|
||||
// SchedulesScreen convention (registerNavigationGuard on the current dirty state + a `beforeunload`
|
||||
// listener while dirty) so an editor with unsaved changes vetoes both in-app navigation (App's
|
||||
// popstate owner consults canLeaveCurrentScreen) and full-page unloads (reload / close tab).
|
||||
//
|
||||
// The guard predicate reads the LATEST dirty value via a ref updated on every render, so App's
|
||||
// synchronous popstate handler sees the current state without the effect having to re-register on
|
||||
// each keystroke. A single hook keeps the three editors (connection / libraries / path-replacements)
|
||||
// consistent instead of copy-pasting the guard block into each.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { registerNavigationGuard } from '../navigationGuard';
|
||||
|
||||
export function useDirtyGuard(dirty: boolean, prompt: string): void {
|
||||
const dirtyRef = useRef(dirty);
|
||||
// Keep the ref current for the synchronous guard predicate. Updated in an effect (committed before
|
||||
// any user pop/nav gesture can fire) rather than during render.
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty;
|
||||
}, [dirty]);
|
||||
|
||||
useEffect(() => registerNavigationGuard(() => !dirtyRef.current || window.confirm(prompt)), [prompt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) {
|
||||
return undefined;
|
||||
}
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [dirty]);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { parseRemoteFamily, type RemoteFamily } from './mediaSources/familyMeta';
|
||||
|
||||
// Client-side navigation shared by the shell (App.tsx) and screens that live in
|
||||
// their own modules (e.g. the Channel Builder). Kept in its own file so screens
|
||||
// don't import App.tsx (which would create an import cycle).
|
||||
@@ -69,3 +71,76 @@ export function parseMediaSubRoute(pathname: string): MediaSubRoute | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The Libraries nav entry (an allowSubPaths route) owns the media-source editor sub-paths. Unlike
|
||||
// PlayoutsRouteScreen/MediaRouteScreen (which self-listen for popstate), the libraries wrapper's
|
||||
// sub-screens register a dirty guard, so App owns pathname/popstate for this route and passes the
|
||||
// approved sub-path DOWN (see spa-conventions §8, design §D.2). Parsing lives here so the screen
|
||||
// module can stay component-only (react-refresh) and App.tsx's wrapper can dispatch. A `null` result
|
||||
// means "render the hub" (base path, `/local`, or any unrecognized sub-path).
|
||||
//
|
||||
// The union is split into a Local group (S6a owns these) and a Remote group (S6b owns these) so the
|
||||
// two feature slices edit disjoint dispatch branches.
|
||||
export type LibrariesSubRoute =
|
||||
// Local (slice S6a)
|
||||
| { kind: 'local-new' }
|
||||
| { kind: 'local-edit'; id: number }
|
||||
// Remote (slice S6b)
|
||||
| { kind: 'remote-source'; family: RemoteFamily }
|
||||
| { kind: 'remote-connection'; family: 'jellyfin' | 'emby' }
|
||||
| { kind: 'remote-libraries'; family: RemoteFamily; id: number }
|
||||
| { kind: 'remote-path-replacements'; family: RemoteFamily; id: number };
|
||||
|
||||
export function parseLibrariesSubRoute(pathname: string): LibrariesSubRoute | null {
|
||||
const base = '/app/libraries';
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
if (!normalized.startsWith(`${base}/`)) {
|
||||
return null; // base path -> hub
|
||||
}
|
||||
const parts = normalized.slice(base.length + 1).split('/');
|
||||
|
||||
// --- Local: /app/libraries/local/new | /app/libraries/local/{id} ---
|
||||
if (parts[0] === 'local') {
|
||||
if (parts.length === 2 && parts[1] === 'new') {
|
||||
return { kind: 'local-new' };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
const id = Number(parts[1]);
|
||||
if (Number.isInteger(id) && id > 0) {
|
||||
return { kind: 'local-edit', id };
|
||||
}
|
||||
}
|
||||
return null; // bare /app/libraries/local (or garbage) -> hub (its list lives on the hub)
|
||||
}
|
||||
|
||||
// --- Remote: /app/libraries/{plex|jellyfin|emby}[/...] ---
|
||||
const family = parseRemoteFamily(parts[0]);
|
||||
if (family === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// /app/libraries/{family}
|
||||
if (parts.length === 1) {
|
||||
return { kind: 'remote-source', family };
|
||||
}
|
||||
|
||||
// /app/libraries/{jellyfin|emby}/connection
|
||||
if (parts.length === 2 && parts[1] === 'connection' && (family === 'jellyfin' || family === 'emby')) {
|
||||
return { kind: 'remote-connection', family };
|
||||
}
|
||||
|
||||
// /app/libraries/{family}/{id}/sync | /app/libraries/{family}/{id}/path-replacements
|
||||
if (parts.length === 3) {
|
||||
const id = Number(parts[1]);
|
||||
if (Number.isInteger(id) && id > 0) {
|
||||
if (parts[2] === 'sync') {
|
||||
return { kind: 'remote-libraries', family, id };
|
||||
}
|
||||
if (parts[2] === 'path-replacements') {
|
||||
return { kind: 'remote-path-replacements', family, id };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null; // unrecognized sub-path -> hub
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LibrariesScreen } from './LibrariesScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
// MediaSourceLibraryResponseModel row.
|
||||
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 31,
|
||||
itemCount: 0,
|
||||
lastScan: null,
|
||||
mediaKind: 'Movies',
|
||||
name: 'Movies',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// MediaSourceResponseModel row.
|
||||
function mediaSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
connectionAddress: null,
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [],
|
||||
name: 'Local',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
interface MockOptions {
|
||||
mediaSources?: unknown[];
|
||||
mediaSourcesFailuresBeforeSuccess?: number;
|
||||
libraryScanStatuses?: unknown[];
|
||||
libraryScanStatusSequence?: unknown[][] | null;
|
||||
mutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
|
||||
}
|
||||
|
||||
interface MockHandle {
|
||||
fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
requests: Array<{ url: string; method: string; body: unknown }>;
|
||||
}
|
||||
|
||||
function mockApi(options: MockOptions = {}): MockHandle {
|
||||
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||
const mediaSources = options.mediaSources ?? [mediaSource()];
|
||||
const mutationFailures = options.mutationFailures ?? {};
|
||||
let remainingMediaSourcesFailures = options.mediaSourcesFailuresBeforeSuccess ?? 0;
|
||||
let scanStatusSequenceIndex = 0;
|
||||
|
||||
const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const body = init?.body ? JSON.parse(String(init.body)) : undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
if (url === '/api/media-sources' && method === 'GET') {
|
||||
if (remainingMediaSourcesFailures > 0) {
|
||||
remainingMediaSourcesFailures -= 1;
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(mediaSources));
|
||||
}
|
||||
|
||||
if (url === '/api/libraries/scan-status' && method === 'GET') {
|
||||
if (options.libraryScanStatusSequence) {
|
||||
const sequenceValue =
|
||||
options.libraryScanStatusSequence[Math.min(scanStatusSequenceIndex, options.libraryScanStatusSequence.length - 1)];
|
||||
scanStatusSequenceIndex += 1;
|
||||
return Promise.resolve(jsonResponse(sequenceValue));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(options.libraryScanStatuses ?? []));
|
||||
}
|
||||
|
||||
if (/^\/api\/libraries\/\d+\/scan$/.test(url) && method === 'POST') {
|
||||
if (url in mutationFailures) {
|
||||
return Promise.resolve(jsonResponse(mutationFailures[url], mutationFailures[url].status ?? 422));
|
||||
}
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(null, 404));
|
||||
});
|
||||
|
||||
return { fetchSpy, requests };
|
||||
}
|
||||
|
||||
function fetchCount(handle: MockHandle, path: string): number {
|
||||
return handle.requests.filter((r) => r.url === path).length;
|
||||
}
|
||||
|
||||
async function runPollTick(intervalHandlers: Array<() => void>): Promise<void> {
|
||||
await act(async () => {
|
||||
intervalHandlers.forEach((handler) => handler());
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('LibrariesScreen', () => {
|
||||
it('renders sources and libraries from live media source and scan status APIs', async () => {
|
||||
const handle = mockApi({
|
||||
libraryScanStatuses: [{ libraryId: 31, percent: 0.625 }],
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
connectionAddress: null,
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 1250, lastScan: '2026-07-05T14:30:00Z', mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 14, lastScan: null, mediaKind: 'OtherVideos', name: 'Station IDs' })
|
||||
],
|
||||
name: 'Local'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://plex.example.test',
|
||||
id: 40,
|
||||
kind: 'Plex',
|
||||
libraries: [
|
||||
library({ id: 41, itemCount: 80, lastScan: '2026-07-04T08:00:00Z', mediaKind: 'Shows', name: 'TV Shows' })
|
||||
],
|
||||
name: 'Plex Server'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://jellyfin.example.test',
|
||||
id: 50,
|
||||
kind: 'Jellyfin',
|
||||
libraries: [],
|
||||
name: 'Jellyfin Home'
|
||||
}),
|
||||
mediaSource({
|
||||
connectionAddress: 'https://emby.example.test',
|
||||
id: 60,
|
||||
kind: 'Emby',
|
||||
libraries: [
|
||||
library({ id: 61, itemCount: 22, lastScan: '2026-07-01T00:00:00Z', mediaKind: 'MusicVideos', name: 'Music Videos' })
|
||||
],
|
||||
name: 'Emby Archive'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
||||
expect(screen.getByText('4 sources')).toBeInTheDocument();
|
||||
expect(screen.getByText('1,366 items')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Local media source' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Local connection')).toBeInTheDocument();
|
||||
expect(screen.getByRole('region', { name: 'Plex Server media source' })).toBeInTheDocument();
|
||||
expect(screen.getByText('https://plex.example.test')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Movies').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Station IDs')).toBeInTheDocument();
|
||||
expect(screen.getByText('TV Shows')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Music Videos').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('Other Videos')).toBeInTheDocument();
|
||||
// "Scanning" appears twice: once as the source-header status label (paired with the
|
||||
// StatusDot, not color alone) and once as the per-library scanning Badge.
|
||||
expect(screen.getAllByText('Scanning').length).toBeGreaterThan(0);
|
||||
// Wire contract: percent is a 0-1 fraction (0.625) - the UI must render it as 63%, not 0.625%.
|
||||
expect(screen.getByText('63%')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Synced').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Never scanned').length).toBeGreaterThan(0);
|
||||
// Add Source is now a live menu trigger; the disabled Scan All button and the deferred-card were
|
||||
// removed in slice S5 (design §C7/§D.1).
|
||||
expect(screen.getByRole('button', { name: 'Add Source' })).toBeEnabled();
|
||||
expect(screen.queryByRole('button', { name: 'Scan All' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Adding sources is deferred to the existing server setup screens.')).not.toBeInTheDocument();
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/media-sources', expect.any(Object));
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/libraries/scan-status', expect.any(Object));
|
||||
});
|
||||
|
||||
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
|
||||
mockApi({
|
||||
libraryScanStatuses: [
|
||||
{ libraryId: 31, percent: 0 },
|
||||
{ libraryId: 32, percent: 1 }
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
|
||||
expect(await screen.findByText('0%')).toBeInTheDocument();
|
||||
expect(screen.getByText('100%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('triggers a library scan via POST /api/libraries/{id}/scan, disables the button while in flight, and polls scan status', async () => {
|
||||
const handle = mockApi({
|
||||
libraryScanStatuses: [],
|
||||
libraryScanStatusSequence: [[], [{ libraryId: 31, percent: 0.05 }]],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
expect(await screen.findByText('5%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('stops scan polling and refreshes sources once when scans complete', async () => {
|
||||
const intervalHandlers: Array<() => void> = [];
|
||||
vi.spyOn(window, 'setInterval').mockImplementation((handler: TimerHandler, timeout?: number) => {
|
||||
if (typeof handler === 'function' && (timeout ?? 0) >= 10000) {
|
||||
intervalHandlers.push(handler as () => void);
|
||||
}
|
||||
return intervalHandlers.length;
|
||||
});
|
||||
vi.spyOn(window, 'clearInterval').mockImplementation(() => undefined);
|
||||
|
||||
const handle = mockApi({
|
||||
libraryScanStatusSequence: [
|
||||
[{ libraryId: 31, percent: 0.75 }],
|
||||
[]
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect(await screen.findByText('75%')).toBeInTheDocument();
|
||||
|
||||
const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/media-sources');
|
||||
await runPollTick(intervalHandlers);
|
||||
|
||||
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
||||
expect(fetchCount(handle, '/api/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
});
|
||||
|
||||
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
|
||||
mockApi({
|
||||
libraryScanStatusSequence: [
|
||||
[], // initial screen load: nothing active yet
|
||||
[{ libraryId: 31, percent: 0.3 }] // post-409 reconcile fetch: the in-progress scan
|
||||
],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { status: 409 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
expect(await screen.findByText('30%')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).toBeDisabled();
|
||||
expect(screen.queryByText(/Request failed/)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/Unable to scan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a 422 "sync disabled" scan rejection and leaves the button enabled (#232)', async () => {
|
||||
mockApi({
|
||||
libraryScanStatuses: [],
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { detail: 'Item sync is disabled for library 31.', status: 422 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
expect(await screen.findByText('Item sync is disabled for library 31.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Scan Movies' })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows Libraries API errors and retries', async () => {
|
||||
// 1 failure (the initial mount's load) then success on the Retry-triggered reload - unlike
|
||||
// the App-level nav-smoke test, this standalone render has no earlier Dashboard fetch
|
||||
// consuming a failure first.
|
||||
mockApi({
|
||||
mediaSourcesFailuresBeforeSuccess: 1,
|
||||
mediaSources: [mediaSource({ libraries: [] })]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
|
||||
expect(await screen.findByText('Request failed with status 500')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Media Libraries' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows scan trigger failures without inventing application ProblemDetails', async () => {
|
||||
mockApi({
|
||||
mediaSources: [
|
||||
mediaSource({ libraries: [library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' })] })
|
||||
],
|
||||
mutationFailures: {
|
||||
'/api/libraries/31/scan': { status: 404 }
|
||||
}
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan Movies' }));
|
||||
|
||||
expect(await screen.findByText('Request failed with status 404')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('scans every library in a source when its Scan-all button is clicked', async () => {
|
||||
const handle = mockApi({
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
id: 30,
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 10, mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 5, mediaKind: 'Shows', name: 'TV Shows' })
|
||||
],
|
||||
name: 'Local'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect(await screen.findByText('TV Shows')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Scan all libraries in Local' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/libraries/31/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/libraries/32/scan', expect.objectContaining({ method: 'POST' }));
|
||||
});
|
||||
expect(fetchCount(handle, '/api/libraries/31/scan')).toBe(1);
|
||||
expect(fetchCount(handle, '/api/libraries/32/scan')).toBe(1);
|
||||
});
|
||||
|
||||
it('shows an empty state when no media sources exist', async () => {
|
||||
mockApi({ mediaSources: [] });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
|
||||
expect(await screen.findByText('No media sources returned')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('hub wiring (slice S5)', () => {
|
||||
it('Add Source menu navigates to the local-new and each family route', async () => {
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
mockApi({ mediaSources: [mediaSource({ libraries: [] })] });
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByRole('heading', { name: 'Media Libraries' });
|
||||
|
||||
// Local library → create editor route.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Source' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Local library' }));
|
||||
expect(pushSpy).toHaveBeenLastCalledWith(null, '', '/app/libraries/local/new');
|
||||
|
||||
// Plex → its family screen.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Source' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Plex' }));
|
||||
expect(pushSpy).toHaveBeenLastCalledWith(null, '', '/app/libraries/plex');
|
||||
|
||||
// Jellyfin → its family screen.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Source' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Jellyfin' }));
|
||||
expect(pushSpy).toHaveBeenLastCalledWith(null, '', '/app/libraries/jellyfin');
|
||||
|
||||
// Emby → its family screen.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Source' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Emby' }));
|
||||
expect(pushSpy).toHaveBeenLastCalledWith(null, '', '/app/libraries/emby');
|
||||
});
|
||||
|
||||
it('a remote source gear navigates to its family screen', async () => {
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
mockApi({
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
connectionAddress: 'https://plex.example.test',
|
||||
id: 40,
|
||||
kind: 'Plex',
|
||||
libraries: [library({ id: 41, mediaKind: 'Shows', name: 'TV Shows' })],
|
||||
name: 'Plex Server'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
await screen.findByText('TV Shows');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Manage Plex Server' }));
|
||||
expect(pushSpy).toHaveBeenLastCalledWith(null, '', '/app/libraries/plex');
|
||||
});
|
||||
|
||||
it('a local library row gear navigates to that library edit route; local sources have no source-level gear', async () => {
|
||||
const pushSpy = vi.spyOn(window.history, 'pushState');
|
||||
mockApi({
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [library({ id: 31, mediaKind: 'Movies', name: 'Movies' })],
|
||||
name: 'Local'
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
|
||||
// No source-level "Manage" gear for a Local source.
|
||||
expect(screen.queryByRole('button', { name: 'Manage Local' })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit Movies' }));
|
||||
expect(pushSpy).toHaveBeenLastCalledWith(null, '', '/app/libraries/local/31');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import {
|
||||
Clapperboard,
|
||||
Clock,
|
||||
FileImage,
|
||||
Film,
|
||||
Folder,
|
||||
HardDrive,
|
||||
MonitorPlay,
|
||||
Music,
|
||||
Plus,
|
||||
Radio,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
TriangleAlert
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
IconButton,
|
||||
ProgressBar,
|
||||
Spinner,
|
||||
StatusDot
|
||||
} from '../components';
|
||||
import {
|
||||
useLibrariesScreenQuery,
|
||||
type LibraryScanStatus,
|
||||
type MediaSource,
|
||||
type MediaSourceLibrary
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import {
|
||||
familyRoute,
|
||||
localLibraryEditRoute,
|
||||
localLibraryNewRoute,
|
||||
parseRemoteFamily,
|
||||
REMOTE_FAMILIES
|
||||
} from '../mediaSources/familyMeta';
|
||||
|
||||
export function LibrariesScreen() {
|
||||
const query = useLibrariesScreenQuery();
|
||||
|
||||
if (query.status === 'loading') {
|
||||
return <LibrariesLoadingState />;
|
||||
}
|
||||
|
||||
if (query.status === 'error') {
|
||||
return <LibrariesErrorState error={query.error} refresh={query.refresh} />;
|
||||
}
|
||||
|
||||
if (query.data.sources.length === 0) {
|
||||
return <LibrariesEmptyState />;
|
||||
}
|
||||
|
||||
const totalItems = query.data.sources.reduce(
|
||||
(sum, source) => sum + source.libraries.reduce((librarySum, library) => librarySum + library.itemCount, 0),
|
||||
0
|
||||
);
|
||||
const scanStatusesByLibraryId = new Map(query.data.scanStatuses.map((status) => [status.libraryId, status]));
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">Sources</span>
|
||||
<h2>Media Libraries</h2>
|
||||
<p>Monitor connected media sources and trigger per-library scans.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<AddSourceMenu />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{query.error && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{query.error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-libraries-summary">
|
||||
<span>{query.data.sources.length} source{query.data.sources.length === 1 ? '' : 's'}</span>
|
||||
<span>{totalItems.toLocaleString()} items</span>
|
||||
<span><StatusDot status={query.data.scanStatuses.length > 0 ? 'live' : 'ok'} size={7} />{query.data.scanStatuses.length} active scan{query.data.scanStatuses.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
|
||||
<div className="ctv-libraries-grid">
|
||||
{query.data.sources.map((source) => (
|
||||
<MediaSourceCard
|
||||
key={source.id}
|
||||
onScanLibrary={query.scanLibrary}
|
||||
scanStatusesByLibraryId={scanStatusesByLibraryId}
|
||||
scanningLibraryIds={query.scanningLibraryIds}
|
||||
source={source}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// "Add Source" dropdown: Local library opens the create editor; each remote family opens its screen.
|
||||
// Mirrors the AddToMenu popover pattern (click-outside + Escape close). Navigation goes through
|
||||
// navigateToPath, which App's popstate owner turns into the right libraries sub-path (design §D.2).
|
||||
function AddSourceMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
rootRef.current?.querySelector<HTMLButtonElement>(':scope > button')?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const go = (path: string) => {
|
||||
setOpen(false);
|
||||
navigateToPath(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-addto-menu" ref={rootRef}>
|
||||
<Button aria-expanded={open} aria-haspopup="menu" onClick={() => setOpen((v) => !v)} startIcon={<Plus aria-hidden="true" size={15} />}>
|
||||
Add Source
|
||||
</Button>
|
||||
{open && (
|
||||
<div className="ctv-addto-menu-popover" role="menu">
|
||||
<button className="ctv-addto-menu-item" onClick={() => go(localLibraryNewRoute)} role="menuitem" type="button">
|
||||
<HardDrive aria-hidden="true" size={14} />Local library
|
||||
</button>
|
||||
{REMOTE_FAMILIES.map((family) => (
|
||||
<button className="ctv-addto-menu-item" key={family} onClick={() => go(familyRoute(family))} role="menuitem" type="button">
|
||||
<Server aria-hidden="true" size={14} />{familyLabel(family)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function familyLabel(family: string): string {
|
||||
return family.charAt(0).toUpperCase() + family.slice(1);
|
||||
}
|
||||
|
||||
function LibrariesLoadingState() {
|
||||
return (
|
||||
<Card title={<h2>Loading libraries</h2>} subtitle="Fetching media sources and active scan status.">
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner tone="muted" />
|
||||
<span>Loading libraries</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrariesErrorState({ error, refresh }: { error: string; refresh: () => void }) {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Libraries unavailable</h2>}
|
||||
subtitle="The API returned an error while loading media sources."
|
||||
actions={<Button onClick={refresh} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">Retry</Button>}
|
||||
>
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function LibrariesEmptyState() {
|
||||
return (
|
||||
<Card title={<h2>No media sources</h2>} subtitle="Configure a source in the server setup screens before monitoring scans here.">
|
||||
<div className="ctv-schedule-empty">No media sources returned</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function MediaSourceCard({
|
||||
onScanLibrary,
|
||||
scanStatusesByLibraryId,
|
||||
scanningLibraryIds,
|
||||
source
|
||||
}: {
|
||||
onScanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanStatusesByLibraryId: Map<number, LibraryScanStatus>;
|
||||
scanningLibraryIds: Set<number>;
|
||||
source: MediaSource;
|
||||
}) {
|
||||
const totalItems = source.libraries.reduce((sum, library) => sum + library.itemCount, 0);
|
||||
const scanning = source.libraries.some((library) => scanningLibraryIds.has(library.id));
|
||||
// Remote families (Plex/Jellyfin/Emby) manage the whole source from its family screen; Local
|
||||
// libraries are edited per-row (each row is a library) via localLibraryEditRoute below.
|
||||
const family = parseRemoteFamily(source.kind);
|
||||
const isLocal = family === null;
|
||||
|
||||
const scanSource = () => {
|
||||
source.libraries.forEach((library) => {
|
||||
void onScanLibrary(library.id);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="ctv-library-source-card" aria-label={`${source.name} media source`}>
|
||||
<div className="ctv-library-source-head">
|
||||
<span className={`ctv-library-kind-icon ctv-library-kind-${source.kind.toLowerCase()}`}>{sourceKindIcon(source.kind)}</span>
|
||||
<div>
|
||||
<div className="ctv-library-source-title">
|
||||
<h3>{source.name}</h3>
|
||||
<StatusDot status={scanning ? 'live' : 'ok'} label={scanning ? 'Scanning' : 'Synced'} size={7} />
|
||||
</div>
|
||||
<span>{source.connectionAddress ?? 'Local connection'}</span>
|
||||
</div>
|
||||
<div className="ctv-library-source-actions">
|
||||
<IconButton disabled={scanning} onClick={scanSource} size="sm" title={`Scan all libraries in ${source.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
{family !== null && (
|
||||
<IconButton onClick={() => navigateToPath(familyRoute(family))} size="sm" title={`Manage ${source.name}`}>
|
||||
<Settings aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ctv-library-list" role="list" aria-label={`${source.name} libraries`}>
|
||||
{source.libraries.length === 0 ? (
|
||||
<div className="ctv-library-empty">No synced libraries returned</div>
|
||||
) : source.libraries.map((library) => (
|
||||
<LibraryRow
|
||||
key={library.id}
|
||||
editable={isLocal}
|
||||
library={library}
|
||||
onScanLibrary={onScanLibrary}
|
||||
scanStatus={scanStatusesByLibraryId.get(library.id) ?? null}
|
||||
scanning={scanningLibraryIds.has(library.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ctv-library-source-foot">
|
||||
<span><Clock aria-hidden="true" size={13} />{scanning ? 'Scanning now' : sourceLastScanLabel(source)}</span>
|
||||
<span><code>{totalItems.toLocaleString()}</code> items · {source.libraries.length} librar{source.libraries.length === 1 ? 'y' : 'ies'}</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryRow({
|
||||
editable,
|
||||
library,
|
||||
onScanLibrary,
|
||||
scanStatus,
|
||||
scanning
|
||||
}: {
|
||||
editable: boolean;
|
||||
library: MediaSourceLibrary;
|
||||
onScanLibrary: (libraryId: number) => Promise<void>;
|
||||
scanStatus: LibraryScanStatus | null;
|
||||
scanning: boolean;
|
||||
}) {
|
||||
const scanActive = scanStatus !== null;
|
||||
const disabled = scanning || scanActive;
|
||||
|
||||
return (
|
||||
<div className="ctv-library-row" role="listitem">
|
||||
<span className="ctv-library-media-icon">{libraryMediaIcon(library.mediaKind)}</span>
|
||||
<div className="ctv-library-row-main">
|
||||
<strong>{library.name}</strong>
|
||||
<span><span>{formatLibraryMediaKind(library.mediaKind)}</span> · <code>{library.itemCount.toLocaleString()}</code> items</span>
|
||||
<small>{library.lastScan ? `Last scan ${formatDateTime(library.lastScan)}` : 'Never scanned'}</small>
|
||||
</div>
|
||||
<div className="ctv-library-row-status">
|
||||
{scanStatus ? (
|
||||
<div className="ctv-library-progress">
|
||||
<ProgressBar value={scanStatus.percent} showLabel />
|
||||
<Badge tone="accent" dot>Scanning</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge tone="ok" dot>Synced</Badge>
|
||||
)}
|
||||
<IconButton disabled={disabled} onClick={() => void onScanLibrary(library.id)} size="sm" title={`Scan ${library.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
{editable && (
|
||||
<IconButton onClick={() => navigateToPath(localLibraryEditRoute(library.id))} size="sm" title={`Edit ${library.name}`}>
|
||||
<Settings aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceKindIcon(kind: string): ReactNode {
|
||||
return kind === 'Local'
|
||||
? <HardDrive aria-hidden="true" size={18} />
|
||||
: <Server aria-hidden="true" size={18} />;
|
||||
}
|
||||
|
||||
function libraryMediaIcon(kind: string): ReactNode {
|
||||
switch (kind) {
|
||||
case 'Movies':
|
||||
return <Clapperboard aria-hidden="true" size={14} />;
|
||||
case 'Shows':
|
||||
return <MonitorPlay aria-hidden="true" size={14} />;
|
||||
case 'MusicVideos':
|
||||
case 'Songs':
|
||||
return <Music aria-hidden="true" size={14} />;
|
||||
case 'Images':
|
||||
return <FileImage aria-hidden="true" size={14} />;
|
||||
case 'RemoteStreams':
|
||||
return <Radio aria-hidden="true" size={14} />;
|
||||
case 'OtherVideos':
|
||||
return <Film aria-hidden="true" size={14} />;
|
||||
default:
|
||||
return <Folder aria-hidden="true" size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatLibraryMediaKind(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'MusicVideos':
|
||||
return 'Music Videos';
|
||||
case 'OtherVideos':
|
||||
return 'Other Videos';
|
||||
case 'RemoteStreams':
|
||||
return 'Remote Streams';
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
function sourceLastScanLabel(source: MediaSource): string {
|
||||
const scans = source.libraries
|
||||
.map((library) => library.lastScan)
|
||||
.filter((scan): scan is string => Boolean(scan))
|
||||
.sort();
|
||||
|
||||
return scans.length > 0 ? `Last scan ${formatDateTime(scans[scans.length - 1])}` : 'Never scanned';
|
||||
}
|
||||
|
||||
// Inlined rather than imported back from App.tsx (mirrors the ChannelsScreen #244 extraction):
|
||||
// App.tsx's formatDateTime is still used by other App.tsx-owned screens (Playouts, Guide), so
|
||||
// keeping this screen self-sufficient means duplicating the tiny formatter here.
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value.slice(0, 5);
|
||||
}
|
||||
|
||||
return parsed.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { localLibraryModeFromPathname, LocalLibraryEditScreen } from './LocalLibraryEditScreen';
|
||||
import { canLeaveCurrentScreen } from '../navigationGuard';
|
||||
|
||||
const library = {
|
||||
id: 3,
|
||||
name: 'Feature Films',
|
||||
mediaKind: 'Movies',
|
||||
isLocked: false,
|
||||
mediaItemCount: 2,
|
||||
paths: [
|
||||
{ id: 10, path: '/media/movies', mediaItemCount: 2 },
|
||||
{ id: 11, path: '/media/movies2', mediaItemCount: 0 }
|
||||
]
|
||||
};
|
||||
|
||||
const otherLibraries = [
|
||||
{ id: 3, name: 'Movies', mediaKind: 'Movies', isLocked: false },
|
||||
{ id: 4, name: 'Movies (backup)', mediaKind: 'Movies', isLocked: false },
|
||||
{ id: 5, name: 'TV Shows', mediaKind: 'Shows', isLocked: false }
|
||||
];
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(body === null ? null : JSON.stringify(body), {
|
||||
headers: body === null ? undefined : { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
interface FetchOptions {
|
||||
createResponse?: Record<string, unknown>;
|
||||
libraryOverrides?: Record<string, unknown>;
|
||||
libraryStatus?: number;
|
||||
onCreate?: (body: unknown) => void;
|
||||
onDelete?: () => void;
|
||||
onMove?: (pathId: number, body: unknown) => void;
|
||||
onPathExists?: (path: string) => boolean;
|
||||
onPut?: (body: unknown) => void;
|
||||
putResponseOverrides?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mockApi({
|
||||
createResponse,
|
||||
libraryOverrides = {},
|
||||
libraryStatus = 200,
|
||||
onCreate,
|
||||
onDelete,
|
||||
onMove,
|
||||
onPathExists,
|
||||
onPut,
|
||||
putResponseOverrides = {}
|
||||
}: FetchOptions = {}) {
|
||||
const loadedLibrary = { ...library, ...libraryOverrides };
|
||||
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/libraries/local/path-exists' && method === 'POST') {
|
||||
const body = JSON.parse(init?.body as string) as { path: string };
|
||||
const exists = onPathExists ? onPathExists(body.path) : true;
|
||||
return Promise.resolve(json({ exists }));
|
||||
}
|
||||
|
||||
if (url === '/api/libraries/local' && method === 'POST') {
|
||||
const body = JSON.parse(init?.body as string);
|
||||
onCreate?.(body);
|
||||
return Promise.resolve(json(createResponse ?? { id: 99, name: body.name, mediaKind: body.mediaKind, isLocked: false }, 201));
|
||||
}
|
||||
|
||||
if (url === '/api/libraries/local') {
|
||||
return Promise.resolve(json(otherLibraries));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/libraries/local/paths/') && url.endsWith('/move') && method === 'POST') {
|
||||
const pathId = Number(url.split('/')[5]);
|
||||
onMove?.(pathId, JSON.parse(init?.body as string));
|
||||
return Promise.resolve(json(null, 204));
|
||||
}
|
||||
|
||||
if (url === '/api/libraries/local/3' && method === 'PUT') {
|
||||
onPut?.(JSON.parse(init?.body as string));
|
||||
return Promise.resolve(json({ ...loadedLibrary, ...putResponseOverrides }));
|
||||
}
|
||||
|
||||
if (url === '/api/libraries/local/3' && method === 'DELETE') {
|
||||
onDelete?.();
|
||||
return Promise.resolve(json(null, 204));
|
||||
}
|
||||
|
||||
if (url === '/api/libraries/local/3') {
|
||||
return libraryStatus === 200
|
||||
? Promise.resolve(json(loadedLibrary))
|
||||
: Promise.resolve(json({ status: libraryStatus, title: 'Not Found' }, libraryStatus));
|
||||
}
|
||||
|
||||
return Promise.resolve(json({ status: 404, title: 'Not Found' }, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe('localLibraryModeFromPathname', () => {
|
||||
it('parses the create route', () => {
|
||||
expect(localLibraryModeFromPathname('/app/libraries/local/new')).toEqual({ kind: 'create' });
|
||||
});
|
||||
|
||||
it('parses the edit route', () => {
|
||||
expect(localLibraryModeFromPathname('/app/libraries/local/3')).toEqual({ kind: 'edit', id: 3 });
|
||||
});
|
||||
|
||||
it('rejects a non-numeric id and unrelated paths', () => {
|
||||
expect(localLibraryModeFromPathname('/app/libraries/local/abc')).toBeNull();
|
||||
expect(localLibraryModeFromPathname('/app/libraries/plex')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalLibraryEditScreen', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
it('hydrates the edit form from the loaded library and disables the media-kind select', async () => {
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi();
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
expect(await screen.findByDisplayValue('Feature Films')).toBeInTheDocument();
|
||||
expect(screen.getByText('/media/movies')).toBeInTheDocument();
|
||||
|
||||
const mediaKindSelect = screen.getByDisplayValue('Movies') as HTMLSelectElement;
|
||||
expect(mediaKindSelect).toBeDisabled();
|
||||
});
|
||||
|
||||
it('rejects Add Path for a path that does not exist on the filesystem', async () => {
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi({ onPathExists: () => false });
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Feature Films');
|
||||
|
||||
const input = screen.getByPlaceholderText('/media/movies');
|
||||
fireEvent.change(input, { target: { value: '/media/missing' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Path' }));
|
||||
|
||||
expect(await screen.findByText('Path must exist on filesystem.')).toBeInTheDocument();
|
||||
expect(screen.queryByText('/media/missing')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts Add Path for an existing path and rejects an in-draft duplicate', async () => {
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi({ onPathExists: () => true });
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Feature Films');
|
||||
|
||||
const input = screen.getByPlaceholderText('/media/movies');
|
||||
fireEvent.change(input, { target: { value: '/media/movies3' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Path' }));
|
||||
|
||||
expect(await screen.findByText('/media/movies3')).toBeInTheDocument();
|
||||
|
||||
// Duplicate of an already-added path, case/trailing-slash-insensitive (mirrors the server's
|
||||
// NormalizePath compare, design §C4c).
|
||||
fireEvent.change(input, { target: { value: '/MEDIA/MOVIES3/' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Path' }));
|
||||
|
||||
expect(await screen.findByText('This path is already in the list.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('retains the draft when Save fails with a 422', async () => {
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
const spy = vi.spyOn(window, 'fetch').mockImplementation((input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : (input as Request).url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url === '/api/libraries/local/3' && method === 'PUT') {
|
||||
return Promise.resolve(json({ status: 422, title: 'Validation error', detail: 'Path does not exist' }, 422));
|
||||
}
|
||||
if (url === '/api/libraries/local/3') {
|
||||
return Promise.resolve(json(library));
|
||||
}
|
||||
return Promise.resolve(json({ status: 404 }, 404));
|
||||
});
|
||||
void spy;
|
||||
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Feature Films');
|
||||
fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
expect(await screen.findByText('Path does not exist')).toBeInTheDocument();
|
||||
// The draft (the renamed value) is retained, not reverted.
|
||||
expect(screen.getByDisplayValue('Feature Films (renamed)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves an edited name with a PUT carrying the updated paths', async () => {
|
||||
const puts: unknown[] = [];
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi({ onPut: (body) => puts.push(body) });
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Feature Films');
|
||||
fireEvent.change(nameInput, { target: { value: 'Feature Films (renamed)' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(puts).toHaveLength(1));
|
||||
expect(puts[0]).toEqual({
|
||||
name: 'Feature Films (renamed)',
|
||||
paths: [
|
||||
{ id: 10, path: '/media/movies' },
|
||||
{ id: 11, path: '/media/movies2' }
|
||||
]
|
||||
});
|
||||
});
|
||||
|
||||
it('confirms before removing a path with media items, but not an empty one', async () => {
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi();
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Feature Films');
|
||||
|
||||
// /media/movies2 has 0 items -> removed immediately, no confirm dialog.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove /media/movies2' }));
|
||||
expect(screen.queryByText('/media/movies2')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('dialog', { name: 'Remove path' })).not.toBeInTheDocument();
|
||||
|
||||
// /media/movies has 2 items -> confirm dialog appears and is required.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove /media/movies' }));
|
||||
expect(await screen.findByRole('dialog', { name: 'Remove path' })).toBeInTheDocument();
|
||||
expect(screen.getByText('/media/movies')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
await waitFor(() => expect(screen.queryByText('/media/movies')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('moves a path via the move dialog, including composing "(New Library)"', async () => {
|
||||
const moves: Array<[number, unknown]> = [];
|
||||
const creates: unknown[] = [];
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi({
|
||||
onCreate: (body) => creates.push(body),
|
||||
onMove: (pathId, body) => moves.push([pathId, body]),
|
||||
createResponse: { id: 77, name: 'New home', mediaKind: 'Movies', isLocked: false }
|
||||
});
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Feature Films');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Move /media/movies to another library' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: /Move \/media\/movies/ });
|
||||
|
||||
const select = within(dialog).getByRole('combobox');
|
||||
// Same-kind, excludes the source library; "(New Library)" is always offered.
|
||||
expect(within(dialog).getByText('Movies (backup)')).toBeInTheDocument();
|
||||
expect(within(dialog).queryByText('TV Shows')).not.toBeInTheDocument();
|
||||
expect(within(dialog).getByText('(New Library)')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(select, { target: { value: '__new__' } });
|
||||
fireEvent.change(within(dialog).getByPlaceholderText('New library name'), {
|
||||
target: { value: 'New home' }
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Move' }));
|
||||
|
||||
await waitFor(() => expect(creates).toHaveLength(1));
|
||||
expect(creates[0]).toEqual({ mediaKind: 'Movies', name: 'New home', paths: [] });
|
||||
await waitFor(() => expect(moves).toHaveLength(1));
|
||||
expect(moves[0]).toEqual([10, { targetLibraryId: 77 }]);
|
||||
});
|
||||
|
||||
it('deletes the library with a media-item-count confirm', async () => {
|
||||
let deleted = false;
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi({ onDelete: () => { deleted = true; } });
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
await screen.findByDisplayValue('Feature Films');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete library' }));
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Delete library' });
|
||||
expect(within(dialog).getByText(/2 media item/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Confirm' }));
|
||||
|
||||
await waitFor(() => expect(deleted).toBe(true));
|
||||
});
|
||||
|
||||
it('rejects Save when the name is blank', async () => {
|
||||
window.history.replaceState(null, '', '/app/libraries/local/3');
|
||||
mockApi();
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
const nameInput = await screen.findByDisplayValue('Feature Films');
|
||||
fireEvent.change(nameInput, { target: { value: '' } });
|
||||
|
||||
const saveButton = await screen.findByRole('button', { name: 'Save changes' });
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('creates a new library and navigates to its edit route', async () => {
|
||||
const creates: unknown[] = [];
|
||||
window.history.replaceState(null, '', '/app/libraries/local/new');
|
||||
mockApi({ onCreate: (body) => creates.push(body), createResponse: { id: 42, name: 'New Library', mediaKind: 'Shows', isLocked: false } });
|
||||
render(<LocalLibraryEditScreen />);
|
||||
|
||||
// Two textboxes render in create mode (Name + the Add-Path field); Name is the first.
|
||||
const [nameInput] = await screen.findAllByRole('textbox');
|
||||
fireEvent.change(nameInput, { target: { value: 'New Library' } });
|
||||
|
||||
const kindSelect = screen.getByDisplayValue('Movies');
|
||||
fireEvent.change(kindSelect, { target: { value: 'Shows' } });
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Create' }));
|
||||
|
||||
await waitFor(() => expect(creates).toHaveLength(1));
|
||||
expect(creates[0]).toEqual({ mediaKind: 'Shows', name: 'New Library', paths: [] });
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/app/libraries/local/42'));
|
||||
|
||||
// Regression (live E2E): after a successful create the dirty guard must NOT prompt on the
|
||||
// post-save navigation to the edit route. window.confirm returns false here, so a spurious
|
||||
// prompt would make the guard block; assert it allows the leave and confirm is never called.
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
expect(canLeaveCurrentScreen()).toBe(true);
|
||||
expect(confirmSpy).not.toHaveBeenCalled();
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,650 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ArrowRightLeft, Check, Plus, Save as SaveIcon, Trash2, TriangleAlert, Undo2 } from 'lucide-react';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
checkLocalPathExists,
|
||||
createLocalLibrary,
|
||||
deleteLocalLibrary,
|
||||
getLocalLibraries,
|
||||
getLocalLibrary,
|
||||
messageFromMediaSourcesError,
|
||||
moveLocalLibraryPath,
|
||||
updateLocalLibrary,
|
||||
type LocalLibrary,
|
||||
type LocalLibraryDetail,
|
||||
type UpdateLocalLibraryPathRequest
|
||||
} from '../api';
|
||||
import { registerNavigationGuard } from '../navigationGuard';
|
||||
import { isDuplicatePath } from '../mediaSources/paths';
|
||||
import { localLibraryEditRoute } from '../mediaSources/familyMeta';
|
||||
|
||||
const LOCAL_BASE_PATH = '/app/libraries/local';
|
||||
const DIRTY_PROMPT = 'You have unsaved library changes. Discard them?';
|
||||
|
||||
type LocalLibraryMode = { kind: 'create' } | { kind: 'edit'; id: number };
|
||||
|
||||
/* eslint-disable react-refresh/only-export-components -- small pure helpers colocated for the unit test */
|
||||
export function localLibraryModeFromPathname(pathname: string): LocalLibraryMode | null {
|
||||
const normalized = pathname.replace(/\/+$/, '');
|
||||
|
||||
if (!normalized.startsWith(`${LOCAL_BASE_PATH}/`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rest = normalized.slice(LOCAL_BASE_PATH.length + 1);
|
||||
|
||||
if (rest === 'new') {
|
||||
return { kind: 'create' };
|
||||
}
|
||||
|
||||
const id = Number(rest);
|
||||
return Number.isInteger(id) && id > 0 ? { kind: 'edit', id } : null;
|
||||
}
|
||||
|
||||
const MEDIA_KIND_OPTIONS: Array<{ label: string; value: LocalLibraryDetail['mediaKind'] }> = [
|
||||
{ label: 'Movies', value: 'Movies' },
|
||||
{ label: 'Shows', value: 'Shows' },
|
||||
{ label: 'Music Videos', value: 'MusicVideos' },
|
||||
{ label: 'Other Videos', value: 'OtherVideos' },
|
||||
{ label: 'Songs', value: 'Songs' },
|
||||
{ label: 'Images', value: 'Images' },
|
||||
{ label: 'Remote Streams', value: 'RemoteStreams' }
|
||||
];
|
||||
|
||||
// A path row in the draft. `id === 0` means "not yet persisted" (added in this session, not saved
|
||||
// yet) — Move is unavailable for such a row because L6 (move-path) operates on a persisted
|
||||
// `LibraryPath.Id`; the row must survive a Save first (design §C3/§C5).
|
||||
interface DraftPath {
|
||||
id: number;
|
||||
mediaItemCount: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface EditDraft {
|
||||
name: string;
|
||||
paths: DraftPath[];
|
||||
}
|
||||
/* eslint-enable react-refresh/only-export-components */
|
||||
|
||||
function draftFromDetail(detail: LocalLibraryDetail): EditDraft {
|
||||
return {
|
||||
name: detail.name ?? '',
|
||||
paths: (detail.paths ?? []).map((path) => ({ id: path.id, mediaItemCount: path.mediaItemCount, path: path.path ?? '' }))
|
||||
};
|
||||
}
|
||||
|
||||
function toUpdateRequestPaths(paths: DraftPath[]): UpdateLocalLibraryPathRequest[] {
|
||||
return paths.map((path) => ({ id: path.id, path: path.path }));
|
||||
}
|
||||
|
||||
type LoadState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; error: string; notFound: boolean }
|
||||
| { status: 'ready' };
|
||||
|
||||
export function LocalLibraryEditScreen() {
|
||||
const [mode] = useState<LocalLibraryMode | null>(() => localLibraryModeFromPathname(window.location.pathname));
|
||||
|
||||
// --- edit-mode server state ---
|
||||
const [load, setLoad] = useState<LoadState>({ status: mode?.kind === 'edit' ? 'loading' : 'ready' });
|
||||
const [detail, setDetail] = useState<LocalLibraryDetail | null>(null);
|
||||
const [draft, setDraft] = useState<EditDraft>({ name: '', paths: [] });
|
||||
const [saved, setSaved] = useState<EditDraft>({ name: '', paths: [] });
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
|
||||
const [newMediaKind, setNewMediaKind] = useState<LocalLibraryDetail['mediaKind']>('Movies');
|
||||
|
||||
const [newPath, setNewPath] = useState('');
|
||||
const [addPathError, setAddPathError] = useState<string | null>(null);
|
||||
const [checkingPath, setCheckingPath] = useState(false);
|
||||
|
||||
const [deletePathTarget, setDeletePathTarget] = useState<DraftPath | null>(null);
|
||||
const [movePathTarget, setMovePathTarget] = useState<DraftPath | null>(null);
|
||||
const [confirmDeleteLibrary, setConfirmDeleteLibrary] = useState(false);
|
||||
const [deleteLibraryError, setDeleteLibraryError] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const activeRef = useRef(true);
|
||||
const dirtyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode?.kind !== 'edit') {
|
||||
return;
|
||||
}
|
||||
|
||||
getLocalLibrary(mode.id)
|
||||
.then((response) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
const initial = draftFromDetail(response);
|
||||
setDetail(response);
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setLoad({ status: 'ready' });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
const notFound = error instanceof ApiError && error.status === 404;
|
||||
setLoad({
|
||||
status: 'error',
|
||||
error: notFound ? 'Local library not found.' : messageFromMediaSourcesError(error, 'Unable to load library'),
|
||||
notFound
|
||||
});
|
||||
});
|
||||
}, [mode]);
|
||||
|
||||
const dirty = mode?.kind === 'create'
|
||||
? draft.name.trim() !== '' || draft.paths.length > 0
|
||||
: JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
|
||||
useEffect(() => {
|
||||
dirtyRef.current = dirty;
|
||||
}, [dirty]);
|
||||
|
||||
// §8: register the shared guard on mount; App consults it before any in-app nav (sidebar click or
|
||||
// popstate) and a full-page unload gets its own beforeunload listener below.
|
||||
useEffect(() => registerNavigationGuard(() => !dirtyRef.current || window.confirm(DIRTY_PROMPT)), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) {
|
||||
return;
|
||||
}
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
};
|
||||
window.addEventListener('beforeunload', handler);
|
||||
return () => window.removeEventListener('beforeunload', handler);
|
||||
}, [dirty]);
|
||||
|
||||
if (mode === null) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>Invalid local library route.</span>
|
||||
<Button onClick={() => navigateToPath('/app/libraries')} size="sm" variant="secondary">
|
||||
Back to libraries
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === 'edit' && load.status === 'loading') {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<span>Loading library…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === 'edit' && load.status === 'error') {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{load.error}</span>
|
||||
<Button onClick={() => navigateToPath('/app/libraries')} size="sm" variant="secondary">
|
||||
Back to libraries
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const mediaKind = mode.kind === 'create' ? newMediaKind : (detail?.mediaKind ?? 'Movies');
|
||||
const isLocked = mode.kind === 'edit' ? (detail?.isLocked ?? false) : false;
|
||||
const nameValid = draft.name.trim() !== '';
|
||||
const valid = nameValid;
|
||||
|
||||
const set = (patch: Partial<EditDraft>) => {
|
||||
setDraft((current) => ({ ...current, ...patch }));
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setDraft(saved);
|
||||
setSaveError(null);
|
||||
setAddPathError(null);
|
||||
};
|
||||
|
||||
const refetchDetail = () => {
|
||||
if (mode.kind !== 'edit') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return getLocalLibrary(mode.id).then((response) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setDetail(response);
|
||||
const next = draftFromDetail(response);
|
||||
setSaved(next);
|
||||
setDraft(next);
|
||||
});
|
||||
};
|
||||
|
||||
const addPath = async () => {
|
||||
const trimmed = newPath.trim();
|
||||
setAddPathError(null);
|
||||
|
||||
if (!trimmed) {
|
||||
setAddPathError('Enter a path.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDuplicatePath(draft.paths.map((p) => p.path), trimmed)) {
|
||||
setAddPathError('This path is already in the list.');
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingPath(true);
|
||||
try {
|
||||
const result = await checkLocalPathExists({ path: trimmed });
|
||||
if (!result.exists) {
|
||||
setAddPathError('Path must exist on filesystem.');
|
||||
return;
|
||||
}
|
||||
set({ paths: [...draft.paths, { id: 0, mediaItemCount: 0, path: trimmed }] });
|
||||
setNewPath('');
|
||||
} catch (error) {
|
||||
setAddPathError(messageFromMediaSourcesError(error, 'Unable to verify path'));
|
||||
} finally {
|
||||
setCheckingPath(false);
|
||||
}
|
||||
};
|
||||
|
||||
const requestDeletePath = (path: DraftPath) => {
|
||||
if (path.mediaItemCount > 0) {
|
||||
setDeletePathTarget(path);
|
||||
return;
|
||||
}
|
||||
set({ paths: draft.paths.filter((p) => p !== path) });
|
||||
};
|
||||
|
||||
const confirmDeletePath = () => {
|
||||
if (!deletePathTarget) {
|
||||
return;
|
||||
}
|
||||
set({ paths: draft.paths.filter((p) => p !== deletePathTarget) });
|
||||
setDeletePathTarget(null);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!valid || saving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
if (mode.kind === 'create') {
|
||||
const created = await createLocalLibrary({
|
||||
mediaKind: newMediaKind,
|
||||
name: draft.name,
|
||||
paths: draft.paths.map((p) => p.path)
|
||||
});
|
||||
// The create persisted; clear the dirty flag before navigating to the new library's edit
|
||||
// route so the shared navigation guard doesn't spuriously prompt "discard changes?" on this
|
||||
// post-save navigation (create-mode `dirty` is content-based, so it's still true here).
|
||||
dirtyRef.current = false;
|
||||
navigateToPath(localLibraryEditRoute(created.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateLocalLibrary(mode.id, {
|
||||
name: draft.name,
|
||||
paths: toUpdateRequestPaths(draft.paths)
|
||||
});
|
||||
setDetail(updated);
|
||||
const next = draftFromDetail(updated);
|
||||
setSaved(next);
|
||||
setDraft(next);
|
||||
} catch (error) {
|
||||
setSaveError(messageFromMediaSourcesError(error, 'Unable to save library'));
|
||||
if (mode.kind === 'edit' && error instanceof ApiError && error.status === 409) {
|
||||
void refetchDetail();
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLibrary = async () => {
|
||||
if (mode.kind !== 'edit' || deleting) {
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
setDeleteLibraryError(null);
|
||||
try {
|
||||
await deleteLocalLibrary(mode.id);
|
||||
setConfirmDeleteLibrary(false);
|
||||
navigateToPath('/app/libraries');
|
||||
} catch (error) {
|
||||
setDeleteLibraryError(messageFromMediaSourcesError(error, 'Unable to delete library'));
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
void refetchDetail();
|
||||
}
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onMoved = () => {
|
||||
setMovePathTarget(null);
|
||||
void refetchDetail();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-settings">
|
||||
<div className="ctv-settings-body">
|
||||
<div className="ctv-settings-scroll">
|
||||
<Card title={<h2>{mode.kind === 'create' ? 'New local library' : `Edit ${detail?.name ?? 'library'}`}</h2>}>
|
||||
<Row first help="Library display name." label="Name">
|
||||
<Input error={nameValid ? null : 'Name is required'} onChange={(e) => set({ name: e.target.value })} size="sm" value={draft.name} />
|
||||
</Row>
|
||||
<Row help={mode.kind === 'edit' ? 'Media kind is immutable once a library is created.' : 'What kind of media this library holds.'} label="Media kind">
|
||||
<Select
|
||||
disabled={mode.kind === 'edit'}
|
||||
onChange={(e) => setNewMediaKind(e.target.value as LocalLibraryDetail['mediaKind'])}
|
||||
options={MEDIA_KIND_OPTIONS}
|
||||
size="sm"
|
||||
value={mediaKind}
|
||||
/>
|
||||
</Row>
|
||||
{isLocked && (
|
||||
<div className="ctv-schedule-empty" role="status">
|
||||
This library is locked while a scan is in progress.
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card title={<h3>Paths</h3>}>
|
||||
<div className="ctv-library-path-list" role="list">
|
||||
{draft.paths.length === 0 && <div className="ctv-schedule-empty">No paths added yet.</div>}
|
||||
{draft.paths.map((path) => (
|
||||
<div className="ctv-library-path-row" key={`${path.id}-${path.path}`} role="listitem">
|
||||
<span className="ctv-library-path-text">{path.path}</span>
|
||||
{path.mediaItemCount > 0 && <Badge tone="neutral">{path.mediaItemCount} items</Badge>}
|
||||
<IconButton
|
||||
disabled={path.id === 0 || dirty || saving}
|
||||
onClick={() => setMovePathTarget(path)}
|
||||
size="sm"
|
||||
title={path.id === 0 ? `Save first to move ${path.path}` : `Move ${path.path} to another library`}
|
||||
>
|
||||
<ArrowRightLeft aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton onClick={() => requestDeletePath(path)} size="sm" title={`Remove ${path.path}`}>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ctv-library-path-add">
|
||||
<Input
|
||||
error={addPathError}
|
||||
onChange={(e) => {
|
||||
setNewPath(e.target.value);
|
||||
setAddPathError(null);
|
||||
}}
|
||||
placeholder="/media/movies"
|
||||
size="sm"
|
||||
value={newPath}
|
||||
/>
|
||||
<Button loading={checkingPath} onClick={() => void addPath()} size="sm" startIcon={<Plus aria-hidden="true" size={14} />}>
|
||||
Add Path
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{mode.kind === 'edit' && (
|
||||
<Card title={<h3>Delete library</h3>}>
|
||||
<p>
|
||||
{detail?.mediaItemCount ?? 0} media item{(detail?.mediaItemCount ?? 0) === 1 ? '' : 's'} will be removed from
|
||||
the library index.
|
||||
</p>
|
||||
{deleteLibraryError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{deleteLibraryError}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
disabled={isLocked || saving}
|
||||
onClick={() => setConfirmDeleteLibrary(true)}
|
||||
startIcon={<Trash2 aria-hidden="true" size={14} />}
|
||||
variant="danger"
|
||||
>
|
||||
Delete library
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(dirty || saveError) && (
|
||||
<div className="ctv-settings-savebar-wrap">
|
||||
<div className="ctv-settings-savebar ctv-lift">
|
||||
<span className="ctv-settings-savebar-text">Unsaved changes</span>
|
||||
{!nameValid && <Badge tone="neutral">Name is required</Badge>}
|
||||
{saveError && <span className="ctv-settings-savebar-error">{saveError}</span>}
|
||||
<span className="ctv-settings-savebar-divider" />
|
||||
{mode.kind === 'edit' && (
|
||||
<Button disabled={saving} onClick={discard} size="sm" startIcon={<Undo2 aria-hidden="true" size={14} />} variant="ghost">
|
||||
Discard
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={!valid || (mode.kind === 'edit' && !dirty) || saving || isLocked}
|
||||
loading={saving}
|
||||
onClick={() => void save()}
|
||||
size="sm"
|
||||
startIcon={<SaveIcon aria-hidden="true" size={14} />}
|
||||
variant="primary"
|
||||
>
|
||||
{mode.kind === 'create' ? 'Create' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
busy={false}
|
||||
message={`This path contains ${deletePathTarget?.mediaItemCount ?? 0} media item(s). Remove it from the library? (Persisted on Save.)`}
|
||||
onCancel={() => setDeletePathTarget(null)}
|
||||
onConfirm={confirmDeletePath}
|
||||
open={deletePathTarget !== null}
|
||||
title="Remove path"
|
||||
tone="danger"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
busy={deleting}
|
||||
message={`This library contains ${detail?.mediaItemCount ?? 0} media item(s). Delete it anyway?`}
|
||||
onCancel={() => setConfirmDeleteLibrary(false)}
|
||||
onConfirm={() => void deleteLibrary()}
|
||||
open={confirmDeleteLibrary}
|
||||
title="Delete library"
|
||||
tone="danger"
|
||||
/>
|
||||
|
||||
{movePathTarget && mode.kind === 'edit' && (
|
||||
<MovePathDialog
|
||||
currentLibraryId={mode.id}
|
||||
mediaKind={mediaKind}
|
||||
onClose={() => setMovePathTarget(null)}
|
||||
onMoved={onMoved}
|
||||
path={movePathTarget}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
children,
|
||||
first = false,
|
||||
help,
|
||||
label
|
||||
}: {
|
||||
children: ReactNode;
|
||||
first?: boolean;
|
||||
help?: string;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctv-settings-row" style={first ? { borderTop: 'none' } : undefined}>
|
||||
<div className="ctv-settings-row-main">
|
||||
<div className="ctv-settings-row-label">{label}</div>
|
||||
{help && <div className="ctv-settings-row-help">{help}</div>}
|
||||
</div>
|
||||
<div className="ctv-settings-row-control" style={{ flex: '0 0 300px' }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NEW_LIBRARY_OPTION = '__new__';
|
||||
|
||||
function MovePathDialog({
|
||||
currentLibraryId,
|
||||
mediaKind,
|
||||
onClose,
|
||||
onMoved,
|
||||
path
|
||||
}: {
|
||||
currentLibraryId: number;
|
||||
mediaKind: LocalLibraryDetail['mediaKind'];
|
||||
onClose: () => void;
|
||||
onMoved: () => void;
|
||||
path: DraftPath;
|
||||
}) {
|
||||
const [candidates, setCandidates] = useState<LocalLibrary[] | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [target, setTarget] = useState<string>('');
|
||||
const [newLibraryName, setNewLibraryName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
getLocalLibraries()
|
||||
.then((libraries) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
const eligible = libraries.filter((library) => library.mediaKind === mediaKind && library.id !== currentLibraryId);
|
||||
setCandidates(eligible);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (active) {
|
||||
setLoadError(messageFromMediaSourcesError(err, 'Unable to load libraries'));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [currentLibraryId, mediaKind]);
|
||||
|
||||
const confirm = async () => {
|
||||
if (!target || busy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
let targetLibraryId: number;
|
||||
|
||||
if (target === NEW_LIBRARY_OPTION) {
|
||||
if (!newLibraryName.trim()) {
|
||||
setError('Enter a name for the new library.');
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
const created = await createLocalLibrary({ mediaKind, name: newLibraryName.trim(), paths: [] });
|
||||
targetLibraryId = created.id;
|
||||
} else {
|
||||
targetLibraryId = Number(target);
|
||||
}
|
||||
|
||||
await moveLocalLibraryPath(path.id, { targetLibraryId });
|
||||
onMoved();
|
||||
} catch (err) {
|
||||
// Blazor parity (design §C3): if the move fails after a successful "(New Library)" create,
|
||||
// surface the error and leave the newly created empty library in place rather than rolling it
|
||||
// back — the user can see it on the hub and delete it manually if unwanted.
|
||||
setError(messageFromMediaSourcesError(err, 'Unable to move path'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
footer={
|
||||
<>
|
||||
<Button disabled={busy} onClick={onClose} variant="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !target || (target === NEW_LIBRARY_OPTION && !newLibraryName.trim())}
|
||||
loading={busy}
|
||||
onClick={() => void confirm()}
|
||||
startIcon={<Check aria-hidden="true" size={14} />}
|
||||
>
|
||||
Move
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onClose={onClose}
|
||||
open
|
||||
title={`Move ${path.path}`}
|
||||
>
|
||||
{loadError && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{loadError}
|
||||
</span>
|
||||
)}
|
||||
{candidates === null ? (
|
||||
<span>Loading libraries…</span>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
options={[
|
||||
{ label: '(choose a library)', value: '' },
|
||||
...candidates.map((library) => ({ label: library.name ?? `Library ${library.id}`, value: String(library.id) })),
|
||||
{ label: '(New Library)', value: NEW_LIBRARY_OPTION }
|
||||
]}
|
||||
value={target}
|
||||
/>
|
||||
{target === NEW_LIBRARY_OPTION && (
|
||||
<Input
|
||||
onChange={(e) => setNewLibraryName(e.target.value)}
|
||||
placeholder="New library name"
|
||||
value={newLibraryName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<span className="ctv-field-error" role="alert">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PathReplacementsEditScreen } from './PathReplacementsEditScreen';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
interface Row {
|
||||
id: number;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
rows: Row[];
|
||||
saveStatus?: number;
|
||||
saveBody?: Row[];
|
||||
}
|
||||
|
||||
function installFetch(family: string, id: number, options: Options) {
|
||||
const putBodies: string[] = [];
|
||||
const spy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const base = `/api/media-sources/${family}/${id}/path-replacements`;
|
||||
|
||||
if (url.endsWith(base) && method === 'GET') {
|
||||
return Promise.resolve(json(options.rows));
|
||||
}
|
||||
if (url.endsWith(base) && method === 'PUT') {
|
||||
putBodies.push(String(init?.body ?? ''));
|
||||
const status = options.saveStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return Promise.resolve(json({ title: 'Save failed', detail: 'Bad path' }, status));
|
||||
}
|
||||
return Promise.resolve(json(options.saveBody ?? options.rows));
|
||||
}
|
||||
return Promise.resolve(json({}, 404));
|
||||
});
|
||||
return { spy, putBodies };
|
||||
}
|
||||
|
||||
describe('PathReplacementsEditScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/app/libraries/jellyfin/4/path-replacements');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('uses the family-specific remote-path column label', async () => {
|
||||
installFetch('jellyfin', 4, { rows: [{ id: 1, remotePath: '/media', localPath: '/mnt/media' }] });
|
||||
|
||||
render(<PathReplacementsEditScreen family="jellyfin" sourceId={4} />);
|
||||
|
||||
expect(await screen.findByText('Jellyfin Path')).toBeTruthy();
|
||||
expect(screen.getByText('Local Path')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('adds a row and requires both fields before Save enables', async () => {
|
||||
installFetch('jellyfin', 4, { rows: [] });
|
||||
|
||||
render(<PathReplacementsEditScreen family="jellyfin" sourceId={4} />);
|
||||
await screen.findByRole('button', { name: /Add/i });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add/i }));
|
||||
|
||||
const save = () => screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
|
||||
// New blank row: invalid, Save disabled.
|
||||
expect(save().disabled).toBe(true);
|
||||
|
||||
const [remote, local] = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
fireEvent.change(remote, { target: { value: '/media' } });
|
||||
// Only one field filled → still invalid.
|
||||
expect(save().disabled).toBe(true);
|
||||
fireEvent.change(local, { target: { value: '/mnt/media' } });
|
||||
expect(save().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('saves the rows and resets the baseline from the reloaded response', async () => {
|
||||
const { putBodies } = installFetch('jellyfin', 4, {
|
||||
rows: [{ id: 1, remotePath: '/media', localPath: '/mnt/media' }],
|
||||
saveBody: [{ id: 1, remotePath: '/movies', localPath: '/mnt/media' }]
|
||||
});
|
||||
|
||||
render(<PathReplacementsEditScreen family="jellyfin" sourceId={4} />);
|
||||
await screen.findByText('Jellyfin Path');
|
||||
const [remote] = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
|
||||
fireEvent.change(remote, { target: { value: '/movies' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await vi.waitFor(() => expect(putBodies.length).toBe(1));
|
||||
const body = JSON.parse(putBodies[0]);
|
||||
expect(body.items).toEqual([{ id: 1, remotePath: '/movies', localPath: '/mnt/media' }]);
|
||||
|
||||
// After the refetch the baseline is clean.
|
||||
await vi.waitFor(() => expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(true));
|
||||
});
|
||||
|
||||
it('retains the draft on a 422 save failure', async () => {
|
||||
const { putBodies } = installFetch('jellyfin', 4, {
|
||||
rows: [{ id: 1, remotePath: '/media', localPath: '/mnt/media' }],
|
||||
saveStatus: 422
|
||||
});
|
||||
|
||||
render(<PathReplacementsEditScreen family="jellyfin" sourceId={4} />);
|
||||
await screen.findByText('Jellyfin Path');
|
||||
const [remote] = screen.getAllByRole('textbox') as HTMLInputElement[];
|
||||
|
||||
fireEvent.change(remote, { target: { value: '/movies' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await vi.waitFor(() => expect(putBodies.length).toBe(1));
|
||||
expect(await screen.findByText(/Bad path/i)).toBeTruthy();
|
||||
// Draft retained.
|
||||
expect((screen.getAllByRole('textbox')[0] as HTMLInputElement).value).toBe('/movies');
|
||||
expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('removes a row', async () => {
|
||||
installFetch('jellyfin', 4, {
|
||||
rows: [
|
||||
{ id: 1, remotePath: '/media', localPath: '/mnt/media' },
|
||||
{ id: 2, remotePath: '/music', localPath: '/mnt/music' }
|
||||
]
|
||||
});
|
||||
|
||||
render(<PathReplacementsEditScreen family="jellyfin" sourceId={4} />);
|
||||
await screen.findByText('/media');
|
||||
expect(screen.getByText('/music')).toBeTruthy();
|
||||
|
||||
// Two Remove buttons; remove the second row.
|
||||
const removes = screen.getAllByRole('button', { name: /Remove/i });
|
||||
fireEvent.click(removes[1]);
|
||||
|
||||
expect(screen.queryByText('/music')).toBeNull();
|
||||
// Removal makes the draft dirty → Save enabled.
|
||||
expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, Plus, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, Input, Spinner } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
getPathReplacements,
|
||||
messageFromMediaSourcesError,
|
||||
replacePathReplacements,
|
||||
type PathReplacement,
|
||||
type RemoteFamily
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { familyMeta, familyRoute } from '../mediaSources/familyMeta';
|
||||
import { useDirtyGuard } from '../mediaSources/useDirtyGuard';
|
||||
|
||||
const DIRTY_PROMPT = 'You have unsaved path replacement changes. Discard them?';
|
||||
|
||||
interface DraftRow {
|
||||
_key: string;
|
||||
id: number;
|
||||
remotePath: string;
|
||||
localPath: string;
|
||||
}
|
||||
|
||||
let keySeq = 0;
|
||||
function nextKey(): string {
|
||||
keySeq += 1;
|
||||
return `row-${keySeq}`;
|
||||
}
|
||||
|
||||
function fromResponse(rows: PathReplacement[]): DraftRow[] {
|
||||
return rows.map((row) => ({ _key: nextKey(), id: row.id, remotePath: row.remotePath ?? '', localPath: row.localPath ?? '' }));
|
||||
}
|
||||
|
||||
// Compare by (remotePath, localPath) — ids are assigned by the server on save, so the dirty flag and
|
||||
// baseline reset must ignore them (design §C4b round-trip identity).
|
||||
function toComparable(rows: DraftRow[]): string {
|
||||
return JSON.stringify(rows.map((row) => ({ remotePath: row.remotePath, localPath: row.localPath })));
|
||||
}
|
||||
|
||||
// Shared path-replacements editor for Plex / Jellyfin / Emby
|
||||
// (RemoteMediaSourcePathReplacementsEditor). A row list + a selected-row edit form; the remote-path
|
||||
// column label comes from familyMeta ("Plex Path" / "Jellyfin Path" / "Emby Path"). Both fields are
|
||||
// required per row. Saved as one PUT; the response (reloaded list) becomes the new baseline.
|
||||
export function PathReplacementsEditScreen({ family, sourceId }: { family: RemoteFamily; sourceId: number }) {
|
||||
const meta = familyMeta(family);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [draft, setDraft] = useState<DraftRow[]>([]);
|
||||
const [saved, setSaved] = useState<DraftRow[]>([]);
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ingest = useCallback((rows: PathReplacement[]) => {
|
||||
const drafted = fromResponse(rows);
|
||||
setDraft(drafted);
|
||||
setSaved(drafted.map((row) => ({ ...row })));
|
||||
setSelectedKey(drafted[0]?._key ?? null);
|
||||
setLoaded(true);
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getPathReplacements(family, sourceId)
|
||||
.then((rows) => {
|
||||
if (activeRef.current) {
|
||||
ingest(rows);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setNotFound(true);
|
||||
} else {
|
||||
setLoadError(messageFromMediaSourcesError(error, `Unable to load ${meta.label} path replacements`));
|
||||
}
|
||||
});
|
||||
}, [family, sourceId, meta.label, ingest]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const dirty = toComparable(draft) !== toComparable(saved);
|
||||
useDirtyGuard(dirty, DIRTY_PROMPT);
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>That {meta.label} server no longer exists.</span>
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} size="sm" variant="secondary">Back to {meta.label}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{loadError}</span>
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} size="sm" variant="secondary">Back to {meta.label}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<Spinner size={16} tone="muted" />
|
||||
<span>Loading {meta.label} path replacements…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const selected = draft.find((row) => row._key === selectedKey) ?? null;
|
||||
// Every row must have a non-blank remote and local path (Blazor required both).
|
||||
const valid = draft.every((row) => row.remotePath.trim().length > 0 && row.localPath.trim().length > 0);
|
||||
|
||||
const updateSelected = (patch: Partial<Pick<DraftRow, 'remotePath' | 'localPath'>>) => {
|
||||
if (!selected || saving) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => current.map((row) => (row._key === selected._key ? { ...row, ...patch } : row)));
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
const row: DraftRow = { _key: nextKey(), id: 0, remotePath: '', localPath: '' };
|
||||
setDraft((current) => [...current, row]);
|
||||
setSelectedKey(row._key);
|
||||
};
|
||||
|
||||
const removeRow = (key: string) => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => {
|
||||
const index = current.findIndex((row) => row._key === key);
|
||||
const next = current.filter((row) => row._key !== key);
|
||||
setSelectedKey(next[Math.min(index, next.length - 1)]?._key ?? null);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
if (!dirty || window.confirm(DIRTY_PROMPT)) {
|
||||
const restored = saved.map((row) => ({ ...row }));
|
||||
setDraft(restored);
|
||||
setSelectedKey(restored[0]?._key ?? null);
|
||||
setSaveError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!valid || !dirty || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
replacePathReplacements(family, sourceId, {
|
||||
items: draft.map((row) => ({ id: row.id, remotePath: row.remotePath.trim(), localPath: row.localPath.trim() }))
|
||||
})
|
||||
.then((rows) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
ingest(rows);
|
||||
setSaving(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
setSaveError(messageFromMediaSourcesError(error, `Unable to save ${meta.label} path replacements`));
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
load();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">{meta.label} path replacements</span>
|
||||
<h2>Edit path replacements</h2>
|
||||
<p>Map {meta.label} media paths onto the paths ErsatzTV can reach.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">Back to {meta.label}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{saveError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{saveError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-schedule-grid">
|
||||
<section className="ctv-schedule-lineup-panel" aria-label="Path replacements">
|
||||
<div className="ctv-schedule-panel-head">
|
||||
<span>Replacements</span>
|
||||
<Button disabled={saving} onClick={addRow} size="sm" startIcon={<Plus aria-hidden="true" size={14} />} variant="ghost">Add</Button>
|
||||
</div>
|
||||
{draft.length === 0 ? (
|
||||
<div className="ctv-schedule-empty">No path replacements</div>
|
||||
) : (
|
||||
<div className="ctv-library-list" role="list" aria-label="Path replacement rows">
|
||||
{draft.map((row) => (
|
||||
<div className={`ctv-library-row${row._key === selectedKey ? ' ctv-library-row-active' : ''}`} role="listitem" key={row._key}>
|
||||
<button className="ctv-library-row-main" onClick={() => setSelectedKey(row._key)} type="button" style={{ textAlign: 'left', background: 'none', border: 'none', cursor: 'pointer' }}>
|
||||
<strong>{row.remotePath || '(new)'}</strong>
|
||||
<span>→ {row.localPath || '(unset)'}</span>
|
||||
</button>
|
||||
<div className="ctv-library-row-status">
|
||||
<Button disabled={saving} onClick={() => removeRow(row._key)} size="sm" startIcon={<Trash2 aria-hidden="true" size={14} />} variant="ghost">Remove</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="ctv-schedule-inspector" aria-label="Path replacement editor">
|
||||
{selected ? (
|
||||
<Card title={<h3>Edit replacement</h3>}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<Input
|
||||
label={meta.remotePathColumnLabel}
|
||||
value={selected.remotePath}
|
||||
disabled={saving}
|
||||
onChange={(event) => updateSelected({ remotePath: event.target.value })}
|
||||
error={selected.remotePath.trim() ? null : `${meta.remotePathColumnLabel} is required`}
|
||||
/>
|
||||
<Input
|
||||
label="Local Path"
|
||||
value={selected.localPath}
|
||||
disabled={saving}
|
||||
onChange={(event) => updateSelected({ localPath: event.target.value })}
|
||||
error={selected.localPath.trim() ? null : 'Local Path is required'}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="ctv-schedule-empty">Select or add a path replacement</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="ctv-schedule-toolbar">
|
||||
<div className="ctv-schedule-toolbar-spacer" />
|
||||
<Button disabled={!dirty || saving} onClick={discard} variant="ghost">Discard</Button>
|
||||
<Button disabled={!valid || !dirty || saving} loading={saving} onClick={save} startIcon={<Check aria-hidden="true" size={14} />}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PlexSourceScreen } from './PlexSourceScreen';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
interface PlexState {
|
||||
isAuthorized: boolean;
|
||||
isLocked: boolean;
|
||||
servers: Array<{ id: number; name: string; address: string }>;
|
||||
}
|
||||
|
||||
// A URL-keyed fetch stub. `stateRef.current` lets a test flip the polled Plex state over time; the
|
||||
// counter proves the poll kept running (a stop would freeze the count).
|
||||
function installFetch(stateRef: { current: PlexState }, counter: { state: number }) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url.endsWith('/api/media-sources/plex/pin-flow') && method === 'POST') {
|
||||
return Promise.resolve(json({ authUrl: 'https://plex.tv/link?code=ABCD' }));
|
||||
}
|
||||
if (url.endsWith('/api/media-sources/plex/sign-out') && method === 'POST') {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (url.includes('/refresh-libraries') && method === 'POST') {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (url.endsWith('/api/media-sources/plex') && method === 'GET') {
|
||||
counter.state += 1;
|
||||
return Promise.resolve(json(stateRef.current));
|
||||
}
|
||||
return Promise.resolve(json({}, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe('PlexSourceScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('pin flow: waiting → finalizing → success, and keeps polling at authorized&locked (does NOT stop)', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
installFetch(stateRef, counter);
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
|
||||
|
||||
// Polls now read "locked, not authorized" = waiting.
|
||||
stateRef.current = { isAuthorized: false, isLocked: true, servers: [] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
|
||||
await vi.waitFor(() => expect(screen.getByText(/Waiting for you to authorize/i)).toBeTruthy());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(screen.getByText(/Waiting for you to authorize/i)).toBeTruthy();
|
||||
|
||||
// Authorized but STILL LOCKED = finalizing. The terminal signal is the lock releasing, so the
|
||||
// screen must keep polling here (design §C1 / finding 5): no servers shown, count keeps rising.
|
||||
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(screen.getByText(/discovering your Plex servers/i)).toBeTruthy();
|
||||
expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull();
|
||||
|
||||
const callsAtFinalizing = counter.state;
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
// Still finalizing after another interval — proves it did NOT stop at authorized&locked.
|
||||
expect(counter.state).toBeGreaterThan(callsAtFinalizing);
|
||||
expect(screen.getByText(/discovering your Plex servers/i)).toBeTruthy();
|
||||
|
||||
// Lock releases while authorized = terminal success; servers appear and polling stops.
|
||||
stateRef.current = { isAuthorized: true, isLocked: false, servers: [{ id: 5, name: 'Home Server', address: 'http://plex:32400' }] };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect(screen.getByRole('heading', { name: 'Servers' })).toBeTruthy());
|
||||
expect(screen.getByText('Home Server')).toBeTruthy();
|
||||
|
||||
const callsAfterSuccess = counter.state;
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(counter.state).toBe(callsAfterSuccess); // polling stopped on success
|
||||
});
|
||||
|
||||
it('pin flow: an ended flow without auth reports a timeout and stops polling', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
installFetch(stateRef, counter);
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
|
||||
|
||||
stateRef.current = { isAuthorized: false, isLocked: true, servers: [] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
|
||||
await vi.waitFor(() => expect(screen.getByText(/Waiting for you to authorize/i)).toBeTruthy());
|
||||
|
||||
// Flow ends without authorization (tab closed / plex.tv timed out).
|
||||
stateRef.current = { isAuthorized: false, isLocked: false, servers: [] };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Plex sign-in timed out/i)).toBeTruthy());
|
||||
|
||||
const callsAfterTimeout = counter.state;
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(counter.state).toBe(callsAfterTimeout); // stopped
|
||||
});
|
||||
|
||||
it('pin flow: budget exhausted while still locked shows "still working" and stops', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
installFetch(stateRef, counter);
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
|
||||
|
||||
// Stay authorized-but-locked forever (a very large first sync legitimately holds the lock).
|
||||
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
|
||||
await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers|Waiting/i)).toBeTruthy());
|
||||
|
||||
// Consume the whole 150s budget.
|
||||
await vi.advanceTimersByTimeAsync(152_000);
|
||||
await vi.waitFor(() => expect(screen.getByText(/Still working/i)).toBeTruthy());
|
||||
|
||||
const callsAtBudget = counter.state;
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(counter.state).toBe(callsAtBudget); // stopped, not a failure loop
|
||||
});
|
||||
|
||||
it('renders a clickable fallback link when the popup is blocked', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
installFetch(stateRef, counter);
|
||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await screen.findByRole('button', { name: 'Sign in to Plex' });
|
||||
|
||||
stateRef.current = { isAuthorized: false, isLocked: true, servers: [] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
|
||||
|
||||
const link = await screen.findByRole('link', { name: /Open the Plex sign-in page/i });
|
||||
expect(link).toHaveAttribute('href', 'https://plex.tv/link?code=ABCD');
|
||||
});
|
||||
|
||||
it('sign out shows the content-removal warning dialog and posts on confirm', async () => {
|
||||
const stateRef = {
|
||||
current: { isAuthorized: true, isLocked: false, servers: [{ id: 9, name: 'Living Room', address: 'http://plex:32400' }] } as PlexState
|
||||
};
|
||||
const counter = { state: 0 };
|
||||
const fetchSpy = installFetch(stateRef, counter);
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await screen.findByRole('heading', { name: 'Servers' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign out of Plex' }));
|
||||
expect(await screen.findByText(/removes all synced Plex servers/i)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
|
||||
await vi.waitFor(() =>
|
||||
expect(fetchSpy.mock.calls.some(([input, init]) => String(input).endsWith('/sign-out') && (init?.method ?? '').toUpperCase() === 'POST')).toBe(true)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { KeyRound, LogIn, LogOut, RefreshCw, Server, TriangleAlert } from 'lucide-react';
|
||||
import { Badge, Button, Card, ConfirmDialog, IconButton, Spinner, StatusDot } from '../components';
|
||||
import {
|
||||
getPlexState,
|
||||
messageFromMediaSourcesError,
|
||||
refreshRemoteLibraries,
|
||||
signOutOfPlex,
|
||||
startPlexPinFlow,
|
||||
type RemoteMediaSourceState
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { pathReplacementsRoute, remoteLibrariesRoute } from '../mediaSources/familyMeta';
|
||||
import {
|
||||
evaluatePinFlow,
|
||||
initialPinFlowState,
|
||||
isPinFlowBudgetExhausted,
|
||||
PIN_FLOW_POLL_INTERVAL_MS,
|
||||
type PinFlowState
|
||||
} from '../mediaSources/pinFlowPoll';
|
||||
|
||||
type BootState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; error: string }
|
||||
| { status: 'ready'; state: RemoteMediaSourceState };
|
||||
|
||||
// The Plex source screen: mirrors PlexMediaSources.razor. It drives the OAuth pin flow (design §C1)
|
||||
// — the poll runs against GET /api/media-sources/plex every 2s and, crucially, keeps polling while
|
||||
// authorized-but-still-locked ("finalizing / discovering servers"); the terminal success signal is
|
||||
// the lock RELEASING (see pinFlowPoll.ts). Server rows offer Refresh / Edit Libraries / Edit Path
|
||||
// Replacements. Refresh is disabled while the Plex lock is held.
|
||||
export function PlexSourceScreen() {
|
||||
const [boot, setBoot] = useState<BootState>({ status: 'loading' });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmSignOut, setConfirmSignOut] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
||||
|
||||
// Pin-flow UI state.
|
||||
const [pinState, setPinState] = useState<PinFlowState | null>(null);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [authUrl, setAuthUrl] = useState<string | null>(null);
|
||||
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const startedAtRef = useRef(0);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getPlexState()
|
||||
.then((state) => {
|
||||
if (activeRef.current) {
|
||||
setBoot({ status: 'ready', state });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setBoot({ status: 'error', error: messageFromMediaSourcesError(error, 'Unable to load Plex') });
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// The poll loop. A single interval, armed only while `polling` is true (flipped off on any
|
||||
// terminal state), so the 2s cadence is stable rather than re-armed on every observation.
|
||||
useEffect(() => {
|
||||
if (!polling) {
|
||||
return undefined;
|
||||
}
|
||||
const id = window.setInterval(() => {
|
||||
getPlexState()
|
||||
.then((state) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setBoot({ status: 'ready', state });
|
||||
const elapsed = Date.now() - startedAtRef.current;
|
||||
const next = evaluatePinFlow(state, { budgetExhausted: isPinFlowBudgetExhausted(elapsed) });
|
||||
setPinState(next);
|
||||
if (next.done) {
|
||||
setPolling(false);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setActionError(messageFromMediaSourcesError(error, 'Plex sign-in polling failed'));
|
||||
setPolling(false);
|
||||
});
|
||||
}, PIN_FLOW_POLL_INTERVAL_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [polling]);
|
||||
|
||||
const startPinFlow = () => {
|
||||
if (starting || polling) {
|
||||
return;
|
||||
}
|
||||
setStarting(true);
|
||||
setActionError(null);
|
||||
setPopupBlocked(false);
|
||||
startPlexPinFlow()
|
||||
.then((flow) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
const url = flow.authUrl ?? '';
|
||||
setAuthUrl(url);
|
||||
// Open the Plex auth page in a new tab (design §C1 step 3). window.open initiated from the
|
||||
// click gesture; if the browser blocks the popup it returns null and we surface a clickable
|
||||
// link fallback so the user can still reach plex.tv.
|
||||
const popup = url ? window.open(url, '_blank') : null;
|
||||
setPopupBlocked(url !== '' && !popup);
|
||||
startedAtRef.current = Date.now();
|
||||
setPinState(initialPinFlowState());
|
||||
setPolling(true);
|
||||
setStarting(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setActionError(messageFromMediaSourcesError(error, 'Unable to start Plex sign-in'));
|
||||
setStarting(false);
|
||||
});
|
||||
};
|
||||
|
||||
const doSignOut = () => {
|
||||
setSigningOut(true);
|
||||
setActionError(null);
|
||||
signOutOfPlex()
|
||||
.then(() => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setConfirmSignOut(false);
|
||||
setSigningOut(false);
|
||||
setPinState(null);
|
||||
setPolling(false);
|
||||
setAuthUrl(null);
|
||||
load();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setSigningOut(false);
|
||||
setActionError(messageFromMediaSourcesError(error, 'Unable to sign out of Plex'));
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = (serverId: number) => {
|
||||
setRefreshingId(serverId);
|
||||
setActionError(null);
|
||||
refreshRemoteLibraries('plex', serverId)
|
||||
.then(() => {
|
||||
if (activeRef.current) {
|
||||
setRefreshingId(null);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setRefreshingId(null);
|
||||
setActionError(messageFromMediaSourcesError(error, 'Unable to refresh Plex libraries'));
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
if (boot.status === 'loading') {
|
||||
return (
|
||||
<Card title={<h2>Loading Plex</h2>} subtitle="Fetching Plex connection state.">
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner tone="muted" />
|
||||
<span>Loading Plex</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (boot.status === 'error') {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>Plex unavailable</h2>}
|
||||
subtitle="The API returned an error while loading Plex."
|
||||
actions={
|
||||
<Button onClick={() => { setBoot({ status: 'loading' }); load(); }} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{boot.error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { isAuthorized, isLocked, servers } = boot.state;
|
||||
const serverList = servers ?? [];
|
||||
const connected = serverList.length > 0;
|
||||
const needsFix = connected && !isAuthorized;
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">Media source</span>
|
||||
<h2>Plex</h2>
|
||||
<p>Sign in to Plex to discover servers and choose which libraries to sync.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<Button onClick={() => navigateToPath('/app/libraries')} variant="ghost">Back to libraries</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{actionError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{actionError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={<h3>Connection</h3>}
|
||||
subtitle={connected ? (isAuthorized ? 'Signed in to Plex.' : 'Signed in, but credentials need attention.') : 'Not signed in.'}
|
||||
actions={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!connected && (
|
||||
<Button disabled={starting || polling} loading={starting} onClick={startPinFlow} startIcon={<LogIn aria-hidden="true" size={14} />}>
|
||||
Sign in to Plex
|
||||
</Button>
|
||||
)}
|
||||
{needsFix && (
|
||||
<Button disabled={starting || polling} loading={starting} onClick={startPinFlow} startIcon={<KeyRound aria-hidden="true" size={14} />}>
|
||||
Fix Plex Credentials
|
||||
</Button>
|
||||
)}
|
||||
{connected && (
|
||||
<Button disabled={signingOut || polling} onClick={() => setConfirmSignOut(true)} startIcon={<LogOut aria-hidden="true" size={14} />} variant="secondary">
|
||||
Sign out of Plex
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(polling || pinState) && (
|
||||
<div className="ctv-dashboard-state" role="status">
|
||||
{polling && <Spinner size={16} tone="accent" />}
|
||||
<span>{pinState?.message}</span>
|
||||
{(popupBlocked || (polling && authUrl)) && authUrl && (
|
||||
<a href={authUrl} target="_blank" rel="noreferrer noopener">Open the Plex sign-in page</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!polling && !pinState && !connected && (
|
||||
<div className="ctv-schedule-empty">Sign in to Plex to connect a server.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{connected && (
|
||||
<Card title={<h3>Servers</h3>} subtitle={`${serverList.length} server${serverList.length === 1 ? '' : 's'} discovered.`} padded={false}>
|
||||
<div className="ctv-library-list" role="list" aria-label="Plex servers">
|
||||
{serverList.map((server) => (
|
||||
<div className="ctv-library-row" role="listitem" key={server.id}>
|
||||
<span className="ctv-library-media-icon"><Server aria-hidden="true" size={14} /></span>
|
||||
<div className="ctv-library-row-main">
|
||||
<strong>{server.name}</strong>
|
||||
<span>{server.address ?? 'Unknown address'}</span>
|
||||
</div>
|
||||
<div className="ctv-library-row-status">
|
||||
<StatusDot status={isLocked ? 'live' : 'ok'} label={isLocked ? 'Busy' : 'Ready'} size={7} />
|
||||
<IconButton disabled={isLocked || refreshingId === server.id} onClick={() => refresh(server.id)} size="sm" title={`Refresh ${server.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<Button onClick={() => navigateToPath(remoteLibrariesRoute('plex', server.id))} size="sm" variant="ghost">Edit Libraries</Button>
|
||||
<Button onClick={() => navigateToPath(pathReplacementsRoute('plex', server.id))} size="sm" variant="ghost">Edit Path Replacements</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{needsFix && (
|
||||
<div className="ctv-libraries-summary">
|
||||
<span><Badge tone="neutral">Action needed</Badge> Plex reported servers but the stored credentials are not authorized. Use Fix Plex Credentials.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmSignOut}
|
||||
tone="danger"
|
||||
title="Sign out of Plex"
|
||||
message="Signing out removes all synced Plex servers, libraries, and content from ErsatzTV. This cannot be undone. Continue?"
|
||||
confirmLabel="Sign out"
|
||||
busy={signingOut}
|
||||
onConfirm={doSignOut}
|
||||
onCancel={() => setConfirmSignOut(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoteConnectionEditScreen } from './RemoteConnectionEditScreen';
|
||||
import { canLeaveCurrentScreen } from '../navigationGuard';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
const contentType = status === 204 ? undefined : 'application/json';
|
||||
return new Response(status === 204 ? null : JSON.stringify(body), {
|
||||
headers: contentType ? { 'Content-Type': contentType } : undefined,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
interface Options {
|
||||
connection: { address: string | null; hasApiKey: boolean };
|
||||
saveStatus?: number;
|
||||
saveBody?: unknown;
|
||||
}
|
||||
|
||||
function installFetch(family: string, options: Options) {
|
||||
const putBodies: string[] = [];
|
||||
const spy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url.endsWith(`/api/media-sources/${family}/connection`) && method === 'GET') {
|
||||
return Promise.resolve(json(options.connection));
|
||||
}
|
||||
if (url.endsWith(`/api/media-sources/${family}/connection`) && method === 'PUT') {
|
||||
putBodies.push(String(init?.body ?? ''));
|
||||
const status = options.saveStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return Promise.resolve(json({ title: 'Invalid', detail: 'Connection failed' }, status));
|
||||
}
|
||||
return Promise.resolve(json(options.saveBody ?? { address: options.connection.address, hasApiKey: true }));
|
||||
}
|
||||
return Promise.resolve(json({}, 404));
|
||||
});
|
||||
return { spy, putBodies };
|
||||
}
|
||||
|
||||
describe('RemoteConnectionEditScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/app/libraries/jellyfin/connection');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('secure key affordance: shows "leave blank to keep" and NEVER renders the stored key', async () => {
|
||||
installFetch('jellyfin', { connection: { address: 'http://jf:8096', hasApiKey: true } });
|
||||
|
||||
render(<RemoteConnectionEditScreen family="jellyfin" />);
|
||||
|
||||
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
|
||||
expect(address.value).toBe('http://jf:8096');
|
||||
|
||||
const keyField = screen.getByPlaceholderText(/leave blank to keep/i) as HTMLInputElement;
|
||||
// The stored key is never sent to the client, so the field is empty and there is no secret in the DOM.
|
||||
expect(keyField.value).toBe('');
|
||||
expect(document.body.textContent).not.toMatch(/secret|apikey-value/i);
|
||||
});
|
||||
|
||||
it('blank key is retained on save (PUT body carries an empty apiKey)', async () => {
|
||||
const { putBodies } = installFetch('jellyfin', {
|
||||
connection: { address: 'http://jf:8096', hasApiKey: true },
|
||||
saveBody: { address: 'http://jf:9000', hasApiKey: true }
|
||||
});
|
||||
|
||||
render(<RemoteConnectionEditScreen family="jellyfin" />);
|
||||
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
|
||||
|
||||
// Change only the address, leave the key blank.
|
||||
fireEvent.change(address, { target: { value: 'http://jf:9000' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save connection' }));
|
||||
|
||||
await vi.waitFor(() => expect(putBodies.length).toBe(1));
|
||||
const body = JSON.parse(putBodies[0]);
|
||||
expect(body.address).toBe('http://jf:9000');
|
||||
expect(body.apiKey).toBe(''); // blank = retain existing key server-side
|
||||
});
|
||||
|
||||
it('first connect (no stored key) requires an api key before Save enables', async () => {
|
||||
installFetch('jellyfin', { connection: { address: '', hasApiKey: false } });
|
||||
|
||||
render(<RemoteConnectionEditScreen family="jellyfin" />);
|
||||
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(address, { target: { value: 'http://jf:8096' } });
|
||||
// Address valid but no key yet: Connect stays disabled.
|
||||
const connect = screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement;
|
||||
expect(connect.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Enter an API key/i), { target: { value: 'newkey123' } });
|
||||
expect((screen.getByRole('button', { name: 'Connect' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('retains the draft on a 422 save failure', async () => {
|
||||
const { putBodies } = installFetch('jellyfin', {
|
||||
connection: { address: 'http://jf:8096', hasApiKey: true },
|
||||
saveStatus: 422
|
||||
});
|
||||
|
||||
render(<RemoteConnectionEditScreen family="jellyfin" />);
|
||||
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(address, { target: { value: 'http://jf:9999' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save connection' }));
|
||||
|
||||
await vi.waitFor(() => expect(putBodies.length).toBe(1));
|
||||
// Draft not reset: the edited address is still present and the error is surfaced.
|
||||
expect((screen.getByPlaceholderText(/192.168/i) as HTMLInputElement).value).toBe('http://jf:9999');
|
||||
expect(await screen.findByText(/Connection failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dirty guard vetoes navigation until confirmed', async () => {
|
||||
installFetch('jellyfin', { connection: { address: 'http://jf:8096', hasApiKey: true } });
|
||||
|
||||
render(<RemoteConnectionEditScreen family="jellyfin" />);
|
||||
const address = (await screen.findByPlaceholderText(/192.168/i)) as HTMLInputElement;
|
||||
|
||||
// Clean: guard allows leaving.
|
||||
expect(canLeaveCurrentScreen()).toBe(true);
|
||||
|
||||
fireEvent.change(address, { target: { value: 'http://jf:changed' } });
|
||||
// Let the dirty effect commit.
|
||||
await vi.waitFor(() => expect((screen.getByPlaceholderText(/192.168/i) as HTMLInputElement).value).toBe('http://jf:changed'));
|
||||
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
expect(canLeaveCurrentScreen()).toBe(false); // dirty + confirm=false → vetoed
|
||||
confirmSpy.mockReturnValue(true);
|
||||
expect(canLeaveCurrentScreen()).toBe(true); // confirm=true → allowed
|
||||
confirmSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Check, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, Input, Spinner } from '../components';
|
||||
import {
|
||||
getRemoteConnection,
|
||||
messageFromMediaSourcesError,
|
||||
saveRemoteConnection
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { familyMeta, familyRoute } from '../mediaSources/familyMeta';
|
||||
import { useDirtyGuard } from '../mediaSources/useDirtyGuard';
|
||||
|
||||
type RemoteFamilyWithConnection = 'jellyfin' | 'emby';
|
||||
|
||||
interface Draft {
|
||||
address: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface LoadedState {
|
||||
hasApiKey: boolean;
|
||||
}
|
||||
|
||||
const DIRTY_PROMPT = 'You have unsaved connection changes. Discard them?';
|
||||
|
||||
// Absolute-URL validation matching RemoteMediaSourceEditViewModelValidator (non-empty absolute URI).
|
||||
function isValidAddress(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared connection editor for the api-key families (Jellyfin / Emby), mirroring
|
||||
// RemoteMediaSourceEditor. SECURE KEY AFFORDANCE (design §C3 / finding 1): the stored api key NEVER
|
||||
// crosses the wire — GET returns only { address, hasApiKey }. When a key is already set the field
|
||||
// shows "leave blank to keep" and is optional (blank retains the existing key server-side); on first
|
||||
// connect the key is required. The stored key is never displayed.
|
||||
export function RemoteConnectionEditScreen({ family }: { family: RemoteFamilyWithConnection }) {
|
||||
const meta = familyMeta(family);
|
||||
const [loaded, setLoaded] = useState<LoadedState | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>({ address: '', apiKey: '' });
|
||||
const [saved, setSaved] = useState<Draft>({ address: '', apiKey: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getRemoteConnection(family)
|
||||
.then((connection) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
const initial: Draft = { address: connection.address ?? '', apiKey: '' };
|
||||
setDraft(initial);
|
||||
setSaved(initial);
|
||||
setLoaded({ hasApiKey: connection.hasApiKey });
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setLoadError(messageFromMediaSourcesError(error, `Unable to load ${meta.label} connection`));
|
||||
}
|
||||
});
|
||||
}, [family, meta.label]);
|
||||
|
||||
const hasApiKey = loaded?.hasApiKey ?? false;
|
||||
const addressValid = isValidAddress(draft.address);
|
||||
// First connect (no stored key) requires a key; when a key is already set, blank means "retain".
|
||||
const apiKeyValid = hasApiKey || draft.apiKey.trim().length > 0;
|
||||
const valid = addressValid && apiKeyValid;
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(saved);
|
||||
|
||||
useDirtyGuard(dirty, DIRTY_PROMPT);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{loadError}</span>
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} size="sm" variant="secondary">Back to {meta.label}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loaded === null) {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<Spinner size={16} tone="muted" />
|
||||
<span>Loading {meta.label} connection…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const discard = () => {
|
||||
if (!dirty || window.confirm(DIRTY_PROMPT)) {
|
||||
setDraft(saved);
|
||||
setSaveError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!valid || !dirty || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
saveRemoteConnection(family, { address: draft.address.trim(), apiKey: draft.apiKey })
|
||||
.then((connection) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// Reset the baseline from the reloaded connection; the key is intentionally not returned.
|
||||
const next: Draft = { address: connection.address ?? '', apiKey: '' };
|
||||
setDraft(next);
|
||||
setSaved(next);
|
||||
setLoaded({ hasApiKey: connection.hasApiKey });
|
||||
setSaving(false);
|
||||
navigateToPath(familyRoute(family));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// Draft retained on 422 / network / 409 so the user's input is not lost.
|
||||
setSaving(false);
|
||||
setSaveError(messageFromMediaSourcesError(error, `Unable to save ${meta.label} connection`));
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">Media source</span>
|
||||
<h2>{hasApiKey ? `Edit ${meta.label} connection` : `Connect ${meta.label}`}</h2>
|
||||
<p>Point ErsatzTV at your {meta.label} server and authenticate with an API key.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">Back to {meta.label}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card title={<h3>Connection</h3>} subtitle={`${meta.label} server address and API key.`}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, maxWidth: 480 }}>
|
||||
<Input
|
||||
label="Address"
|
||||
value={draft.address}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, address: event.target.value }))}
|
||||
placeholder="http://192.168.1.50:8096"
|
||||
error={draft.address.trim() && !addressValid ? 'Enter a valid absolute http(s) URL' : null}
|
||||
/>
|
||||
<Input
|
||||
label="API key"
|
||||
type="password"
|
||||
value={draft.apiKey}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, apiKey: event.target.value }))}
|
||||
placeholder={hasApiKey ? 'Key is set — leave blank to keep, or enter a new key' : 'Enter an API key'}
|
||||
error={!apiKeyValid && draft.apiKey.length === 0 && !hasApiKey ? 'API key is required' : null}
|
||||
/>
|
||||
{hasApiKey && (
|
||||
<span className="ctv-settings-row-help">Key is set — leave blank to keep the existing key, or enter a new key to replace it.</span>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{saveError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{saveError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ctv-schedule-toolbar">
|
||||
<div className="ctv-schedule-toolbar-spacer" />
|
||||
<Button disabled={!dirty || saving} onClick={discard} variant="ghost">Discard</Button>
|
||||
<Button disabled={!valid || !dirty || saving} loading={saving} onClick={save} startIcon={<Check aria-hidden="true" size={14} />}>
|
||||
{hasApiKey ? 'Save connection' : 'Connect'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoteLibrariesEditScreen } from './RemoteLibrariesEditScreen';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
interface Library {
|
||||
id: number;
|
||||
name: string;
|
||||
mediaKind: string;
|
||||
shouldSyncItems: boolean;
|
||||
}
|
||||
|
||||
interface Options {
|
||||
libraries: Library[];
|
||||
saveStatus?: number;
|
||||
saveBody?: Library[];
|
||||
}
|
||||
|
||||
function installFetch(family: string, id: number, options: Options) {
|
||||
const putBodies: string[] = [];
|
||||
const spy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const base = `/api/media-sources/${family}/${id}/libraries`;
|
||||
|
||||
if (url.endsWith(base) && method === 'GET') {
|
||||
return Promise.resolve(json(options.libraries));
|
||||
}
|
||||
if (url.endsWith(base) && method === 'PUT') {
|
||||
putBodies.push(String(init?.body ?? ''));
|
||||
const status = options.saveStatus ?? 200;
|
||||
if (status >= 400) {
|
||||
return Promise.resolve(json({ title: 'Save failed', detail: 'Nope' }, status));
|
||||
}
|
||||
return Promise.resolve(json(options.saveBody ?? options.libraries));
|
||||
}
|
||||
return Promise.resolve(json({}, 404));
|
||||
});
|
||||
return { spy, putBodies };
|
||||
}
|
||||
|
||||
function bodyRowNames(): string[] {
|
||||
const rows = screen.getAllByRole('row').slice(1); // skip header
|
||||
return rows.map((row) => within(row).getAllByRole('cell')[0]?.textContent ?? '');
|
||||
}
|
||||
|
||||
describe('RemoteLibrariesEditScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/app/libraries/plex/7/sync');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('sorts by Name and toggles direction on repeat click', async () => {
|
||||
installFetch('plex', 7, {
|
||||
libraries: [
|
||||
{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: true },
|
||||
{ id: 2, name: 'Anime', mediaKind: 'Shows', shouldSyncItems: false },
|
||||
{ id: 3, name: 'Zed', mediaKind: 'Shows', shouldSyncItems: false }
|
||||
]
|
||||
});
|
||||
|
||||
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
|
||||
await screen.findByRole('switch', { name: /Sync Movies/i });
|
||||
|
||||
// Default ascending by name.
|
||||
expect(bodyRowNames()).toEqual(['Anime', 'Movies', 'Zed']);
|
||||
|
||||
// Click Name → toggles to descending.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Name/i }));
|
||||
expect(bodyRowNames()).toEqual(['Zed', 'Movies', 'Anime']);
|
||||
});
|
||||
|
||||
it('sorts by Media Kind', async () => {
|
||||
installFetch('plex', 7, {
|
||||
libraries: [
|
||||
{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: true },
|
||||
{ id: 2, name: 'Anime', mediaKind: 'Shows', shouldSyncItems: false }
|
||||
]
|
||||
});
|
||||
|
||||
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
|
||||
await screen.findByRole('switch', { name: /Sync Movies/i });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Media Kind/i }));
|
||||
// Movies < Shows ascending, so Movies row first.
|
||||
expect(bodyRowNames()).toEqual(['Movies', 'Anime']);
|
||||
});
|
||||
|
||||
it('toggling a sync switch enables Save, and Save PUTs the preferences and refetches', async () => {
|
||||
const { putBodies } = installFetch('plex', 7, {
|
||||
libraries: [{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: false }],
|
||||
// Reloaded list: id CHANGES on a toggle (design §C4a) — the SPA keys by name/mediaKind, so it copes.
|
||||
saveBody: [{ id: 99, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: true }]
|
||||
});
|
||||
|
||||
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
|
||||
await screen.findByRole('switch', { name: /Sync Movies/i });
|
||||
|
||||
const save = () => screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement;
|
||||
expect(save().disabled).toBe(true); // clean
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Sync Movies/i }));
|
||||
expect(save().disabled).toBe(false); // dirty
|
||||
|
||||
fireEvent.click(save());
|
||||
await vi.waitFor(() => expect(putBodies.length).toBe(1));
|
||||
const body = JSON.parse(putBodies[0]);
|
||||
expect(body.libraries).toEqual([{ id: 1, shouldSyncItems: true }]);
|
||||
|
||||
// After the refetch the new baseline is clean again despite the id change.
|
||||
await vi.waitFor(() => expect(save().disabled).toBe(true));
|
||||
});
|
||||
|
||||
it('retains the draft on a 422 save failure', async () => {
|
||||
const { putBodies } = installFetch('plex', 7, {
|
||||
libraries: [{ id: 1, name: 'Movies', mediaKind: 'Movies', shouldSyncItems: false }],
|
||||
saveStatus: 422
|
||||
});
|
||||
|
||||
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
|
||||
await screen.findByRole('switch', { name: /Sync Movies/i });
|
||||
|
||||
fireEvent.click(screen.getByRole('switch', { name: /Sync Movies/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await vi.waitFor(() => expect(putBodies.length).toBe(1));
|
||||
// Wait for the failure to settle (saving clears in the catch).
|
||||
expect(await screen.findByText(/Nope/i)).toBeTruthy();
|
||||
// Still dirty (draft retained) and the switch stays on.
|
||||
expect((screen.getByRole('switch', { name: /Sync Movies/i })).getAttribute('aria-checked')).toBe('true');
|
||||
expect((screen.getByRole('button', { name: 'Save' }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('renders a not-found state on a 404', async () => {
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(json({ title: 'Not found' }, 404));
|
||||
|
||||
render(<RemoteLibrariesEditScreen family="plex" sourceId={7} />);
|
||||
|
||||
expect(await screen.findByText(/no longer exists/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, ArrowUpDown, Check, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, Spinner, Switch } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
getRemoteLibraries,
|
||||
messageFromMediaSourcesError,
|
||||
replaceRemoteLibraryPreferences,
|
||||
type RemoteFamily,
|
||||
type RemoteLibrary
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { familyMeta, familyRoute } from '../mediaSources/familyMeta';
|
||||
import { useDirtyGuard } from '../mediaSources/useDirtyGuard';
|
||||
|
||||
const DIRTY_PROMPT = 'You have unsaved library changes. Discard them?';
|
||||
|
||||
type SortColumn = 'name' | 'mediaKind';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function formatMediaKind(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'MusicVideos':
|
||||
return 'Music Videos';
|
||||
case 'OtherVideos':
|
||||
return 'Other Videos';
|
||||
case 'RemoteStreams':
|
||||
return 'Remote Streams';
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
// A draft is keyed by (name, mediaKind), NOT id (design §C4a): disabling a library removes and
|
||||
// re-adds its row with a fresh id, so the id is not stable across a save. We compare/reset by the
|
||||
// stable (name, mediaKind, shouldSyncItems) tuple and refetch the reloaded list after every save.
|
||||
function draftKey(library: Pick<RemoteLibrary, 'name' | 'mediaKind'>): string {
|
||||
return `${library.name ?? ''}\u0000${library.mediaKind}`;
|
||||
}
|
||||
|
||||
function toComparable(libraries: RemoteLibrary[]): string {
|
||||
return JSON.stringify(
|
||||
[...libraries]
|
||||
.map((library) => ({ key: draftKey(library), shouldSyncItems: library.shouldSyncItems }))
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadedState {
|
||||
libraries: RemoteLibrary[];
|
||||
}
|
||||
|
||||
// Shared library-preferences editor for Plex / Jellyfin / Emby (RemoteMediaSourceLibrariesEditor).
|
||||
// Client-side sortable Name + MediaKind columns (finding 10) + a per-library sync Switch, saved as
|
||||
// one PUT. The PUT returns the reloaded list (ids may have changed) which becomes the new baseline.
|
||||
export function RemoteLibrariesEditScreen({ family, sourceId }: { family: RemoteFamily; sourceId: number }) {
|
||||
const meta = familyMeta(family);
|
||||
const [loaded, setLoaded] = useState<LoadedState | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
const [draft, setDraft] = useState<RemoteLibrary[]>([]);
|
||||
const [saved, setSaved] = useState<RemoteLibrary[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn>('name');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ingest = useCallback((libraries: RemoteLibrary[]) => {
|
||||
setDraft(libraries.map((library) => ({ ...library })));
|
||||
setSaved(libraries.map((library) => ({ ...library })));
|
||||
setLoaded({ libraries });
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getRemoteLibraries(family, sourceId)
|
||||
.then((libraries) => {
|
||||
if (activeRef.current) {
|
||||
ingest(libraries);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
if (error instanceof ApiError && error.status === 404) {
|
||||
setNotFound(true);
|
||||
} else {
|
||||
setLoadError(messageFromMediaSourcesError(error, `Unable to load ${meta.label} libraries`));
|
||||
}
|
||||
});
|
||||
}, [family, sourceId, meta.label, ingest]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const dirty = toComparable(draft) !== toComparable(saved);
|
||||
useDirtyGuard(dirty, DIRTY_PROMPT);
|
||||
|
||||
if (notFound) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>That {meta.label} server no longer exists.</span>
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} size="sm" variant="secondary">Back to {meta.label}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="ctv-settings-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={16} />
|
||||
<span>{loadError}</span>
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} size="sm" variant="secondary">Back to {meta.label}</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loaded === null) {
|
||||
return (
|
||||
<div className="ctv-settings-loading">
|
||||
<Spinner size={16} tone="muted" />
|
||||
<span>Loading {meta.label} libraries…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDir((dir) => (dir === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortColumn(column);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sorted = [...draft].sort((a, b) => {
|
||||
const left = sortColumn === 'name' ? a.name ?? '' : a.mediaKind;
|
||||
const right = sortColumn === 'name' ? b.name ?? '' : b.mediaKind;
|
||||
const cmp = left.localeCompare(right);
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
const setSync = (key: string, shouldSyncItems: boolean) => {
|
||||
if (saving) {
|
||||
return;
|
||||
}
|
||||
setDraft((current) => current.map((library) => (draftKey(library) === key ? { ...library, shouldSyncItems } : library)));
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
if (!dirty || window.confirm(DIRTY_PROMPT)) {
|
||||
setDraft(saved.map((library) => ({ ...library })));
|
||||
setSaveError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
if (!dirty || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
replaceRemoteLibraryPreferences(family, sourceId, {
|
||||
libraries: draft.map((library) => ({ id: library.id, shouldSyncItems: library.shouldSyncItems }))
|
||||
})
|
||||
.then((libraries) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// Refetch semantics: the endpoint returns the reloaded list (ids change on disable), so the
|
||||
// response — not the in-memory draft — becomes the new baseline.
|
||||
ingest(libraries);
|
||||
setSaving(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setSaving(false);
|
||||
setSaveError(messageFromMediaSourcesError(error, `Unable to save ${meta.label} libraries`));
|
||||
// On a 409 the resource moved under us; refetch so the draft catches up (design §C6).
|
||||
if (error instanceof ApiError && error.status === 409) {
|
||||
load();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">{meta.label} libraries</span>
|
||||
<h2>Edit libraries</h2>
|
||||
<p>Choose which {meta.label} libraries ErsatzTV syncs.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<Button onClick={() => navigateToPath(familyRoute(family))} startIcon={<ArrowLeft aria-hidden="true" size={14} />} variant="ghost">Back to {meta.label}</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{saveError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{saveError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card padded={false}>
|
||||
<div className="ctv-channels-table-frame">
|
||||
<div className="ctv-channels-table-scroll">
|
||||
<table className="ctv-channels-table" aria-label={`${meta.label} libraries`}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th aria-sort={sortColumn === 'name' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
||||
<button className="ctv-logs-sort-button" onClick={() => toggleSort('name')} type="button">
|
||||
Name
|
||||
{sortColumn === 'name' && <ArrowUpDown aria-hidden="true" size={13} />}
|
||||
</button>
|
||||
</th>
|
||||
<th aria-sort={sortColumn === 'mediaKind' ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'}>
|
||||
<button className="ctv-logs-sort-button" onClick={() => toggleSort('mediaKind')} type="button">
|
||||
Media Kind
|
||||
{sortColumn === 'mediaKind' && <ArrowUpDown aria-hidden="true" size={13} />}
|
||||
</button>
|
||||
</th>
|
||||
<th>Sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.length === 0 ? (
|
||||
<tr><td colSpan={3}><div className="ctv-schedule-empty">No libraries discovered</div></td></tr>
|
||||
) : sorted.map((library) => {
|
||||
const key = draftKey(library);
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td>{library.name}</td>
|
||||
<td>{formatMediaKind(library.mediaKind)}</td>
|
||||
<td>
|
||||
<Switch checked={library.shouldSyncItems} disabled={saving} onChange={(next) => setSync(key, next)} label={`Sync ${library.name ?? ''}`} size="sm" />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="ctv-schedule-toolbar">
|
||||
<div className="ctv-schedule-toolbar-spacer" />
|
||||
<Button disabled={!dirty || saving} onClick={discard} variant="ghost">Discard</Button>
|
||||
<Button disabled={!dirty || saving} loading={saving} onClick={save} startIcon={<Check aria-hidden="true" size={14} />}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { RemoteSourceScreen } from './RemoteSourceScreen';
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status });
|
||||
}
|
||||
|
||||
interface RemoteState {
|
||||
isAuthorized: boolean;
|
||||
isLocked: boolean;
|
||||
servers: Array<{ id: number; name: string; address: string }>;
|
||||
}
|
||||
|
||||
function installFetch(family: string, stateRef: { current: RemoteState }) {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
|
||||
if (url.endsWith(`/api/media-sources/${family}/disconnect`) && method === 'POST') {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (url.includes('/refresh-libraries') && method === 'POST') {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
if (url.endsWith(`/api/media-sources/${family}`) && method === 'GET') {
|
||||
return Promise.resolve(json(stateRef.current));
|
||||
}
|
||||
return Promise.resolve(json({}, 404));
|
||||
});
|
||||
}
|
||||
|
||||
describe('RemoteSourceScreen', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/app/libraries/jellyfin');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('shows a Connect action when no server is connected', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as RemoteState };
|
||||
installFetch('jellyfin', stateRef);
|
||||
|
||||
render(<RemoteSourceScreen family="jellyfin" />);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Connect' })).toBeTruthy();
|
||||
expect(screen.getByText(/Connect a Jellyfin server to sync/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lists servers with Refresh disabled while the family lock is held', async () => {
|
||||
const stateRef = { current: { isAuthorized: true, isLocked: true, servers: [{ id: 3, name: 'Media Box', address: 'http://jf:8096' }] } as RemoteState };
|
||||
installFetch('jellyfin', stateRef);
|
||||
|
||||
render(<RemoteSourceScreen family="jellyfin" />);
|
||||
|
||||
await screen.findByRole('heading', { name: 'Servers' });
|
||||
expect(screen.getByText('Media Box')).toBeTruthy();
|
||||
const refresh = screen.getByRole('button', { name: /Refresh Media Box/i }) as HTMLButtonElement;
|
||||
expect(refresh.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disconnect shows the content-removal warning and posts on confirm', async () => {
|
||||
const stateRef = { current: { isAuthorized: true, isLocked: false, servers: [{ id: 3, name: 'Media Box', address: 'http://jf:8096' }] } as RemoteState };
|
||||
const fetchSpy = installFetch('jellyfin', stateRef);
|
||||
|
||||
render(<RemoteSourceScreen family="jellyfin" />);
|
||||
await screen.findByRole('heading', { name: 'Servers' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Disconnect' }));
|
||||
expect(await screen.findByText(/removes all synced Jellyfin libraries/i)).toBeTruthy();
|
||||
|
||||
// The header button and the dialog confirm share the label; the dialog's is the last one.
|
||||
const disconnects = screen.getAllByRole('button', { name: 'Disconnect' });
|
||||
fireEvent.click(disconnects[disconnects.length - 1]);
|
||||
await vi.waitFor(() =>
|
||||
expect(fetchSpy.mock.calls.some(([input, init]) => String(input).endsWith('/disconnect') && (init?.method ?? '').toUpperCase() === 'POST')).toBe(true)
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the family label for Emby', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as RemoteState };
|
||||
installFetch('emby', stateRef);
|
||||
|
||||
render(<RemoteSourceScreen family="emby" />);
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Emby' })).toBeTruthy();
|
||||
expect(screen.getByText(/Connect a Emby server to sync/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plug, RefreshCw, Server, Unplug, TriangleAlert } from 'lucide-react';
|
||||
import { Button, Card, ConfirmDialog, IconButton, Spinner, StatusDot } from '../components';
|
||||
import {
|
||||
disconnectRemote,
|
||||
getRemoteState,
|
||||
messageFromMediaSourcesError,
|
||||
refreshRemoteLibraries,
|
||||
type RemoteMediaSourceState
|
||||
} from '../api';
|
||||
import { navigateToPath } from '../routing';
|
||||
import {
|
||||
familyMeta,
|
||||
pathReplacementsRoute,
|
||||
remoteConnectionRoute,
|
||||
remoteLibrariesRoute
|
||||
} from '../mediaSources/familyMeta';
|
||||
|
||||
type RemoteFamilyWithConnection = 'jellyfin' | 'emby';
|
||||
|
||||
type BootState =
|
||||
| { status: 'loading' }
|
||||
| { status: 'error'; error: string }
|
||||
| { status: 'ready'; state: RemoteMediaSourceState };
|
||||
|
||||
// Shared source screen for the api-key families (Jellyfin / Emby), mirroring
|
||||
// {Jellyfin,Emby}MediaSources.razor. Connect / edit-connection navigate to the shared connection
|
||||
// editor; Disconnect purges all synced content (confirm dialog). Server rows offer Refresh (disabled
|
||||
// while the family lock is held) / Edit Libraries / Edit Path Replacements.
|
||||
export function RemoteSourceScreen({ family }: { family: RemoteFamilyWithConnection }) {
|
||||
const meta = familyMeta(family);
|
||||
const [boot, setBoot] = useState<BootState>({ status: 'loading' });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
const [disconnecting, setDisconnecting] = useState(false);
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
||||
const activeRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => {
|
||||
getRemoteState(family)
|
||||
.then((state) => {
|
||||
if (activeRef.current) {
|
||||
setBoot({ status: 'ready', state });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (activeRef.current) {
|
||||
setBoot({ status: 'error', error: messageFromMediaSourcesError(error, `Unable to load ${meta.label}`) });
|
||||
}
|
||||
});
|
||||
}, [family, meta.label]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const doDisconnect = () => {
|
||||
setDisconnecting(true);
|
||||
setActionError(null);
|
||||
disconnectRemote(family)
|
||||
.then(() => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setConfirmDisconnect(false);
|
||||
setDisconnecting(false);
|
||||
load();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setDisconnecting(false);
|
||||
setActionError(messageFromMediaSourcesError(error, `Unable to disconnect ${meta.label}`));
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = (serverId: number) => {
|
||||
setRefreshingId(serverId);
|
||||
setActionError(null);
|
||||
refreshRemoteLibraries(family, serverId)
|
||||
.then(() => {
|
||||
if (activeRef.current) {
|
||||
setRefreshingId(null);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setRefreshingId(null);
|
||||
setActionError(messageFromMediaSourcesError(error, `Unable to refresh ${meta.label} libraries`));
|
||||
load();
|
||||
});
|
||||
};
|
||||
|
||||
if (boot.status === 'loading') {
|
||||
return (
|
||||
<Card title={<h2>Loading {meta.label}</h2>} subtitle={`Fetching ${meta.label} connection state.`}>
|
||||
<div className="ctv-dashboard-state">
|
||||
<Spinner tone="muted" />
|
||||
<span>Loading {meta.label}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (boot.status === 'error') {
|
||||
return (
|
||||
<Card
|
||||
title={<h2>{meta.label} unavailable</h2>}
|
||||
subtitle={`The API returned an error while loading ${meta.label}.`}
|
||||
actions={
|
||||
<Button onClick={() => { setBoot({ status: 'loading' }); load(); }} startIcon={<RefreshCw aria-hidden="true" size={15} />} variant="secondary">
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{boot.error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const { isLocked, servers } = boot.state;
|
||||
const serverList = servers ?? [];
|
||||
const connected = serverList.length > 0;
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
<section className="ctv-libraries-header">
|
||||
<div>
|
||||
<span className="ctv-kicker">Media source</span>
|
||||
<h2>{meta.label}</h2>
|
||||
<p>Connect a {meta.label} server and choose which libraries to sync.</p>
|
||||
</div>
|
||||
<div className="ctv-libraries-header-actions">
|
||||
<Button onClick={() => navigateToPath('/app/libraries')} variant="ghost">Back to libraries</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{actionError && (
|
||||
<div className="ctv-channels-error" role="alert">
|
||||
<TriangleAlert aria-hidden="true" size={15} />
|
||||
<span>{actionError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card
|
||||
title={<h3>Connection</h3>}
|
||||
subtitle={connected ? `Connected to ${meta.label}.` : `No ${meta.label} server connected.`}
|
||||
actions={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button onClick={() => navigateToPath(remoteConnectionRoute(family))} startIcon={<Plug aria-hidden="true" size={14} />} variant={connected ? 'secondary' : 'primary'}>
|
||||
{connected ? 'Edit connection' : 'Connect'}
|
||||
</Button>
|
||||
{connected && (
|
||||
<Button disabled={disconnecting} onClick={() => setConfirmDisconnect(true)} startIcon={<Unplug aria-hidden="true" size={14} />} variant="secondary">
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!connected && <div className="ctv-schedule-empty">Connect a {meta.label} server to sync libraries.</div>}
|
||||
</Card>
|
||||
|
||||
{connected && (
|
||||
<Card title={<h3>Servers</h3>} subtitle={`${serverList.length} server${serverList.length === 1 ? '' : 's'}.`} padded={false}>
|
||||
<div className="ctv-library-list" role="list" aria-label={`${meta.label} servers`}>
|
||||
{serverList.map((server) => (
|
||||
<div className="ctv-library-row" role="listitem" key={server.id}>
|
||||
<span className="ctv-library-media-icon"><Server aria-hidden="true" size={14} /></span>
|
||||
<div className="ctv-library-row-main">
|
||||
<strong>{server.name}</strong>
|
||||
<span>{server.address ?? 'Unknown address'}</span>
|
||||
</div>
|
||||
<div className="ctv-library-row-status">
|
||||
<StatusDot status={isLocked ? 'live' : 'ok'} label={isLocked ? 'Busy' : 'Ready'} size={7} />
|
||||
<IconButton disabled={isLocked || refreshingId === server.id} onClick={() => refresh(server.id)} size="sm" title={`Refresh ${server.name}`}>
|
||||
<RefreshCw aria-hidden="true" size={15} />
|
||||
</IconButton>
|
||||
<Button onClick={() => navigateToPath(remoteLibrariesRoute(family, server.id))} size="sm" variant="ghost">Edit Libraries</Button>
|
||||
<Button onClick={() => navigateToPath(pathReplacementsRoute(family, server.id))} size="sm" variant="ghost">Edit Path Replacements</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDisconnect}
|
||||
tone="danger"
|
||||
title={`Disconnect ${meta.label}`}
|
||||
message={`Disconnecting removes all synced ${meta.label} libraries and content from ErsatzTV. This cannot be undone. Continue?`}
|
||||
confirmLabel="Disconnect"
|
||||
busy={disconnecting}
|
||||
onConfirm={doDisconnect}
|
||||
onCancel={() => setConfirmDisconnect(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user