thats-me/backend/app/Console/Commands/ImportLifelineImages.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

108 lines
3.5 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Event;
use App\Models\User;
use App\Services\EventMediaStore;
use Database\Seeders\LifelineSeeder;
use Illuminate\Console\Command;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\File;
/**
* Attaches generated key images to the seeded lifeline events.
*
* Images are expected as `<age>-<month>.png|jpg` (zero padded, e.g. 04-07)
* in `storage/app/private/lifeline-images/<handle>/`. Events that already
* have a key image are skipped unless --force is given.
*/
class ImportLifelineImages extends Command
{
protected $signature = 'lifelines:import-images
{handle : Lifeline handle, e.g. bruno}
{--from= : Image directory (default: storage/app/private/lifeline-images/<handle>)}
{--force : Replace key images that are already attached}
{--dry-run : Only report what would be imported}';
protected $description = 'Attach generated key images to seeded lifeline events';
public function handle(EventMediaStore $store): int
{
$handle = (string) $this->argument('handle');
$dataPath = database_path("data/lifelines/{$handle}.json");
$imageDirectory = (string) ($this->option('from') ?: storage_path("app/private/lifeline-images/{$handle}"));
if (! File::exists($dataPath)) {
$this->error("No lifeline data found at {$dataPath}.");
return self::FAILURE;
}
$lifeline = json_decode(File::get($dataPath), true, flags: JSON_THROW_ON_ERROR);
$user = User::query()->where('email', $lifeline['person']['email'])->first();
if ($user === null) {
$this->error("User {$lifeline['person']['email']} is not seeded yet. Run the LifelineSeeder first.");
return self::FAILURE;
}
$imported = $skipped = $missing = 0;
foreach ($lifeline['events'] as $data) {
$slug = sprintf('%02d-%02d', $data['age'], $data['month']);
$imagePath = $this->findImage($imageDirectory, $slug);
if ($imagePath === null) {
$missing++;
continue;
}
$clientId = LifelineSeeder::clientId($lifeline['person']['email'], $data['age'], $data['month'], $data['day'] ?? null);
$event = Event::query()->where('user_id', $user->id)->where('client_id', $clientId)->first();
if ($event === null) {
$this->warn("{$slug}: event not seeded, skipping.");
$missing++;
continue;
}
if ($event->image !== null && ! $this->option('force')) {
$skipped++;
continue;
}
if ($this->option('dry-run')) {
$this->line("{$slug}: would import ".basename($imagePath)." → {$event->title}");
$imported++;
continue;
}
$store->store($event, new UploadedFile($imagePath, basename($imagePath), null, null, true), 'key_image');
$this->line("{$slug}: {$event->title}");
$imported++;
}
$this->info("Imported {$imported}, skipped {$skipped} (already attached), {$missing} without image.");
return self::SUCCESS;
}
private function findImage(string $directory, string $slug): ?string
{
foreach (['png', 'jpg', 'jpeg'] as $extension) {
$path = "{$directory}/{$slug}.{$extension}";
if (File::exists($path)) {
return $path;
}
}
return null;
}
}