From 325fb4b5d1854fec46a08296017584ec1beed250 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Tue, 6 Aug 2024 18:27:05 +0200 Subject: [PATCH] fix(server): video duration extraction (#11610) --- server/src/services/metadata.service.spec.ts | 69 +++++++++++++------- server/src/services/metadata.service.ts | 56 ++++++++-------- 2 files changed, 72 insertions(+), 53 deletions(-) diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 956b45e214..e9d09e33aa 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -647,13 +647,19 @@ describe(MetadataService.name, () => { }); }); - it('should handle duration', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: 6.21 }); + it('should extract duration', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 6.21, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ @@ -663,10 +669,15 @@ describe(MetadataService.name, () => { ); }); - it('should handle duration in ISO time string', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: '00:00:08.41' }); - + it('only extracts duration for videos', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 6.21, + }, + }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); @@ -674,39 +685,51 @@ describe(MetadataService.name, () => { expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, - duration: '00:00:08.410', + duration: null, }), ); }); - it('should handle duration as an object without Scale', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: { Value: 6.2 } }); + it('omits duration of zero', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 0, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ id: assetStub.image.id, - duration: '00:00:06.200', + duration: null, }), ); }); - it('should handle duration with scale', async () => { - assetMock.getByIds.mockResolvedValue([assetStub.image]); - metadataMock.readTags.mockResolvedValue({ Duration: { Scale: 1.111_111_111_111_11e-5, Value: 558_720 } }); + it('handles duration of 1 week', async () => { + assetMock.getByIds.mockResolvedValue([{ ...assetStub.video }]); + mediaMock.probe.mockResolvedValue({ + ...probeStub.videoStreamH264, + format: { + ...probeStub.videoStreamH264.format, + duration: 604_800, + }, + }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: assetStub.video.id }); - expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); + expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]); expect(assetMock.upsertExif).toHaveBeenCalled(); expect(assetMock.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, - duration: '00:00:06.207', + id: assetStub.video.id, + duration: '168:00:00.000', }), ); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index ee3b24fad5..aa29d47131 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -214,28 +214,7 @@ export class MetadataService implements OnEvents { const { exifData, tags } = await this.exifData(asset); if (asset.type === AssetType.VIDEO) { - const { videoStreams } = await this.mediaRepository.probe(asset.originalPath); - - if (videoStreams[0]) { - switch (videoStreams[0].rotation) { - case -90: { - exifData.orientation = Orientation.Rotate90CW; - break; - } - case 0: { - exifData.orientation = Orientation.Horizontal; - break; - } - case 90: { - exifData.orientation = Orientation.Rotate270CW; - break; - } - case 180: { - exifData.orientation = Orientation.Rotate180; - break; - } - } - } + await this.applyVideoMetadata(asset, exifData); } await this.applyMotionPhotos(asset, tags); @@ -252,7 +231,7 @@ export class MetadataService implements OnEvents { } await this.assetRepository.update({ id: asset.id, - duration: tags.Duration ? this.getDuration(tags.Duration) : null, + duration: asset.duration, localDateTime, fileCreatedAt: exifData.dateTimeOriginal ?? undefined, }); @@ -567,16 +546,33 @@ export class MetadataService implements OnEvents { return bitsPerSample; } - private getDuration(seconds?: ImmichTags['Duration']): string { - let _seconds = seconds as number; + private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { + const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath); - if (typeof seconds === 'object') { - _seconds = seconds.Value * (seconds?.Scale || 1); - } else if (typeof seconds === 'string') { - _seconds = Duration.fromISOTime(seconds).as('seconds'); + if (videoStreams[0]) { + switch (videoStreams[0].rotation) { + case -90: { + exifData.orientation = Orientation.Rotate90CW; + break; + } + case 0: { + exifData.orientation = Orientation.Horizontal; + break; + } + case 90: { + exifData.orientation = Orientation.Rotate270CW; + break; + } + case 180: { + exifData.orientation = Orientation.Rotate180; + break; + } + } } - return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS'); + if (format.duration) { + asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS'); + } } private async processSidecar(id: string, isSync: boolean): Promise {