Files
ersatztv/ErsatzTV.Application/FFmpegProfiles/Commands/UpdateFFmpegProfileHandler.cs
T
timothyandClaude Opus 4.8 216130b4d7 fix(#172): API hardening — null-name 500s, duplicate template items, unreachable 404
Clears the still-live findings from #172 (verified against main; #2/#4/#7 and the
auth/search/Trakt tail were already deliberate-documented or fixed since 2026-07-07).

- Null/empty Name → 500 (10 create/replace handlers). Block/Template/DecoTemplate/Deco
  Create+Replace/Update + UpdateFFmpegProfile did `request.Name.Length > 50` on a
  client-nullable string → unhandled NullReferenceException → HTTP 500 (no global
  exception filter). Now `string.IsNullOrWhiteSpace(request.Name) || .Length > 50` →
  422; also rejects empty/whitespace names, matching the group-create handlers'
  NotEmpty behavior. CreatePlaylist coalesces null→"" at the DTO so it was an
  empty-name persist, not a 500; guarded the same way.
- ReplaceTemplateItems overlap validation iterated with an `item == otherItem`
  record value-equality skip, so two exact-duplicate items were value-equal and
  bypassed the intersection check (both persisted). Now index-based (i != j) so
  duplicates register as a self-intersection and are rejected 422.
- Trimmed the unreachable 404 ProducesResponseType from POST /api/blocks/groups and
  POST /api/templates/groups (a create has no parent lookup that can 404); v1.json
  regenerated.
- Regression tests: all 10 name-guard paths + the duplicate-items path (19 cases).
- Docs: decisions.md entry + api-conventions.md §3b null-safe-validation bullet.

fixes #172

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 01:16:52 +02:00

169 lines
7.1 KiB
C#

