Skip to content

Commit 86298f9

Browse files
authored
feat(35) Chunked Archive Download (#4300)
1 parent a478d59 commit 86298f9

60 files changed

Lines changed: 1968 additions & 217 deletions

Some content is hidden

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

app/Actions/Album/BaseArchive.php

Lines changed: 293 additions & 141 deletions
Large diffs are not rendered by default.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\Actions\Album;
10+
11+
use App\Contracts\Models\AbstractAlbum;
12+
use App\Events\Metrics\AlbumDownload;
13+
use App\Http\Resources\GalleryConfigs\ZipChunkData;
14+
use App\Models\Album;
15+
use App\Policies\AlbumPolicy;
16+
use Illuminate\Support\Collection;
17+
use Illuminate\Support\Facades\Gate;
18+
19+
class ZipChunkCount
20+
{
21+
public function __construct(
22+
private bool $should_measure,
23+
private string $visitor_id,
24+
) {
25+
}
26+
27+
/**
28+
* @param Collection<int,AbstractAlbum> $albums
29+
*
30+
* @return ZipChunkData
31+
*/
32+
public function getZipChunkData(
33+
Collection $albums,
34+
): ZipChunkData {
35+
$chunk_size = max(1, request()->configs()->getValueAsInt('download_archive_chunk_size'));
36+
37+
// We dispatch one event per album.
38+
$total = 0;
39+
foreach ($albums as $album) {
40+
$total += $this->getPhotoCountForAlbum($album);
41+
}
42+
$total_chunks = max(1, (int) ceil($total / $chunk_size));
43+
44+
return new ZipChunkData(
45+
total_chunks: $total_chunks,
46+
total_photos: $total,
47+
);
48+
}
49+
50+
/**
51+
* Count recursively.
52+
* Not ideal for query, but we need to check permissions and dispatch events, so we need to load the albums anyway.
53+
*
54+
* @param AbstractAlbum $album
55+
*
56+
* @return int
57+
*/
58+
private function getPhotoCountForAlbum(
59+
AbstractAlbum $album,
60+
): int {
61+
if (!Gate::check(AlbumPolicy::CAN_DOWNLOAD, $album)) {
62+
return 0;
63+
}
64+
65+
AlbumDownload::dispatchIf($this->should_measure, $this->visitor_id, $album->get_id());
66+
$total = $album->photos()->count();
67+
68+
if ($album instanceof Album) {
69+
foreach ($album->children()->get() as $child) { /** @phpstan-ignore foreach.nonIterable (false positive) */
70+
$total += $this->getPhotoCountForAlbum($child);
71+
}
72+
}
73+
74+
return $total;
75+
}
76+
}

app/Actions/Photo/BaseArchive.php

Lines changed: 175 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010

1111
use App\Actions\Photo\Extensions\ArchiveFileInfo;
1212
use App\Contracts\Exceptions\LycheeException;
13+
use App\DTO\ChunkSlice;
14+
use App\DTO\ZippablePhoto;
1315
use App\Enum\DownloadVariantType;
1416
use App\Enum\SizeVariantType;
1517
use App\Exceptions\ConfigurationKeyMissingException;
1618
use App\Exceptions\Internal\FrameworkException;
1719
use App\Exceptions\Internal\InvalidSizeVariantException;
1820
use App\Exceptions\Internal\LycheeLogicException;
19-
use App\Image\Files\BaseMediaFile;
2021
use App\Image\Files\FlysystemFile;
2122
use App\Models\Photo;
2223
use App\Repositories\ConfigManager;
@@ -46,6 +47,26 @@ abstract class BaseArchive
4647

4748
protected int $deflate_level = -1;
4849

50+
/**
51+
* @return ZipStream
52+
*
53+
* @throws ConfigurationKeyMissingException
54+
*/
55+
abstract protected function createZip(): ZipStream;
56+
57+
/**
58+
* @param ZipStream $zip
59+
* @param ZippablePhoto $zippable_photo,
60+
*
61+
* @return void
62+
*
63+
* @codeCoverageIgnore
64+
*/
65+
abstract protected function addFileToZip(
66+
ZipStream $zip,
67+
ZippablePhoto $zippable_photo,
68+
): void;
69+
4970
/**
5071
* Resolve which version of the archive to use.
5172
*
@@ -72,13 +93,18 @@ public static function resolve(): self
7293
*
7394
* @param Collection<int,Photo> $photos the photos which shall be included in the response
7495
* @param DownloadVariantType $download_variant the desired variant of the photo
96+
* @param ChunkSlice|null $slice optional chunk slice for chunked downloads
7597
*
7698
* @return StreamedResponse
7799
*
78100
* @throws LycheeException
79101
*/
80-
public function do(Collection $photos, DownloadVariantType $download_variant): StreamedResponse
102+
public function do(Collection $photos, DownloadVariantType $download_variant, ?ChunkSlice $slice = null): StreamedResponse
81103
{
104+
if ($slice !== null) {
105+
return $this->zipSliced($photos, $download_variant, $slice);
106+
}
107+
82108
if ($photos->count() === 1) {
83109
$response = $this->file($photos->firstOrFail(), $download_variant);
84110
} else {
@@ -153,6 +179,145 @@ protected function file(Photo $photo, DownloadVariantType $download_variant): St
153179
}
154180
}
155181

182+
/**
183+
* Produces a chunked (partial) ZIP archive for photos.
184+
*
185+
* Pre-computes filenames for the complete photo set so that names are
186+
* globally unique across all chunks, then streams only the slice.
187+
*
188+
* @param Collection<int,Photo> $photos
189+
* @param DownloadVariantType $download_variant
190+
* @param ChunkSlice $slice
191+
*
192+
* @return StreamedResponse
193+
*
194+
* @throws FrameworkException
195+
* @throws ConfigurationKeyMissingException
196+
*/
197+
protected function zipSliced(Collection $photos, DownloadVariantType $download_variant, ChunkSlice $slice): StreamedResponse
198+
{
199+
$config_manager = app(ConfigManager::class);
200+
$this->deflate_level = $config_manager->getValueAsInt('zip_deflate_level');
201+
202+
// Pass 1: pre-compute globally-unique filenames for the full photo set.
203+
$filename_map = $this->buildFilenameMap($photos, $download_variant);
204+
205+
// Slice the ordered list of photo IDs (keys of the map) to select the chunk.
206+
$photo_ids_in_order = array_keys($filename_map);
207+
$ids_in_slice = array_slice($photo_ids_in_order, $slice->offset, $slice->limit);
208+
$ids_set = array_flip($ids_in_slice);
209+
210+
$response_generator = function () use ($photos, $download_variant, $filename_map, $ids_set): void {
211+
$zip = $this->createZip();
212+
213+
/** @var Photo $photo */
214+
foreach ($photos as $photo) {
215+
if (!array_key_exists($photo->id, $ids_set)) {
216+
continue;
217+
}
218+
219+
try {
220+
$archive_file_info = $this->extractFileInfo($photo, $download_variant);
221+
} catch (\Throwable) {
222+
continue;
223+
}
224+
225+
$filename = $filename_map[$photo->id];
226+
$zippable_photo = new ZippablePhoto(
227+
file_name: $filename,
228+
file: $archive_file_info->file,
229+
title: null,
230+
last_modification_date_time: null,
231+
);
232+
233+
$this->addFileToZip($zip, $zippable_photo);
234+
$archive_file_info->file->close();
235+
236+
try {
237+
set_time_limit((int) ini_get('max_execution_time'));
238+
} catch (InfoException) {
239+
// Silently do nothing, if `set_time_limit` is denied.
240+
}
241+
}
242+
243+
$zip->finish();
244+
};
245+
246+
try {
247+
$response = new StreamedResponse($response_generator);
248+
$disposition = HeaderUtils::makeDisposition(
249+
HeaderUtils::DISPOSITION_ATTACHMENT,
250+
'Photos.part' . $slice->chunk . '.zip'
251+
);
252+
$response->headers->set('Content-Type', 'application/x-zip');
253+
$response->headers->set('Content-Disposition', $disposition);
254+
$response->headers->set('Cache-Control', 'no-cache, no-store, must-revalidate');
255+
$response->headers->set('Pragma', 'no-cache');
256+
$response->headers->set('Expires', '0');
257+
} catch (\InvalidArgumentException $e) {
258+
throw new FrameworkException('Symfony\'s response component', $e);
259+
}
260+
261+
return $response;
262+
}
263+
264+
/**
265+
* Pre-computes globally-unique filenames for every photo in the collection.
266+
*
267+
* Returns an ordered map of photo_id => final_filename, preserving the
268+
* iteration order of $photos so that offset/limit slicing is stable.
269+
*
270+
* @param Collection<int,Photo> $photos
271+
* @param DownloadVariantType $download_variant
272+
*
273+
* @return array<string,string> photo_id => final_filename
274+
*/
275+
private function buildFilenameMap(Collection $photos, DownloadVariantType $download_variant): array
276+
{
277+
/** @var array<string,ArchiveFileInfo> $archive_file_infos photo_id => info */
278+
$archive_file_infos = [];
279+
$unique_filenames = [];
280+
$ambiguous_filenames = [];
281+
282+
// Partition into unique / ambiguous (same logic as zip())
283+
/** @var Photo $photo */
284+
foreach ($photos as $photo) {
285+
try {
286+
$info = $this->extractFileInfo($photo, $download_variant);
287+
} catch (\Throwable) {
288+
continue;
289+
}
290+
$archive_file_infos[$photo->id] = $info;
291+
$filename = $info->getFilename();
292+
if (array_key_exists($filename, $ambiguous_filenames)) {
293+
// already known duplicate
294+
} elseif (array_key_exists($filename, $unique_filenames)) {
295+
unset($unique_filenames[$filename]);
296+
$ambiguous_filenames[$filename] = 0;
297+
} else {
298+
$unique_filenames[$filename] = 0;
299+
}
300+
}
301+
302+
// Resolve final filenames
303+
$filename_map = [];
304+
foreach ($archive_file_infos as $photo_id => $info) {
305+
$true_filename = $info->getFilename();
306+
if (array_key_exists($true_filename, $unique_filenames)) {
307+
$filename_map[$photo_id] = $true_filename;
308+
} else {
309+
do {
310+
$filename = $info->getFilename('-' . ++$ambiguous_filenames[$true_filename]);
311+
} while (array_key_exists($filename, $unique_filenames));
312+
$filename_map[$photo_id] = $filename;
313+
}
314+
// Close files opened during extractFileInfo to avoid resource leaks.
315+
$info->file->close();
316+
}
317+
318+
return $filename_map;
319+
}
320+
156321
/**
157322
* @param Collection<int,Photo> $photos
158323
* @param DownloadVariantType $download_variant
@@ -259,7 +424,14 @@ protected function zip(Collection $photos, DownloadVariantType $download_variant
259424
);
260425
} while (array_key_exists($filename, $unique_filenames));
261426
}
262-
$this->addFileToZip($zip, $filename, $archive_file_info->file, null);
427+
$zippable_photo = new ZippablePhoto(
428+
file_name: $filename,
429+
file: $archive_file_info->file,
430+
title: null,
431+
last_modification_date_time: null,
432+
);
433+
434+
$this->addFileToZip($zip, $zippable_photo);
263435
$archive_file_info->file->close();
264436
// Reset the execution timeout for every iteration.
265437
try {
@@ -293,15 +465,6 @@ protected function zip(Collection $photos, DownloadVariantType $download_variant
293465
return $response;
294466
}
295467

296-
abstract protected function addFileToZip(ZipStream $zip, string $file_name, FlysystemFile|BaseMediaFile $file, Photo|null $photo): void;
297-
298-
/**
299-
* @return ZipStream
300-
*
301-
* @throws ConfigurationKeyMissingException
302-
*/
303-
abstract protected function createZip(): ZipStream;
304-
305468
/**
306469
* Creates a {@link ArchiveFileInfo} for the indicated photo and variant.
307470
*

app/Contracts/Http/Requests/RequestAttribute.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,4 +150,6 @@ class RequestAttribute
150150
* Album slug attribute.
151151
*/
152152
public const SLUG_ATTRIBUTE = 'slug';
153+
154+
public const CHUNK_ATTRIBUTE = 'chunk';
153155
}

app/DTO/ChunkSlice.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\DTO;
10+
11+
/**
12+
* Value object representing a slice of the photo set for chunked ZIP download.
13+
*/
14+
class ChunkSlice
15+
{
16+
public function __construct(
17+
public readonly int $offset,
18+
public readonly int $limit,
19+
public readonly int $chunk,
20+
) {
21+
}
22+
}

app/DTO/ZippablePhoto.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/**
4+
* SPDX-License-Identifier: MIT
5+
* Copyright (c) 2017-2018 Tobias Reich
6+
* Copyright (c) 2018-2026 LycheeOrg.
7+
*/
8+
9+
namespace App\DTO;
10+
11+
use App\Image\Files\BaseMediaFile;
12+
use App\Image\Files\FlysystemFile;
13+
14+
class ZippablePhoto
15+
{
16+
public function __construct(
17+
public readonly string $file_name,
18+
public readonly FlysystemFile|BaseMediaFile $file,
19+
public readonly ?string $title,
20+
public readonly ?\DateTimeInterface $last_modification_date_time,
21+
) {
22+
}
23+
}

0 commit comments

Comments
 (0)