Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(server): xmp sidecar metadata #2466

Merged
merged 37 commits into from
May 25, 2023
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
df6df1a
initial commit for XMP sidecar support
alex-phillips May 16, 2023
8ed2003
Added support for 'missing' metadata files to include those without s…
alex-phillips May 17, 2023
50454e9
didn't mean to commit default log level during testing
alex-phillips May 17, 2023
5732251
new sidecar logic for video metadata as well
alex-phillips May 17, 2023
c33eaf9
Merge branch 'main' into xmp-sidecars
alex-phillips May 17, 2023
5e3ab35
Added xml mimetype for sidecars only
alex-phillips May 18, 2023
f9920ee
don't need capture group for this regex
alex-phillips May 18, 2023
1803ec5
wrong default value reverted
alex-phillips May 18, 2023
84602b7
simplified the move here - keep it in the same try catch since the ou…
alex-phillips May 18, 2023
bda9918
simplified setter logic
alex-phillips May 18, 2023
4346d1b
simplified logic per suggestions
alex-phillips May 18, 2023
9e8aab2
sidecar is now its own queue with a discover and sync, updated UI for…
alex-phillips May 18, 2023
4b84ebe
queue a sidecar job for every asset based on discovery or sync, thoug…
alex-phillips May 18, 2023
39ef43c
now queue sidecar jobs for each assset, though logic is mostly the sa…
alex-phillips May 18, 2023
8b0a773
simplified logic of filename extraction and asset instantiation
alex-phillips May 18, 2023
43ce0b7
not sure how that got deleted..
alex-phillips May 19, 2023
9ffb38a
updated code per suggestions and comments in the PR
alex-phillips May 20, 2023
36e780f
stat was not being used, removed the variable set
alex-phillips May 20, 2023
ef9ebac
better type checking, using in-scope variables for exif getter instea…
alex-phillips May 20, 2023
33b4d3c
removed commented out test
alex-phillips May 20, 2023
208653f
ran and resolved all lints, formats, checks, and tests
alex-phillips May 20, 2023
f141da9
resolved suggested change in PR
alex-phillips May 20, 2023
5aed905
made getExifProperty more dynamic with multiple possible args for fal…
alex-phillips May 20, 2023
0d51233
better error handling and moving files back to positions on move or s…
alex-phillips May 20, 2023
40a5fa5
Merged in main
alex-phillips May 20, 2023
f3f255d
regenerated api
alex-phillips May 21, 2023
52be700
Merge branch 'main' into xmp-sidecars
alex-phillips May 21, 2023
7a2be55
format fixes
alex-phillips May 21, 2023
faa2d8f
Added XMP documentation
alex-phillips May 22, 2023
a89e4eb
documentation typo
alextran1502 May 22, 2023
acfc04b
Merged in main
alex-phillips May 23, 2023
327a912
merged in main
alex-phillips May 23, 2023
6b7136f
missed merge conflict
alex-phillips May 23, 2023
9dc4549
more changes due to a merge
alex-phillips May 24, 2023
1337eaf
merged in main
alex-phillips May 24, 2023
1602d5a
Resolving conflicts
alex-phillips May 24, 2023
b90d482
added icon for sidecar jobs
alex-phillips May 24, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/docs/features/img/sidecar-jobs.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/docs/features/img/xmp-sidecars.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions docs/docs/features/xmp-sidecars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# XMP Sidecars

Immich can ingest XMP sidecars on file upload (via the CLI) as well as detect new sidecars that are placed in the filesystem for existing images.

<img src={require('./img/xmp-sidecars.png').default} title='XMP sidecars' />

XMP sidecars are external XML files that contain metadata related to media files. Many applications read and write these files either exclusively or in addition to the metadata written to image files. They can be a powerful tool for editing and storing metadata of a media file without modifying the mdia file itself. When Immich receives or detects an XMP sidecar for a media file, it will attempt to extract the metadata from both the sidecar as well as the media file. It will prioritize the metadata for fields in the sidecar but will fall back and use the metadata in the media file if necessary.

When importing files via the CLI bulk uploader, Immich will automatically detect XMP sidecar files as files that exist next to the original media file and have the exact same name with an additional `.xmp` file extension (i.e., `PXL_20230401_203352928.MP.jpg` and `PXL_20230401_203352928.MP.jpg.xmp`).

There are 2 administrator jobs associated with sidecar files: `SYNC` and `DISCOVER`. The sync job will re-scan all media with existing sidecar files and queue them for a metadata refresh. This is a great use case when third-party applications are used to modify the metadata of media. The discover job will attempt to scan the filesystem for new sidecar files for all media that does not currently have a sidecar file associated with it.

<img src={require('./img/sidecar-jobs.png').default} title='Sidecar Administrator Jobs' />
1 change: 1 addition & 0 deletions mobile/openapi/doc/AllJobStatusResponseDto.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions mobile/openapi/doc/AssetApi.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 12 additions & 3 deletions mobile/openapi/lib/api/asset_api.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 11 additions & 3 deletions mobile/openapi/lib/model/all_job_status_response_dto.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions mobile/openapi/lib/model/job_name.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions mobile/openapi/test/all_job_status_response_dto_test.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mobile/openapi/test/asset_api_test.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 9 additions & 2 deletions server/apps/immich/src/api-v1/asset/asset.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class AssetController {
[
{ name: 'assetData', maxCount: 1 },
{ name: 'livePhotoData', maxCount: 1 },
{ name: 'sidecarData', maxCount: 1 },
],
assetUploadOption,
),
Expand All @@ -88,18 +89,24 @@ export class AssetController {
async uploadFile(
@GetAuthUser() authUser: AuthUserDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] }))
files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[] },
files: { assetData: ImmichFile[]; livePhotoData?: ImmichFile[]; sidecarData: ImmichFile[] },
@Body(new ValidationPipe()) dto: CreateAssetDto,
@Response({ passthrough: true }) res: Res,
): Promise<AssetFileUploadResponseDto> {
const file = mapToUploadFile(files.assetData[0]);
const _livePhotoFile = files.livePhotoData?.[0];
const _sidecarFile = files.sidecarData?.[0];
let livePhotoFile;
if (_livePhotoFile) {
livePhotoFile = mapToUploadFile(_livePhotoFile);
}

const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile);
let sidecarFile;
if (_sidecarFile) {
sidecarFile = mapToUploadFile(_sidecarFile);
}

