- Event-Medien tragen Typ (image/video/audio), Dauer und Notiz; Videos bekommen ein Poster, Audio wird per ffmpeg nach AAC (audio/mp4) gewandelt, Originale werden mit Range-Unterstützung gestreamt. - Upload nimmt eine Notiz (caption) direkt mit, PATCH ändert sie später. - Neue Event-Felder: Zeitraum (end_date), Kategorie, Personen, Meilenstein. Regeln zentral in EventFields, damit CRUD und Sync gleich prüfen. - Titel darf leer sein: ein Event entsteht mit dem ersten Eintrag. - Artisan-Befehl media:transcode-voice-notes wandelt alte WebM/Ogg-Notizen, die Safari nicht abspielen kann. - Login-Throttling, Settings-Validierung, Sync-Fehlercodes ohne Rohtext. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
94 lines
2.8 KiB
PHP
94 lines
2.8 KiB
PHP
<?php
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Models\EventMedia;
|
|
use App\Services\EventMediaProbe;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Re-encodes voice notes that are still stored as they were recorded.
|
|
*
|
|
* Uploads are transcoded to AAC (audio/mp4) since 2026-09-23. Notes from
|
|
* before, or notes uploaded while ffmpeg was missing, sit in WebM or Ogg,
|
|
* which Safari cannot play. This brings them to the same format.
|
|
*/
|
|
class TranscodeVoiceNotes extends Command
|
|
{
|
|
protected $signature = 'media:transcode-voice-notes
|
|
{--dry-run : Only report what would be converted}';
|
|
|
|
protected $description = 'Convert voice notes still stored as WebM/Ogg to AAC so every browser can play them';
|
|
|
|
public function handle(EventMediaProbe $probe): int
|
|
{
|
|
$pending = EventMedia::query()
|
|
->where('collection', 'voice_note')
|
|
->where('mime_type', '!=', 'audio/mp4')
|
|
->orderBy('id')
|
|
->get();
|
|
|
|
if ($pending->isEmpty()) {
|
|
$this->info('Every voice note is already audio/mp4.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->info("{$pending->count()} voice note(s) to convert.");
|
|
|
|
$converted = 0;
|
|
$failed = 0;
|
|
|
|
foreach ($pending as $media) {
|
|
$disk = Storage::disk($media->disk);
|
|
|
|
if (! $disk->exists($media->path)) {
|
|
$this->warn("#{$media->id}: file missing ({$media->path}), skipped.");
|
|
$failed++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($this->option('dry-run')) {
|
|
$this->line("#{$media->id}: {$media->mime_type} → audio/mp4");
|
|
|
|
continue;
|
|
}
|
|
|
|
$oldPath = $media->path;
|
|
$source = $disk->path($oldPath);
|
|
$targetPath = preg_replace('/\.[A-Za-z0-9]+$/', '', $oldPath).'.m4a';
|
|
$target = $disk->path($targetPath);
|
|
|
|
if (! $probe->transcodeAudioToM4a($source, $target)) {
|
|
$this->error("#{$media->id}: ffmpeg could not convert {$media->path}.");
|
|
$failed++;
|
|
|
|
continue;
|
|
}
|
|
|
|
$media->update([
|
|
'path' => $targetPath,
|
|
'mime_type' => 'audio/mp4',
|
|
'size' => (int) filesize($target),
|
|
'duration_ms' => $probe->durationMs($target) ?? $media->duration_ms,
|
|
]);
|
|
|
|
if ($targetPath !== $oldPath) {
|
|
$disk->delete($oldPath);
|
|
}
|
|
|
|
$this->line("#{$media->id}: converted.");
|
|
$converted++;
|
|
}
|
|
|
|
if ($this->option('dry-run')) {
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$this->info("Converted {$converted}, failed {$failed}.");
|
|
|
|
return $failed === 0 ? self::SUCCESS : self::FAILURE;
|
|
}
|
|
}
|