feat: script

This commit is contained in:
hashbro
2026-09-13 03:01:12 +08:00
parent 1746e7b485
commit bca2eb4602
2 changed files with 128 additions and 40 deletions
@@ -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
@@ -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}
*/