fix: handle removing motion part if asset is not motion photo anymore

This commit is contained in:
Daniel Dietzler
2026-06-11 14:28:10 +02:00
parent 0fb18ed241
commit 144ee8e01b
5 changed files with 49 additions and 4 deletions
+3
View File
@@ -82,6 +82,9 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/353224133"
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[[tools."github:webassembly/binaryen"]]
version = "version_124"
backend = "github:webassembly/binaryen"
+2 -1
View File
@@ -532,4 +532,5 @@ export const lockableProperties = [
'rating',
'timeZone',
'tags',
] as const;
'livePhotoCID',
] as const satisfies Array<keyof AssetExifTable>;
+1 -1
View File
@@ -181,7 +181,7 @@ export class AssetMediaService extends BaseService {
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif({
exif: { assetId: asset.id, fileSizeInByte: file.size },
exif: { assetId: asset.id, fileSizeInByte: file.size, livePhotoCID: dto.livePhotoVideoId },
lockedPropertiesBehavior: 'override',
});
+20 -1
View File
@@ -396,6 +396,23 @@ export class MetadataService extends BaseService {
tasks.push(() => this.applyMotionPhotos(asset, exifTags, dates, stats));
}
if (!this.isMotionPhoto(asset, exifTags) && !exifData.livePhotoCID && asset.livePhotoVideoId) {
// delete the motion part if the asset gets changed to not be a live photo anymore
tasks.push(async () => {
if (!asset.livePhotoVideoId) {
throw new Error('asset.livePhotoVideoId should not have been reset');
}
await this.assetRepository.update({ id: asset.id, livePhotoVideoId: null });
const count = await this.assetRepository.getLivePhotoCount(asset.livePhotoVideoId);
if (count === 0) {
await this.jobRepository.queue({
name: JobName.AssetDelete,
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
});
}
});
}
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
tasks.push(() => this.applyTaggedFaces(asset, exifTags));
}
@@ -500,7 +517,7 @@ export class MetadataService extends BaseService {
const { sidecarFile } = getAssetFiles(asset.files);
const sidecarPath = sidecarFile?.path || `${asset.originalPath}.xmp`;
const { description, dateTimeOriginal, latitude, longitude, rating, tags, timeZone } = _.pick(
const { description, dateTimeOriginal, latitude, longitude, rating, tags, timeZone, livePhotoCID } = _.pick(
{
description: asset.exifInfo.description,
dateTimeOriginal: asset.exifInfo.dateTimeOriginal,
@@ -509,6 +526,7 @@ export class MetadataService extends BaseService {
rating: asset.exifInfo.rating ?? 0,
tags: asset.exifInfo.tags,
timeZone: asset.exifInfo.timeZone,
livePhotoCID: asset.exifInfo.livePhotoCID,
},
lockedProperties,
);
@@ -522,6 +540,7 @@ export class MetadataService extends BaseService {
GPSLongitude: longitude,
Rating: rating,
TagsList: tags,
ContentIdentifier: livePhotoCID,
},
_.isUndefined,
);
@@ -3,10 +3,12 @@ import { Stats } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AssetType, JobName } from 'src/enum';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
@@ -41,7 +43,7 @@ const setup = (db?: Kysely<DB>) => {
SystemMetadataRepository,
TagRepository,
],
mock: [EventRepository, StorageRepository, LoggingRepository],
mock: [EventRepository, JobRepository, StorageRepository, LoggingRepository],
});
ctx.getMock(StorageRepository).stat.mockResolvedValue({
@@ -152,4 +154,24 @@ describe(MetadataService.name, () => {
).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') });
});
});
it('should remove motion asset if asset is updated to not be a motion photo anymore', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
ctx.getMock(JobRepository).queue.mockResolvedValue();
const { user } = await ctx.newUser();
const { asset: motionAsset } = await ctx.newAsset({ ownerId: user.id, type: AssetType.Video });
const { asset } = await ctx.newAsset({ ownerId: user.id, livePhotoVideoId: motionAsset.id });
await ctx.newExif({ assetId: asset.id, description: '' });
await sut.handleMetadataExtraction({ id: asset.id });
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual(
expect.objectContaining({ livePhotoVideoId: null }),
);
expect(ctx.getMock(JobRepository).queue).toHaveBeenCalledWith({
name: JobName.AssetDelete,
data: { id: motionAsset.id, deleteOnDisk: true },
});
});
});