using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.FFmpeg.Preset;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Extensions;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.FFmpegProfiles;
public class UpdateFFmpegProfileHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
: IRequestHandler<UpdateFFmpegProfile, Either<BaseError, UpdateFFmpegProfileResult>>
{
public async Task<Either<BaseError, UpdateFFmpegProfileResult>> Handle(
UpdateFFmpegProfile request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<FFmpegProfile> maybeProfile = await FFmpegProfileMustExist(dbContext, request, cancellationToken);
return await maybeProfile.Match(
Some: async profile =>
{
Option<int> maybeResolutionId = await ResolutionMustExist(dbContext, request, cancellationToken);
return await maybeResolutionId.Match(
Some: async _ =>
{
Validation<BaseError, FFmpegProfile> validation = await Validate(dbContext, request, profile);
return await validation.Apply(p => ApplyUpdateRequest(dbContext, p, request, cancellationToken));
},
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
new NotFoundError($"[Resolution] {request.ResolutionId} does not exist")));
},
None: () => Task.FromResult<Either<BaseError, UpdateFFmpegProfileResult>>(
new NotFoundError("FFmpegProfile does not exist.")));
}
private async Task<UpdateFFmpegProfileResult> ApplyUpdateRequest(
TvContext dbContext,
FFmpegProfile p,
UpdateFFmpegProfile update,
CancellationToken cancellationToken)
{
var hwAccel = update.NormalizeVideo
? update.HardwareAcceleration
: HardwareAccelerationKind.None;
p.Name = update.Name;
p.ThreadCount = update.ThreadCount;
p.NormalizeAudio = update.NormalizeAudio;
p.NormalizeVideo = update.NormalizeVideo;
p.HardwareAcceleration = hwAccel;
p.VaapiDisplay = update.VaapiDisplay;
p.VaapiDriver = update.VaapiDriver;
p.VaapiDevice = update.VaapiDevice;
p.QsvExtraHardwareFrames = update.QsvExtraHardwareFrames;
p.ResolutionId = update.ResolutionId;
p.ScalingBehavior = update.ScalingBehavior;
p.PadMode = update.PadMode;
p.VideoFormat = update.NormalizeVideo ? update.VideoFormat : FFmpegProfileVideoFormat.Copy;
p.VideoProfile = update.VideoProfile;
p.VideoPreset = update.VideoPreset;
p.AllowBFrames = update.AllowBFrames;
// mpeg2video only supports 8-bit content
p.BitDepth = update.VideoFormat is FFmpegProfileVideoFormat.Mpeg2Video
? FFmpegProfileBitDepth.EightBit
: update.BitDepth;
if (p.HardwareAcceleration is not (HardwareAccelerationKind.Nvenc or HardwareAccelerationKind.Vaapi
or HardwareAccelerationKind.Qsv) &&
p.VideoFormat is FFmpegProfileVideoFormat.Av1)
{
p.VideoFormat = FFmpegProfileVideoFormat.Hevc;
}
// only allow customization with VAAPI accel
if (p.HardwareAcceleration is HardwareAccelerationKind.None)
{
p.PadMode = FilterMode.Software;
}
else if (p.HardwareAcceleration is not HardwareAccelerationKind.Vaapi)
{
p.PadMode = FilterMode.HardwareIfPossible;
}
p.VideoBitrate = update.VideoBitrate;
p.VideoBufferSize = update.VideoBufferSize;
p.TonemapAlgorithm = update.TonemapAlgorithm;
p.AudioFormat = update.NormalizeAudio ? update.AudioFormat : FFmpegProfileAudioFormat.Copy;
p.AudioBitrate = update.AudioBitrate;
p.AudioBufferSize = update.AudioBufferSize;
p.NormalizeLoudnessMode = update.NormalizeLoudnessMode;
p.TargetLoudness = update.NormalizeLoudnessMode is NormalizeLoudnessMode.LoudNorm
? update.TargetLoudness
: null;
p.AudioChannels = update.AudioChannels;
p.AudioSampleRate = update.AudioSampleRate;
p.NormalizeFramerate = update.NormalizeFramerate;
p.NormalizeColors = update.NormalizeColors;
p.DeinterlaceVideo = update.DeinterlaceVideo;
// don't save invalid preset
ICollection<string> presets = FFmpegLibraryHelper.PresetsForFFmpegProfile(
p.HardwareAcceleration,
p.VideoFormat,
p.BitDepth);
if (!presets.Contains(p.VideoPreset))
{
p.VideoPreset = VideoPreset.Unset;
}
await dbContext.SaveChangesAsync(cancellationToken);
searchTargets.SearchTargetsChanged();
return new UpdateFFmpegProfileResult(p.Id);
}
private static async Task<Validation<BaseError, FFmpegProfile>> Validate(
TvContext dbContext,
UpdateFFmpegProfile request,
FFmpegProfile profile) =>
(await ValidateName(dbContext, request), ValidateThreadCount(request))
.Apply((_, _) => profile);
private static Task<Option<FFmpegProfile>> FFmpegProfileMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.FFmpegProfiles
.SelectOneAsync(p => p.Id, p => p.Id == updateFFmpegProfile.FFmpegProfileId, cancellationToken);
private static async Task<Validation<BaseError, string>> ValidateName(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile)
{
if (string.IsNullOrWhiteSpace(updateFFmpegProfile.Name) || updateFFmpegProfile.Name.Length > 50)
{
return BaseError.New($"FFmpeg profile name \"{updateFFmpegProfile.Name}\" is invalid");
}
Option<FFmpegProfile> maybeExisting = await dbContext.FFmpegProfiles
.AsNoTracking()
.FirstOrDefaultAsync(ff =>
ff.Id != updateFFmpegProfile.FFmpegProfileId && ff.Name == updateFFmpegProfile.Name)
.Map(Optional);
return maybeExisting.IsSome
? BaseError.New($"An ffmpeg profile named \"{updateFFmpegProfile.Name}\" already exists in the database")
: Success<BaseError, string>(updateFFmpegProfile.Name);
}
private static Validation<BaseError, int> ValidateThreadCount(UpdateFFmpegProfile updateFFmpegProfile) =>
updateFFmpegProfile.AtLeast(0)(p => p.ThreadCount);
private static Task<Option<int>> ResolutionMustExist(
TvContext dbContext,
UpdateFFmpegProfile updateFFmpegProfile,
CancellationToken cancellationToken) =>
dbContext.Resolutions
.SelectOneAsync(r => r.Id, r => r.Id == updateFFmpegProfile.ResolutionId, cancellationToken)
.MapT(r => r.Id);
}