Skip to content

Commit bf38642

Browse files
authored
Add pre-computation of thumb images for improved speed. (#3905)
1 parent 58ee6bc commit bf38642

File tree

72 files changed

+4074
-611
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

72 files changed

+4074
-611
lines changed

.github/workflows/php_tests.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
- Install
3333
- Webshop
3434
- ImageProcessing
35+
- Precomputing
3536
exclude:
3637
# Some skips to reduce the number of runs
3738
- php-version: 8.4
@@ -42,6 +43,8 @@ jobs:
4243
test-suite: Webshop
4344
- php-version: 8.4
4445
test-suite: ImageProcessing
46+
- php-version: 8.4
47+
test-suite: Precomputing
4548
# Service containers to run with `container-job`
4649
services:
4750
# Label used to access the service container

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ test_install:
151151
test_ImageProcessing:
152152
vendor/bin/phpunit --testsuite ImageProcessing --stop-on-failure --stop-on-error --no-coverage --log-junit report_imageprocessing.xml
153153

154+
test_precomputing:
155+
vendor/bin/phpunit --testsuite Precomputing --stop-on-failure --stop-on-error --no-coverage --log-junit report_precomputing.xml
156+
154157
test_v2:
155158
vendor/bin/phpunit --testsuite Feature_v2 --stop-on-failure --stop-on-error --no-coverage --log-junit report_v2.xml
156159

app/Actions/Album/Create.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use App\Constants\AccessPermissionConstants as APC;
1212
use App\Enum\DefaultAlbumProtectionType;
13+
use App\Events\AlbumSaved;
1314
use App\Exceptions\ConfigurationKeyMissingException;
1415
use App\Exceptions\ModelDBException;
1516
use App\Exceptions\UnauthenticatedException;
@@ -43,6 +44,9 @@ public function create(string $title, ?Album $parent_album): Album
4344
$this->set_permissions($album, $parent_album);
4445
$this->setStatistics($album);
4546

47+
// Dispatch event for album stats recomputation
48+
AlbumSaved::dispatch($album);
49+
4650
return $album;
4751
}
4852

app/Actions/Album/Delete.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use App\Contracts\Exceptions\InternalLycheeException;
1515
use App\Enum\SmartAlbumType;
1616
use App\Enum\StorageDiskType;
17+
use App\Events\AlbumDeleted;
1718
use App\Exceptions\Internal\LycheeAssertionError;
1819
use App\Exceptions\Internal\QueryBuilderException;
1920
use App\Exceptions\ModelDBException;
@@ -90,6 +91,9 @@ public function do(array $album_ids): FileDeleter
9091
->select(['id', 'parent_id', '_lft', '_rgt', 'track_short_path'])
9192
->findMany($album_ids);
9293

94+
// Collect unique parent IDs BEFORE deletion for event dispatching
95+
$parent_ids = $albums->pluck('parent_id')->filter()->unique()->values()->all();
96+
9397
$recursive_album_ids = $albums->pluck('id')->all(); // only IDs which refer to regular albums are incubators for recursive IDs
9498
$recursive_album_tracks = $albums->pluck('track_short_path');
9599

@@ -146,6 +150,11 @@ public function do(array $album_ids): FileDeleter
146150
->whereNotIn(APC::ACCESS_PERMISSIONS . '.' . APC::BASE_ALBUM_ID, SmartAlbumType::values())
147151
->delete();
148152

153+
// Dispatch events for parent albums to trigger recomputation
154+
foreach ($parent_ids as $parent_id) {
155+
AlbumDeleted::dispatch($parent_id);
156+
}
157+
149158
return $file_deleter;
150159
// @codeCoverageIgnoreStart
151160
} catch (QueryBuilderException|InternalLycheeException $e) {

app/Actions/Album/SetProtectionPolicy.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
namespace App\Actions\Album;
1010

11+
use App\Events\AlbumSaved;
1112
use App\Exceptions\Internal\FrameworkException;
1213
use App\Exceptions\InvalidPropertyException;
1314
use App\Exceptions\ModelDBException;
@@ -34,10 +35,12 @@ public function do(BaseAlbum $album, AlbumProtectionPolicy $protection_policy, b
3435
$album->save();
3536

3637
$active_permissions = $album->public_permissions();
37-
3838
if (!$protection_policy->is_public) {
3939
$active_permissions?->delete();
4040

41+
// Just dispatch if something changed.
42+
AlbumSaved::dispatch($album);
43+
4144
return;
4245
}
4346

@@ -62,5 +65,7 @@ public function do(BaseAlbum $album, AlbumProtectionPolicy $protection_policy, b
6265
}
6366
$active_permissions->base_album_id = $album->get_id();
6467
$active_permissions->save();
68+
69+
AlbumSaved::dispatch($album);
6570
}
6671
}

