Compare commits

..

1 Commits

Author SHA1 Message Date
Daniel Dietzler
1eae7f3f8a refactor: extract isEdited into its own column in asset_file 2026-01-20 15:11:00 +01:00
52 changed files with 456 additions and 471 deletions

View File

@@ -1,3 +1,3 @@
{
"flutter": "3.38.7"
"flutter": "3.35.7"
}

View File

@@ -6,8 +6,8 @@ environment:
dependencies:
analyzer: ^7.0.0
analyzer_plugin: 0.14.0
custom_lint_builder: 0.8.1
analyzer_plugin: ^0.13.0
custom_lint_builder: ^0.7.5
glob: ^2.1.2
dev_dependencies:

View File

@@ -344,8 +344,8 @@ class _SortButtonState extends ConsumerState<_SortButton> {
Padding(
padding: const EdgeInsets.only(right: 5),
child: albumSortIsReverse
? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface)
: Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface),
? const Icon(Icons.keyboard_arrow_down)
: const Icon(Icons.keyboard_arrow_up_rounded),
),
Text(
albumSortOption.label.t(context: context),
@@ -542,11 +542,7 @@ class _QuickSortAndViewMode extends StatelessWidget {
initialIsReverse: currentIsReverse,
),
IconButton(
icon: Icon(
isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined,
size: 24,
color: context.colorScheme.onSurface,
),
icon: Icon(isGrid ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: onToggleViewMode,
),
],

View File

@@ -1,5 +1,5 @@
[tools]
flutter = "3.38.7"
flutter = "3.35.7"
[tools."github:CQLabs/homebrew-dcm"]
version = "1.30.0"

View File

@@ -6,7 +6,7 @@ version: 2.4.1+3030
environment:
sdk: '>=3.8.0 <4.0.0'
flutter: 3.38.7
flutter: 3.35.7
dependencies:
async: ^2.13.0
@@ -39,7 +39,7 @@ dependencies:
flutter_web_auth_2: ^5.0.0-alpha.0
fluttertoast: ^8.2.12
geolocator: ^14.0.2
home_widget: 0.9.0
home_widget: ^0.8.1
hooks_riverpod: ^2.6.1
http: ^1.5.0
image_picker: ^1.2.0
@@ -54,7 +54,7 @@ dependencies:
isar_community_flutter_libs: 3.3.0-dev.3
local_auth: ^2.3.0
logging: ^1.3.0
maplibre_gl: 0.25.0
maplibre_gl: ^0.22.0
native_video_player:
git:
@@ -81,7 +81,7 @@ dependencies:
socket_io_client: ^2.0.3+1
stream_transform: ^2.1.1
thumbhash: 0.1.0+1
timezone: 0.11.0
timezone: ^0.9.4
url_launcher: ^6.3.2
uuid: ^4.5.1
wakelock_plus: ^1.3.0
@@ -90,7 +90,7 @@ dependencies:
dev_dependencies:
auto_route_generator: ^9.0.0
build_runner: ^2.4.8
custom_lint: 0.8.1
custom_lint: ^0.7.5
# Drift generator
drift_dev: ^2.26.0
fake_async: ^1.3.3

View File

@@ -11,7 +11,6 @@ packages:
- .github
ignoredBuiltDependencies:
- '@nestjs/core'
- '@parcel/watcher'
- '@scarf/scarf'
- '@swc/core'
- canvas

View File

@@ -1,7 +1,15 @@
import { randomUUID } from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { StorageAsset } from 'src/database';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import {
AssetFileType,
AssetPathType,
ImageFormat,
PathType,
PersonPathType,
RawExtractedFormat,
StorageFolder,
} from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@@ -24,15 +32,6 @@ export interface MoveRequest {
};
}
export type GeneratedImageType =
| AssetPathType.Preview
| AssetPathType.Thumbnail
| AssetPathType.FullSize
| AssetPathType.EditedPreview
| AssetPathType.EditedThumbnail
| AssetPathType.EditedFullSize;
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
export type ThumbnailPathEntity = { id: string; ownerId: string };
let instance: StorageCore | null;
@@ -111,8 +110,19 @@ export class StorageCore {
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
}
static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') {
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`);
static getImagePath(
asset: ThumbnailPathEntity,
{
fileType,
format,
isEdited,
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
) {
return StorageCore.getNestedPath(
StorageFolder.Thumbnails,
asset.ownerId,
`${asset.id}_${fileType}${isEdited ? '_edited' : ''}.${format}`,
);
}
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
@@ -137,14 +147,14 @@ export class StorageCore {
return normalizedPath.startsWith(normalizedAppMediaLocation);
}
async moveAssetImage(asset: StorageAsset, pathType: GeneratedImageType, format: ImageFormat) {
async moveAssetImage(asset: StorageAsset, fileType: AssetFileType, format: ImageFormat) {
const { id: entityId, files } = asset;
const oldFile = getAssetFile(files, pathType);
const oldFile = getAssetFile(files, fileType, { isEdited: false });
return this.moveFile({
entityId,
pathType,
pathType: fileType,
oldPath: oldFile?.path || null,
newPath: StorageCore.getImagePath(asset, pathType, format),
newPath: StorageCore.getImagePath(asset, { fileType, format, isEdited: false }),
});
}
@@ -298,19 +308,19 @@ export class StorageCore {
case AssetPathType.Original: {
return this.assetRepository.update({ id, originalPath: newPath });
}
case AssetPathType.FullSize: {
case AssetFileType.FullSize: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.FullSize, path: newPath });
}
case AssetPathType.Preview: {
case AssetFileType.Preview: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Preview, path: newPath });
}
case AssetPathType.Thumbnail: {
case AssetFileType.Thumbnail: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Thumbnail, path: newPath });
}
case AssetPathType.EncodedVideo: {
return this.assetRepository.update({ id, encodedVideoPath: newPath });
}
case AssetPathType.Sidecar: {
case AssetFileType.Sidecar: {
return this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.Sidecar, path: newPath });
}
case PersonPathType.Face: {

View File

@@ -39,6 +39,7 @@ export type AssetFile = {
id: string;
type: AssetFileType;
path: string;
isEdited: boolean;
};
export type Library = {
@@ -344,7 +345,7 @@ export const columns = {
'asset.width',
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
authApiKey: ['api_key.id', 'api_key.permissions'],
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],

View File

@@ -45,9 +45,6 @@ export enum AssetFileType {
Preview = 'preview',
Thumbnail = 'thumbnail',
Sidecar = 'sidecar',
FullSizeEdited = 'fullsize_edited',
PreviewEdited = 'preview_edited',
ThumbnailEdited = 'thumbnail_edited',
}
export enum AlbumUserRole {
@@ -364,14 +361,7 @@ export enum ManualJobName {
export enum AssetPathType {
Original = 'original',
FullSize = 'fullsize',
Preview = 'preview',
EditedFullSize = 'edited_fullsize',
EditedPreview = 'edited_preview',
EditedThumbnail = 'edited_thumbnail',
Thumbnail = 'thumbnail',
EncodedVideo = 'encoded_video',
Sidecar = 'sidecar',
}
export enum PersonPathType {
@@ -382,7 +372,7 @@ export enum UserPathType {
Profile = 'profile',
}
export type PathType = AssetPathType | PersonPathType | UserPathType;
export type PathType = AssetFileType | AssetPathType | PersonPathType | UserPathType;
export enum TranscodePolicy {
All = 'all',

View File

@@ -29,7 +29,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -72,7 +73,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -99,7 +101,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -145,7 +148,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -174,7 +178,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -244,7 +249,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -269,7 +275,8 @@ where
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -318,7 +325,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -357,7 +365,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -444,7 +453,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -536,7 +546,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where
@@ -575,7 +586,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where

View File

@@ -286,7 +286,8 @@ select
select
"asset_file"."id",
"asset_file"."path",
"asset_file"."type"
"asset_file"."type",
"asset_file"."isEdited"
from
"asset_file"
where

View File

@@ -903,20 +903,22 @@ export class AssetRepository {
.execute();
}
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>): Promise<void> {
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
const value = { ...file, assetId: asUuid(file.assetId) };
await this.db
.insertInto('asset_file')
.values(value)
.onConflict((oc) =>
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
})),
)
.execute();
}
async upsertFiles(files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type'>[]): Promise<void> {
async upsertFiles(
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
): Promise<void> {
if (files.length === 0) {
return;
}
@@ -926,7 +928,7 @@ export class AssetRepository {
.insertInto('asset_file')
.values(values)
.onConflict((oc) =>
oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
path: eb.ref('excluded.path'),
})),
)

View File

@@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_uq";`.execute(db);
await sql`ALTER TABLE "asset_file" ADD "isEdited" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_isEdited_uq" UNIQUE ("assetId", "type", "isEdited");`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_file" DROP CONSTRAINT "asset_file_assetId_type_isEdited_uq";`.execute(db);
await sql`ALTER TABLE "asset_file" ADD CONSTRAINT "asset_file_assetId_type_uq" UNIQUE ("assetId", "type");`.execute(db);
await sql`ALTER TABLE "asset_file" DROP COLUMN "isEdited";`.execute(db);
}

