thats-me/backend/app/Services/MediaCleaner.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

91 lines
2.4 KiB
PHP

<?php
namespace App\Services;
use App\Models\Event;
use App\Models\EventMedia;
use App\Models\User;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
/**
* Removes media files for every deletion path.
*
* Database rows disappear on their own through `cascadeOnDelete`, but the
* files behind them do not. Paths are therefore collected *before* the rows
* are deleted, and each deletion path routes through this service.
*/
class MediaCleaner
{
/**
* Delete the files of an event's media. The rows are left to the
* caller / database cascade.
*/
public function purgeEventMedia(Event $event): void
{
$this->deleteFiles($event->media()->get());
}
/**
* Delete the files of every media row belonging to a user, plus the
* user's settings background.
*/
public function purgeUserMedia(User $user): void
{
$this->deleteFiles(EventMedia::query()->where('user_id', $user->id)->get());
$this->delete('local', ["settings-media/{$user->id}/background.jpg"]);
}
/**
* Delete the files of a single media row.
*/
public function purgeMedia(EventMedia $media): void
{
$this->deleteFiles([$media]);
}
/**
* @param iterable<int, EventMedia> $mediaItems
*/
private function deleteFiles(iterable $mediaItems): void
{
$pathsByDisk = [];
foreach ($mediaItems as $media) {
$disk = $media->disk ?: 'local';
foreach ([$media->path, $media->preview_path, $media->thumbnail_path] as $path) {
if (is_string($path) && $path !== '') {
$pathsByDisk[$disk][] = $path;
}
}
}
foreach ($pathsByDisk as $disk => $paths) {
$this->delete($disk, $paths);
}
}
/**
* @param array<int, string> $paths
*/
private function delete(string $disk, array $paths): void
{
if ($paths === []) {
return;
}
try {
Storage::disk($disk)->delete($paths);
} catch (\Throwable $e) {
// A failed file delete must not abort the surrounding deletion —
// an orphaned file is recoverable, a half-deleted account is not.
Log::warning('Media cleanup failed', [
'disk' => $disk,
'paths' => $paths,
'exception' => $e->getMessage(),
]);
}
}
}