Compare commits

...

9 Commits

Author SHA1 Message Date
mertalev
b803a35bdc preserve file extension 2025-04-01 16:37:06 -04:00
mertalev
0da1c3b279 add empty array fallback just in case for now 2025-04-01 15:58:32 -04:00
mertalev
33c9ea1c9c update tests 2025-04-01 15:51:08 -04:00
mertalev
e7503ce3dc fix file path logic 2025-04-01 15:51:08 -04:00
Daimolean
e4b0c00885 fix(web): select all button displays incorrectly (#17305)
* fix(web): select all show incorrectly

* fix: lint

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2025-04-01 19:00:48 +00:00
Alex
946507231d fix(web): blank locale cause blank timeline to render (#17284)
* fix(web): blank locale cause blank timeline to render

* correct fix

* newline

* pr feedback
2025-04-01 18:58:11 +00:00
Alex
20ba800a50 fix(web): date time change reactivity (#17306)
* fix(web): date time change reactivity

* remove logs
2025-04-01 18:57:53 +00:00
Alex
f434e858ed fix(mobile): getAllByRemoteId return all assets on empty arguments value (#17263)
* chore: post release tasks

* fix(mobile): getAllByRemoteId return all assets if ids is empty
2025-04-01 08:59:21 -05:00
bo0tzz
3e03c47fbf fix: strip extra metadata when transcoding (#17297) 2025-04-01 08:58:59 -05:00
20 changed files with 146 additions and 54 deletions

View File

@@ -71,8 +71,13 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
Future<List<Asset>> getAllByRemoteId( Future<List<Asset>> getAllByRemoteId(
Iterable<String> ids, { Iterable<String> ids, {
AssetState? state, AssetState? state,
}) => }) async {
_getAllByRemoteIdImpl(ids, state).findAll(); if (ids.isEmpty) {
return [];
}
return _getAllByRemoteIdImpl(ids, state).findAll();
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> _getAllByRemoteIdImpl( QueryBuilder<Asset, Asset, QAfterFilterCondition> _getAllByRemoteIdImpl(
Iterable<String> ids, Iterable<String> ids,

View File

@@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path'; import path, { dirname, join, resolve } from 'node:path';
import { APP_MEDIA_LOCATION } from 'src/constants'; import { APP_MEDIA_LOCATION } from 'src/constants';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
@@ -12,7 +12,7 @@ import { MoveRepository } from 'src/repositories/move.repository';
import { PersonRepository } from 'src/repositories/person.repository'; import { PersonRepository } from 'src/repositories/person.repository';
import { StorageRepository } from 'src/repositories/storage.repository'; import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFile } from 'src/utils/asset.util';
import { getConfig } from 'src/utils/config'; import { getConfig } from 'src/utils/config';
export interface MoveRequest { export interface MoveRequest {
@@ -115,19 +115,21 @@ export class StorageCore {
return normalizedPath.startsWith(normalizedAppMediaLocation); return normalizedPath.startsWith(normalizedAppMediaLocation);
} }
async moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType, format: ImageFormat) { moveAssetImage(asset: AssetEntity, pathType: GeneratedImageType) {
const { id: entityId, files } = asset; const oldFile = getAssetFile(asset.files, pathType);
const { thumbnailFile, previewFile } = getAssetFiles(files); if (!oldFile?.path) {
const oldFile = pathType === AssetPathType.PREVIEW ? previewFile : thumbnailFile; return;
}
return this.moveFile({ return this.moveFile({
entityId, entityId: asset.id,
pathType, pathType,
oldPath: oldFile?.path || null, oldPath: oldFile.path,
newPath: StorageCore.getImagePath(asset, pathType, format), newPath: StorageCore.getImagePath(asset, pathType, path.extname(oldFile.path).slice(1) as ImageFormat),
}); });
} }
async moveAssetVideo(asset: AssetEntity) { moveAssetVideo(asset: AssetEntity) {
return this.moveFile({ return this.moveFile({
entityId: asset.id, entityId: asset.id,
pathType: AssetPathType.ENCODED_VIDEO, pathType: AssetPathType.ENCODED_VIDEO,

View File

@@ -584,7 +584,7 @@ describe(AssetMediaService.name, () => {
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }), sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).resolves.toEqual( ).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: assetStub.image.files[0].path, path: '/uploads/user-id/thumbs/path.jpg',
cacheControl: CacheControl.PRIVATE_WITH_CACHE, cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'image/jpeg', contentType: 'image/jpeg',
fileName: 'asset-id_preview.jpg', fileName: 'asset-id_preview.jpg',
@@ -599,7 +599,7 @@ describe(AssetMediaService.name, () => {
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }), sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual( ).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: assetStub.image.files[1].path, path: '/uploads/user-id/webp/path.ext',
cacheControl: CacheControl.PRIVATE_WITH_CACHE, cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'application/octet-stream', contentType: 'application/octet-stream',
fileName: 'asset-id_thumbnail.ext', fileName: 'asset-id_thumbnail.ext',

View File

@@ -578,6 +578,7 @@ describe(AssetService.name, () => {
files: [ files: [
'/uploads/user-id/webp/path.ext', '/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg', '/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
assetWithFace.encodedVideoPath, assetWithFace.encodedVideoPath,
assetWithFace.sidecarPath, assetWithFace.sidecarPath,
assetWithFace.originalPath, assetWithFace.originalPath,
@@ -637,7 +638,14 @@ describe(AssetService.name, () => {
{ {
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { data: {
files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'], files: [
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
undefined,
undefined,
'fake_path/asset_1.jpeg',
],
}, },
}, },
], ],
@@ -658,7 +666,14 @@ describe(AssetService.name, () => {
{ {
name: JobName.DELETE_FILES, name: JobName.DELETE_FILES,
data: { data: {
files: [undefined, undefined, undefined, undefined, 'fake_path/asset_1.jpeg'], files: [
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
undefined,
undefined,
'fake_path/asset_1.jpeg',
],
}, },
}, },
], ],

View File

@@ -233,8 +233,8 @@ export class AssetService extends BaseService {
} }
} }
const { thumbnailFile, previewFile } = getAssetFiles(asset.files); const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files);
const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath]; const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) { if (deleteOnDisk) {
files.push(asset.sidecarPath, asset.originalPath); files.push(asset.sidecarPath, asset.originalPath);

View File

@@ -136,8 +136,14 @@ export class AuditService extends BaseService {
for await (const assets of pagination) { for await (const assets of pagination) {
assetCount += assets.length; assetCount += assets.length;
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) { for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
const { previewFile, thumbnailFile } = getAssetFiles(files); const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(files);
for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) { for (const file of [
originalPath,
fullsizeFile?.path,
previewFile?.path,
encodedVideoPath,
thumbnailFile?.path,
]) {
track(file); track(file);
} }

View File

@@ -5,12 +5,12 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { JobName, JobStatus, QueueName } from 'src/enum'; import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository'; import { WithoutProperty } from 'src/repositories/asset.repository';
import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { AssetDuplicateResult } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFile } from 'src/utils/asset.util';
import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { isDuplicateDetectionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -69,7 +69,7 @@ export class DuplicateService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const { previewFile } = getAssetFiles(asset.files); const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
if (!previewFile) { if (!previewFile) {
this.logger.warn(`Asset ${id} is missing preview image`); this.logger.warn(`Asset ${id} is missing preview image`);
return JobStatus.FAILED; return JobStatus.FAILED;

View File

@@ -234,6 +234,24 @@ describe(MediaService.name, () => {
}); });
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.FULLSIZE,
oldPath: '/uploads/user-id/fullsize/path.webp',
newPath: 'upload/thumbs/user-id/as/se/asset-id-fullsize.webp',
});
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.PREVIEW,
oldPath: '/uploads/user-id/thumbs/path.jpg',
newPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpg',
});
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.THUMBNAIL,
oldPath: '/uploads/user-id/webp/path.ext',
newPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.ext',
});
expect(mocks.move.create).toHaveBeenCalledTimes(3); expect(mocks.move.create).toHaveBeenCalledTimes(3);
}); });
}); });