View File

@@ -14,7 +14,7 @@ import {
} from 'src/sql-tools';
@Table('asset_file')
@Unique({ columns: ['assetId', 'type'] })
@Unique({ columns: ['assetId', 'type', 'isEdited'] })
@UpdatedAtTrigger('asset_file_updatedAt')
export class AssetFileTable {
@PrimaryGeneratedColumn()
@@ -37,4 +37,7 @@ export class AssetFileTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ type: 'boolean', default: false })
isEdited!: Generated<boolean>;
}

View File

@@ -107,78 +107,6 @@ describe(ApiKeyService.name, () => {
permissions: newPermissions,
});
});
describe('api key auth', () => {
it('should prevent adding Permission.all', async () => {
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
const auth = factory.auth({ apiKey: { permissions } });
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
mocks.apiKey.getById.mockResolvedValue(apiKey);
await expect(sut.update(auth, apiKey.id, { permissions: [Permission.All] })).rejects.toThrow(
'Cannot grant permissions you do not have',
);
expect(mocks.apiKey.update).not.toHaveBeenCalled();
});
it('should prevent adding a new permission', async () => {
const permissions = [Permission.ApiKeyCreate, Permission.ApiKeyUpdate, Permission.AssetRead];
const auth = factory.auth({ apiKey: { permissions } });
const apiKey = factory.apiKey({ userId: auth.user.id, permissions });
mocks.apiKey.getById.mockResolvedValue(apiKey);
await expect(sut.update(auth, apiKey.id, { permissions: [Permission.AssetCopy] })).rejects.toThrow(
'Cannot grant permissions you do not have',
);
expect(mocks.apiKey.update).not.toHaveBeenCalled();
});
it('should allow removing permissions', async () => {
const auth = factory.auth({ apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead] } });
const apiKey = factory.apiKey({
userId: auth.user.id,
permissions: [Permission.AssetRead, Permission.AssetDelete],
});
mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.update.mockResolvedValue(apiKey);
// remove Permission.AssetDelete
await sut.update(auth, apiKey.id, { permissions: [Permission.AssetRead] });
expect(mocks.apiKey.update).toHaveBeenCalledWith(
auth.user.id,
apiKey.id,
expect.objectContaining({ permissions: [Permission.AssetRead] }),
);
});
it('should allow adding new permissions', async () => {
const auth = factory.auth({
apiKey: { permissions: [Permission.ApiKeyUpdate, Permission.AssetRead, Permission.AssetUpdate] },
});
const apiKey = factory.apiKey({ userId: auth.user.id, permissions: [Permission.AssetRead] });
mocks.apiKey.getById.mockResolvedValue(apiKey);
mocks.apiKey.update.mockResolvedValue(apiKey);
// add Permission.AssetUpdate
await sut.update(auth, apiKey.id, {
name: apiKey.name,
permissions: [Permission.AssetRead, Permission.AssetUpdate],
});
expect(mocks.apiKey.update).toHaveBeenCalledWith(
auth.user.id,
apiKey.id,
expect.objectContaining({ permissions: [Permission.AssetRead, Permission.AssetUpdate] }),
);
});
});
});
describe('delete', () => {

View File

@@ -32,14 +32,6 @@ export class ApiKeyService extends BaseService {
throw new BadRequestException('API Key not found');
}
if (
auth.apiKey &&
dto.permissions &&
!isGranted({ requested: dto.permissions, current: auth.apiKey.permissions })
) {
throw new BadRequestException('Cannot grant permissions you do not have');
}
const key = await this.apiKeyRepository.update(auth.user.id, id, { name: dto.name, permissions: dto.permissions });
return this.map(key);

View File

@@ -529,9 +529,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSizeEdited,
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile,
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -554,9 +555,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSizeEdited,
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile,
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -579,9 +581,10 @@ describe(AssetMediaService.name, () => {
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSizeEdited,
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
} as AssetFile,
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
@@ -656,6 +659,7 @@ describe(AssetMediaService.name, () => {
id: '42',
path: '/path/to/preview',
type: AssetFileType.Thumbnail,
isEdited: false,
},
],
});
@@ -673,6 +677,7 @@ describe(AssetMediaService.name, () => {
id: '42',
path: '/path/to/preview.jpg',
type: AssetFileType.Preview,
isEdited: false,
},
],
});

View File

