Merge branch 'main' of github.com:immich-app/immich into feature/kebab-menu-1

pull/24175/head
Alex 2025-12-15 14:36:05 -06:00
commit d362c4f03e
No known key found for this signature in database
GPG Key ID: 53CD082B3A5E1082
7 changed files with 72 additions and 30 deletions

View File

@ -17,8 +17,10 @@ class PersonApiRepository extends ApiRepository {
}
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday)));
return _toPerson(dto);
final birthdayUtc = birthday == null ? null : DateTime.utc(birthday.year, birthday.month, birthday.day);
final dto = PersonUpdateDto(name: name, birthDate: birthdayUtc);
final response = await checkNull(_api.updatePerson(id, dto));
return _toPerson(response);
}
static PersonDto _toPerson(PersonResponseDto dto) => PersonDto(

View File

@ -1034,7 +1034,10 @@ describe(MetadataService.name, () => {
});
it('should use Duration from exif', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.image,
originalPath: '/original/path.webp',
});
mockReadTags({ Duration: 123 }, {});
await sut.handleMetadataExtraction({ id: assetStub.image.id });
@ -1046,6 +1049,7 @@ describe(MetadataService.name, () => {
it('should prefer Duration from exif over sidecar', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.image,
originalPath: '/original/path.webp',
files: [
{
id: 'some-id',
@ -1063,6 +1067,16 @@ describe(MetadataService.name, () => {
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:02:03.000' }));
});
it('should ignore all Duration tags for definitely static images', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.imageDng);
mockReadTags({ Duration: 123 }, { Duration: 456 });
await sut.handleMetadataExtraction({ id: assetStub.imageDng.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: null }));
});
it('should ignore Duration from exif for videos', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video);
mockReadTags({ Duration: 123 }, {});

View File

@ -32,6 +32,7 @@ import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled } from 'src/utils/misc';
import { upsertTags } from 'src/utils/tag';
@ -486,7 +487,8 @@ export class MetadataService extends BaseService {
}
// prefer duration from video tags
if (videoTags) {
// don't save duration if asset is definitely not an animated image (see e.g. CR3 with Duration: 1s)
if (videoTags || !mimeTypes.isPossiblyAnimatedImage(asset.originalPath)) {
delete mediaTags.Duration;
}

View File

@ -152,6 +152,26 @@ describe('mimeTypes', () => {
}
});
describe('animated image', () => {
for (const img of ['a.avif', 'a.gif', 'a.webp']) {
it('should identify animated image mime types as such', () => {
expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeTruthy();
});
}
for (const img of ['a.cr3', 'a.jpg', 'a.tiff']) {
it('should identify static image mime types as such', () => {
expect(mimeTypes.isPossiblyAnimatedImage(img)).toBeFalsy();
});
}
for (const extension of Object.keys(mimeTypes.video)) {
it('should not identify video mime types as animated', () => {
expect(mimeTypes.isPossiblyAnimatedImage(extension)).toBeFalsy();
});
}
});
describe('video', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video);

View File

@ -64,6 +64,11 @@ const image: Record<string, string[]> = {
'.tiff': ['image/tiff'],
};
const possiblyAnimatedImageExtensions = new Set(['.avif', '.gif', '.heic', '.heif', '.jxl', '.png', '.webp']);
const possiblyAnimatedImage: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => possiblyAnimatedImageExtensions.has(key)),
);
const extensionOverrides: Record<string, string> = {
'image/jpeg': '.jpg',
};
@ -119,6 +124,7 @@ export const mimeTypes = {
isAsset: (filename: string) => isType(filename, image) || isType(filename, video),
isImage: (filename: string) => isType(filename, image),
isWebSupportedImage: (filename: string) => isType(filename, webSupportedImage),
isPossiblyAnimatedImage: (filename: string) => isType(filename, possiblyAnimatedImage),
isProfile: (filename: string) => isType(filename, profile),
isSidecar: (filename: string) => isType(filename, sidecar),
isVideo: (filename: string) => isType(filename, video),

View File

@ -16,7 +16,7 @@
{#if downloadManager.isDownloading}
<div
transition:fly={{ x: -100, duration: 350 }}
class="fixed bottom-10 start-2 max-h-67.5 w-79 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
class="fixed bottom-10 start-2 max-h-67.5 w-79 z-60 rounded-2xl border dark:border-white/10 p-4 shadow-lg bg-subtle"
>
<Heading size="tiny">{$t('downloading')}</Heading>
<div class="my-2 mb-2 flex max-h-50 flex-col overflow-y-auto text-sm">

View File

@ -79,10 +79,30 @@
searchStore.isSearchEnabled = false;
};
const buildSearchPayload = (term: string): SmartSearchDto | MetadataSearchDto => {
const searchType = getSearchType();
switch (searchType) {
case 'smart': {
return { query: term };
}
case 'metadata': {
return { originalFileName: term };
}
case 'description': {
return { description: term };
}
case 'ocr': {
return { ocr: term };
}
default: {
return { query: term };
}
}
};
const onHistoryTermClick = async (searchTerm: string) => {
value = searchTerm;
const searchPayload = { query: searchTerm };
await handleSearch(searchPayload);
await handleSearch(buildSearchPayload(searchTerm));
};
const onFilterClick = async () => {
@ -112,29 +132,7 @@
};
const onSubmit = () => {
const searchType = getSearchType();
let payload = {} as SmartSearchDto | MetadataSearchDto;
switch (searchType) {
case 'smart': {
payload = { query: value } as SmartSearchDto;
break;
}
case 'metadata': {
payload = { originalFileName: value } as MetadataSearchDto;
break;
}
case 'description': {
payload = { description: value } as MetadataSearchDto;
break;
}
case 'ocr': {
payload = { ocr: value } as MetadataSearchDto;
break;
}
}
handlePromiseError(handleSearch(payload));
handlePromiseError(handleSearch(buildSearchPayload(value)));
saveSearchTerm(value);
};