View File

@@ -134,15 +134,14 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION }) @OnJob({ name: JobName.MIGRATE_ASSET, queue: QueueName.MIGRATION })
async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> { async handleAssetMigration({ id }: JobOf<JobName.MIGRATE_ASSET>): Promise<JobStatus> {
const { image } = await this.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id], { files: true }); const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) { if (!asset) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE, ImageFormat.JPEG); await this.storageCore.moveAssetImage(asset, AssetPathType.FULLSIZE);
await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetPathType.PREVIEW);
await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL, image.thumbnail.format); await this.storageCore.moveAssetImage(asset, AssetPathType.THUMBNAIL);
await this.storageCore.moveAssetVideo(asset); await this.storageCore.moveAssetVideo(asset);
return JobStatus.SUCCESS; return JobStatus.SUCCESS;

View File

@@ -2,12 +2,12 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto'; import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity'; import { AlbumEntity } from 'src/entities/album.entity';
import { JobName, JobStatus, QueueName } from 'src/enum'; import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { EmailTemplate } from 'src/repositories/notification.repository'; import { EmailTemplate } from 'src/repositories/notification.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types'; import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFile } from 'src/utils/asset.util';
import { getFilenameExtension } from 'src/utils/file'; import { getFilenameExtension } from 'src/utils/file';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
import { isEqualObject } from 'src/utils/object'; import { isEqualObject } from 'src/utils/object';
@@ -398,7 +398,11 @@ export class NotificationService extends BaseService {
} }
const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true }); const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true });
const { thumbnailFile } = getAssetFiles(albumThumbnail?.files); if (!albumThumbnail) {
return;
}
const thumbnailFile = getAssetFile(albumThumbnail.files, AssetFileType.THUMBNAIL);
if (!thumbnailFile) { if (!thumbnailFile) {
return; return;
} }

