1010
1111use App \Actions \Photo \Extensions \ArchiveFileInfo ;
1212use App \Contracts \Exceptions \LycheeException ;
13+ use App \DTO \ChunkSlice ;
14+ use App \DTO \ZippablePhoto ;
1315use App \Enum \DownloadVariantType ;
1416use App \Enum \SizeVariantType ;
1517use App \Exceptions \ConfigurationKeyMissingException ;
1618use App \Exceptions \Internal \FrameworkException ;
1719use App \Exceptions \Internal \InvalidSizeVariantException ;
1820use App \Exceptions \Internal \LycheeLogicException ;
19- use App \Image \Files \BaseMediaFile ;
2021use App \Image \Files \FlysystemFile ;
2122use App \Models \Photo ;
2223use 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 *
0 commit comments