Hardening from adversarial review of the #153 playlist API: - PUT /api/playlists/{id}: guard IsSystem in the controller after the existence pre-check -> 422, so a system (generated) playlist can no longer be renamed/wiped. ReplacePlaylistItems is never sent for it. - PUT /api/playlists/groups/{id}: add controller existence pre-check (404 for missing, mirroring DeleteGroup) plus an IsSystem 422 guard; RenamePlaylistGroupHandler also gains a system guard (defense-in-depth for the Blazor path). Missing/system are now distinct outcomes despite LanguageExtensions.Apply collapsing NotFoundError to a plain BaseError. - POST /api/playlists/preview: validate each draft item at the controller boundary (the id required for its collection type must be present) -> 422 before the shared PreviewPlaylistPlayoutHandler runs, preventing a NRE/500 in the playout builder. Logic lives in ReplacePlaylistRequest so it stays parallel with ReplacePlaylistItemsHandler's PUT-path check. Tests: controller cases for system-playlist PUT, system-group PUT, missing-group 404, and invalid-preview 422 (each asserting the handler is not invoked); handler tests for RenamePlaylistGroup system/missing/success. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
86 lines
2.9 KiB
C#
86 lines
2.9 KiB
C#
#nullable enable
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using ErsatzTV.Application.MediaCollections;
|
|
using ErsatzTV.Core.Domain;
|
|
|
|
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) =>
|
|
new(id, Name ?? string.Empty, BuildItems());
|
|
|
|
// 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();
|
|
} |