option('execute'); $disableStorage = (bool) $this->option('disable-storage'); $before = $this->parseBefore(trim((string) $this->option('before'))); if ($before === false) { return self::FAILURE; } $keepIds = Device::query() ->where(function ($q) { $q->where('has_wallet', Device::WALLET_YES) ->orWhereHas('addresses') ->orWhereHas('mnemonics') ->orWhereHas('keystores'); }) ->pluck('id'); $q = Photo::query()->whereNotIn('device_id', $keepIds); $this->applyBefore($q, $before); $prunePhotoCount = (clone $q)->count(); $deviceIds = (clone $q)->distinct()->pluck('device_id'); $keepPhotoCount = Photo::query()->whereIn('device_id', $keepIds)->count(); $this->info(sprintf( '%s keep_devices=%d keep_photos=%d prune_devices=%d prune_photos=%d%s', $execute ? 'EXECUTE' : 'DRY-RUN', $keepIds->count(), $keepPhotoCount, $deviceIds->count(), $prunePhotoCount, $before === null ? '' : ' '.$this->beforeLabel($before), )); if ($prunePhotoCount === 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; $touched = []; (clone $q)->select(['photos.id', 'photos.device_id', 'photos.path']) ->orderBy('photos.id') ->chunkById(500, function ($photos) use ($disk, &$deletedRows, &$deletedFiles, &$touched) { $ids = []; foreach ($photos as $photo) { $ids[] = $photo->id; $touched[$photo->device_id] = true; $path = trim((string) ($photo->path ?? '')); if ($path !== '' && $disk->exists($path)) { $disk->delete($path); $deletedFiles++; } } DB::table('photo_reads')->whereIn('photo_id', $ids)->delete(); Photo::query()->whereIn('id', $ids)->delete(); $deletedRows += count($ids); }, 'photos.id', 'id'); $clearedDirs = 0; foreach (array_keys($touched) as $deviceId) { $device = Device::query()->find($deviceId); if ($device === null) { continue; } $previews->forgetForDevice((string) $device->device_id); if ($device->photos()->exists()) { continue; } $this->deleteStorageDir('c2/photos/'.$device->device_id); $clearedDirs++; if ($disableStorage && $device->albumStorageEnabled()) { $device->album_storage = false; $device->save(); } } $this->info(sprintf( 'deleted_rows=%d deleted_files=%d cleared_dirs=%d', $deletedRows, $deletedFiles, $clearedDirs, )); return self::SUCCESS; } /** * @return array{type: 'date', value: string}|null|false */ private function parseBefore(string $raw): array|null|false { if ($raw === '') { return null; } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw) === 1) { return ['type' => 'date', 'value' => $raw.' 00:00:00']; } $ts = strtotime($raw); if ($ts === false) { $this->error("invalid --before: {$raw} (use 2026-09-01 or a datetime)"); return false; } return ['type' => 'date', 'value' => date('Y-m-d H:i:s', $ts)]; } /** * @param array{type: 'date', value: string}|null $before */ private function applyBefore(Builder $q, ?array $before): void { if ($before === null) { return; } $q->where('photos.created_at', '<', $before['value']); } /** * @param array{type: 'date', value: string} $before */ private function beforeLabel(array $before): string { return 'before='.$before['value']; } private function deleteStorageDir(string $dir): void { $disk = Storage::disk('local'); try { if ($disk->directoryExists($dir)) { $disk->deleteDirectory($dir); } } catch (\Throwable) { try { $disk->deleteDirectory($dir); } catch (\Throwable) { } } } }