View File

@@ -26,6 +26,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { FaceSearchEntity } from 'src/entities/face-search.entity'; import { FaceSearchEntity } from 'src/entities/face-search.entity';
import { PersonEntity } from 'src/entities/person.entity'; import { PersonEntity } from 'src/entities/person.entity';
import { import {
AssetFileType,
AssetType, AssetType,
CacheControl, CacheControl,
ImageFormat, ImageFormat,
@@ -42,7 +43,7 @@ import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { UpdateFacesData } from 'src/repositories/person.repository'; import { UpdateFacesData } from 'src/repositories/person.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types'; import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFile } from 'src/utils/asset.util';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
@@ -300,7 +301,7 @@ export class PersonService extends BaseService {
const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true }; const relations = { exifInfo: true, faces: { person: false, withDeleted: true }, files: true };
const [asset] = await this.assetRepository.getByIds([id], relations); const [asset] = await this.assetRepository.getByIds([id], relations);
const { previewFile } = getAssetFiles(asset.files); const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
if (!asset || !previewFile) { if (!asset || !previewFile) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }
@@ -674,7 +675,7 @@ export class PersonService extends BaseService {
throw new Error(`Asset ${asset.id} dimensions are unknown`); throw new Error(`Asset ${asset.id} dimensions are unknown`);
} }
const { previewFile } = getAssetFiles(asset.files); const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
if (!previewFile) { if (!previewFile) {
throw new Error(`Asset ${asset.id} has no preview path`); throw new Error(`Asset ${asset.id} has no preview path`);
} }

View File

@@ -2,12 +2,12 @@ import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetFileType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository'; import { WithoutProperty } from 'src/repositories/asset.repository';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobOf } from 'src/types';
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFile } from 'src/utils/asset.util';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc'; import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination'; import { usePagination } from 'src/utils/pagination';
@@ -116,7 +116,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const { previewFile } = getAssetFiles(asset.files); const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
if (!previewFile) { if (!previewFile) {
return JobStatus.FAILED; return JobStatus.FAILED;
} }

View File

