Raise audio upload limit to 2 GB per file.
Update validation, dropzone checks, Docker PHP ini, and docs so multi-hour recordings can be uploaded.
This commit is contained in:
@@ -36,6 +36,8 @@ RUN install-php-extensions \
|
|||||||
intl \
|
intl \
|
||||||
opcache
|
opcache
|
||||||
|
|
||||||
|
COPY docker/php.ini /usr/local/etc/php/conf.d/99-uploads.ini
|
||||||
|
|
||||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Built with Laravel 13, Blade, Alpine.js, Tailwind CSS 4, [Laravel Reverb](https:
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 100 MB)
|
- Upload common audio formats (MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, AIFF — up to 2 GB)
|
||||||
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
|
- Automatic metadata extraction when tags are present (title, artist, album, duration, recorded date)
|
||||||
- Search recordings by title, artist, or transcript
|
- Search recordings by title, artist, or transcript
|
||||||
- Queued local transcription (faster-whisper in Docker)
|
- Queued local transcription (faster-whisper in Docker)
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class StoreRecordingRequest extends FormRequest
|
|||||||
'aif',
|
'aif',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum upload size accepted by validation (2 GiB).
|
||||||
|
*/
|
||||||
|
public const MAX_AUDIO_KILOBYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
@@ -54,7 +59,7 @@ class StoreRecordingRequest extends FormRequest
|
|||||||
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
'audio' => ['required', 'array', 'min:1', 'max:50'],
|
||||||
'audio.*' => [
|
'audio.*' => [
|
||||||
'required',
|
'required',
|
||||||
File::types(self::AUDIO_EXTENSIONS)->max(102400),
|
File::types(self::AUDIO_EXTENSIONS)->max('2gb'),
|
||||||
],
|
],
|
||||||
'title' => ['nullable', 'string', 'max:255'],
|
'title' => ['nullable', 'string', 'max:255'],
|
||||||
];
|
];
|
||||||
@@ -71,7 +76,7 @@ class StoreRecordingRequest extends FormRequest
|
|||||||
'audio.max' => 'You can upload at most 50 files at once.',
|
'audio.max' => 'You can upload at most 50 files at once.',
|
||||||
'audio.*.required' => 'Please choose an audio file to upload.',
|
'audio.*.required' => 'Please choose an audio file to upload.',
|
||||||
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
'audio.*' => 'Unsupported audio type. Use MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF.',
|
||||||
'audio.*.max' => 'Each audio file may not be larger than 100 MB.',
|
'audio.*.max' => 'Each audio file may not be larger than 2 GB.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
; PHP limits for large pocket-recorder uploads (up to 2 GB per file).
|
||||||
|
upload_max_filesize = 2G
|
||||||
|
post_max_size = 10G
|
||||||
|
memory_limit = 512M
|
||||||
|
max_execution_time = 3600
|
||||||
|
max_input_time = 3600
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
* before submitting the form.
|
* before submitting the form.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const MAX_FILE_BYTES = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
export function uploadDropzone({ existingFingerprints = [] } = {}) {
|
export function uploadDropzone({ existingFingerprints = [] } = {}) {
|
||||||
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
|
const acceptExt = ['.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.mp4', '.aac', '.webm', '.wma', '.aiff', '.aif'];
|
||||||
const known = new Set(existingFingerprints);
|
const known = new Set(existingFingerprints);
|
||||||
@@ -50,7 +52,7 @@ export function uploadDropzone({ existingFingerprints = [] } = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.size > 100 * 1024 * 1024) {
|
if (file.size > MAX_FILE_BYTES) {
|
||||||
skippedTooLarge++;
|
skippedTooLarge++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -80,7 +82,7 @@ export function uploadDropzone({ existingFingerprints = [] } = {}) {
|
|||||||
if (skippedUnsupported > 0) {
|
if (skippedUnsupported > 0) {
|
||||||
this.error = 'Skipped unsupported file type. Use common audio formats only.';
|
this.error = 'Skipped unsupported file type. Use common audio formats only.';
|
||||||
} else if (skippedTooLarge > 0) {
|
} else if (skippedTooLarge > 0) {
|
||||||
this.error = 'Skipped a file larger than 100 MB.';
|
this.error = 'Skipped a file larger than 2 GB.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (skippedDuplicates > 0) {
|
if (skippedDuplicates > 0) {
|
||||||
@@ -153,7 +155,11 @@ export function uploadDropzone({ existingFingerprints = [] } = {}) {
|
|||||||
return (bytes / 1024).toFixed(1) + ' KB';
|
return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
>
|
>
|
||||||
<p class="text-sm font-medium text-stone-800">Drop audio files here</p>
|
<p class="text-sm font-medium text-stone-800">Drop audio files here</p>
|
||||||
<p class="mt-1 text-sm text-stone-600">or click to browse</p>
|
<p class="mt-1 text-sm text-stone-600">or click to browse</p>
|
||||||
<p class="mt-3 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 100 MB each · up to 50 files</p>
|
<p class="mt-3 text-xs text-stone-500">MP3, WAV, OGG, FLAC, M4A, AAC, WebM, WMA, or AIFF · max 2 GB each · up to 50 files</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -99,4 +99,20 @@ class RecordingDuplicateUploadTest extends TestCase
|
|||||||
$this->assertSame(1, Recording::query()->count());
|
$this->assertSame(1, Recording::query()->count());
|
||||||
Bus::assertNothingDispatched();
|
Bus::assertNothingDispatched();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_stored_recording_persists_content_hash(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$file = UploadedFile::fake()->createWithContent('hash-me.mp3', 'payload-for-hash');
|
||||||
|
|
||||||
|
$this->post(route('recordings.store'), [
|
||||||
|
'audio' => [$file],
|
||||||
|
])->assertRedirect();
|
||||||
|
|
||||||
|
$recording = Recording::query()->first();
|
||||||
|
$this->assertNotNull($recording);
|
||||||
|
$this->assertSame(hash('sha256', 'payload-for-hash'), $recording->content_hash);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Http\Requests\StoreRecordingRequest;
|
||||||
use App\Jobs\TranscribeRecording;
|
use App\Jobs\TranscribeRecording;
|
||||||
use App\Models\Recording;
|
use App\Models\Recording;
|
||||||
use App\Services\TranscriptionService;
|
use App\Services\TranscriptionService;
|
||||||
@@ -87,9 +88,29 @@ class RecordingUploadTest extends TestCase
|
|||||||
$this->get(route('recordings.create'))
|
$this->get(route('recordings.create'))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('Drop audio files here')
|
->assertSee('Drop audio files here')
|
||||||
|
->assertSee('max 2 GB each', false)
|
||||||
->assertSee('name="audio[]"', false);
|
->assertSee('name="audio[]"', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_files_larger_than_two_gigabytes_are_rejected(): void
|
||||||
|
{
|
||||||
|
Storage::fake('local');
|
||||||
|
Bus::fake();
|
||||||
|
|
||||||
|
$file = UploadedFile::fake()
|
||||||
|
->create('huge.mp3', 10, 'audio/mpeg')
|
||||||
|
->size(StoreRecordingRequest::MAX_AUDIO_KILOBYTES + 1);
|
||||||
|
|
||||||
|
$this->from(route('recordings.create'))
|
||||||
|
->post(route('recordings.store'), [
|
||||||
|
'audio' => [$file],
|
||||||
|
])
|
||||||
|
->assertSessionHasErrors(['audio.0']);
|
||||||
|
|
||||||
|
$this->assertSame(0, Recording::query()->count());
|
||||||
|
Bus::assertNothingDispatched();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_user_can_upload_wav_and_ogg(): void
|
public function test_user_can_upload_wav_and_ogg(): void
|
||||||
{
|
{
|
||||||
Storage::fake('local');
|
Storage::fake('local');
|
||||||
|
|||||||
Reference in New Issue
Block a user