feat(media-sources): SPA management screens + write REST API (#202) #262

Merged
timothy merged 19 commits from feat/202-media-sources into main 2026-07-11 17:38:24 +02:00
86 changed files with 12568 additions and 861 deletions
@@ -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 &lt; 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
+31
View File
@@ -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
View File
@@ -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:
+86
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+88
View File
@@ -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"];
+1
View File
@@ -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';
+172
View File
@@ -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');
});
});
+138
View File
@@ -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;
}
+64
View File
@@ -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');
});
});
+92
View File
@@ -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}`;
}
+43
View File
@@ -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);
});
});
+29
View File
@@ -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);
}
+81
View File
@@ -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);
});
});
+90
View File
@@ -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');
}
+35
View File
@@ -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]);
}
+75
View File
@@ -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
}
+445
View File
@@ -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');
});
});
});
+379
View File
@@ -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();
});
});
+650
View File
@@ -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>
);
}
+173
View File
@@ -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)
);
});
});
+315
View File
@@ -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();
});
});
+214
View File
@@ -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>
);
}