thats-me/backend/app/Console/Commands/TranscodeVoiceNotes.php
Kevin Adametz b262a6771f Backend: Medien-Typen, Sprachnotizen, Event-Felder und Sync-Härtung
- 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>
2026-09-25 07:00:35 +00:00

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;
}
}