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); $this->info(sprintf( '%s keep_devices=%d streaming batches of 500 (no pre-count)%s', $execute ? 'EXECUTE' : 'DRY-RUN', $keepIds->count(), $before === null ? '' : ' '.$this->beforeLabel($before), )); $disk = Storage::disk('local'); $seen = 0; $deletedRows = 0; $deletedFiles = 0; $touched = []; $lastId = 0; while (true) { $photos = (clone $q) ->select(['photos.id', 'photos.device_id', 'photos.path']) ->where('photos.id', '>', $lastId) ->orderBy('photos.id') ->limit(500) ->get(); if ($photos->isEmpty()) { break; } $ids = []; foreach ($photos as $photo) { $ids[] = $photo->id; $touched[$photo->device_id] = true; $seen++; if (! $execute) { continue; } $path = trim((string) ($photo->path ?? '')); if ($path !== '' && $disk->delete($path)) { $deletedFiles++; } } if ($execute && $ids !== []) { DB::table('photo_reads')->whereIn('photo_id', $ids)->delete(); Photo::query()->whereIn('id', $ids)->delete(); $deletedRows += count($ids); } $lastId = (int) $photos->last()->id; $this->info($execute ? sprintf(' deleted_rows=%d deleted_files=%d', $deletedRows, $deletedFiles) : sprintf(' matched=%d', $seen)); } if ($seen === 0) { $this->info('nothing to prune'); return self::SUCCESS; } if (! $execute) { $this->info(sprintf( 'dry-run matched_photos=%d prune_devices=%d; pass --execute to delete', $seen, count($touched), )); return self::SUCCESS; } $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) { } } } }