@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { StorageCore } from 'src/cores/storage.core'; import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
@@ -13,14 +13,14 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile } from 'src/types'; import { IBulkAsset, ImmichFile, UploadFile } from 'src/types';
import { checkAccess } from 'src/utils/access'; import { checkAccess } from 'src/utils/access';
const getFileByType = (files: AssetFileEntity[] | undefined, type: AssetFileType) => { export const getAssetFile = (files: AssetFileEntity[], type: AssetFileType | GeneratedImageType) => {
return (files || []).find((file) => file.type === type); return (files || []).find((file) => file.type === type);
}; };
export const getAssetFiles = (files?: AssetFileEntity[]) => ({ export const getAssetFiles = (files: AssetFileEntity[]) => ({
fullsizeFile: getFileByType(files, AssetFileType.FULLSIZE), fullsizeFile: getAssetFile(files, AssetFileType.FULLSIZE),
previewFile: getFileByType(files, AssetFileType.PREVIEW), previewFile: getAssetFile(files, AssetFileType.PREVIEW),
thumbnailFile: getFileByType(files, AssetFileType.THUMBNAIL), thumbnailFile: getAssetFile(files, AssetFileType.THUMBNAIL),
}); });
export const addAssets = async ( export const addAssets = async (

View File

@@ -128,6 +128,8 @@ export class BaseConfig implements VideoCodecSWConfig {
'-fps_mode passthrough', '-fps_mode passthrough',
// explicitly selects the video stream instead of leaving it up to FFmpeg // explicitly selects the video stream instead of leaving it up to FFmpeg
`-map 0:${videoStream.index}`, `-map 0:${videoStream.index}`,
// Strip metadata like capture date, camera, and GPS
'-map_metadata -1',
]; ];
if (audioStream) { if (audioStream) {

View File

@@ -26,7 +26,16 @@ const thumbnailFile: AssetFileEntity = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'),
}; };
const files: AssetFileEntity[] = [previewFile, thumbnailFile]; const fullsizeFile: AssetFileEntity = {
id: 'file-3',
assetId: 'asset-id',
type: AssetFileType.FULLSIZE,
path: '/uploads/user-id/fullsize/path.webp',
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
};
const files: AssetFileEntity[] = [fullsizeFile, previewFile, thumbnailFile];
export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => { export const stackStub = (stackId: string, assets: AssetEntity[]): StackEntity => {
return { return {
@@ -553,6 +562,7 @@ export const assetStub = {
fileSizeInByte: 25_000, fileSizeInByte: 25_000,
timeZone: `America/New_York`, timeZone: `America/New_York`,
}, },
files,
} as AssetEntity), } as AssetEntity),
livePhotoWithOriginalFileName: Object.freeze({ livePhotoWithOriginalFileName: Object.freeze({

View File

@@ -22,7 +22,7 @@
<input <input
{...rest} {...rest}
{type} {type}
{value} bind:value
max={max || fallbackMax} max={max || fallbackMax}
oninput={(e) => (updatedValue = e.currentTarget.value)} oninput={(e) => (updatedValue = e.currentTarget.value)}
onblur={() => (value = updatedValue)} onblur={() => (value = updatedValue)}

View File

@@ -1,6 +1,12 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte'; import Icon from '$lib/components/elements/icon.svelte';
import { AssetBucket, assetSnapshot, assetsSnapshot } from '$lib/stores/assets-store.svelte'; import {
type AssetStore,
type AssetBucket,
assetSnapshot,
assetsSnapshot,
isSelectingAllAssets,
} from '$lib/stores/assets-store.svelte';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { getDateLocaleString } from '$lib/utils/timeline-util'; import { getDateLocaleString } from '$lib/utils/timeline-util';
import type { AssetResponseDto } from '@immich/sdk'; import type { AssetResponseDto } from '@immich/sdk';
@@ -22,6 +28,7 @@
withStacked: boolean; withStacked: boolean;
showArchiveIcon: boolean; showArchiveIcon: boolean;
bucket: AssetBucket; bucket: AssetBucket;
assetStore: AssetStore;
assetInteraction: AssetInteraction; assetInteraction: AssetInteraction;
onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void; onSelect: ({ title, assets }: { title: string; assets: AssetResponseDto[] }) => void;
@@ -36,6 +43,7 @@
showArchiveIcon, showArchiveIcon,
bucket = $bindable(), bucket = $bindable(),
assetInteraction, assetInteraction,
assetStore,
onSelect, onSelect,
onSelectAssets, onSelectAssets,
onSelectAssetCandidates, onSelectAssetCandidates,
@@ -46,9 +54,9 @@
const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150)); const transitionDuration = $derived.by(() => (bucket.store.suspendTransitions && !$isUploading ? 0 : 150));
const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100); const scaleDuration = $derived(transitionDuration === 0 ? 0 : transitionDuration + 100);
const onClick = (assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => { const onClick = (assetStore: AssetStore, assets: AssetResponseDto[], groupTitle: string, asset: AssetResponseDto) => {
if (isSelectionMode || assetInteraction.selectionActive) { if (isSelectionMode || assetInteraction.selectionActive) {
assetSelectHandler(asset, assets, groupTitle); assetSelectHandler(assetStore, asset, assets, groupTitle);
return; return;
} }
void navigate({ targetRoute: 'current', assetId: asset.id }); void navigate({ targetRoute: 'current', assetId: asset.id });
@@ -56,7 +64,12 @@
const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets }); const handleSelectGroup = (title: string, assets: AssetResponseDto[]) => onSelect({ title, assets });
const assetSelectHandler = (asset: AssetResponseDto, assetsInDateGroup: AssetResponseDto[], groupTitle: string) => { const assetSelectHandler = (
assetStore: AssetStore,
asset: AssetResponseDto,
assetsInDateGroup: AssetResponseDto[],
groupTitle: string,
) => {
onSelectAssets(asset); onSelectAssets(asset);
// Check if all assets are selected in a group to toggle the group selection's icon // Check if all assets are selected in a group to toggle the group selection's icon
@@ -70,6 +83,12 @@
} else { } else {
assetInteraction.removeGroupFromMultiselectGroup(groupTitle); assetInteraction.removeGroupFromMultiselectGroup(groupTitle);
} }
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
}; };
const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => { const assetMouseEventHandler = (groupTitle: string, asset: AssetResponseDto | null) => {
@@ -164,8 +183,8 @@
{asset} {asset}
{groupIndex} {groupIndex}
focussed={assetInteraction.isFocussedAsset(asset.id)} focussed={assetInteraction.isFocussedAsset(asset.id)}
onClick={(asset) => onClick(dateGroup.getAssets(), dateGroup.groupTitle, asset)} onClick={(asset) => onClick(assetStore, dateGroup.getAssets(), dateGroup.groupTitle, asset)}
onSelect={(asset) => assetSelectHandler(asset, dateGroup.getAssets(), dateGroup.groupTitle)} onSelect={(asset) => assetSelectHandler(assetStore, asset, dateGroup.getAssets(), dateGroup.groupTitle)}
onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))} onMouseEvent={() => assetMouseEventHandler(dateGroup.groupTitle, assetSnapshot(asset))}
selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)} selected={assetInteraction.hasSelectedAsset(asset.id) || dateGroup.bucket.store.albumAssets.has(asset.id)}
selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)} selectionCandidate={assetInteraction.hasSelectionCandidate(asset.id)}

