---
description: Implement playlist track reordering with move operations and position management (project)
---
# Add Playlist Reordering Skill
Implement track reordering within playlists using move operations with stable position management.
## Overview
Playlist reordering allows users to change the order of tracks via move operations. Each move specifies a source position and target position.
## Steps
### 1. Create Move Operation Model
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Models/Playlists/`
```csharp
namespace NovaTuneApp.ApiService.Models.Playlists;
///
/// Represents a single move operation in a reorder request.
///
/// Current position of the track (0-based)
/// Target position for the track (0-based)
public record MoveOperation(int From, int To);
///
/// Request to reorder tracks within a playlist.
///
/// List of move operations (applied sequentially)
public record ReorderRequest(IReadOnlyList Moves);
```
### 2. Add Validation
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Validators/`
```csharp
using FluentValidation;
namespace NovaTuneApp.ApiService.Validators;
public class ReorderRequestValidator : AbstractValidator
{
public ReorderRequestValidator(IOptions options)
{
RuleFor(x => x.Moves)
.NotEmpty()
.WithMessage("At least one move operation is required")
.Must(m => m.Count <= options.Value.MaxMovesPerReorderRequest)
.WithMessage($"Maximum {options.Value.MaxMovesPerReorderRequest} moves per request");
RuleForEach(x => x.Moves)
.ChildRules(move =>
{
move.RuleFor(m => m.From)
.GreaterThanOrEqualTo(0)
.WithMessage("From position must be non-negative");
move.RuleFor(m => m.To)
.GreaterThanOrEqualTo(0)
.WithMessage("To position must be non-negative");
});
}
}
```
### 3. Implement Reorder Logic in Service
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Services/PlaylistService.cs`
```csharp
public async Task ReorderTracksAsync(
string playlistId,
string userId,
ReorderRequest request,
CancellationToken ct = default)
{
var playlist = await _session.LoadAsync($"Playlists/{playlistId}", ct);
if (playlist is null)
throw new PlaylistNotFoundException(playlistId);
if (playlist.UserId != userId)
throw new PlaylistAccessDeniedException(playlistId);
if (playlist.Tracks.Count == 0)
throw new InvalidOperationException("Cannot reorder empty playlist");
// Validate all positions before applying any moves
foreach (var move in request.Moves)
{
if (move.From < 0 || move.From >= playlist.Tracks.Count)
throw new InvalidPositionException(move.From, playlist.Tracks.Count);
if (move.To < 0 || move.To >= playlist.Tracks.Count)
throw new InvalidPositionException(move.To, playlist.Tracks.Count);
}
// Sort tracks by position to work with a proper list
var tracks = playlist.Tracks.OrderBy(t => t.Position).ToList();
// Apply moves sequentially
foreach (var move in request.Moves)
{
if (move.From == move.To)
continue; // No-op move
var track = tracks[move.From];
tracks.RemoveAt(move.From);
tracks.Insert(move.To, track);
}
// Reassign positions to maintain contiguous 0-based indices
for (var i = 0; i < tracks.Count; i++)
{
tracks[i].Position = i;
}
playlist.Tracks = tracks;
playlist.UpdatedAt = DateTimeOffset.UtcNow;
await _session.SaveChangesAsync(ct);
_logger.LogInformation(
"Reordered {MoveCount} tracks in playlist {PlaylistId} for user {UserId}",
request.Moves.Count, playlistId, userId);
return await MapToDetailsAsync(playlist, ct);
}
```
### 4. Create Custom Exception
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Infrastructure/Exceptions/`
```csharp
namespace NovaTuneApp.ApiService.Infrastructure.Exceptions;
///
/// Thrown when a position is out of valid range.
///
public class InvalidPositionException : Exception
{
public int Position { get; }
public int MaxPosition { get; }
public InvalidPositionException(int position, int trackCount)
: base($"Position {position} is out of range. Valid range: 0 to {trackCount - 1}")
{
Position = position;
MaxPosition = trackCount - 1;
}
}
```
### 5. Add Endpoint
Location: `src/NovaTuneApp/NovaTuneApp.ApiService/Endpoints/PlaylistEndpoints.cs`
```csharp
group.MapPost("/{playlistId}/reorder", HandleReorderTracks)
.WithName("ReorderPlaylistTracks")
.WithSummary("Reorder tracks within a playlist")
.Produces(StatusCodes.Status200OK)
.ProducesProblem(StatusCodes.Status400BadRequest)
.ProducesProblem(StatusCodes.Status404NotFound)
.ProducesProblem(StatusCodes.Status409Conflict)
.RequireRateLimiting("playlist-reorder");
private static async Task HandleReorderTracks(
[FromRoute] string playlistId,
[FromBody] ReorderRequest request,
[FromServices] IPlaylistService playlistService,
[FromServices] IValidator validator,
ClaimsPrincipal user,
CancellationToken ct)
{
if (!Ulid.TryParse(playlistId, out _))
{
return Results.Problem(
title: "Invalid playlist ID",
detail: "Playlist ID must be a valid ULID.",
statusCode: StatusCodes.Status400BadRequest,
type: "https://novatune.dev/errors/invalid-playlist-id");
}
var validationResult = await validator.ValidateAsync(request, ct);
if (!validationResult.IsValid)
{
return Results.ValidationProblem(validationResult.ToDictionary());
}
var userId = user.FindFirstValue(ClaimTypes.NameIdentifier)!;
try
{
var playlist = await playlistService.ReorderTracksAsync(
playlistId, userId, request, ct);
return Results.Ok(playlist);
}
catch (PlaylistNotFoundException)
{
return Results.Problem(
title: "Playlist not found",
statusCode: StatusCodes.Status404NotFound,
type: "https://novatune.dev/errors/playlist-not-found");
}
catch (PlaylistAccessDeniedException)
{
return Results.Problem(
title: "Access denied",
statusCode: StatusCodes.Status403Forbidden,
type: "https://novatune.dev/errors/forbidden");
}
catch (InvalidPositionException ex)
{
return Results.Problem(
title: "Invalid position",
detail: ex.Message,
statusCode: StatusCodes.Status400BadRequest,
type: "https://novatune.dev/errors/invalid-position",
extensions: new Dictionary
{
["position"] = ex.Position,
["maxPosition"] = ex.MaxPosition
});
}
catch (ConcurrencyException)
{
return Results.Problem(
title: "Concurrent modification",
detail: "The playlist was modified by another request. Please retry.",
statusCode: StatusCodes.Status409Conflict,
type: "https://novatune.dev/errors/concurrency-conflict");
}
}
```
## Request/Response Examples
### Request
```json
POST /playlists/01HXK.../reorder
{
"moves": [
{ "from": 5, "to": 0 },
{ "from": 10, "to": 3 }
]
}
```
### Response (200 OK)
```json
{
"playlistId": "01HXK...",
"name": "My Playlist",
"trackCount": 20,
"tracks": {
"items": [
{ "position": 0, "trackId": "01HXL...", "title": "Moved Track" },
{ "position": 1, "trackId": "01HXM...", "title": "Second Track" }
],
"hasMore": true
}
}
```
### Error Response (400 Bad Request)
```json
{
"type": "https://novatune.dev/errors/invalid-position",
"title": "Invalid position",
"status": 400,
"detail": "Position 25 is out of range. Valid range: 0 to 19",
"position": 25,
"maxPosition": 19
}
```
## Move Semantics
Moves are applied **sequentially**, which means:
1. **Move A from 5 to 0**: Track at position 5 becomes position 0, others shift
2. **Move B from 10 to 3**: Applied to the **new** state after Move A
This allows complex reorderings with predictable results.
### Example: Moving track from end to beginning
Before: `[A, B, C, D, E]` (positions 0-4)
Move: `{ "from": 4, "to": 0 }`
After: `[E, A, B, C, D]` (positions 0-4)
### Example: Swapping two tracks
Before: `[A, B, C, D, E]`
Moves: `[{ "from": 0, "to": 4 }, { "from": 4, "to": 0 }]`
After Move 1: `[B, C, D, E, A]`
After Move 2: `[A, B, C, D, E]` (back to original - this is NOT a swap)
For a true swap, use: `[{ "from": 0, "to": 4 }, { "from": 3, "to": 0 }]`
## Alternative: Single Move Endpoint
For simpler UX, consider also exposing a single-move endpoint:
```csharp
group.MapPost("/{playlistId}/tracks/{position:int}/move", HandleMoveTrack)
.WithName("MovePlaylistTrack")
.WithSummary("Move a single track to a new position");
```
Request: `POST /playlists/01HXK.../tracks/5/move?to=0`
## Validation Rules
| Rule | Error |
|------|-------|
| Moves array not empty | 400 Bad Request |
| Max 50 moves per request | 400 Bad Request |
| From position in valid range | 400 Bad Request |
| To position in valid range | 400 Bad Request |
| Playlist exists | 404 Not Found |
| User owns playlist | 403 Forbidden |
## Performance Considerations
- **Embedded list**: Track entries are embedded in the playlist document, so reordering is atomic
- **Position reindexing**: O(n) operation where n = track count
- **Optimistic concurrency**: Use RavenDB etag to detect concurrent modifications
- **Max moves limit**: Prevents abuse and excessive computation
## Stage 6 Documentation
- **Reorder API**: `doc/implementation/stage-6/09-api-reorder-tracks.md`
- **Service Interface**: `doc/implementation/stage-6/10-service-interface.md`
- **Test Strategy**: `doc/implementation/stage-6/18-test-strategy.md`
## Related Skills
- **implement-playlists** - Full playlist implementation plan
- **add-playlist-tracks** - Track add/remove operations
- **add-api-endpoint** - Minimal API endpoint structure