Fans the frozen ETag/If-Match/412 recipe (Block reference, #253) onto the Playlist aggregate: - ReplacePlaylistItems command carries ExpectedVersion; the handler runs CheckVersion as a standalone Either after validation (so a stale write survives as 412, not flattened to 422 by Apply/Join), bumps Version unconditionally before saving, and persists via SaveChangesWithConcurrencyGuard (EF concurrency-token backstop). - PlaylistViewModel carries Version; the items GET sets a strong ETag and the PUT parses If-Match, threads it into the command, and returns the refreshed ETag on success (400 on a malformed If-Match). - Sibling item-adding handlers (AddItemsToPlaylist, AddMovie/Episode/ Season/ShowToPlaylist) bump Version too, since they mutate the same editor-visible item list. - SPA: playlists.ts exposes getPlaylistItemsWithMeta and an If-Match-aware updatePlaylist; PlaylistEditor holds the ETag in a ref, round-trips it on save, and opens a "changed elsewhere" ConfirmDialog on 412 (mirrors BlockEditor). Tests: new ReplacePlaylistItemsHandlerConcurrencyTests (stale/match/ force-write/no-op-bump/racing-save), new PlaylistController tests (ETag on GET items, 400/412/thread-version/force-write on PUT), and a vitest 412-conflict-dialog test for PlaylistsScreen. dotnet test: 1304/1304 green. web: npm run typecheck clean, npm run build clean, vitest 664/664 green. Ref #253 PR2.
87 lines
3.0 KiB
C#
87 lines
3.0 KiB
C#
#nullable enable
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Core.Domain;
|
|
using LanguageExt;
|
|
|
|
namespace ErsatzTV.Controllers.Api.Requests;
|
|
|
|
public record PlaylistItemRequest(
|
|
int Index,
|
|
CollectionType CollectionType,
|
|
int? CollectionId,
|
|
int? MultiCollectionId,
|
|
int? SmartCollectionId,
|
|
int? MediaItemId,
|
|
PlaybackOrder PlaybackOrder,
|
|
int? Count,
|
|
bool PlayAll,
|
|
bool IncludeInProgramGuide)
|
|
{
|
|
public ReplacePlaylistItem ToReplaceCommand(int index) =>
|
|
new(
|
|
index,
|
|
CollectionType,
|
|
CollectionId,
|
|
MultiCollectionId,
|
|
SmartCollectionId,
|
|
MediaItemId,
|
|
PlaybackOrder,
|
|
Count,
|
|
PlayAll,
|
|
IncludeInProgramGuide);
|
|
|
|
// Mirrors ReplacePlaylistItemsHandler.CollectionTypeMustBeValid: the id required for the
|
|
// item's collection type must be present. Kept in sync with that handler so the PUT and
|
|
// preview endpoints enforce the same rule (the handler operates on the command type in the
|
|
// Application layer and cannot reference this request DTO, hence the parallel logic).
|
|
public bool HasRequiredId() =>
|
|
CollectionType switch
|
|
{
|
|
CollectionType.Collection => CollectionId is not null,
|
|
CollectionType.MultiCollection => MultiCollectionId is not null,
|
|
CollectionType.SmartCollection => SmartCollectionId is not null,
|
|
CollectionType.TelevisionShow
|
|
or CollectionType.TelevisionSeason
|
|
or CollectionType.Artist
|
|
or CollectionType.Movie
|
|
or CollectionType.Episode
|
|
or CollectionType.MusicVideo
|
|
or CollectionType.OtherVideo
|
|
or CollectionType.Song
|
|
or CollectionType.Image => MediaItemId is not null,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
public record ReplacePlaylistRequest(string? Name, List<PlaylistItemRequest>? Items)
|
|
{
|
|
public ReplacePlaylistItems ToCommand(int id, Option<int> expectedVersion = default) =>
|
|
new(id, Name ?? string.Empty, BuildItems(), expectedVersion);
|
|
|
|
// Preview operates on the posted draft, so there is no persisted playlist id (0).
|
|
public ReplacePlaylistItems ToReplaceCommand() =>
|
|
new(0, Name ?? string.Empty, BuildItems());
|
|
|
|
// Array indexes of draft items missing the id required for their collection type.
|
|
public List<int> ItemsMissingRequiredId()
|
|
{
|
|
List<PlaylistItemRequest> items = Items ?? new List<PlaylistItemRequest>();
|
|
var invalid = new List<int>();
|
|
for (int i = 0; i < items.Count; i++)
|
|
{
|
|
if (!items[i].HasRequiredId())
|
|
{
|
|
invalid.Add(i);
|
|
}
|
|
}
|
|
|
|
return invalid;
|
|
}
|
|
|
|
private List<ReplacePlaylistItem> BuildItems() =>
|
|
(Items ?? new List<PlaylistItemRequest>())
|
|
.Select((item, index) => item.ToReplaceCommand(index))
|
|
.ToList();
|
|
} |