option('execute'); $dateArg = trim((string) $this->argument('date')); $date = date('Y-m-d H:i:s', strtotime($dateArg)); if ($date === false || $date === '1970-01-01 00:00:00') { $this->error("invalid date: {$dateArg}"); return self::FAILURE; } $q = Photo::query()->where('photos.created_at', '<', $date); $deviceFilter = trim((string) $this->option('device')); if ($deviceFilter !== '') { if (ctype_digit($deviceFilter)) { $q->where('photos.device_id', (int) $deviceFilter); } else { $q->join('devices', 'devices.id', '=', 'photos.device_id') ->where('devices.device_id', $deviceFilter) ->select('photos.*'); } } $count = (clone $q)->count(); $this->info(sprintf( '%s cutoff=%s photos=%d', $execute ? 'EXECUTE' : 'DRY-RUN', $date, $count, )); if ($count === 0) { $this->info('nothing to prune'); return self::SUCCESS; } if (! $execute) { $this->warn('dry-run only; pass --execute to delete'); return self::SUCCESS; } $disk = Storage::disk('local'); $deletedRows = 0; $deletedFiles = 0; $deviceIds = []; // Process in chunks to avoid loading everything into memory. $q->select(['photos.id', 'photos.device_id', 'photos.path']) ->orderBy('photos.id') ->chunk(500, function ($photos) use ($disk, $previews, &$deletedRows, &$deletedFiles, &$deviceIds) { $ids = []; foreach ($photos as $photo) { $ids[] = $photo->id; $path = trim((string) ($photo->path ?? '')); if ($path !== '' && $disk->exists($path)) { $disk->delete($path); $deletedFiles++; } $deviceIds[$photo->device_id] = true; } // Delete PhotoRead markers first (foreign key), then photos. DB::table('photo_reads')->whereIn('photo_id', $ids)->delete(); Photo::query()->whereIn('id', $ids)->delete(); $deletedRows += count($ids); }); // Clear preview caches for affected devices. foreach (array_keys($deviceIds) as $deviceId) { $device = \App\Models\Device::find($deviceId); if ($device) { $previews->forgetForDevice((string) $device->device_id); } } $this->info(sprintf('deleted_rows=%d deleted_files=%d devices=%d', $deletedRows, $deletedFiles, count($deviceIds))); return self::SUCCESS; } }