app/Actions/Albums/Flow.php

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,6 @@ public function do(): AlbumBuilder
5555
// In this specific case we want to know if an album is recursive NSFW.
5656
// This will be used to determine if the album should be blurred.
5757
$base_query->addVirtualIsRecursiveNSFW();
58-
59-
// Due to the way the virtual columns are added in AlbumBuilder::getModel(), we need to add them here.
60-
$base_query->addVirtualMinTakenAt();
61-
$base_query->addVirtualMaxTakenAt();
62-
$base_query->addVirtualNumChildren();
63-
$base_query->addVirtualNumPhotos();
6458
}
6559

6660
// Apply the security policy to the query.

app/Actions/Photo/Delete.php

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use App\Actions\Shop\PurchasableService;
1212
use App\Constants\PhotoAlbum as PA;
1313
use App\Enum\SizeVariantType;
14+
use App\Events\PhotoDeleted;
1415
use App\Exceptions\Internal\LycheeAssertionError;
1516
use App\Exceptions\Internal\LycheeLogicException;
1617
use App\Exceptions\Internal\QueryBuilderException;
@@ -91,6 +92,9 @@ public function do(array $photo_ids, string|null $from_id, array $album_ids = []
9192
{
9293
$this->validateArguments($photo_ids, $from_id, $album_ids);
9394

95+
// Collect affected album IDs BEFORE deletion for event dispatching
96+
$affected_album_ids = $this->collectAffectedAlbumIds($photo_ids, $from_id, $album_ids);
97+
9498
// First find out which photos do not have an album.
9599
// Those will be deleted.
96100
$unsorted_photo_ids = $this->collectUnsortedPhotos($photo_ids);
@@ -124,6 +128,11 @@ public function do(array $photo_ids, string|null $from_id, array $album_ids = []
124128
// @codeCoverageIgnoreEnd
125129
Album::query()->whereIn('header_id', $photo_ids)->update(['header_id' => null]);
126130

131+
// Dispatch events for affected albums to trigger recomputation
132+
foreach ($affected_album_ids as $album_id) {
133+
PhotoDeleted::dispatch($album_id);
134+
}
135+
127136
return $this->file_deleter;
128137
}
129138

@@ -149,6 +158,32 @@ private function validateArguments(array $photo_ids, string|null $from_id, array
149158
};
150159
}
151160

161+
/**
162+
* Collect album IDs that will be affected by photo deletion.
163+
* This must be called BEFORE photos are deleted.
164+
*
165+
* @param string[] $photo_ids
166+
* @param string|null $from_id
167+
* @param string[] $album_ids
168+
*
169+
* @return string[] album IDs that need stats recomputation
170+
*/
171+
private function collectAffectedAlbumIds(array $photo_ids, string|null $from_id, array $album_ids): array
172+
{
173+
if (count($album_ids) > 0) {
174+
// Deleting from specific albums
175+
return $album_ids;
176+
}
177+
178+
if (count($photo_ids) > 0 && $from_id !== null) {
179+
// Deleting specific photos from a specific album
180+
return [$from_id];
181+
}
182+
183+
// No albums affected
184+
return [];
185+
}
186+
152187
/**
153188
* We select all photos which are not in an album and in the preselection.
154189
*

app/Actions/Photo/MoveOrDuplicate.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
use App\Actions\User\Notify;
1313
use App\Constants\PhotoAlbum as PA;
1414
use App\Contracts\Models\AbstractAlbum;
15+
use App\Events\PhotoDeleted;
16+
use App\Events\PhotoSaved;
1517
use App\Models\Album;
1618
use App\Models\Photo;
1719
use App\Models\Purchasable;
@@ -50,6 +52,9 @@ public function do(Collection $photos, ?AbstractAlbum $from_album, ?Album $to_al
5052
->delete();
5153
}
5254

55+
// Dispatch event for source album (photos removed)
56+
PhotoDeleted::dispatchIf($from_album !== null, $from_album?->get_id());
57+
5358
if ($to_album !== null) {
5459
// Delete the existing links at destination (avoid duplicates key contraint)
5560
// If $from === to this operation is not needed.
@@ -60,6 +65,12 @@ public function do(Collection $photos, ?AbstractAlbum $from_album, ?Album $to_al
6065

6166
// Add the new links.
6267
DB::table(PA::PHOTO_ALBUM)->insert(array_map(fn (string $id) => ['photo_id' => $id, 'album_id' => $to_album->id], $photos_ids));
68+
69+
// Dispatch event for destination album (photos added)
70+
// Note: We dispatch PhotoSaved for each photo to trigger recomputation
71+
foreach ($photos as $photo) {
72+
PhotoSaved::dispatch($photo);
73+
}
6374
}
6475

6576
// In case of move, we need to remove the header_id of said photos.

app/Actions/Photo/Pipes/Shared/Save.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use App\Contracts\PhotoCreate\PhotoDTO;
1212
use App\Contracts\PhotoCreate\PhotoPipe;
13+
use App\Events\PhotoSaved;
1314

1415
/**
1516
* Persist current Photo object into database.
@@ -21,6 +22,9 @@ public function handle(PhotoDTO $state, \Closure $next): PhotoDTO
2122
$state->getPhoto()->save();
2223
$state->getPhoto()->tags()->sync($state->getTags()->pluck('id')->all());
2324

25+
// Dispatch event for album stats recomputation
26+
PhotoSaved::dispatch($state->getPhoto());
27+
2428
return $next($state);
2529
}
2630
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2025 LycheeOrg.
7+
*/
8+
9+
namespace App\Console\Commands;
10+
11+
use App\Jobs\RecomputeAlbumStatsJob;
12+
use App\Models\Album;
13+
use Illuminate\Console\Command;
14+
use Illuminate\Support\Facades\Log;
15+
16+
class BackfillAlbumFields extends Command
17+
{
18+
/**
19+
* The name and signature of the console command.
20+
*
21+
* @var string
22+
*/
23+
protected $signature = 'lychee:backfill-album-fields
24+
{--dry-run : Preview without making changes}
25+
{--chunk=1000 : Number of albums to process per batch}';
26+
27+
/**
28+
* The console command description.
29+
*
30+
* @var string
31+
*/
32+
protected $description = 'Backfill computed fields (min/max taken_at, num_children, num_photos, cover IDs) for all albums';
33+
34+
/**
35+
* Execute the console command.
36+
*/
37+
public function handle(): int
38+
{
39+
$dry_run = $this->option('dry-run');
40+
$chunk_size = (int) $this->option('chunk');
41+
42+
if ($chunk_size < 1) {
43+
$this->error('Chunk size must be at least 1');
44+
45+
return Command::FAILURE;
46+
}
47+
48+
$this->info('Starting album fields backfill...');
49+
if ($dry_run) {
50+
$this->warn('DRY RUN MODE - No changes will be made');
51+
}
52+
53+
// Get total count
54+
$total = Album::query()->count();
55+
$this->info("Found {$total} albums to process");
56+
57+
if ($total === 0) {
58+
$this->info('No albums to process');
59+
60+
return Command::SUCCESS;
61+
}
62+
63+
// Process albums ordered by _lft ASC (leaf-to-root order)
64+
// This ensures child albums are computed before parents
65+
$bar = $this->output->createProgressBar($total);
66+
$bar->start();
67+
68+
$processed = 0;
69+
70+
Album::query()
71+
->orderBy('_lft', 'asc')
72+
->chunk($chunk_size, function (\Illuminate\Database\Eloquent\Collection $albums) use ($dry_run, &$processed, $bar): void {
73+
/** @var Album $album */
74+
foreach ($albums as $album) {
75+
if (!$dry_run) {
76+
// Dispatch job to recompute stats for this album
77+
RecomputeAlbumStatsJob::dispatch($album->id);
78+
}
79+
80+
$processed++;
81+
$bar->advance();
82+
83+
// Log progress at intervals
84+
if ($processed % 100 === 0) {
85+
$percentage = round(($processed / $bar->getMaxSteps()) * 100, 2);
86+
Log::info("Backfilled {$processed}/{$bar->getMaxSteps()} albums ({$percentage}%)");
87+
}
88+
}
89+
});
90+
91+
$bar->finish();
92+
$this->newLine(2);
93+
94+
if ($dry_run) {
95+
$this->info("DRY RUN: Would have dispatched {$processed} jobs");
96+
} else {
97+
$this->info("Dispatched {$processed} jobs to recompute album stats");
98+
$this->info('Jobs will be processed by the queue worker');
99+
$this->warn('Note: This operation may take some time for large galleries');
100+
}
101+
102+
Log::info("Backfill completed: {$processed} albums processed");
103+
104+
return Command::SUCCESS;
105+
}
106+
}

0 commit comments

Comments
 (0)