Files
ersatztv/ErsatzTV/Controllers/Api/Requests/ReplacePlaylistRequest.cs
T
timothy 1a8c0f60de feat(playlists): wire optimistic-concurrency contract onto Playlist (#253 PR2)
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.
2026-07-11 18:33:40 +02:00

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();
}