const responseDto = await this.assetService.uploadFile(authUser, dto, file, livePhotoFile, sidecarFile);
if (responseDto.duplicate) {
res.status(200);
}
Expand Down
2 changes: 2 additions & 0 deletions server/apps/immich/src/api-v1/asset/asset.core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export class AssetCore {
dto: CreateAssetDto,
file: UploadFile,
livePhotoAssetId?: string,
sidecarFile?: UploadFile,
): Promise<AssetEntity> {
const asset = await this.repository.create({
owner: { id: authUser.id } as UserEntity,
Expand Down Expand Up @@ -39,6 +40,7 @@ export class AssetCore {
sharedLinks: [],
originalFileName: parse(file.originalName).name,
faces: [],
sidecarPath: sidecarFile?.originalPath || null,
});

await this.jobRepository.queue({ name: JobName.ASSET_UPLOADED, data: { asset } });
Expand Down
6 changes: 5 additions & 1 deletion server/apps/immich/src/api-v1/asset/asset.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ describe('AssetService', () => {

expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined] },
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
});
expect(storageMock.moveFile).not.toHaveBeenCalled();
});
Expand Down Expand Up @@ -413,10 +413,12 @@ describe('AssetService', () => {
undefined,
undefined,
undefined,
undefined,
'fake_path/asset_1.mp4',
undefined,
undefined,
undefined,
undefined,
],
},
});
Expand Down Expand Up @@ -462,10 +464,12 @@ describe('AssetService', () => {
'web-path-1',
'resize-path-1',
undefined,
undefined,
'original-path-2',
'web-path-2',
'resize-path-2',
'encoded-video-path-2',
undefined,
],
},
},
Expand Down
13 changes: 10 additions & 3 deletions server/apps/immich/src/api-v1/asset/asset.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export class AssetService {
dto: CreateAssetDto,
file: UploadFile,
livePhotoFile?: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetFileUploadResponseDto> {
if (livePhotoFile) {
livePhotoFile = {
Expand All @@ -116,14 +117,14 @@ export class AssetService {
livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
}

const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id);
const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile);

return { id: asset.id, duplicate: false };
} catch (error: any) {
// clean up files
await this.jobRepository.queue({
name: JobName.DELETE_FILES,
data: { files: [file.originalPath, livePhotoFile?.originalPath] },
data: { files: [file.originalPath, livePhotoFile?.originalPath, sidecarFile?.originalPath] },
});

// handle duplicates with a success response
Expand Down Expand Up @@ -359,7 +360,13 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });

result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
deleteQueue.push(asset.originalPath, asset.webpPath, asset.resizePath, asset.encodedVideoPath);
deleteQueue.push(
asset.originalPath,
asset.webpPath,
asset.resizePath,
asset.encodedVideoPath,
asset.sidecarPath,
);

// TODO refactor this to use cascades
if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
Expand Down
3 changes: 3 additions & 0 deletions server/apps/immich/src/api-v1/asset/dto/create-asset.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export class CreateAssetDto {

@ApiProperty({ type: 'string', format: 'binary' })
livePhotoData?: any;

@ApiProperty({ type: 'string', format: 'binary' })
sidecarData?: any;
}

export interface UploadFile {
Expand Down
10 changes: 10 additions & 0 deletions server/apps/immich/src/config/asset-upload.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ function fileFilter(req: AuthRequest, file: any, cb: any) {
) {
cb(null, true);
} else {
// Additionally support XML but only for sidecar files
if (file.fieldname == 'sidecarData' && file.mimetype.match(/\/xml$/)) {
return cb(null, true);
}

logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`);
cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
}
Expand Down Expand Up @@ -95,6 +100,11 @@ function filename(req: AuthRequest, file: Express.Multer.File, cb: any) {
return cb(null, sanitize(livePhotoFileName));
}

if (file.fieldname === 'sidecarData') {
const sidecarFileName = `${fileNameUUID}.xmp`;
return cb(null, sanitize(sidecarFileName));
}

const fileName = `${fileNameUUID}${req.body['fileExtension']}`;
return cb(null, sanitize(fileName));
}
3 changes: 2 additions & 1 deletion server/apps/microservices/src/microservices.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ThumbnailGeneratorProcessor,
VideoTranscodeProcessor,
} from './processors';
import { MetadataExtractionProcessor } from './processors/metadata-extraction.processor';
import { MetadataExtractionProcessor, SidecarProcessor } from './processors/metadata-extraction.processor';

@Module({
imports: [
Expand All @@ -31,6 +31,7 @@ import { MetadataExtractionProcessor } from './processors/metadata-extraction.pr
BackgroundTaskProcessor,
SearchIndexProcessor,
FacialRecognitionProcessor,
SidecarProcessor,
],
})
export class MicroservicesModule {}
Loading