View File

@@ -4,7 +4,7 @@
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { isSearchEnabled } from '$lib/stores/search.store'; import { isSearchEnabled } from '$lib/stores/search.store';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
@@ -456,7 +456,7 @@
lastAssetMouseEvent = asset; lastAssetMouseEvent = asset;
}; };
const handleGroupSelect = (group: string, assets: AssetResponseDto[]) => { const handleGroupSelect = (assetStore: AssetStore, group: string, assets: AssetResponseDto[]) => {
if (assetInteraction.selectedGroup.has(group)) { if (assetInteraction.selectedGroup.has(group)) {
assetInteraction.removeGroupFromMultiselectGroup(group); assetInteraction.removeGroupFromMultiselectGroup(group);
for (const asset of assets) { for (const asset of assets) {
@@ -468,6 +468,12 @@
handleSelectAsset(asset); handleSelectAsset(asset);
} }
} }
if (assetStore.getAssets().length == assetInteraction.selectedAssets.length) {
isSelectingAllAssets.set(true);
} else {
isSelectingAllAssets.set(false);
}
}; };
const handleSelectAssets = async (asset: AssetResponseDto) => { const handleSelectAssets = async (asset: AssetResponseDto) => {
@@ -774,10 +780,11 @@
{withStacked} {withStacked}
{showArchiveIcon} {showArchiveIcon}
{assetInteraction} {assetInteraction}
{assetStore}
{isSelectionMode} {isSelectionMode}
{singleSelect} {singleSelect}
{bucket} {bucket}
onSelect={({ title, assets }) => handleGroupSelect(title, assets)} onSelect={({ title, assets }) => handleGroupSelect(assetStore, title, assets)}
onSelectAssetCandidates={handleSelectAssetCandidates} onSelectAssetCandidates={handleSelectAssetCandidates}
onSelectAssets={handleSelectAssets} onSelectAssets={handleSelectAssets}
/> />

View File

@@ -38,7 +38,7 @@ export const colorTheme = persisted<ThemeSetting>('color-theme', initialTheme, {
// Locale to use for formatting dates, numbers, etc. // Locale to use for formatting dates, numbers, etc.
export const locale = persisted<string | undefined>('locale', undefined, { export const locale = persisted<string | undefined>('locale', undefined, {
serializer: { serializer: {
parse: (text) => text, parse: (text) => (text == '' ? 'en-US' : text),
stringify: (object) => object ?? '', stringify: (object) => object ?? '',
}, },
}); });

View File

@@ -486,6 +486,10 @@ export const selectAllAssets = async (assetStore: AssetStore, assetInteraction:
break; // Cancelled break; // Cancelled
} }
assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets())); assetInteraction.selectAssets(assetsSnapshot(bucket.getAssets()));
for (const dateGroup of bucket.dateGroups) {
assetInteraction.addGroupToMultiselectGroup(dateGroup.groupTitle);
}
} }
} catch (error) { } catch (error) {
const $t = get(t); const $t = get(t);