chore!: duration in milliseconds (#28003)

* server changes

* openapi

* web changes

* mobile changes

* assume 3.0 client

* deprecate

* review feedback

* update medium tests

* linting
This commit is contained in:
Mert
2026-04-30 09:44:27 -04:00
committed by GitHub
parent 97c62136b7
commit b554664791
44 changed files with 1092 additions and 335 deletions
+2 -2
View File
@@ -101,7 +101,7 @@ export class JobService extends BaseService {
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
if (asset) {
this.websocketRepository.clientSend('AssetEditReadyV1', asset.ownerId, {
this.websocketRepository.clientSend('AssetEditReadyV2', asset.ownerId, {
asset: {
id: asset.id,
ownerId: asset.ownerId,
@@ -156,7 +156,7 @@ export class JobService extends BaseService {
this.websocketRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset));
if (asset.exifInfo) {
const exif = asset.exifInfo;
this.websocketRepository.clientSend('AssetUploadReadyV1', asset.ownerId, {
this.websocketRepository.clientSend('AssetUploadReadyV2', asset.ownerId, {
// TODO remove `on_upload_success` and then modify the query to select only the required fields)
asset: {
id: asset.id,
+5 -5
View File
@@ -999,7 +999,7 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: asset.id,
duration: '00:00:06.210',
duration: 6210,
}),
);
});
@@ -1067,7 +1067,7 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: asset.id,
duration: '168:00:00.000',
duration: 604_800_000,
}),
);
});
@@ -1080,7 +1080,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 123_000 }));
});
it('should prefer Duration from exif over sidecar', async () => {
@@ -1092,7 +1092,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(2);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 123_000 }));
});
it('should ignore all Duration tags for definitely static images', async () => {
@@ -1121,7 +1121,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:07:36.000' }));
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: 456_000 }));
});
it('should trim whitespace from description', async () => {
+3 -11
View File
@@ -1001,18 +1001,10 @@ export class MetadataService extends BaseService {
return bitsPerSample;
}
private getDuration(tags: ImmichTags): string | null {
private getDuration(tags: ImmichTags): number | null {
const duration = tags.Duration;
if (typeof duration === 'string') {
return duration;
}
if (typeof duration === 'number') {
return Duration.fromObject({ seconds: duration }).toFormat('hh:mm:ss.SSS');
}
return null;
const seconds = typeof duration === 'number' ? duration : Number.parseFloat(duration as string);
return Number.isFinite(seconds) ? Math.round(Duration.fromObject({ seconds }).toMillis()) : null;
}
private async getVideoTags(originalPath: string) {
+54 -40
View File
@@ -8,8 +8,7 @@ import {
SyncAckDeleteDto,
SyncAckSetDto,
syncAlbumV2ToV1,
syncAssetFaceV2ToV1,
SyncAssetV1,
SyncAssetV2,
SyncItem,
SyncStreamDto,
} from 'src/dtos/sync.dto';
@@ -22,7 +21,7 @@ import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync';
type CheckpointMap = Partial<Record<SyncEntityType, SyncAck>>;
type AssetLike = Omit<SyncAssetV1, 'checksum' | 'thumbhash'> & {
type AssetLike = Omit<SyncAssetV2, 'checksum' | 'thumbhash'> & {
checksum: Buffer<ArrayBufferLike>;
thumbhash: Buffer<ArrayBufferLike> | null;
};
@@ -31,7 +30,7 @@ const COMPLETE_ID = 'complete';
const MAX_DAYS = 30;
const MAX_DURATION = Duration.fromObject({ days: MAX_DAYS });
const mapSyncAssetV1 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV1 => ({
const mapSyncAssetV2 = ({ checksum, thumbhash, ...data }: AssetLike): SyncAssetV2 => ({
...data,
checksum: hexOrBufferToBase64(checksum),
thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
@@ -56,10 +55,13 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.UsersV1,
SyncRequestType.PartnersV1,
SyncRequestType.AssetsV1,
SyncRequestType.AssetsV2,
SyncRequestType.StacksV1,
SyncRequestType.PartnerAssetsV1,
SyncRequestType.PartnerAssetsV2,
SyncRequestType.PartnerStacksV1,
SyncRequestType.AlbumAssetsV1,
SyncRequestType.AlbumAssetsV2,
SyncRequestType.AlbumsV1,
SyncRequestType.AlbumsV2,
SyncRequestType.AlbumUsersV1,
@@ -156,20 +158,26 @@ export class SyncService extends BaseService {
const options: SyncQueryOptions = { nowId, userId: auth.user.id };
const handlers: Record<SyncRequestType, () => Promise<void>> = {
// deprecated handlers
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(),
[SyncRequestType.AssetFacesV1]: () => this.syncAssetFacesV1(),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(),
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(),
[SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(options, response, checkpointMap),
[SyncRequestType.UsersV1]: () => this.syncUsersV1(options, response, checkpointMap),
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(options, response, checkpointMap),
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(options, response, checkpointMap),
[SyncRequestType.AssetsV2]: () => this.syncAssetsV2(options, response, checkpointMap),
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(options, response, checkpointMap),
[SyncRequestType.AssetEditsV1]: () => this.syncAssetEditsV1(options, response, checkpointMap),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.PartnerAssetsV2]: () => this.syncPartnerAssetsV2(options, response, checkpointMap, session.id),
[SyncRequestType.AssetMetadataV1]: () => this.syncAssetMetadataV1(options, response, checkpointMap, auth),
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(options, response, checkpointMap),
[SyncRequestType.AlbumsV2]: () => this.syncAlbumsV2(options, response, checkpointMap),
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumAssetsV2]: () => this.syncAlbumAssetsV2(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(options, response, checkpointMap, session.id),
[SyncRequestType.AlbumAssetExifsV1]: () =>
this.syncAlbumAssetExifsV1(options, response, checkpointMap, session.id),
@@ -178,13 +186,12 @@ export class SyncService extends BaseService {
[SyncRequestType.StacksV1]: () => this.syncStackV1(options, response, checkpointMap),
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(options, response, checkpointMap, session.id),
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV1]: async () => this.syncAssetFacesV1(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: async () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.AssetFacesV2]: () => this.syncAssetFacesV2(options, response, checkpointMap),
[SyncRequestType.UserMetadataV1]: () => this.syncUserMetadataV1(options, response, checkpointMap),
};
} as const;
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
const handler = handlers[type];
const handler = handlers[type as keyof typeof handlers];
await handler();
}
@@ -260,21 +267,31 @@ export class SyncService extends BaseService {
}
}
private async syncAssetsV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
private syncAssetsV1(): Promise<void> {
throw new BadRequestException('SyncRequestType.AssetsV1 is deprecated, use SyncRequestType.AssetsV2 instead');
}
private async syncAssetsV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AssetDeleteV1;
const deletes = this.syncRepository.asset.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetV1;
const upsertType = SyncEntityType.AssetV2;
const upserts = this.syncRepository.asset.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) });
}
}
private async syncPartnerAssetsV1(
private syncPartnerAssetsV1(): Promise<void> {
throw new BadRequestException(
'SyncRequestType.PartnerAssetsV1 is deprecated, use SyncRequestType.PartnerAssetsV2 instead',
);
}
private async syncPartnerAssetsV2(
options: SyncQueryOptions,
response: Writable,
checkpointMap: CheckpointMap,
@@ -286,13 +303,13 @@ export class SyncService extends BaseService {
send(response, { type: deleteType, ids: [id], data });
}
const backfillType = SyncEntityType.PartnerAssetBackfillV1;
const backfillType = SyncEntityType.PartnerAssetBackfillV2;
const backfillCheckpoint = checkpointMap[backfillType];
const partners = await this.syncRepository.partner.getCreatedAfter({
...options,
afterCreateId: backfillCheckpoint?.updateId,
});
const upsertType = SyncEntityType.PartnerAssetV1;
const upsertType = SyncEntityType.PartnerAssetV2;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@@ -313,7 +330,7 @@ export class SyncService extends BaseService {
send(response, {
type: backfillType,
ids: [createId, updateId],
data: mapSyncAssetV1(data),
data: mapSyncAssetV2(data),
});
}
@@ -329,7 +346,7 @@ export class SyncService extends BaseService {
const upserts = this.syncRepository.partnerAsset.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV2(data) });
}
}
@@ -490,20 +507,26 @@ export class SyncService extends BaseService {
}
}
private async syncAlbumAssetsV1(
private syncAlbumAssetsV1(): Promise<void> {
throw new BadRequestException(
'SyncRequestType.AlbumAssetsV1 is deprecated, use SyncRequestType.AlbumAssetsV2 instead',
);
}
private async syncAlbumAssetsV2(
options: SyncQueryOptions,
response: Writable,
checkpointMap: CheckpointMap,
sessionId: string,
) {
const backfillType = SyncEntityType.AlbumAssetBackfillV1;
const backfillType = SyncEntityType.AlbumAssetBackfillV2;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.album.getCreatedAfter({
...options,
afterCreateId: backfillCheckpoint?.updateId,
});
const updateType = SyncEntityType.AlbumAssetUpdateV1;
const createType = SyncEntityType.AlbumAssetCreateV1;
const updateType = SyncEntityType.AlbumAssetUpdateV2;
const createType = SyncEntityType.AlbumAssetCreateV2;
const updateCheckpoint = checkpointMap[updateType];
const createCheckpoint = checkpointMap[createType];
if (createCheckpoint) {
@@ -522,7 +545,7 @@ export class SyncService extends BaseService {
);
for await (const { updateId, ...data } of backfill) {
send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV1(data) });
send(response, { type: backfillType, ids: [createId, updateId], data: mapSyncAssetV2(data) });
}
sendEntityBackfillCompleteAck(response, backfillType, createId);
@@ -541,7 +564,7 @@ export class SyncService extends BaseService {
createCheckpoint,
);
for await (const { updateId, ...data } of updates) {
send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV1(data) });
send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV2(data) });
}
}
@@ -552,12 +575,12 @@ export class SyncService extends BaseService {
send(response, {
type: SyncEntityType.SyncAckV1,
data: {},
ackType: SyncEntityType.AlbumAssetUpdateV1,
ackType: SyncEntityType.AlbumAssetUpdateV2,
ids: [options.nowId],
});
first = false;
}
send(response, { type: createType, ids: [updateId], data: mapSyncAssetV1(data) });
send(response, { type: createType, ids: [updateId], data: mapSyncAssetV2(data) });
}
}
@@ -802,19 +825,10 @@ export class SyncService extends BaseService {
}
}
private async syncAssetFacesV1(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.AssetFaceDeleteV1;
const deletes = this.syncRepository.assetFace.getDeletes({ ...options, ack: checkpointMap[deleteType] });
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.AssetFaceV1;
const upserts = this.syncRepository.assetFace.getUpserts({ ...options, ack: checkpointMap[upsertType] });
for await (const { updateId, ...data } of upserts) {
const v1 = syncAssetFaceV2ToV1(data);
send(response, { type: upsertType, ids: [updateId], data: v1 });
}
private syncAssetFacesV1(): Promise<void> {
throw new BadRequestException(
'SyncRequestType.AssetFacesV1 is deprecated, use SyncRequestType.AssetFacesV2 instead',
);
}
private async syncAssetFacesV2(options: SyncQueryOptions, response: Writable, checkpointMap: CheckpointMap) {