Adds LocalLibrariesController (L1-L7: list/get/create/update/delete/move-path/ path-exists) wrapping the existing local-library MediatR commands, mapping to the shared S0 response DTOs. Per design #202 §A.1/§C5/§C6: - 404 for L4/L5/L6 comes from a controller pre-check (GetLocalLibraryById is None), not the handler -- .Apply/.ToEitherAsync both .Join() a NotFoundError into a plain 422, so relying on the handler would be dead code. This is check-then-act; a delete racing the pre-check falls through to the handler's 422, documented in the controller. - L4/L5 409 via IEntityLocker.IsLibraryLocked(id); L6 resolves the source library from the path id (new ILibraryRepository.GetLibraryIdForPath) before its own lock check. - MoveLocalLibraryPathHandler gains same-MediaKind and different-library validation (finding 3) -- Blazor only filtered these client-side in the move dialog, so an API/MCP client could bypass them. - CreateLocalLibraryHandler/UpdateLocalLibraryHandler gain a shared NewPathsMustExist validation (LocalLibraryHandlerBase) that Directory.Exists- checks only new paths (Id < 1); existing rows stay exempt so an unmounted share doesn't block a rename. L7 (path-exists) is a controller-local IFileSystem check with no command. Tests: controller route/mediator-arg tests incl. 404-pre-check vs fall-through-422 and 409-lock cases; handler tests for the move-path cross-kind/same-library 422s, new-path 422 (missing/mixed), and a lossless round-trip proving local paths are identified by normalized path string, not id. Full solution test suite (Scanner/Core/Architecture/Tests/Infrastructure) green, 0 regressions. Deviations: none from the S1 slice description. Did not touch MediaSourceRepository.cs or any Plex/Jellyfin/Emby file (S2/S3 scope). Did not run the OpenAPI regen scripts (separate gate after S1-S3 merge per design §E).
86 lines
3.4 KiB
C#
86 lines
3.4 KiB
C#
using System.IO.Abstractions;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ErsatzTV.Application.Libraries;
|
|
|
|
public abstract class LocalLibraryHandlerBase
|
|
{
|
|
protected static Task<Validation<BaseError, LocalLibrary>> NameMustBeValid(
|
|
ILocalLibraryRequest request,
|
|
LocalLibrary localLibrary) =>
|
|
request.NotEmpty(c => c.Name)
|
|
.Bind(_ => request.NotLongerThan(50)(c => c.Name))
|
|
.Map(_ => localLibrary).AsTask();
|
|
|
|
/// <summary>
|
|
/// Validates that every NEW path (<c>Id < 1</c> — see design #202 §C2) exists on the
|
|
/// filesystem. Existing rows are exempt: an unmounted share must not block saving a rename,
|
|
/// matching Blazor's behavior of only checking existence when a path is added.
|
|
/// </summary>
|
|
protected static Task<Validation<BaseError, LocalLibrary>> NewPathsMustExist(
|
|
IFileSystem fileSystem,
|
|
LocalLibrary localLibrary)
|
|
{
|
|
List<string> missing = localLibrary.Paths
|
|
.Filter(p => p.Id < 1)
|
|
.Filter(p => !fileSystem.Directory.Exists(p.Path))
|
|
.Map(p => p.Path)
|
|
.ToList();
|
|
|
|
Validation<BaseError, LocalLibrary> result = missing.Count == 0
|
|
? Success<BaseError, LocalLibrary>(localLibrary)
|
|
: Fail<BaseError, LocalLibrary>($"Path(s) do not exist on the filesystem: {string.Join(", ", missing)}");
|
|
|
|
return result.AsTask();
|
|
}
|
|
|
|
protected static async Task<Validation<BaseError, LocalLibrary>> PathsMustBeValid(
|
|
TvContext dbContext,
|
|
LocalLibrary localLibrary,
|
|
int? existingLibraryId = null)
|
|
{
|
|
List<LocalPath> allPaths = await dbContext.LocalLibraries
|
|
.Include(ll => ll.Paths)
|
|
.Filter(ll => existingLibraryId == null || ll.Id != existingLibraryId)
|
|
.ToListAsync()
|
|
.Map(list => list.SelectMany(ll => ll.Paths.Map(lp => new LocalPath(ll.MediaKind, lp.Path))).ToList());
|
|
|
|
var localPaths = localLibrary.Paths.Map(lp => new LocalPath(localLibrary.MediaKind, lp.Path)).ToList();
|
|
|
|
return Optional(localPaths.Count(folder => allPaths.Any(f => AreSubPaths(f, folder))))
|
|
.Where(length => length == 0)
|
|
.Map(_ => localLibrary)
|
|
.ToValidation<BaseError>("Path must not belong to another library path");
|
|
}
|
|
|
|
private static bool AreSubPaths(LocalPath path1, LocalPath path2)
|
|
{
|
|
string one = path1.Path + Path.DirectorySeparatorChar;
|
|
string two = path2.Path + Path.DirectorySeparatorChar;
|
|
|
|
bool isConflict = one == two || one.StartsWith(two, StringComparison.OrdinalIgnoreCase) ||
|
|
two.StartsWith(one, StringComparison.OrdinalIgnoreCase);
|
|
|
|
// Images and OtherVideos do not conflict
|
|
if (isConflict)
|
|
{
|
|
bool imagesAndOtherVideos = path1.MediaKind is LibraryMediaKind.Images &&
|
|
path2.MediaKind is LibraryMediaKind.OtherVideos
|
|
|| path2.MediaKind is LibraryMediaKind.Images &&
|
|
path1.MediaKind is LibraryMediaKind.OtherVideos;
|
|
|
|
if (imagesAndOtherVideos)
|
|
{
|
|
isConflict = false;
|
|
}
|
|
}
|
|
|
|
return isConflict;
|
|
}
|
|
|
|
protected record LocalPath(LibraryMediaKind MediaKind, string Path);
|
|
}
|