fix: stage audio uploads on data disk instead of /tmp

Relocate both the framework multipart buffer (via ASPNETCORE_TEMP) and the controller staging file to a configurable data-disk directory, so large WAV/FLAC/MP3 uploads no longer fail on the host's small tmpfs.
This commit is contained in:
daniel-c-harvey
2026-06-19 17:25:51 -04:00
parent 37bbfb947f
commit 37cf19c405
8 changed files with 171 additions and 47 deletions
+50 -43
View File
@@ -20,6 +20,7 @@ public class TrackController : ControllerBase
private readonly UnifiedTrackService _unifiedService;
private readonly ITrackService _sqlTrackService;
private readonly WaveformProfileService _waveformProfileService;
private readonly UploadStagingDirectory _stagingDirectory;
private readonly ILogger<TrackController> _logger;
// FileDatabase is injected directly for PutTrack because that endpoint receives a pre-processed
@@ -34,6 +35,7 @@ public class TrackController : ControllerBase
UnifiedTrackService unifiedService,
ITrackService sqlTrackService,
WaveformProfileService waveformProfileService,
UploadStagingDirectory stagingDirectory,
ILogger<TrackController> logger)
{
_trackContentService = trackContentService;
@@ -41,9 +43,47 @@ public class TrackController : ControllerBase
_unifiedService = unifiedService;
_sqlTrackService = sqlTrackService;
_waveformProfileService = waveformProfileService;
_stagingDirectory = stagingDirectory;
_logger = logger;
}
// Streams an uploaded audio body to a freshly-named staging file on the data disk, preserving the
// validated extension (the processor router selects by extension and reads from disk; .tmp would be
// rejected). Staging lives under UploadStagingDirectory, never Path.GetTempPath() — on the Linux
// host /tmp is a small tmpfs that cannot hold a large WAV. Returns the staging path; the caller
// owns deletion in a finally block.
private async Task<string> StageUploadAsync(
IFormFile audioFile, string uploadExtension, CancellationToken cancellationToken)
{
var stagingPath = Path.Combine(
_stagingDirectory.Path, Guid.NewGuid().ToString("N") + uploadExtension);
await using var stagingStream = new FileStream(
stagingPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true);
await using var uploadStream = audioFile.OpenReadStream();
await uploadStream.CopyToAsync(stagingStream, cancellationToken);
return stagingPath;
}
// Best-effort removal of a staging file. Logs and swallows — a stranded staging file is a
// disk-hygiene concern, not a request failure.
private void DeleteStagingFile(string stagingPath)
{
try
{
if (System.IO.File.Exists(stagingPath))
{
System.IO.File.Delete(stagingPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete staging file {StagingPath}", stagingPath);
}
}
// --- Literal-segment routes first ---
// These are declared before the parameterized "{trackId}" / "{id:long}" actions so route
// resolution never treats "page", "upload", or "meta" as a trackId.
@@ -319,23 +359,13 @@ public class TrackController : ControllerBase
var resolvedTrackNumber = trackNumber is > 0 ? trackNumber.Value : 1;
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Path.GetTempFileName() yields .tmp, which the router rejects —
// generate our own path preserving the validated .wav/.mp3/.flac extension.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
string? stagingPath = null;
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
stagingPath = await StageUploadAsync(audioFile, uploadExtension, cancellationToken);
var result = await _unifiedService.UploadAsync(
tempPath,
stagingPath,
trackName,
artist,
string.IsNullOrWhiteSpace(album) ? null : album,
@@ -381,16 +411,9 @@ public class TrackController : ControllerBase
}
finally
{
try
if (stagingPath is not null)
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "UploadTrack: failed to delete temp file {TempPath}", tempPath);
DeleteStagingFile(stagingPath);
}
}
}
@@ -567,21 +590,12 @@ public class TrackController : ControllerBase
return BadRequest("Uploaded file must have a .wav, .mp3, or .flac extension");
}
// The processor router selects by extension and reads from disk, so the temp file must carry
// the upload's real extension. Mirrors UploadTrack — Path.GetTempFileName() yields .tmp.
var tempPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + uploadExtension);
string? stagingPath = null;
try
{
await using (var tempStream = new FileStream(
tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None,
bufferSize: 81920, useAsync: true))
await using (var uploadStream = audioFile.OpenReadStream())
{
await uploadStream.CopyToAsync(tempStream, cancellationToken);
}
stagingPath = await StageUploadAsync(audioFile, uploadExtension, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, tempPath, cancellationToken);
var result = await _unifiedService.ReplaceAudioAsync(id, stagingPath, cancellationToken);
if (result.Success)
{
_logger.LogInformation("ReplaceAudio succeeded: id={Id}", id);
@@ -604,16 +618,9 @@ public class TrackController : ControllerBase
}
finally
{
try
if (stagingPath is not null)
{
if (System.IO.File.Exists(tempPath))
{
System.IO.File.Delete(tempPath);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ReplaceAudio: failed to delete temp file {TempPath}", tempPath);
DeleteStagingFile(stagingPath);
}
}
}