diff --git a/src/MediaCollections/Commands/CleanCommand.php b/src/MediaCollections/Commands/CleanCommand.php index fc79d9640..48fa28304 100644 --- a/src/MediaCollections/Commands/CleanCommand.php +++ b/src/MediaCollections/Commands/CleanCommand.php @@ -23,7 +23,8 @@ class CleanCommand extends Command protected $signature = 'media-library:clean {modelType?} {collectionName?} {disk?} {--dry-run : List files that will be removed without removing them}, {--force : Force the operation to run when in production}, - {--rate-limit= : Limit the number of requests per second }, + {--rate-limit= : Limit the number of requests per second}, + {--delete-orphaned : Delete orphaned media items}, {--skip-conversions : Do not remove deprecated conversions}'; protected $description = 'Clean deprecated conversions and files without related model.'; @@ -54,6 +55,10 @@ public function handle( $this->isDryRun = $this->option('dry-run'); $this->rateLimit = (int) $this->option('rate-limit'); + if ($this->option('delete-orphaned')) { + $this->deleteOrphanedMediaItems(); + } + if (! $this->option('skip-conversions')) { $this->deleteFilesGeneratedForDeprecatedConversions(); } @@ -87,6 +92,37 @@ public function getMediaItems(): Collection return $this->mediaRepository->all(); } + protected function deleteOrphanedMediaItems(): void + { + $this->getOrphanedMediaItems()->each(function (Media $media): void { + if ($this->isDryRun) { + $this->info("Orphaned Media[id={$media->id}] found"); + + return; + } + + $media->delete(); + + if ($this->rateLimit) { + usleep((1 / $this->rateLimit) * 1_000_000); + } + + $this->info("Orphaned Media[id={$media->id}] has been removed"); + }); + } + + /** @return Collection */ + protected function getOrphanedMediaItems(): Collection + { + $collectionName = $this->argument('collectionName'); + + if (is_string($collectionName)) { + return $this->mediaRepository->getOrphansByCollectionName($collectionName); + } + + return $this->mediaRepository->getOrphans(); + } + protected function deleteFilesGeneratedForDeprecatedConversions(): void { $this->getMediaItems()->each(function (Media $media) { diff --git a/src/MediaCollections/MediaRepository.php b/src/MediaCollections/MediaRepository.php index f0fcf3b79..cc499eb96 100644 --- a/src/MediaCollections/MediaRepository.php +++ b/src/MediaCollections/MediaRepository.php @@ -88,6 +88,21 @@ public function getByCollectionName(string $collectionName): DbCollection ->get(); } + public function getOrphans(): DbCollection + { + return $this->query() + ->whereDoesntHave('model') + ->get(); + } + + public function getOrphansByCollectionName(string $collectionName): DbCollection + { + return $this->query() + ->whereDoesntHave('model') + ->where('collection_name', $collectionName) + ->get(); + } + protected function query(): Builder { return $this->model->newQuery(); diff --git a/tests/Conversions/Commands/CleanConversionsTest.php b/tests/Conversions/Commands/CleanCommandTest.php similarity index 81% rename from tests/Conversions/Commands/CleanConversionsTest.php rename to tests/Conversions/Commands/CleanCommandTest.php index 8262bc185..fd8fb7430 100644 --- a/tests/Conversions/Commands/CleanConversionsTest.php +++ b/tests/Conversions/Commands/CleanCommandTest.php @@ -274,3 +274,81 @@ expect($this->getMediaDirectory("{$media->id}/test-thumb.jpg"))->toBeFile(); expect($this->getMediaDirectory("{$media->id}/test.jpg"))->toBeFile(); }); + +it('can clean orphaned media items when enabled', function () { + $mediaToDelete = TestModel::create(['name' => 'test.jpg']) + ->addMedia($this->getTestJpg()) + ->preservingOriginal() + ->toMediaCollection('collection1'); + + $mediaToKeep = TestModel::create(['name' => 'test.jpg']) + ->addMedia($this->getTestJpg()) + ->preservingOriginal() + ->toMediaCollection('collection1'); + + // Delete quietly to avoid deleting the related media file. + $mediaToDelete->model->deletePreservingMedia(); + + $this->artisan('media-library:clean', [ + '--delete-orphaned' => 'true', + ]); + + // Media should be deleted from the database. + $this->assertDatabaseMissing('media', [ + 'id' => $mediaToDelete->id, + ]); + + // This media should still exist. + $this->assertDatabaseHas('media', [ + 'id' => $mediaToKeep->id, + ]); +}); + +it('can clean orphaned media items when enabled for specific collections', function () { + $mediaToClean = TestModel::create(['name' => 'test.jpg']) + ->addMedia($this->getTestJpg()) + ->preservingOriginal() + ->toMediaCollection('collection-to-clean'); + + $mediaToKeep = TestModel::create(['name' => 'test.jpg']) + ->addMedia($this->getTestJpg()) + ->preservingOriginal() + ->toMediaCollection('collection-to-keep'); + + // Delete quietly to avoid deleting the related media file. + $mediaToClean->model->deletePreservingMedia(); + $mediaToKeep->model->deletePreservingMedia(); + + $this->artisan('media-library:clean', [ + '--delete-orphaned' => 'true', + 'collectionName' => 'collection-to-clean', + ]); + + // Media should be deleted from the database. + $this->assertDatabaseMissing('media', [ + 'id' => $mediaToClean->id, + ]); + + // This media should still exist. + $this->assertDatabaseHas('media', [ + 'id' => $mediaToKeep->id, + ]); +}); + +it('will not clean orphaned media items when disabled', function () { + $media = TestModel::create(['name' => 'test.jpg']) + ->addMedia($this->getTestJpg()) + ->preservingOriginal() + ->toMediaCollection('collection1'); + + // Delete quietly to avoid deleting the related media file. + $media->model->deletePreservingMedia(); + + // Without the `--delete-orphaned` flag, the orphaned media should remain. + $this->artisan('media-library:clean'); + + // This media should still exist. + $this->assertDatabaseHas('media', [ + 'id' => $media->id, + ]); +});