@@ -4,7 +4,7 @@ import { DateTime, Duration } from 'luxon';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { AssetFile } from 'src/database';
import { OnJob } from 'src/decorators';
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import {
AssetBulkDeleteDto,
AssetBulkUpdateDto,
@@ -112,7 +112,7 @@ export class AssetService extends BaseService {
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: MapAsset | null = null;
let previousMotion: { id: string } | null = null;
if (rest.livePhotoVideoId) {
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
} else if (rest.livePhotoVideoId === null) {

View File

@@ -241,21 +241,21 @@ describe(MediaService.name, () => {
await expect(sut.handleAssetMigration({ id: assetStub.image.id })).resolves.toBe(JobStatus.Success);
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.FullSize,
pathType: AssetFileType.FullSize,
oldPath: '/uploads/user-id/fullsize/path.webp',
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-fullsize.jpeg'),
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_fullsize.jpeg'),
});
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.Preview,
pathType: AssetFileType.Preview,
oldPath: '/uploads/user-id/thumbs/path.jpg',
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-preview.jpeg'),
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_preview.jpeg'),
});
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: assetStub.image.id,
pathType: AssetPathType.Thumbnail,
pathType: AssetFileType.Thumbnail,
oldPath: '/uploads/user-id/webp/path.ext',
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id-thumbnail.webp'),
newPath: expect.stringContaining('/data/thumbs/user-id/as/se/asset-id_thumbnail.webp'),
});
expect(mocks.move.create).toHaveBeenCalledTimes(3);
});
@@ -385,11 +385,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id',
type: AssetFileType.Preview,
path: expect.any(String),
isEdited: false,
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: expect.any(String),
isEdited: false,
},
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
@@ -421,11 +423,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id',
type: AssetFileType.Preview,
path: expect.any(String),
isEdited: false,
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: expect.any(String),
isEdited: false,
},
]);
});
@@ -456,11 +460,13 @@ describe(MediaService.name, () => {
assetId: 'asset-id',
type: AssetFileType.Preview,
path: expect.any(String),
isEdited: false,
},
{
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: expect.any(String),
isEdited: false,
},
]);
});
@@ -548,8 +554,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `/data/thumbs/user-id/as/se/asset-id-preview.${format}`;
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id-thumbnail.webp`;
const previewPath = `/data/thumbs/user-id/as/se/asset-id_preview.${format}`;
const thumbnailPath = `/data/thumbs/user-id/as/se/asset-id_thumbnail.webp`;
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -595,8 +601,8 @@ describe(MediaService.name, () => {
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-preview.jpeg`);
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id-thumbnail.${format}`);
const previewPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_preview.jpeg`);
const thumbnailPath = expect.stringContaining(`/data/thumbs/user-id/as/se/asset-id_thumbnail.${format}`);
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
@@ -1026,9 +1032,9 @@ describe(MediaService.name, () => {
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ type: AssetFileType.FullSizeEdited }),
expect.objectContaining({ type: AssetFileType.PreviewEdited }),
expect.objectContaining({ type: AssetFileType.ThumbnailEdited }),
expect.objectContaining({ type: AssetFileType.FullSize, isEdited: true }),
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
]),
);
});
@@ -1098,17 +1104,17 @@ describe(MediaService.name, () => {
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.anything(),
expect.stringContaining('edited_preview.jpeg'),
expect.stringContaining('preview_edited.jpeg'),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.anything(),
expect.stringContaining('edited_thumbnail.webp'),
expect.stringContaining('thumbnail_edited.webp'),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.anything(),
expect.stringContaining('edited_fullsize.jpeg'),
expect.stringContaining('fullsize_edited.jpeg'),
);
});
@@ -3254,13 +3260,13 @@ describe(MediaService.name, () => {
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' },
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail },
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled();
@@ -3270,19 +3276,31 @@ describe(MediaService.name, () => {
const asset = {
id: 'asset-id',
files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg' },
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail },
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
]);
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
@@ -3295,17 +3313,38 @@ describe(MediaService.name, () => {
const asset = {
id: 'asset-id',
files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
],
};
await sut['syncFiles'](asset, [{ type: AssetFileType.Preview }, { type: AssetFileType.Thumbnail }]);
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, isEdited: false },
{ type: AssetFileType.Thumbnail, isEdited: false },
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
@@ -3317,14 +3356,26 @@ describe(MediaService.name, () => {
const asset = {
id: 'asset-id',
files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/same/preview.jpg' },
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/same/thumbnail.jpg' },
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/same/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/same/thumbnail.jpg',
isEdited: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg' },
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg' },
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
@@ -3336,23 +3387,41 @@ describe(MediaService.name, () => {
const asset = {
id: 'asset-id',
files: [
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' },
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg' }, // replace
{ type: AssetFileType.Thumbnail }, // delete
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg' }, // new
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
]);
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview },
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize },
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
]);
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
{ id: 'file-2', assetId: 'asset-id', type: AssetFileType.Thumbnail, path: '/old/thumbnail.jpg' },
{
id: 'file-2',
assetId: 'asset-id',
type: AssetFileType.Thumbnail,
path: '/old/thumbnail.jpg',
isEdited: false,
},
]);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
@@ -3376,11 +3445,19 @@ describe(MediaService.name, () => {
it('should delete non-existent file types when newPath is not provided', async () => {
const asset = {
id: 'asset-id',
files: [{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg' }],
files: [
{
id: 'file-1',
assetId: 'asset-id',
type: AssetFileType.Preview,
path: '/old/preview.jpg',
isEdited: false,
},
],
};
await sut['syncFiles'](asset, [
{ type: AssetFileType.Thumbnail }, // file doesn't exist, newPath not provided
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
]);
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();

View File

@@ -8,7 +8,6 @@ import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import {
AssetFileType,
AssetPathType,
AssetType,
AssetVisibility,
AudioCodec,
@@ -50,6 +49,7 @@ interface UpsertFileOptions {
assetId: string;
type: AssetFileType;
path: string;
isEdited: boolean;
}
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
@@ -160,9 +160,9 @@ export class MediaService extends BaseService {
return JobStatus.Failed;
}
await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format);
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
await this.storageCore.moveAssetVideo(asset);
return JobStatus.Success;
@@ -236,9 +236,9 @@ export class MediaService extends BaseService {
}
await this.syncFiles(asset, [
{ type: AssetFileType.Preview, newPath: generated.previewPath },
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath },
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath },
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
]);
const editiedGenerated = await this.generateEditedThumbnails(asset);
@@ -307,16 +307,16 @@ export class MediaService extends BaseService {
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
const { image } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(
asset,
useEdits ? AssetPathType.EditedPreview : AssetPathType.Preview,
image.preview.format,
);
const thumbnailPath = StorageCore.getImagePath(
asset,
useEdits ? AssetPathType.EditedThumbnail : AssetPathType.Thumbnail,
image.thumbnail.format,
);
const previewPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Preview,
isEdited: useEdits,
format: image.preview.format,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Thumbnail,
isEdited: useEdits,
format: image.thumbnail.format,
});
this.storageCore.ensureFolders(previewPath);
// Handle embedded preview extraction for RAW files
@@ -343,11 +343,11 @@ export class MediaService extends BaseService {
if (convertFullsize) {
// convert a new fullsize image from the same source as the thumbnail
fullsizePath = StorageCore.getImagePath(
asset,
useEdits ? AssetPathType.EditedFullSize : AssetPathType.FullSize,
image.fullsize.format,
);
fullsizePath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.FullSize,
isEdited: useEdits,
format: image.fullsize.format,
});
const fullsizeOptions = {
format: image.fullsize.format,
quality: image.fullsize.quality,
@@ -355,7 +355,11 @@ export class MediaService extends BaseService {
};
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizePath = StorageCore.getImagePath(asset, AssetPathType.FullSize, extracted.format);
fullsizePath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.FullSize,
format: extracted.format,
isEdited: false,
});
this.storageCore.ensureFolders(fullsizePath);
// Write the buffer to disk with essential EXIF data
@@ -489,8 +493,16 @@ export class MediaService extends BaseService {
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
const { image, ffmpeg } = await this.getConfig({ withCache: true });
const previewPath = StorageCore.getImagePath(asset, AssetPathType.Preview, image.preview.format);
const thumbnailPath = StorageCore.getImagePath(asset, AssetPathType.Thumbnail, image.thumbnail.format);
const previewPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Preview,
format: image.preview.format,
isEdited: false,
});
const thumbnailPath = StorageCore.getImagePath(asset, {
fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format,
isEdited: false,
});
this.storageCore.ensureFolders(previewPath);
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
@@ -779,18 +791,18 @@ export class MediaService extends BaseService {
private async syncFiles(
asset: { id: string; files: AssetFile[] },
files: { type: AssetFileType; newPath?: string }[],
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
) {
const toUpsert: UpsertFileOptions[] = [];
const pathsToDelete: string[] = [];
const toDelete: AssetFile[] = [];
for (const { type, newPath } of files) {
const existingFile = asset.files.find((file) => file.type === type);
for (const { type, newPath, isEdited } of files) {
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
// upsert new file path
if (newPath && existingFile?.path !== newPath) {
toUpsert.push({ assetId: asset.id, path: newPath, type });
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
// delete old file from disk
if (existingFile) {
@@ -829,9 +841,9 @@ export class MediaService extends BaseService {
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
await this.syncFiles(asset, [
{ type: AssetFileType.PreviewEdited, newPath: generated?.previewPath },
{ type: AssetFileType.ThumbnailEdited, newPath: generated?.thumbnailPath },
{ type: AssetFileType.FullSizeEdited, newPath: generated?.fullsizePath },
{ type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
{ type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
{ type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
]);
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);

View File

@@ -35,7 +35,7 @@ const forSidecarJob = (
asset: {
id?: string;
originalPath?: string;
files?: { id: string; type: AssetFileType; path: string }[];
files?: { id: string; type: AssetFileType; path: string; isEdited: boolean }[];
} = {},
) => {
return {
@@ -1084,6 +1084,7 @@ describe(MetadataService.name, () => {
id: 'some-id',
type: AssetFileType.Sidecar,
path: '/path/to/something',
isEdited: false,
},
],
});
@@ -1691,7 +1692,7 @@ describe(MetadataService.name, () => {
it('should unset sidecar path if file no longer exist', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);
mocks.storage.checkFileExists.mockResolvedValue(false);
@@ -1704,7 +1705,7 @@ describe(MetadataService.name, () => {
it('should do nothing if the sidecar file still exists', async () => {
const asset = forSidecarJob({
originalPath: '/path/to/IMG_123.jpg',
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar }],
files: [{ id: 'sidecar', path: '/path/to/IMG_123.jpg.xmp', type: AssetFileType.Sidecar, isEdited: false }],
});
mocks.assetJob.getForSidecarCheckJob.mockResolvedValue(asset);

View File

@@ -372,7 +372,7 @@ describe(NotificationService.name, () => {
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg' },
{ id: '1', type: AssetFileType.Thumbnail, path: 'path-to-thumb.jpg', isEdited: false },
]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
@@ -403,7 +403,7 @@ describe(NotificationService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ server: {} });
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([assetStub.image.files[2]]);
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([{ ...assetStub.image.files[2], isEdited: false }]);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.getAlbumThumbnailFiles).toHaveBeenCalledWith(

View File

@@ -240,11 +240,11 @@ export class StorageTemplateService extends BaseService {
assetInfo: { sizeInBytes: fileSizeInByte, checksum },
});
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar)?.path;
const sidecarPath = getAssetFile(asset.files, AssetFileType.Sidecar, { isEdited: false })?.path;
if (sidecarPath) {
await this.storageCore.moveFile({
entityId: id,
pathType: AssetPathType.Sidecar,
pathType: AssetFileType.Sidecar,
oldPath: sidecarPath,
newPath: `${newPath}.xmp`,
});

View File

@@ -1,5 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { GeneratedImageType, StorageCore } from 'src/cores/storage.core';
import { StorageCore } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
@@ -14,19 +14,19 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType | GeneratedImageType) => {
return files.find((file) => file.type === type);
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
return files.find((file) => file.type === type && file.isEdited === isEdited);
};
export const getAssetFiles = (files: AssetFile[]) => ({
fullsizeFile: getAssetFile(files, AssetFileType.FullSize),
previewFile: getAssetFile(files, AssetFileType.Preview),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
fullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: false }),
previewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: false }),
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail, { isEdited: false }),
sidecarFile: getAssetFile(files, AssetFileType.Sidecar, { isEdited: false }),
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSizeEdited),
editedPreviewFile: getAssetFile(files, AssetFileType.PreviewEdited),
editedThumbnailFile: getAssetFile(files, AssetFileType.ThumbnailEdited),
editedFullsizeFile: getAssetFile(files, AssetFileType.FullSize, { isEdited: true }),
editedPreviewFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
editedThumbnailFile: getAssetFile(files, AssetFileType.Preview, { isEdited: true }),
});
export const addAssets = async (

View File

@@ -31,18 +31,21 @@ const sidecarFileWithoutExt = factory.assetFile({
});
const editedPreviewFile = factory.assetFile({
type: AssetFileType.PreviewEdited,
type: AssetFileType.Preview,
path: '/uploads/user-id/preview/path_edited.jpg',
isEdited: true,
});
const editedThumbnailFile = factory.assetFile({
type: AssetFileType.ThumbnailEdited,
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/thumbnail/path_edited.jpg',
isEdited: true,
});
const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSizeEdited,
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path_edited.jpg',
isEdited: true,
});
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];

View File

@@ -335,6 +335,7 @@ const assetSidecarWriteFactory = () => {
id: newUuid(),
path: '/path/to/original-path.jpg.xmp',
type: AssetFileType.Sidecar,
isEdited: false,
},
],
exifInfo: {
@@ -386,6 +387,7 @@ const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
id: newUuid(),
type: AssetFileType.Preview,
path: '/uploads/user-id/thumbs/path.jpg',
isEdited: false,
...file,
});

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { shortcut } from '$lib/actions/shortcut';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import { Route } from '$lib/route';
import { removeTag } from '$lib/utils/asset-utils';
import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { Icon, modalManager } from '@immich/ui';
@@ -45,7 +46,7 @@
<div class="flex group transition-all">
<a
class="inline-block h-min whitespace-nowrap ps-3 pe-1 group-hover:ps-3 py-1 text-center align-baseline leading-none text-gray-100 dark:text-immich-dark-gray bg-primary rounded-s-full hover:bg-immich-primary/80 dark:hover:bg-immich-dark-primary/80 transition-all"
href={Route.tags({ path: tag.value })}
href={resolve(`${AppRoute.TAGS}/?path=${encodeURI(tag.value)}`)}
>
<p class="text-sm">
{tag.value}

View File

@@ -1,10 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import DetailPanelDescription from '$lib/components/asset-viewer/detail-panel-description.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/detail-panel-location.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/detail-panel-star-rating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/detail-panel-tags.svelte';
import { timeToLoadTheMap } from '$lib/constants';
import { AppRoute, QueryParameter, timeToLoadTheMap } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
@@ -72,7 +73,6 @@
})(),
);
let previousId: string | undefined = $state();
let previousRoute = $derived(currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos());
$effect(() => {
if (!previousId) {
@@ -100,8 +100,11 @@
};
const getAssetFolderHref = (asset: AssetResponseDto) => {
const folderUrl = new URL(AppRoute.FOLDERS, globalThis.location.href);
// Remove the last part of the path to get the parent path
return Route.folders({ path: getParentPath(asset.originalPath) });
const assetParentPath = getParentPath(asset.originalPath);
folderUrl.searchParams.set(QueryParameter.PATH, assetParentPath);
return folderUrl.href;
};
const toggleAssetPath = () => (showAssetPath = !showAssetPath);
@@ -202,7 +205,11 @@
{#if showingHiddenPeople || !person.isHidden}
<a
class="w-22"
href={Route.viewPerson(person, { previousRoute })}
href={resolve(
`${AppRoute.PEOPLE}/${person.id}?${QueryParameter.PREVIOUS_ROUTE}=${
currentAlbum?.id ? Route.viewAlbum(currentAlbum) : Route.photos()
}`,
)}
onfocus={() => ($boundingBoxesArray = people[index].faces)}
onblur={() => ($boundingBoxesArray = [])}
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
@@ -465,7 +472,7 @@
simplified
useLocationPin
showSimpleControls={!showEditFaces}
onOpenInMapView={() => goto(Route.map({ ...latlng, zoom: 12.5 }))}
onOpenInMapView={() => goto(resolve(`${AppRoute.MAP}#12.5/${latlng.lat}/${latlng.lng}`))}
>
{#snippet popup({ marker })}
{@const { lat, lon } = marker}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Route } from '$lib/route';
import { page } from '$app/state';
import { ActionQueryParameterValue, AppRoute, QueryParameter } from '$lib/constants';
import { handleError } from '$lib/utils/handle-error';
import { getAllPeople, getPerson, mergePerson, type PersonResponseDto } from '@immich/sdk';
import { Button, Icon, IconButton, modalManager, toastManager } from '@immich/ui';
@@ -38,7 +39,8 @@
const handleSwapPeople = async () => {
[person, selectedPeople[0]] = [selectedPeople[0], person];
await goto(Route.viewPerson(person, { previousRoute: Route.people(), action: 'merge' }));
page.url.searchParams.set(QueryParameter.ACTION, ActionQueryParameterValue.MERGE);
await goto(`${AppRoute.PEOPLE}/${person.id}?${page.url.searchParams.toString()}`);
};
const onSelect = async (selected: PersonResponseDto) => {

View File

@@ -2,7 +2,7 @@
import { focusOutside } from '$lib/actions/focus-outside';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { Route } from '$lib/route';
import { AppRoute, QueryParameter } from '$lib/constants';
import { getPersonActions } from '$lib/services/person.service';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
@@ -42,7 +42,7 @@
use:focusOutside={{ onFocusOut: () => (showVerticalDots = false) }}
>
<a
href={Route.viewPerson(person, { previousRoute: Route.people() })}
href="{AppRoute.PEOPLE}/{person.id}?{QueryParameter.PREVIOUS_ROUTE}={AppRoute.PEOPLE}"
draggable="false"
onfocus={() => (showVerticalDots = true)}
>

View File

@@ -2,6 +2,7 @@
import BottomInfo from '$lib/components/shared-components/side-bar/bottom-info.svelte';
import RecentAlbums from '$lib/components/shared-components/side-bar/recent-albums.svelte';
import Sidebar from '$lib/components/sidebar/sidebar.svelte';
import { AppRoute } from '$lib/constants';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { recentAlbumsDropdown } from '$lib/stores/preferences.store';
@@ -44,11 +45,11 @@
{/if}
{#if featureFlagsManager.value.map}
<NavbarItem title={$t('map')} href={Route.map()} icon={mdiMapOutline} activeIcon={mdiMap} />
<NavbarItem title={$t('map')} href={AppRoute.MAP} icon={mdiMapOutline} activeIcon={mdiMap} />
{/if}
{#if $preferences.people.enabled && $preferences.people.sidebarWeb}
<NavbarItem title={$t('people')} href={Route.people()} icon={mdiAccountOutline} activeIcon={mdiAccount} />
<NavbarItem title={$t('people')} href={AppRoute.PEOPLE} icon={mdiAccountOutline} activeIcon={mdiAccount} />
{/if}
{#if $preferences.sharedLinks.enabled && $preferences.sharedLinks.sidebarWeb}
@@ -80,11 +81,11 @@
</NavbarItem>
{#if $preferences.tags.enabled && $preferences.tags.sidebarWeb}
<NavbarItem title={$t('tags')} href={Route.tags()} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
<NavbarItem title={$t('tags')} href={AppRoute.TAGS} icon={{ icon: mdiTagMultipleOutline, flipped: true }} />
{/if}
{#if $preferences.folders.enabled && $preferences.folders.sidebarWeb}
<NavbarItem title={$t('folders')} href={Route.folders()} icon={{ icon: mdiFolderOutline, flipped: true }} />
<NavbarItem title={$t('folders')} href={AppRoute.FOLDERS} icon={{ icon: mdiFolderOutline, flipped: true }} />
{/if}
<NavbarItem title={$t('utilities')} href={Route.utilities()} icon={mdiToolboxOutline} activeIcon={mdiToolbox} />

View File

@@ -19,6 +19,16 @@ export enum AssetAction {
RATING = 'rating',
}
export enum AppRoute {
PEOPLE = '/people',
SEARCH = '/search',
MAP = '/map',
BUY = '/buy',
FOLDERS = '/folders',
TAGS = '/tags',
MAINTENANCE = '/maintenance',
}
export type SharedLinkTab = 'all' | 'album' | 'individual';
export enum ProjectionType {
@@ -72,6 +82,10 @@ export enum OpenQueryParam {
PURCHASE_SETTINGS = 'user-purchase-settings',
}
export enum ActionQueryParameterValue {
MERGE = 'merge',
}
export const maximumLengthSearchPeople = 1000;
// time to load the map before displaying the loading spinner

View File

@@ -65,15 +65,12 @@ export type Events = {
// confirmed permanently deleted from server
UserAdminDeleted: [{ id: string }];
SessionLocked: [];
SystemConfigUpdate: [SystemConfigDto];
LibraryCreate: [LibraryResponseDto];
LibraryUpdate: [LibraryResponseDto];
LibraryDelete: [{ id: string }];
WorkflowCreate: [WorkflowResponseDto];
WorkflowUpdate: [WorkflowResponseDto];
WorkflowDelete: [WorkflowResponseDto];

View File

@@ -24,20 +24,6 @@ describe('Route', () => {
});
});
describe(Route.tags.name, () => {
it('should work', () => {
expect(Route.tags()).toBe('/tags');
});
it('should support query parameters', () => {
expect(Route.tags({ path: '/some/path' })).toBe('/tags?path=%2Fsome%2Fpath');
});
it('should ignore an empty path', () => {
expect(Route.tags({ path: '' })).toBe('/tags');
});
});
describe(Route.systemSettings.name, () => {
it('should work', () => {
expect(Route.systemSettings()).toBe('/admin/system-settings');

View File

@@ -14,29 +14,9 @@ export const fromQueueSlug = (slug: string): QueueName | undefined => {
};
type QueryValue = number | string;
const asQueryString = (
params?: Record<string, QueryValue | undefined>,
options?: { skipEmptyStrings?: boolean; skipNullValues?: boolean },
) => {
const { skipEmptyStrings = true, skipNullValues = true } = options ?? {};
const asQueryString = (params?: Record<string, QueryValue | undefined>) => {
const items = Object.entries(params ?? {})
.filter((item): item is [string, QueryValue] => {
const value = item[1];
if (value === undefined) {
return false;
}
if (skipNullValues && value === null) {
return false;
}
if (skipEmptyStrings && value === '') {
return false;
}
return true;
})
.filter((item): item is [string, QueryValue] => item[1] !== undefined)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
return items.length === 0 ? '' : `?${items.join('&')}`;
@@ -56,40 +36,22 @@ export const Route = {
viewAlbumAsset: ({ albumId, assetId }: { albumId: string; assetId: string }) =>
`/albums/${albumId}/photos/${assetId}`,
// buy
buy: () => '/buy',
// explore
explore: () => '/explore',
places: () => '/places',
// folders
folders: (params?: { path?: string }) => '/folders' + asQueryString(params),
// libraries
libraries: () => '/admin/library-management',
newLibrary: () => '/admin/library-management/new',
viewLibrary: ({ id }: { id: string }) => `/admin/library-management/${id}`,
editLibrary: ({ id }: { id: string }) => `/admin/library-management/${id}/edit`,
// maintenance
maintenanceMode: (params?: { continue?: string }) => '/maintenance' + asQueryString(params),
// map
map: (point?: { zoom: number; lat: number; lng: number }) =>
'/map' + (point ? `#${point.zoom}/${point.lat}/${point.lng}` : ''),
// memories
memories: (params?: { id?: string }) => '/memory' + asQueryString(params),
// partners
viewPartner: ({ id }: { id: string }) => `/partners/${id}`,
// people
people: () => '/people',
viewPerson: ({ id }: { id: string }, params?: { previousRoute?: string; action?: 'merge' }) =>
`/people/${id}` + asQueryString(params),
// photos
photos: (params?: { at?: string }) => '/photos' + asQueryString(params),
viewAsset: ({ id }: { id: string }) => `/photos/${id}`,
@@ -120,10 +82,6 @@ export const Route = {
// system
systemSettings: (params?: { isOpen?: OpenQueryParam }) => '/admin/system-settings' + asQueryString(params),
systemStatistics: () => '/admin/server-status',
systemMaintenance: (params?: { continue?: string }) => '/admin/maintenance' + asQueryString(params),
// tags
tags: (params?: { path?: string }) => '/tags' + asQueryString(params),
// users
users: () => '/admin/users',

View File

@@ -7,7 +7,6 @@ import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { Route } from '$lib/route';
import { user } from '$lib/stores/user.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import { downloadArchive } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error';
@@ -29,16 +28,6 @@ import { mdiLink, mdiPlus, mdiPlusBoxOutline, mdiShareVariantOutline, mdiUpload
import { type MessageFormatter } from 'svelte-i18n';
import { get } from 'svelte/store';
export const getAlbumsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_album'),
icon: mdiPlusBoxOutline,
onAction: () => createAlbumAndRedirect(),
};
return { Create };
};
export const getAlbumActions = ($t: MessageFormatter, album: AlbumResponseDto) => {
const isOwned = get(user).id === album.ownerId;

View File

@@ -18,19 +18,9 @@ import {
type SharedLinkResponseDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiContentCopy, mdiLink, mdiPencilOutline, mdiQrcode, mdiTrashCanOutline } from '@mdi/js';
import { mdiContentCopy, mdiPencilOutline, mdiQrcode, mdiTrashCanOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getSharedLinksActions = ($t: MessageFormatter) => {
const ViewAll: ActionItem = {
title: $t('shared_links'),
icon: mdiLink,
onAction: () => goto(Route.sharedLinks()),
};
return { ViewAll };
};
export const getSharedLinkActions = ($t: MessageFormatter, sharedLink: SharedLinkResponseDto) => {
const Edit: ActionItem = {
title: $t('edit_link'),

View File

@@ -1,38 +1,8 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
import {
changePassword,
lockAuthSession,
resetPinCode,
type ChangePasswordDto,
type PinCodeResetDto,
} from '@immich/sdk';
import { toastManager, type ActionItem } from '@immich/ui';
import { mdiLockOutline } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export const getUserActions = ($t: MessageFormatter) => {
const LockSession: ActionItem = {
title: $t('lock'),
color: 'primary',
icon: mdiLockOutline,
onAction: () => handleLockSession(),
};
return { LockSession };
};
const handleLockSession = async () => {
const $t = await getFormatter();
try {
await lockAuthSession();
eventManager.emit('SessionLocked');
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
import { changePassword, resetPinCode, type ChangePasswordDto, type PinCodeResetDto } from '@immich/sdk';
import { toastManager } from '@immich/ui';
export const handleResetPinCode = async (dto: PinCodeResetDto) => {
const $t = await getFormatter();

View File

@@ -17,13 +17,12 @@ import {
type PluginFilterResponseDto,
type PluginTriggerResponseDto,
type WorkflowActionItemDto,
type WorkflowCreateDto,
type WorkflowFilterItemDto,
type WorkflowResponseDto,
type WorkflowUpdateDto,
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay, mdiPlus } from '@mdi/js';
import { mdiCodeJson, mdiDelete, mdiPause, mdiPencil, mdiPlay } from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
export type PickerSubType = 'album-picker' | 'people-picker';
@@ -319,23 +318,6 @@ export const handleUpdateWorkflow = async (
return updateWorkflow({ id: workflowId, workflowUpdateDto: updateDto });
};
export const getWorkflowsActions = ($t: MessageFormatter) => {
const Create: ActionItem = {
title: $t('create_workflow'),
icon: mdiPlus,
onAction: () =>
handleCreateWorkflow({
name: $t('untitled_workflow'),
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
enabled: false,
}),
};
return { Create };
};
export const getWorkflowActions = ($t: MessageFormatter, workflow: WorkflowResponseDto) => {
const ToggleEnabled: ActionItem = {
title: workflow.enabled ? $t('disable') : $t('enable'),
@@ -374,12 +356,22 @@ export const getWorkflowShowSchemaAction = (
onAction: onToggle,
});
const handleCreateWorkflow = async (dto: WorkflowCreateDto) => {
export const handleCreateWorkflow = async (): Promise<WorkflowResponseDto | undefined> => {
const $t = await getFormatter();
try {
const response = await createWorkflow({ workflowCreateDto: dto });
eventManager.emit('WorkflowCreate', response);
const workflow = await createWorkflow({
workflowCreateDto: {
name: $t('untitled_workflow'),
triggerType: PluginTriggerType.AssetCreate,
filters: [],
actions: [],
enabled: false,
},
});
await goto(Route.viewWorkflow(workflow));
return workflow;
} catch (error) {
handleError(error, $t('errors.unable_to_create'));
}

View File

@@ -1,7 +1,7 @@
import { page } from '$app/state';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { Route } from '$lib/route';
import { notificationManager } from '$lib/stores/notification-manager.svelte';
import type { ReleaseEvent } from '$lib/types';
import { createEventEmitter } from '$lib/utils/eventemitter';
@@ -63,7 +63,7 @@ websocket
export const openWebsocketConnection = () => {
try {
if (get(user) || page.url.pathname.startsWith(Route.maintenanceMode())) {
if (get(user) || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
websocket.connect();
}
} catch (error) {

View File

@@ -1,9 +1,11 @@
import { Route } from '$lib/route';
import { AppRoute } from '$lib/constants';
import { maintenanceAuth as maintenanceAuth$ } from '$lib/stores/maintenance.store';
import { maintenanceLogin } from '@immich/sdk';
export function maintenanceCreateUrl(url: URL) {
return new URL(Route.maintenanceMode({ continue: url.pathname + url.search }), url.origin).href;
const target = new URL(AppRoute.MAINTENANCE, url.origin);
target.searchParams.set('continue', url.pathname + url.search);
return target.href;
}
export function maintenanceReturnUrl(searchParams: URLSearchParams) {
@@ -11,7 +13,7 @@ export function maintenanceReturnUrl(searchParams: URLSearchParams) {
}
export function maintenanceShouldRedirect(maintenanceMode: boolean, currentUrl: URL | Location) {
return maintenanceMode !== currentUrl.pathname.startsWith(Route.maintenanceMode());
return maintenanceMode !== currentUrl.pathname.startsWith(AppRoute.MAINTENANCE);
}
export const loadMaintenanceAuth = async () => {

View File

@@ -3,6 +3,7 @@
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import SingleGridRow from '$lib/components/shared-components/single-grid-row.svelte';
import { AppRoute } from '$lib/constants';
import { Route } from '$lib/route';
import { websocketEvents } from '$lib/stores/websocket';
import { getAssetThumbnailUrl, getPeopleThumbnailUrl } from '$lib/utils';
@@ -46,7 +47,7 @@
<div class="flex justify-between">
<p class="mb-4 font-medium dark:text-immich-dark-fg">{$t('people')}</p>
<a
href={Route.people()}
href={AppRoute.PEOPLE}
class="pe-4 text-sm font-medium hover:text-immich-primary dark:text-immich-dark-fg dark:hover:text-immich-dark-primary"
draggable="false">{$t('view_all')}</a
>
@@ -54,7 +55,7 @@
<SingleGridRow class="grid grid-flow-col md:grid-auto-fill-28 grid-auto-fill-20 gap-x-4">
{#snippet children({ itemCount })}
{#each people.slice(0, itemCount) as person (person.id)}
<a href={Route.viewPerson(person)} class="text-center relative">
<a href="{AppRoute.PEOPLE}/{person.id}" class="text-center relative">
<ImageThumbnail
circle
shadow

View File

@@ -19,9 +19,9 @@
import FavoriteAction from '$lib/components/timeline/actions/FavoriteAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import type { Viewport } from '$lib/managers/timeline-manager/types';
import { Route } from '$lib/route';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { preferences } from '$lib/stores/user.store';
@@ -44,7 +44,11 @@
const handleNavigateToFolder = (folderName: string) => navigateToView(joinPaths(data.tree.path, folderName));
const getLinkForPath = (path: string) => Route.folders({ path });
function getLinkForPath(path: string) {
const url = new URL(AppRoute.FOLDERS, globalThis.location.href);
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
}
afterNavigate(function clearAssetSelection() {
// Clear the asset selection when we navigate (like going to another folder)

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import ChangeDate from '$lib/components/timeline/actions/ChangeDateAction.svelte';
@@ -15,10 +14,10 @@
import { AssetAction } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getUserActions } from '$lib/services/user.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical } from '@mdi/js';
import { AssetVisibility, lockAuthSession } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiDotsVertical, mdiLockOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
@@ -45,21 +44,19 @@
timelineManager.removeAssets(assetIds);
};
const { LockSession } = $derived(getUserActions($t));
const onSessionLocked = async () => {
const handleLock = async () => {
await lockAuthSession();
await goto(Route.photos());
};
</script>
<OnEvents {onSessionLocked} />
<UserPageLayout hideNavbar={assetInteraction.selectionActive} title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<Button size="small" variant="ghost" color="primary" leadingIcon={mdiLockOutline} onclick={handleLock}>
{$t('lock')}
</Button>
{/snippet}
<UserPageLayout
title={data.meta.title}
actions={[LockSession]}
hideNavbar={assetInteraction.selectionActive}
scrollbar={false}
>
<Timeline
enableRouting={true}
bind:timelineManager

View File

@@ -10,9 +10,8 @@
import SearchPeople from '$lib/components/faces-page/people-search.svelte';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import { QueryParameter, SessionStorageKey } from '$lib/constants';
import { ActionQueryParameterValue, AppRoute, QueryParameter, SessionStorageKey } from '$lib/constants';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
@@ -206,7 +205,9 @@
};
const handleMergePeople = async (detail: PersonResponseDto) => {
await goto(Route.viewPerson(detail, { previousRoute: Route.people(), action: 'merge' }));
await goto(
`${AppRoute.PEOPLE}/${detail.id}?${QueryParameter.ACTION}=${ActionQueryParameterValue.MERGE}&${QueryParameter.PREVIOUS_ROUTE}=${AppRoute.PEOPLE}`,
);
};
const onResetSearchBar = async () => {
@@ -299,7 +300,7 @@
[
scrollMemory,
{
routeStartsWith: Route.people(),
routeStartsWith: AppRoute.PEOPLE,
beforeSave: () => {
if (currentPage) {
sessionStorage.setItem(SessionStorageKey.INFINITE_SCROLL_PAGE, currentPage.toString());

View File

@@ -27,7 +27,7 @@
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import AssetSelectControlBar from '$lib/components/timeline/AssetSelectControlBar.svelte';
import Timeline from '$lib/components/timeline/Timeline.svelte';
import { PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { AppRoute, PersonPageViewMode, QueryParameter, SessionStorageKey } from '$lib/constants';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import PersonMergeSuggestionModal from '$lib/modals/PersonMergeSuggestionModal.svelte';
@@ -219,7 +219,7 @@
await updateAssetCount();
return { merged: true };
}
await goto(Route.viewPerson(personToBeMergedInto), { replaceState: true });
await goto(`${AppRoute.PEOPLE}/${personToBeMergedInto.id}`, { replaceState: true });
return { merged: true };
};
@@ -339,7 +339,7 @@
<main
class="relative z-0 h-dvh overflow-hidden px-2 md:px-6 md:pt-(--navbar-height-md) pt-(--navbar-height)"
use:scrollMemoryClearer={{
routeStartsWith: Route.people(),
routeStartsWith: AppRoute.PEOPLE,
beforeClear: () => {
sessionStorage.removeItem(SessionStorageKey.INFINITE_SCROLL_PAGE);
},

View File

@@ -5,8 +5,6 @@
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { Route } from '$lib/route';
import { getAlbumsActions } from '$lib/services/album.service';
import { getSharedLinksActions } from '$lib/services/shared-link.service';
import {
AlbumFilter,
AlbumGroupBy,
@@ -15,12 +13,15 @@
SortOrder,
type AlbumViewSettings,
} from '$lib/stores/preferences.store';
import { createAlbumAndRedirect } from '$lib/utils/album-utils';
import { Button, HStack, Text } from '@immich/ui';
import { mdiLink, mdiPlusBoxOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
type Props = {
interface Props {
data: PageData;
};
}
let { data }: Props = $props();
@@ -33,12 +34,26 @@
sortOrder: SortOrder.Desc,
collapsedGroups: {},
};
const { Create: CreateAlbum } = $derived(getAlbumsActions($t));
const { ViewAll: ViewSharedLinks } = $derived(getSharedLinksActions($t));
</script>
<UserPageLayout title={data.meta.title} actions={[ViewSharedLinks, CreateAlbum]}>
<UserPageLayout title={data.meta.title}>
{#snippet buttons()}
<HStack gap={0}>
<Button
leadingIcon={mdiPlusBoxOutline}
onclick={() => createAlbumAndRedirect()}
size="small"
variant="ghost"
color="secondary"
>
<Text class="hidden md:block">{$t('create_album')}</Text>
</Button>
<Button leadingIcon={mdiLink} href={Route.sharedLinks()} size="small" variant="ghost" color="secondary">
<Text class="hidden md:block">{$t('shared_links')}</Text>
</Button>
</HStack>
{/snippet}
<div class="flex flex-col">
{#if data.partners.length > 0}
<div class="mb-6 mt-2">

View File

@@ -21,10 +21,9 @@
import SelectAllAssets from '$lib/components/timeline/actions/SelectAllAction.svelte';
import SetVisibilityAction from '$lib/components/timeline/actions/SetVisibilityAction.svelte';
import TagAction from '$lib/components/timeline/actions/TagAction.svelte';
import { AssetAction } from '$lib/constants';
import { AppRoute, AssetAction, QueryParameter } from '$lib/constants';
import SkipLink from '$lib/elements/SkipLink.svelte';
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import { Route } from '$lib/route';
import { getTagActions } from '$lib/services/tag.service';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { preferences, user } from '$lib/stores/user.store';
@@ -51,7 +50,11 @@
const handleNavigation = (tag: string) => navigateToView(joinPaths(data.path, tag));
const getLink = (path: string) => Route.tags({ path });
const getLink = (path: string) => {
const url = new URL(AppRoute.TAGS, globalThis.location.href);
url.searchParams.set(QueryParameter.PATH, path);
return url.href;
};
const navigateToView = (path: string) => goto(getLink(path));

View File

@@ -1,17 +1,15 @@
<script lang="ts">
import { goto } from '$app/navigation';
import emptyWorkflows from '$lib/assets/empty-workflows.svg';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import OnEvents from '$lib/components/OnEvents.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { Route } from '$lib/route';
import {
getWorkflowActions,
getWorkflowsActions,
getWorkflowShowSchemaAction,
handleCreateWorkflow,
type WorkflowPayload,
} from '$lib/services/workflow.service';
import { type PluginFilterResponseDto, type WorkflowResponseDto } from '@immich/sdk';
import type { PluginFilterResponseDto, WorkflowResponseDto } from '@immich/sdk';
import {
Button,
Card,
@@ -20,13 +18,15 @@
CardHeader,
CardTitle,
CodeBlock,
HStack,
Icon,
IconButton,
MenuItemType,
menuManager,
Text,
VStack,
} from '@immich/ui';
import { mdiClose, mdiDotsVertical } from '@mdi/js';
import { mdiClose, mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { PageData } from './$types';
@@ -40,6 +40,7 @@
let workflows = $state<WorkflowResponseDto[]>(data.workflows);
const expandedWorkflows = new SvelteSet<string>();
const pluginFilterLookup = new SvelteMap<string, PluginFilterResponseDto>();
const pluginActionLookup = new SvelteMap<string, PluginFilterResponseDto>();
@@ -89,6 +90,16 @@
const getJson = (workflow: WorkflowResponseDto) => JSON.stringify(constructPayload(workflow), null, 2);
const onWorkflowUpdate = (updatedWorkflow: WorkflowResponseDto) => {
workflows = workflows.map((currentWorkflow) =>
currentWorkflow.id === updatedWorkflow.id ? updatedWorkflow : currentWorkflow,
);
};
const onWorkflowDelete = (deletedWorkflow: WorkflowResponseDto) => {
workflows = workflows.filter((currentWorkflow) => currentWorkflow.id !== deletedWorkflow.id);
};
const getFilterLabel = (filterId: string) => {
const meta = pluginFilterLookup.get(filterId);
return meta?.title ?? $t('filter');
@@ -127,23 +138,9 @@
],
});
};
const { Create } = $derived(getWorkflowsActions($t));
const onWorkflowCreate = async (response: WorkflowResponseDto) => {
await goto(Route.viewWorkflow(response));
};
const onWorkflowUpdate = (response: WorkflowResponseDto) => {
workflows = workflows.map((workflow) => (workflow.id === response.id ? response : workflow));
};
const onWorkflowDelete = (response: WorkflowResponseDto) => {
workflows = workflows.filter(({ id }) => id !== response.id);
};
</script>
<OnEvents {onWorkflowCreate} {onWorkflowUpdate} {onWorkflowDelete} />
<OnEvents {onWorkflowUpdate} {onWorkflowDelete} />
{#snippet chipItem(title: string)}
<span class="rounded-xl border border-gray-200/80 px-3 py-1.5 text-sm dark:border-gray-600 bg-light">
@@ -151,14 +148,23 @@
</span>
{/snippet}
<UserPageLayout title={data.meta.title} actions={[Create]} scrollbar={false}>
<UserPageLayout title={data.meta.title} scrollbar={false}>
{#snippet buttons()}
<HStack gap={1}>
<Button size="small" variant="ghost" color="secondary" onclick={handleCreateWorkflow}>
<Icon icon={mdiPlus} size="18" />
{$t('create_workflow')}
</Button>
</HStack>
{/snippet}
<section class="flex place-content-center sm:mx-4">
<section class="w-full pb-28 sm:w-5/6 md:w-4xl">
{#if workflows.length === 0}
<EmptyPlaceholder
title={$t('create_first_workflow')}
text={$t('workflows_help_text')}
onClick={() => Create.onAction(Create)}
onClick={handleCreateWorkflow}
src={emptyWorkflows}
class="mt-10 mx-auto"
/>

View File

@@ -8,6 +8,7 @@
import AppleHeader from '$lib/components/shared-components/apple-header.svelte';
import NavigationLoadingBar from '$lib/components/shared-components/navigation-loading-bar.svelte';
import UploadPanel from '$lib/components/shared-components/upload-panel.svelte';
import { AppRoute } from '$lib/constants';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { themeManager } from '$lib/managers/theme-manager.svelte';
@@ -88,7 +89,7 @@
});
$effect.pre(() => {
if ($user || page.url.pathname.startsWith(Route.maintenanceMode())) {
if ($user || page.url.pathname.startsWith(AppRoute.MAINTENANCE)) {
openWebsocketConnection();
} else {
closeWebsocketConnection();

View File

@@ -1,3 +1,4 @@
import { AppRoute } from '$lib/constants';
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
import { Route } from '$lib/route';
import { getFormatter } from '$lib/utils/i18n';
@@ -14,7 +15,7 @@ export const load = (async ({ fetch }) => {
await init(fetch);
if (serverConfigManager.value.maintenanceMode) {
redirect(307, Route.maintenanceMode());
redirect(307, AppRoute.MAINTENANCE);
}
const authenticated = await loadUser();

View File

@@ -1,4 +1,4 @@
import { OpenQueryParam } from '$lib/constants';
import { AppRoute, OpenQueryParam } from '$lib/constants';
import { Route } from '$lib/route';
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from './$types';
@@ -34,7 +34,7 @@ export const load = (({ url }) => {
// https://my.immich.app/link?target=activate_license&licenseKey=IMCL-9XC3-T4S3-37BU-GGJ5-8MWP-F2Y1-BGEX-AQTF
const licenseKey = queryParams.get('licenseKey');
const activationKey = queryParams.get('activationKey');
const redirectUrl = new URL(Route.buy(), url.origin);
const redirectUrl = new URL(AppRoute.BUY, url.origin);
if (licenseKey) {
redirectUrl.searchParams.append('licenseKey', licenseKey);