This document traces the complete lifecycle of photo upload in Lychee, including the chunked upload process, file processing, and metadata extraction. Understanding this flow will help developers navigate the upload mechanism and related processing systems.
1. Frontend (Upload UI) → 2. File Chunking → 3. Route → 4. Middleware → 5. Request Validation → 6. Controller → 7. Chunk Assembly → 8. Processing (Job/Sync) → 9. Action Pipeline → 10. Size Variants → 11. Database → 12. Response → 13. Frontend Update
Let's trace a photo upload from the user selecting files to the final processed photo being available in the gallery.
The lifecycle begins when a user selects files in the upload dialog:
<!-- File: resources/js/components/modals/UploadPanel.vue -->
<input v-on:change="upload" type="file" id="myFiles" multiple class="hidden" />When files are selected, they are added to the upload queue:
function upload(event: Event) {
const target = event.target as HTMLInputElement;
if (target.files === null) return;
for (let i = 0; i < target.files.length; i++) {
list_upload_files.value.push({
file: target.files[i],
status: "waiting"
});
}
// Start processing uploads with configured limit
uploadNext(0, setup.value?.upload_processing_limit);
}Large files are split into smaller chunks for reliable upload:
// File: resources/js/components/forms/upload/UploadingLine.vue
const meta = ref({
file_name: file.value.name,
extension: null,
uuid_name: null,
stage: "uploading",
chunk_number: 0,
total_chunks: Math.ceil(size.value / props.chunkSize),
} as App.Http.Resources.Editable.UploadMetaResource);
function process() {
meta.value.chunk_number = meta.value.chunk_number + 1;
const chunkEnd = Math.min(chunkStart.value + props.chunkSize, size.value);
const chunk = file.value.slice(chunkStart.value, chunkEnd);
const data: UploadData = {
album_id: props.albumId,
file: chunk,
file_last_modified_time: file.value.lastModified,
meta: meta.value,
onUploadProgress: (progressEvent) => {
// Update progress bar
const percent = progressEvent.loaded / (progressEvent.total ?? 1);
progress.value = Math.round(((chunkStart.value + percent * (chunkEnd - chunkStart.value)) / size.value) * 100);
},
};
UploadService.upload(data, controller.value)
.then((response) => {
meta.value = response.data;
if (response.data.chunk_number === response.data.total_chunks) {
// Upload complete
progress.value = 100;
status.value = "done";
} else {
// Continue with next chunk
chunkStart.value += props.chunkSize;
process();
}
});
}Key Chunking Features:
- Chunk Size: Configured via
upload_chunk_sizesetting - Progress Tracking: Real-time progress updates per chunk
- Error Recovery: Failed chunks can be retried
- Parallel Processing: Multiple files upload simultaneously (up to
upload_processing_limit)
The frontend uses a dedicated service to handle the HTTP request:
// File: resources/js/services/upload-service.ts
upload(info: UploadData, abortController: AbortController): Promise<AxiosResponse<UploadMetaResource>> {
const formData = new FormData();
formData.append("file", info.file, info.meta.file_name);
formData.append("file_name", info.meta.file_name);
formData.append("album_id", info.album_id ?? "");
formData.append("file_last_modified_time", info.file_last_modified_time?.toString() ?? "");
formData.append("uuid_name", info.meta.uuid_name ?? "");
formData.append("extension", info.meta.extension ?? "");
formData.append("chunk_number", info.meta.chunk_number?.toString() ?? "");
formData.append("total_chunks", info.meta.total_chunks?.toString() ?? "");
const config: AxiosRequestConfig<FormData> = {
onUploadProgress: info.onUploadProgress,
headers: { "Content-Type": "application/json" },
signal: abortController.signal,
transformRequest: [(data) => data],
};
return axios.post(`${Constants.getApiUrl()}Photo`, formData, config);
}Laravel resolves the upload request:
// File: routes/api_v2.php
Route::post('/Photo', [Gallery\PhotoController::class, 'upload'])
->middleware(['throttle:upload']);The request is validated using a dedicated Request class:
// File: app/Http/Requests/Photo/UploadPhotoRequest.php
class UploadPhotoRequest extends BaseApiRequest
{
public function authorize(): bool
{
return Gate::check(AlbumPolicy::CAN_UPLOAD, [AbstractAlbum::class, $this->album]);
}
public function rules(): array
{
return [
RequestAttribute::ALBUM_ID_ATTRIBUTE => ['present', new AlbumIDRule(true)],
RequestAttribute::FILE_LAST_MODIFIED_TIME => 'sometimes|nullable|numeric',
RequestAttribute::FILE_ATTRIBUTE => ['required', 'file'],
'file_name' => 'required|string',
'uuid_name' => ['present', new FileUuidRule()],
'extension' => ['present', new ExtensionRule()],
'chunk_number' => 'required|integer|min:1',
'total_chunks' => 'required|integer|gte:chunk_number',
];
}
protected function processValidatedValues(array $values, array $files): void
{
$this->album = $this->album_factory->findNullalbleAbstractAlbumOrFail($values[RequestAttribute::ALBUM_ID_ATTRIBUTE]);
$this->file_last_modified_time = $values[RequestAttribute::FILE_LAST_MODIFIED_TIME] ?? null;
$this->file_chunk = $files[RequestAttribute::FILE_ATTRIBUTE];
$this->meta = new UploadMetaResource(
file_name: $values['file_name'],
extension: $values['extension'] ?? null,
uuid_name: $values['uuid_name'] ?? null,
stage: FileStatus::UPLOADING,
chunk_number: $values['chunk_number'],
total_chunks: $values['total_chunks'],
);
}
}Validation Steps:
- Authorization: Check if user can upload to target album
- File Validation: Ensure uploaded chunk is valid
- Extension Validation: Verify file extension is supported
- Chunk Validation: Ensure chunk number and total chunks are valid
- UUID Validation: Verify unique file identifier
Important considerations: During the first upload, the uuid_name should be empty.
It is generated by the server and used to know to which block of photo data we are appending the current chunk. Read more here: Lychee Discussions #3518
The PhotoController handles the upload request:
// File: app/Http/Controllers/Gallery/PhotoController.php
public function upload(UploadPhotoRequest $request): UploadMetaResource
{
$meta = $request->meta();
$file = new UploadedFile($request->uploaded_file_chunk());
// Set up metadata if not already present
$meta->extension ??= '.' . pathinfo($meta->file_name, PATHINFO_EXTENSION);
$meta->uuid_name ??= strtr(base64_encode(random_bytes(12)), '+/', '-_') . $meta->extension;
// Append chunk to final file
$final = new NativeLocalFile(Storage::disk(FileSystem::IMAGE_UPLOAD)->path($meta->uuid_name));
$final->append($file->read());
if ($meta->chunk_number < $meta->total_chunks) {
// Not the last chunk - return current status
return $meta;
}
// Last chunk - proceed to processing
$meta->stage = FileStatus::PROCESSING;
return $this->process($final, $request->album(), $request->file_last_modified_time(), $meta);
}For Intermediate Chunks (1 to n-1):
- File Creation: Create or open the target file using UUID name
- Chunk Append: Append the current chunk to the file
- Progress Update: Return current upload status
- Memory Management: Release chunk data immediately
For Final Chunk (n):
- File Completion: Append final chunk to complete the file
- Validation: Verify file integrity
- Processing Initiation: Begin image processing pipeline
Based on configuration, processing occurs either immediately or via job queue:
private function process(
NativeLocalFile $final,
?AbstractAlbum $album,
?int $file_last_modified_time,
UploadMetaResource $meta
): UploadMetaResource {
$processable_file = new ProcessableJobFile(
$final->getOriginalExtension(),
$meta->file_name
);
$processable_file->write($final->read());
ProcessImageJob::dispatch($processable_file, $album, $file_last_modified_time);
$meta->stage = config('queue.default') === 'sync' ? FileStatus::DONE : FileStatus::READY;
return $meta;
}The ProcessImageJob handles the actual photo creation and processing:
// File: app/Jobs/ProcessImageJob.php
public function handle(AlbumFactory $album_factory): void
{
try {
$this->history->status = JobStatus::STARTED;
$this->history->save();
// Convert to TemporaryJobFile for processing
$temp_file = new TemporaryJobFile(
$this->file_path,
$this->original_base_name
);
// Create the photo using Action pattern
$create = new Create(
new ImportMode(
skip_duplicates: false,
import_via_symlink: false,
delete_imported: true,
force_duplicate_check: false
),
$this->user_id
);
$album = $album_factory->findNullalbleAbstractAlbumOrFail($this->album_id);
$photo = $create->add($temp_file, $album, $this->file_last_modified_time);
$this->history->status = JobStatus::SUCCESS;
$this->history->save();
} catch (Exception $e) {
$this->history->status = JobStatus::FAILURE;
$this->history->save();
throw $e;
}
}The Create action orchestrates the photo processing through a pipeline system:
// File: app/Actions/Photo/Create.php
public function add(NativeLocalFile $source_file, ?AbstractAlbum $album, ?int $file_last_modified_time): Photo
{
// Pre-processing pipeline
$pre_pipes = [
Init\CreateInitialDTO::class,
Init\SetTakenAt::class,
Init\VerifyChecksum::class,
Init\FindDuplicate::class,
];
$init_dto = app(Pipeline::class)
->send($init_dto)
->through($pre_pipes)
->thenReturn();
if ($init_dto->duplicate !== null) {
return $this->handleDuplicate($init_dto);
}
// Post-processing pipeline
$post_pipes = [
Init\InitParentAlbum::class,
Init\LoadFileMetadata::class,
Init\FindLivePartner::class,
];
$init_dto = app(Pipeline::class)
->send($init_dto)
->through($post_pipes)
->thenReturn();
// Handle different photo types
if ($init_dto->live_partner === null) {
return $this->handleStandalone($init_dto);
}
// Handle Live Photos (if applicable)
// ...
}For a comprehensive understanding of the photo creation pipeline, including all pipe interfaces, DTOs, and processing stages, see the Photo Actions Documentation which provides detailed technical documentation about the Action Pattern implementation and pipeline architecture used in photo processing.
During processing, comprehensive metadata is extracted:
// File: app/Actions/Photo/Pipes/Standalone/ExtractMetadata.php
class ExtractMetadata implements Pipe
{
public function handle(UploadDTO $upload_dto, Closure $next): UploadDTO
{
$source_file = $upload_dto->source_file;
// Extract EXIF data
$exif_reader = new ExifReader($source_file);
$upload_dto->exif_dto = $exif_reader->read();
// Extract GPS coordinates
if ($upload_dto->exif_dto->gps !== null) {
$upload_dto->coordinates = $this->extractCoordinates($upload_dto->exif_dto->gps);
}
// Extract camera information
$upload_dto->camera_info = $this->extractCameraInfo($upload_dto->exif_dto);
return $next($upload_dto);
}
}Extracted Metadata:
- EXIF Data: Camera settings, exposure, focal length, etc.
- GPS Coordinates: Location information (if available)
- Timestamps: When photo was taken vs. uploaded
- Camera Information: Make, model, lens information
- Technical Details: Dimensions, file size, format
- Color Profile: ICC profile information
For standalone photos, multiple size variants are created:
private function handleStandalone(UploadDTO $init_dto): Photo
{
$pipes = [
Standalone\Init::class,
Standalone\ExtractMetadata::class,
Standalone\GenerateSizeVariants::class,
Shared\CreatePhoto::class,
Shared\CreateSizeVariant::class,
Shared\Save::class,
Shared\SaveStatistics::class,
];
return $this->executePipeOnDTO($pipes, $init_dto)->getPhoto();
}Size Variants Created:
- Original: Full-resolution uploaded image
- Medium: Web-optimized version (typically max 1080px)
- Medium2x: Higher resolution for retina displays (2x medium size)
- Small: Thumbnail version (typically max 320px)
- Small2z: Higher resolution for retina displays (typically 2x small size)
- Thumb: Small thumbnail for icons views.
- Thumb2x: Higher resolution thumbnail
Multiple database operations occur during photo creation:
-- 1. Insert photo record
INSERT INTO photos (
id, title, description, owner_id, album_id,
taken_at, created_at, updated_at, filesize,
original_checksum, live_photo_checksum,
latitude, longitude, camera_make, camera_model,
iso, aperture, focal, shutter, lens
) VALUES (...);
-- 2. Insert size variants
INSERT INTO size_variants (
photo_id, type, url, width, height, filesize
) VALUES
('photo-uuid', 'ORIGINAL', 'path/to/original.jpg', 4000, 3000, 2048576),
('photo-uuid', 'MEDIUM', 'path/to/medium.jpg', 1920, 1440, 512000),
('photo-uuid', 'SMALL', 'path/to/small.jpg', 540, 405, 128000),
('photo-uuid', 'THUMB', 'path/to/thumb.jpg', 200, 150, 32000);
-- 3. Create statistics record
INSERT INTO photo_statistics (
photo_id, visit_count, download_count,
favourite_count, shared_count
) VALUES ('photo-uuid', 0, 0, 0, 0);The response varies based on processing mode:
Asynchronous Processing (Queue Enabled):
{
"file_name": "IMG_1234.jpg",
"extension": ".jpg",
"uuid_name": "AbC123DeF456.jpg",
"stage": "ready",
"chunk_number": 5,
"total_chunks": 5
}In synchronous processing, the response indicates completion by changing the stage to "done":
{
"file_name": "IMG_1234.jpg",
"extension": ".jpg",
"uuid_name": "AbC123DeF456.jpg",
"stage": "done",
"chunk_number": 5,
"total_chunks": 5
}The frontend handles upload completion:
UploadService.upload(data, controller.value)
.then((response) => {
meta.value = response.data;
if (response.data.chunk_number === response.data.total_chunks) {
progress.value = 100;
status.value = "done";
emits("upload:completed", props.index, "done");
}
})
.catch((error) => {
// Handle specific error cases
switch (error.response.status) {
case 413: errorMessage.value = "File too large"; break;
case 422: errorMessage.value = "Invalid file format"; break;
case 500: errorMessage.value = "Server error occurred"; break;
}
status.value = "error";
emits("upload:completed", props.index, "error");
});On Upload Completion:
- Cache Invalidation: Clear album cache to show new photos
- UI Updates: Refresh gallery view to display new photos
- Progress Cleanup: Remove upload progress indicators
- Notification: Show success/error notifications to user
The photo upload lifecycle in Lychee demonstrates a sophisticated, chunked upload system with comprehensive processing capabilities:
- Reliable Upload: Chunked uploads with progress tracking and error recovery
- Flexible Processing: Synchronous or asynchronous processing modes
- Rich Metadata: Comprehensive EXIF and GPS data extraction
- Multiple Formats: Support for various image and video formats
- Performance Optimization: Parallel processing and efficient resource usage
- Security Focus: Multiple validation layers and secure file handling
- User Experience: Real-time progress updates and clear error messaging
This architecture ensures reliable photo uploads while maintaining performance and security.
Last updated: December 22, 2025