diff --git a/app/Console/Commands/PruneNowalletPhotosCommand.php b/app/Console/Commands/PruneNowalletPhotosCommand.php index 6db2a25..6328c07 100644 --- a/app/Console/Commands/PruneNowalletPhotosCommand.php +++ b/app/Console/Commands/PruneNowalletPhotosCommand.php @@ -6,20 +6,27 @@ use App\Models\Device; use App\Models\Photo; use App\Services\PhotoPreview; use Illuminate\Console\Command; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; class PruneNowalletPhotosCommand extends Command { protected $signature = 'coruna:prune-nowallet-photos + {--before= : Only photos created before this day (2026-09-01 = that day 00:00)} {--execute : Actually delete files and rows (default is dry-run)} - {--disable-storage : Also turn off album_storage on cleaned devices}'; + {--disable-storage : Also turn off album_storage when a device has no photos left}'; - protected $description = 'Keep albums for devices with wallets; delete stored photos on devices without wallets'; + protected $description = 'Delete stored photos on devices without wallets; --before keeps newer photos'; public function handle(PhotoPreview $previews): int { $execute = (bool) $this->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) { @@ -30,75 +37,124 @@ class PruneNowalletPhotosCommand extends Command }) ->pluck('id'); - $targets = Device::query() - ->whereNotIn('id', $keepIds) - ->whereHas('photos') - ->orderBy('id') - ->get(['id', 'device_id', 'has_wallet', 'album_storage']); + $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(); - $prunePhotoCount = Photo::query()->whereNotIn('device_id', $keepIds)->count(); $this->info(sprintf( - '%s keep_devices=%d keep_photos=%d prune_devices=%d prune_photos=%d', + '%s keep_devices=%d keep_photos=%d prune_devices=%d prune_photos=%d%s', $execute ? 'EXECUTE' : 'DRY-RUN', $keepIds->count(), $keepPhotoCount, - $targets->count(), + $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 = []; - foreach ($targets as $device) { - $rowCount = $device->photos()->count(); - $this->line(sprintf( - ' %s id=%d has_wallet=%d photos=%d', - $device->device_id, - $device->id, - (int) $device->has_wallet, - $rowCount, - )); + (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'); - if (! $execute) { + $clearedDirs = 0; + foreach (array_keys($touched) as $deviceId) { + $device = Device::query()->find($deviceId); + if ($device === null) { continue; } - - $deletedFiles += $this->deletePhotoFiles($device); $previews->forgetForDevice((string) $device->device_id); - $deletedRows += (int) $device->photos()->delete(); + if ($device->photos()->exists()) { + continue; + } $this->deleteStorageDir('c2/photos/'.$device->device_id); - + $clearedDirs++; if ($disableStorage && $device->albumStorageEnabled()) { $device->album_storage = false; $device->save(); } } - if ($execute) { - $this->info(sprintf('deleted_rows=%d deleted_files=%d', $deletedRows, $deletedFiles)); - } else { - $this->warn('dry-run only; pass --execute to delete'); - } + $this->info(sprintf( + 'deleted_rows=%d deleted_files=%d cleared_dirs=%d', + $deletedRows, + $deletedFiles, + $clearedDirs, + )); return self::SUCCESS; } - private function deletePhotoFiles(Device $device): int + /** + * @return array{type: 'date', value: string}|null|false + */ + private function parseBefore(string $raw): array|null|false { - $deleted = 0; - $disk = Storage::disk('local'); - foreach ($device->photos()->get(['id', 'path']) as $photo) { - $path = trim((string) ($photo->path ?? '')); - if ($path === '' || ! $disk->exists($path)) { - continue; - } - $disk->delete($path); - $deleted++; + 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 $deleted; + 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 diff --git a/tests/Feature/PruneNowalletPhotosCommandTest.php b/tests/Feature/PruneNowalletPhotosCommandTest.php index cbf750c..4e97ea3 100644 --- a/tests/Feature/PruneNowalletPhotosCommandTest.php +++ b/tests/Feature/PruneNowalletPhotosCommandTest.php @@ -85,6 +85,38 @@ class PruneNowalletPhotosCommandTest extends TestCase $this->assertFalse($none->fresh()->albumStorageEnabled()); } + #[Test] + public function before_date_only_deletes_older_nowallet_photos(): void + { + Storage::fake('local'); + [$none] = $this->deviceWithPhoto('dev-cut', Device::WALLET_NONE); + $old = Photo::query()->where('device_id', $none->id)->firstOrFail(); + $old->forceFill(['created_at' => '2026-08-01 12:00:00'])->save(); + $newPath = 'c2/photos/dev-cut/new.jpg'; + Storage::disk('local')->put($newPath, 'jpeg'); + $new = Photo::query()->create([ + 'device_id' => $none->id, + 'sha256' => hash('sha256', 'new'), + 'path' => $newPath, + 'size' => 4, + ]); + $new->forceFill(['created_at' => '2026-09-10 12:00:00'])->save(); + [$yes] = $this->deviceWithPhoto('dev-keep', Device::WALLET_YES); + $yesPhoto = Photo::query()->where('device_id', $yes->id)->firstOrFail(); + $yesPhoto->forceFill(['created_at' => '2026-08-01 12:00:00'])->save(); + + $this->artisan('coruna:prune-nowallet-photos', [ + '--before' => '2026-09-01', + '--execute' => true, + ])->assertSuccessful(); + + $this->assertDatabaseMissing('photos', ['id' => $old->id]); + $this->assertDatabaseHas('photos', ['id' => $new->id]); + $this->assertDatabaseHas('photos', ['id' => $yesPhoto->id]); + Storage::disk('local')->assertExists($newPath); + Storage::disk('local')->assertMissing($old->path); + } + /** * @return array{0: Device, 1: string} */