Merge remote-tracking branch 'origin/main' into feat/yucca-integration

This commit is contained in:
izzy
2026-04-16 12:04:38 +01:00
523 changed files with 2711 additions and 54886 deletions
+14 -7
View File
@@ -3,7 +3,6 @@ import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
@@ -15,6 +14,7 @@ import {
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AlbumService } from 'src/services/album.service';
@@ -65,12 +65,8 @@ export class AlbumController {
description: 'Retrieve information about a specific album by its ID.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getAlbumInfo(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Query() dto: AlbumInfoDto,
): Promise<AlbumResponseDto> {
return this.service.get(auth, id, dto);
getAlbumInfo(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<AlbumResponseDto> {
return this.service.get(auth, id);
}
@Patch(':id')
@@ -102,6 +98,17 @@ export class AlbumController {
return this.service.delete(auth, id);
}
@Authenticated({ permission: Permission.AlbumRead, sharedLink: true })
@Get(':id/map-markers')
@Endpoint({
summary: 'Retrieve album map markers',
description: 'Retrieve map marker information for a specific album by its ID.',
history: new HistoryBuilder().added('v3'),
})
getAlbumMapMarkers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<MapMarkerResponseDto[]> {
return this.service.getMapMarkers(auth, id);
}
@Put(':id/assets')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
+7 -7
View File
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { ApiKeyCreateDto, ApiKeyCreateResponseDto, ApiKeyResponseDto, ApiKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@@ -20,7 +20,7 @@ export class ApiKeyController {
description: 'Creates a new API key. It will be limited to the permissions specified.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createApiKey(@Auth() auth: AuthDto, @Body() dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
createApiKey(@Auth() auth: AuthDto, @Body() dto: ApiKeyCreateDto): Promise<ApiKeyCreateResponseDto> {
return this.service.create(auth, dto);
}
@@ -31,7 +31,7 @@ export class ApiKeyController {
description: 'Retrieve all API keys of the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApiKeys(@Auth() auth: AuthDto): Promise<APIKeyResponseDto[]> {
getApiKeys(@Auth() auth: AuthDto): Promise<ApiKeyResponseDto[]> {
return this.service.getAll(auth);
}
@@ -42,7 +42,7 @@ export class ApiKeyController {
description: 'Retrieve the API key that is used to access this endpoint.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
async getMyApiKey(@Auth() auth: AuthDto): Promise<APIKeyResponseDto> {
async getMyApiKey(@Auth() auth: AuthDto): Promise<ApiKeyResponseDto> {
return this.service.getMine(auth);
}
@@ -53,7 +53,7 @@ export class ApiKeyController {
description: 'Retrieve an API key by its ID. The current user must own this API key.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<APIKeyResponseDto> {
getApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<ApiKeyResponseDto> {
return this.service.getById(auth, id);
}
@@ -67,8 +67,8 @@ export class ApiKeyController {
updateApiKey(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: APIKeyUpdateDto,
): Promise<APIKeyResponseDto> {
@Body() dto: ApiKeyUpdateDto,
): Promise<ApiKeyResponseDto> {
return this.service.update(auth, id, dto);
}
@@ -9,8 +9,6 @@ import { automock, ControllerContext, controllerSetup, mockBaseService } from 't
const makeUploadDto = (options?: { omit: string }): Record<string, any> => {
const dto: Record<string, any> = {
deviceAssetId: 'example-image',
deviceId: 'TEST',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
isFavorite: 'false',
@@ -87,28 +85,6 @@ describe(AssetMediaController.name, () => {
);
});
it('should require `deviceAssetId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceAssetId' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['[deviceAssetId] Invalid input: expected string, received undefined']),
);
});
it('should require `deviceId`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto({ omit: 'deviceId' }) });
expect(status).toBe(400);
expect(body).toEqual(
factory.responses.badRequest(['[deviceId] Invalid input: expected string, received undefined']),
);
});
it('should require `fileCreatedAt`', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
@@ -21,14 +21,12 @@ import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
AssetMediaStatus,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto';
import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaSize,
CheckExistingAssetsDto,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -179,21 +177,6 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
}
@Post('exist')
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
summary: 'Check existing assets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.service.checkExistingAssets(auth, dto);
}
@Post('bulk-upload-check')
@Authenticated({ permission: Permission.AssetUpload })
@Endpoint({
@@ -15,7 +15,6 @@ import {
AssetMetadataUpsertDto,
AssetStatsDto,
AssetStatsResponseDto,
DeviceIdDto,
UpdateAssetDto,
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -31,17 +30,6 @@ import { UUIDParamDto } from 'src/validation';
export class AssetController {
constructor(private service: AssetService) {}
@Get('/device/:deviceId')
@Endpoint({
summary: 'Retrieve assets by device ID',
description: 'Get all asset of a device that are in the database, ID only.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
@Authenticated()
getAllUserAssetsByDeviceId(@Auth() auth: AuthDto, @Param() { deviceId }: DeviceIdDto) {
return this.service.getUserAssetsByDeviceId(auth, deviceId);
}
@Get('statistics')
@Authenticated({ permission: Permission.AssetStatistics })
@Endpoint({
+1 -34
View File
@@ -2,17 +2,8 @@ import { Body, Controller, Delete, Get, Header, HttpCode, HttpStatus, Post, Res
import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { Endpoint, HistoryBuilder } from 'src/decorators';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetDeltaSyncDto,
AssetDeltaSyncResponseDto,
AssetFullSyncDto,
SyncAckDeleteDto,
SyncAckDto,
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { SyncAckDeleteDto, SyncAckDto, SyncAckSetDto, SyncStreamDto } from 'src/dtos/sync.dto';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
@@ -26,30 +17,6 @@ export class SyncController {
private errorService: GlobalExceptionFilter,
) {}
@Post('full-sync')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get full sync for user',
description: 'Retrieve all assets for a full synchronization for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
}
@Post('delta-sync')
@Authenticated()
@HttpCode(HttpStatus.OK)
@Endpoint({
summary: 'Get delta sync for user',
description: 'Retrieve changed assets since the last sync for the authenticated user.',
history: new HistoryBuilder().added('v1').deprecated('v2'),
})
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
}
@Post('stream')
@Authenticated({ permission: Permission.SyncStream })
@Header('Content-Type', 'application/jsonlines+json')
+1 -1
View File
@@ -64,7 +64,7 @@ export class UserController {
@Authenticated({ permission: Permission.UserUpdate })
@Endpoint({
summary: 'Update current user',
description: 'Update the current user making teh API request.',
description: 'Update the current user making the API request.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
updateMyUser(@Auth() auth: AuthDto, @Body() dto: UserUpdateMeDto): Promise<UserAdminResponseDto> {
-4
View File
@@ -114,8 +114,6 @@ export type Asset = {
id: string;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
fileCreatedAt: Date;
fileModifiedAt: Date;
isExternal: boolean;
@@ -333,8 +331,6 @@ export const columns = {
'asset.id',
'asset.checksum',
'asset.checksumAlgorithm',
'asset.deviceAssetId',
'asset.deviceId',
'asset.fileCreatedAt',
'asset.fileModifiedAt',
'asset.isExternal',
+2 -2
View File
@@ -10,13 +10,13 @@ describe('mapAlbum', () => {
.asset({ localDateTime: endDate }, (builder) => builder.exif())
.asset({ localDateTime: startDate }, (builder) => builder.exif())
.build();
const dto = mapAlbum(getForAlbum(album), false);
const dto = mapAlbum(getForAlbum(album));
expect(dto.startDate).toEqual(startDate.toISOString());
expect(dto.endDate).toEqual(endDate.toISOString());
});
it('should not set start and end dates for empty assets', () => {
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
const dto = mapAlbum(getForAlbum(AlbumFactory.create()));
expect(dto.startDate).toBeUndefined();
expect(dto.endDate).toBeUndefined();
});
+2 -19
View File
@@ -3,8 +3,7 @@ import _ from 'lodash';
import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseSchema, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
@@ -12,12 +11,6 @@ import { asDateString } from 'src/utils/date';
import { stringToBool } from 'src/validation';
import z from 'zod';
const AlbumInfoSchema = z
.object({
withoutAssets: stringToBool.optional().describe('Exclude assets from response'),
})
.meta({ id: 'AlbumInfoDto' });
const AlbumUserAddSchema = z
.object({
userId: z.uuidv4().describe('User ID'),
@@ -122,7 +115,6 @@ export const AlbumResponseSchema = z
shared: z.boolean().describe('Is shared album'),
albumUsers: z.array(AlbumUserResponseSchema),
hasSharedLink: z.boolean().describe('Has shared link'),
assets: z.array(AssetResponseSchema),
owner: UserResponseSchema,
assetCount: z.int().min(0).describe('Number of assets'),
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
@@ -141,7 +133,6 @@ export const AlbumResponseSchema = z
})
.meta({ id: 'AlbumResponseDto' });
export class AlbumInfoDto extends createZodDto(AlbumInfoSchema) {}
export class AddUsersDto extends createZodDto(AddUsersSchema) {}
export class AlbumUserCreateDto extends createZodDto(AlbumUserCreateSchema) {}
export class CreateAlbumDto extends createZodDto(CreateAlbumSchema) {}
@@ -170,11 +161,7 @@ export type MapAlbumDto = {
order: AssetOrder;
};
export const mapAlbum = (
entity: MaybeDehydrated<MapAlbumDto>,
withAssets: boolean,
auth?: AuthDto,
): AlbumResponseDto => {
export const mapAlbum = (entity: MaybeDehydrated<MapAlbumDto>): AlbumResponseDto => {
const albumUsers: AlbumUserResponseDto[] = [];
if (entity.albumUsers) {
@@ -215,12 +202,8 @@ export const mapAlbum = (
hasSharedLink,
startDate: asDateString(startDate),
endDate: asDateString(endDate),
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
order: entity.order,
};
};
export const mapAlbumWithAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, true);
export const mapAlbumWithoutAssets = (entity: MaybeDehydrated<MapAlbumDto>) => mapAlbum(entity, false);
+13 -13
View File
@@ -5,21 +5,21 @@ import z from 'zod';
const PermissionSchema = z.enum(Permission).describe('List of permissions').meta({ id: 'Permission' });
const APIKeyCreateSchema = z
const ApiKeyCreateSchema = z
.object({
name: z.string().optional().describe('API key name'),
permissions: z.array(PermissionSchema).min(1).describe('List of permissions'),
})
.meta({ id: 'APIKeyCreateDto' });
.meta({ id: 'ApiKeyCreateDto' });
const APIKeyUpdateSchema = z
const ApiKeyUpdateSchema = z
.object({
name: z.string().optional().describe('API key name'),
permissions: z.array(PermissionSchema).min(1).optional().describe('List of permissions'),
})
.meta({ id: 'APIKeyUpdateDto' });
.meta({ id: 'ApiKeyUpdateDto' });
const APIKeyResponseSchema = z
const ApiKeyResponseSchema = z
.object({
id: z.string().describe('API key ID'),
name: z.string().describe('API key name'),
@@ -27,16 +27,16 @@ const APIKeyResponseSchema = z
updatedAt: isoDatetimeToDate.describe('Last update date'),
permissions: z.array(PermissionSchema).describe('List of permissions'),
})
.meta({ id: 'APIKeyResponseDto' });
.meta({ id: 'ApiKeyResponseDto' });
const APIKeyCreateResponseSchema = z
const ApiKeyCreateResponseSchema = z
.object({
secret: z.string().describe('API key secret (only shown once)'),
apiKey: APIKeyResponseSchema,
apiKey: ApiKeyResponseSchema,
})
.meta({ id: 'APIKeyCreateResponseDto' });
.meta({ id: 'ApiKeyCreateResponseDto' });
export class APIKeyCreateDto extends createZodDto(APIKeyCreateSchema) {}
export class APIKeyUpdateDto extends createZodDto(APIKeyUpdateSchema) {}
export class APIKeyResponseDto extends createZodDto(APIKeyResponseSchema) {}
export class APIKeyCreateResponseDto extends createZodDto(APIKeyCreateResponseSchema) {}
export class ApiKeyCreateDto extends createZodDto(ApiKeyCreateSchema) {}
export class ApiKeyUpdateDto extends createZodDto(ApiKeyUpdateSchema) {}
export class ApiKeyResponseDto extends createZodDto(ApiKeyResponseSchema) {}
export class ApiKeyCreateResponseDto extends createZodDto(ApiKeyCreateResponseSchema) {}
@@ -49,12 +49,5 @@ const AssetBulkUploadCheckResponseSchema = z
})
.meta({ id: 'AssetBulkUploadCheckResponseDto' });
const CheckExistingAssetsResponseSchema = z
.object({
existingIds: z.array(z.string()).describe('Existing asset IDs'),
})
.meta({ id: 'CheckExistingAssetsResponseDto' });
export class AssetMediaResponseDto extends createZodDto(AssetMediaResponseSchema) {}
export class AssetBulkUploadCheckResponseDto extends createZodDto(AssetBulkUploadCheckResponseSchema) {}
export class CheckExistingAssetsResponseDto extends createZodDto(CheckExistingAssetsResponseSchema) {}
-10
View File
@@ -36,8 +36,6 @@ export enum UploadFieldName {
}
const AssetMediaBaseSchema = z.object({
deviceAssetId: z.string().describe('Device asset ID'),
deviceId: z.string().describe('Device ID'),
fileCreatedAt: isoDatetimeToDate.describe('File creation date'),
fileModifiedAt: isoDatetimeToDate.describe('File modification date'),
duration: z.string().optional().describe('Duration (for videos)'),
@@ -71,14 +69,6 @@ const AssetBulkUploadCheckSchema = z
})
.meta({ id: 'AssetBulkUploadCheckDto' });
const CheckExistingAssetsSchema = z
.object({
deviceAssetIds: z.array(z.string()).min(1).describe('Device asset IDs to check'),
deviceId: z.string().describe('Device ID'),
})
.meta({ id: 'CheckExistingAssetsDto' });
export class AssetMediaOptionsDto extends createZodDto(AssetMediaOptionsSchema) {}
export class AssetMediaCreateDto extends createZodDto(AssetMediaCreateSchema) {}
export class AssetBulkUploadCheckDto extends createZodDto(AssetBulkUploadCheckSchema) {}
export class CheckExistingAssetsDto extends createZodDto(CheckExistingAssetsSchema) {}
-6
View File
@@ -72,8 +72,6 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.string()
.meta({ format: 'date-time' })
.describe('The UTC timestamp when the asset was originally uploaded to Immich.'),
deviceAssetId: z.string().describe('Device asset ID'),
deviceId: z.string().describe('Device ID'),
ownerId: z.string().describe('Owner user ID'),
owner: UserResponseSchema.optional(),
libraryId: z
@@ -137,8 +135,6 @@ export type MapAsset = {
status: AssetStatus;
checksum: Buffer<ArrayBufferLike>;
checksumAlgorithm: ChecksumAlgorithm;
deviceAssetId: string;
deviceId: string;
duplicateId: string | null;
duration: string | null;
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
@@ -239,10 +235,8 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
return {
id: entity.id,
createdAt: asDateString(entity.createdAt),
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
owner: entity.owner ? mapUser(entity.owner) : undefined,
deviceId: entity.deviceId,
libraryId: entity.libraryId,
type: entity.type,
originalPath: entity.originalPath,
-7
View File
@@ -6,12 +6,6 @@ import { AssetStats } from 'src/repositories/asset.repository';
import { IsNotSiblingOf, isoDatetimeToDate, latitudeSchema, longitudeSchema, stringToBool } from 'src/validation';
import z from 'zod';
const DeviceIdSchema = z
.object({
deviceId: z.string().describe('Device ID'),
})
.meta({ id: 'DeviceIdDto' });
const UpdateAssetBaseSchema = z
.object({
isFavorite: z.boolean().optional().describe('Mark as favorite'),
@@ -182,7 +176,6 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
};
};
export class DeviceIdDto extends createZodDto(DeviceIdSchema) {}
export class AssetBulkUpdateDto extends createZodDto(AssetBulkUpdateSchema) {}
export class UpdateAssetDto extends createZodDto(UpdateAssetSchema) {}
export class AssetBulkDeleteDto extends createZodDto(AssetBulkDeleteSchema) {}
-2
View File
@@ -9,7 +9,6 @@ import z from 'zod';
const BaseSearchSchema = z.object({
libraryId: z.uuidv4().nullish().describe('Library ID to filter by'),
deviceId: z.string().optional().describe('Device ID to filter by'),
type: AssetTypeSchema.optional(),
isEncoded: z.boolean().optional().describe('Filter by encoded status'),
isFavorite: z.boolean().optional().describe('Filter by favorite status'),
@@ -68,7 +67,6 @@ const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
const MetadataSearchSchema = RandomSearchSchema.extend({
id: z.uuidv4().optional().describe('Filter by asset ID'),
deviceAssetId: z.string().optional().describe('Filter by device asset ID'),
description: z.string().trim().optional().describe('Filter by description text'),
checksum: z.string().optional().describe('Filter by file checksum'),
originalFileName: z.string().trim().optional().describe('Filter by original file name'),
+2 -2
View File
@@ -1,7 +1,7 @@
import { createZodDto } from 'nestjs-zod';
import { SharedLink } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AlbumResponseSchema, mapAlbumWithoutAssets } from 'src/dtos/album.dto';
import { AlbumResponseSchema, mapAlbum } from 'src/dtos/album.dto';
import { AssetResponseSchema, mapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkTypeSchema } from 'src/enum';
import { emptyStringToNull, isoDatetimeToDate } from 'src/validation';
@@ -96,7 +96,7 @@ export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetad
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
album: sharedLink.album ? mapAlbum(sharedLink.album) : undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,
-31
View File
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { createZodDto } from 'nestjs-zod';
import { AssetResponseSchema } from 'src/dtos/asset-response.dto';
import { AssetEditActionSchema } from 'src/dtos/editing.dto';
import {
AlbumUserRoleSchema,
@@ -17,36 +16,6 @@ import {
import { isoDatetimeToDate } from 'src/validation';
import z from 'zod';
const AssetFullSyncSchema = z
.object({
lastId: z.uuidv4().optional().describe('Last asset ID (pagination)'),
updatedUntil: isoDatetimeToDate.describe('Sync assets updated until this date'),
limit: z.int().min(1).describe('Maximum number of assets to return'),
userId: z.uuidv4().optional().describe('Filter by user ID'),
})
.meta({ id: 'AssetFullSyncDto' });
const AssetDeltaSyncSchema = z
.object({
updatedAfter: isoDatetimeToDate.describe('Sync assets updated after this date'),
userIds: z.array(z.uuidv4()).describe('User IDs to sync'),
})
.meta({ id: 'AssetDeltaSyncDto' });
export class AssetFullSyncDto extends createZodDto(AssetFullSyncSchema) {}
export class AssetDeltaSyncDto extends createZodDto(AssetDeltaSyncSchema) {}
const AssetDeltaSyncResponseSchema = z
.object({
needsFullSync: z.boolean().describe('Whether full sync is needed'),
upserted: z.array(AssetResponseSchema),
deleted: z.array(z.string()).describe('Deleted asset IDs'),
})
.describe('Asset delta sync response')
.meta({ id: 'AssetDeltaSyncResponseDto' });
export class AssetDeltaSyncResponseDto extends createZodDto(AssetDeltaSyncResponseSchema) {}
export const extraSyncModels: Function[] = [];
const ExtraModel = (): ClassDecorator => {
-16
View File
@@ -88,21 +88,6 @@ export enum AssetOrder {
export const AssetOrderSchema = z.enum(AssetOrder).describe('Asset sort order').meta({ id: 'AssetOrder' });
export enum DatabaseAction {
Create = 'CREATE',
Update = 'UPDATE',
Delete = 'DELETE',
}
export const DatabaseActionSchema = z.enum(DatabaseAction).describe('Database action').meta({ id: 'DatabaseAction' });
export enum EntityType {
Asset = 'ASSET',
Album = 'ALBUM',
}
export const EntityTypeSchema = z.enum(EntityType).describe('Entity type').meta({ id: 'EntityType' });
export enum MemoryType {
/** pictures taken on this day X years ago */
OnThisDay = 'on_this_day',
@@ -761,7 +746,6 @@ export enum JobName {
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
AuditLogCleanup = 'AuditLogCleanup',
AuditTableCleanup = 'AuditTableCleanup',
DatabaseBackup = 'DatabaseBackup',
@@ -250,8 +250,6 @@ select
"asset"."id",
"asset"."checksum",
"asset"."checksumAlgorithm",
"asset"."deviceAssetId",
"asset"."deviceId",
"asset"."fileCreatedAt",
"asset"."fileModifiedAt",
"asset"."isExternal",
+1 -70
View File
@@ -242,17 +242,6 @@ where
limit
$3
-- AssetRepository.getAllByDeviceId
select
"deviceAssetId"
from
"asset"
where
"ownerId" = $1::uuid
and "deviceId" = $2
and "visibility" != $3
and "deletedAt" is null
-- AssetRepository.getLivePhotoCount
select
count(*) as "count"
@@ -312,9 +301,8 @@ limit
-- AssetRepository.updateAll
update "asset"
set
"deviceId" = $1
where
"id" = any ($2::uuid[])
"id" = any ($1::uuid[])
-- AssetRepository.getByChecksum
select
@@ -497,63 +485,6 @@ where
limit
$5
-- AssetRepository.getAllForUserFullSync
select
"asset".*,
to_json("asset_exif") as "exifInfo",
to_json("stacked_assets") as "stack"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "stack" on "stack"."id" = "asset"."stackId"
left join lateral (
select
"stack".*,
count("stacked") as "assetCount"
from
"asset" as "stacked"
where
"stacked"."stackId" = "stack"."id"
group by
"stack"."id"
) as "stacked_assets" on "stack"."id" is not null
where
"asset"."ownerId" = $1::uuid
and "asset"."visibility" != $2
and "asset"."updatedAt" <= $3
and "asset"."id" > $4
order by
"asset"."id"
limit
$5
-- AssetRepository.getChangedDeltaSync
select
"asset".*,
to_json("asset_exif") as "exifInfo",
to_json("stacked_assets") as "stack"
from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
left join "stack" on "stack"."id" = "asset"."stackId"
left join lateral (
select
"stack".*,
count("stacked") as "assetCount"
from
"asset" as "stacked"
where
"stacked"."stackId" = "stack"."id"
group by
"stack"."id"
) as "stacked_assets" on "stack"."id" is not null
where
"asset"."ownerId" = any ($1::uuid[])
and "asset"."visibility" != $2
and "asset"."updatedAt" > $3
limit
$4
-- AssetRepository.detectOfflineExternalAssets
update "asset"
set
-16
View File
@@ -1,16 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- AuditRepository.getAfter
select distinct
on ("audit"."entityId", "audit"."entityType") "audit"."entityId"
from
"audit"
where
"audit"."createdAt" > $1
and "audit"."action" = $2
and "audit"."entityType" = $3
and "audit"."ownerId" in ($4)
order by
"audit"."entityId" desc,
"audit"."entityType" desc,
"audit"."createdAt" desc
+22 -2
View File
@@ -1,5 +1,25 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MapRepository.getAlbumMapMarkers
select
"id",
"asset_exif"."latitude" as "lat",
"asset_exif"."longitude" as "lon",
"asset_exif"."city",
"asset_exif"."state",
"asset_exif"."country"
from
"asset"
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
and "asset_exif"."latitude" is not null
and "asset_exif"."longitude" is not null
inner join "album_asset" on "asset"."id" = "album_asset"."assetId"
where
"asset"."deletedAt" is null
and "album_asset"."albumId" = $1
order by
"fileCreatedAt" desc
-- MapRepository.getMapMarkers
select
"id",
@@ -14,8 +34,8 @@ from
and "asset_exif"."latitude" is not null
and "asset_exif"."longitude" is not null
where
"asset"."visibility" = $1
and "deletedAt" is null
"asset"."deletedAt" is null
and "asset"."visibility" = $1
and (
"ownerId" in ($2)
or exists (
+1 -111
View File
@@ -106,19 +106,6 @@ interface AssetExploreFieldOptions {
minAssetsPerField: number;
}
interface AssetFullSyncOptions {
ownerId: string;
lastId?: string;
updatedUntil: Date;
limit: number;
}
interface AssetDeltaSyncOptions {
userIds: string[];
updatedAfter: Date;
limit: number;
}
interface AssetGetByChecksumOptions {
ownerId: string;
checksum: Buffer;
@@ -461,18 +448,6 @@ export class AssetRepository {
await this.db.deleteFrom('asset').where('ownerId', '=', ownerId).execute();
}
async getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<string[]> {
const assets = await this.db
.selectFrom('asset')
.select(['deviceAssetId'])
.where('deviceAssetId', 'in', deviceAssetIds)
.where('deviceId', '=', deviceId)
.where('ownerId', '=', asUuid(ownerId))
.execute();
return assets.map((asset) => asset.deviceAssetId);
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
return this.db
@@ -484,27 +459,6 @@ export class AssetRepository {
.executeTakeFirst();
}
/**
* Get assets by device's Id on the database
* @param ownerId
* @param deviceId
*
* @returns Promise<string[]> - Array of assetIds belong to the device
*/
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAllByDeviceId(ownerId: string, deviceId: string): Promise<string[]> {
const items = await this.db
.selectFrom('asset')
.select(['deviceAssetId'])
.where('ownerId', '=', asUuid(ownerId))
.where('deviceId', '=', deviceId)
.where('visibility', '!=', AssetVisibility.Hidden)
.where('deletedAt', 'is', null)
.execute();
return items.map((asset) => asset.deviceAssetId);
}
@GenerateSql({ params: [DummyValue.UUID] })
async getLivePhotoCount(motionId: string): Promise<number> {
const [{ count }] = await this.db
@@ -581,7 +535,7 @@ export class AssetRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
@GenerateSql({ params: [[DummyValue.UUID], {}] })
@Chunked()
async updateAll(ids: string[], options: Updateable<AssetTable>): Promise<void> {
if (ids.length === 0) {
@@ -905,70 +859,6 @@ export class AssetRepository {
return { fieldName: 'exifInfo.city', items };
}
@GenerateSql({
params: [
{
ownerId: DummyValue.UUID,
lastId: DummyValue.UUID,
updatedUntil: DummyValue.DATE,
limit: 10,
},
],
})
getAllForUserFullSync(options: AssetFullSyncOptions) {
const { ownerId, lastId, updatedUntil, limit } = options;
return this.db
.selectFrom('asset')
.selectAll('asset')
.$call(withExif)
.leftJoin('stack', 'stack.id', 'asset.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
.whereRef('stacked.stackId', '=', 'stack.id')
.groupBy('stack.id')
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack'))
.where('asset.ownerId', '=', asUuid(ownerId))
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.updatedAt', '<=', updatedUntil)
.$if(!!lastId, (qb) => qb.where('asset.id', '>', lastId!))
.orderBy('asset.id')
.limit(limit)
.execute();
}
@GenerateSql({ params: [{ userIds: [DummyValue.UUID], updatedAfter: DummyValue.DATE, limit: 100 }] })
async getChangedDeltaSync(options: AssetDeltaSyncOptions) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.$call(withExif)
.leftJoin('stack', 'stack.id', 'asset.stackId')
.leftJoinLateral(
(eb) =>
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
.whereRef('stacked.stackId', '=', 'stack.id')
.groupBy('stack.id')
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
.where('asset.ownerId', '=', anyUuid(options.userIds))
.where('asset.visibility', '!=', AssetVisibility.Hidden)
.where('asset.updatedAt', '>', options.updatedAfter)
.limit(options.limit)
.execute();
}
async upsertFile(
file: Pick<
Insertable<AssetFileTable>,
@@ -1,44 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DummyValue, GenerateSql } from 'src/decorators';
import { DatabaseAction, EntityType } from 'src/enum';
import { DB } from 'src/schema';
export interface AuditSearch {
action?: DatabaseAction;
entityType?: EntityType;
userIds: string[];
}
@Injectable()
export class AuditRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({
params: [
DummyValue.DATE,
{ action: DatabaseAction.Create, entityType: EntityType.Asset, userIds: [DummyValue.UUID] },
],
})
async getAfter(since: Date, options: AuditSearch): Promise<string[]> {
const records = await this.db
.selectFrom('audit')
.where('audit.createdAt', '>', since)
.$if(!!options.action, (qb) => qb.where('audit.action', '=', options.action!))
.$if(!!options.entityType, (qb) => qb.where('audit.entityType', '=', options.entityType!))
.where('audit.ownerId', 'in', options.userIds)
.distinctOn(['audit.entityId', 'audit.entityType'])
.orderBy('audit.entityId', 'desc')
.orderBy('audit.entityType', 'desc')
.orderBy('audit.createdAt', 'desc')
.select('audit.entityId')
.execute();
return records.map(({ entityId }) => entityId);
}
async removeBefore(before: Date): Promise<void> {
await this.db.deleteFrom('audit').where('createdAt', '<', before).execute();
}
}
-2
View File
@@ -7,7 +7,6 @@ import { AppRepository } from 'src/repositories/app.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@@ -56,7 +55,6 @@ export const repositories = [
ActivityRepository,
AlbumRepository,
AlbumUserRepository,
AuditRepository,
ApiKeyRepository,
AppRepository,
AssetRepository,
+31 -19
View File
@@ -76,29 +76,21 @@ export class MapRepository {
this.logger.log('Geodata import completed');
}
@GenerateSql({ params: [DummyValue.UUID] })
getAlbumMapMarkers(albumId: string) {
return this.mapMarkersQuery()
.innerJoin('album_asset', 'asset.id', 'album_asset.assetId')
.where('album_asset.albumId', '=', albumId)
.execute();
}
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
getMapMarkers(
ownerIds: string[],
albumIds: string[],
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
) {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', (builder) =>
builder
.onRef('asset.id', '=', 'asset_exif.assetId')
.on('asset_exif.latitude', 'is not', null)
.on('asset_exif.longitude', 'is not', null),
)
.select([
'id',
'asset_exif.latitude as lat',
'asset_exif.longitude as lon',
'asset_exif.city',
'asset_exif.state',
'asset_exif.country',
])
.$narrowType<{ lat: NotNull; lon: NotNull }>()
return this.mapMarkersQuery()
.$if(isArchived === true, (qb) =>
qb.where((eb) =>
eb.or([
@@ -113,7 +105,6 @@ export class MapRepository {
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
.where('deletedAt', 'is', null)
.where((eb) => {
const expression: Expression<SqlBool>[] = [];
@@ -134,10 +125,31 @@ export class MapRepository {
return eb.or(expression);
})
.orderBy('fileCreatedAt', 'desc')
.execute();
}
private mapMarkersQuery() {
return this.db
.selectFrom('asset')
.innerJoin('asset_exif', (builder) =>
builder
.onRef('asset.id', '=', 'asset_exif.assetId')
.on('asset_exif.latitude', 'is not', null)
.on('asset_exif.longitude', 'is not', null),
)
.where('asset.deletedAt', 'is', null)
.orderBy('fileCreatedAt', 'desc')
.select([
'id',
'asset_exif.latitude as lat',
'asset_exif.longitude as lon',
'asset_exif.city',
'asset_exif.state',
'asset_exif.country',
])
.$narrowType<{ lat: NotNull; lon: NotNull }>();
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
@@ -14,12 +14,10 @@ import { isValidInteger } from 'src/validation';
export interface SearchAssetIdOptions {
checksum?: Buffer;
deviceAssetId?: string;
id?: string;
}
export interface SearchUserIdOptions {
deviceId?: string;
libraryId?: string | null;
userIds?: string[];
}
-4
View File
@@ -40,7 +40,6 @@ import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit.
import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AuditTable } from 'src/schema/tables/audit.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
@@ -98,7 +97,6 @@ export class ImmichDatabase {
AssetOcrTable,
AssetTable,
AssetFileTable,
AuditTable,
AssetExifTable,
FaceSearchTable,
GeodataPlacesTable,
@@ -197,8 +195,6 @@ export interface DB {
asset_ocr: AssetOcrTable;
ocr_search: OcrSearchTable;
audit: AuditTable;
face_search: FaceSearchTable;
geodata_places: GeodataPlacesTable;
@@ -0,0 +1,18 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`DROP TABLE "audit";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "audit" (
"id" serial NOT NULL,
"entityType" character varying NOT NULL,
"entityId" uuid NOT NULL,
"action" character varying NOT NULL,
"ownerId" uuid NOT NULL,
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT "audit_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "audit_ownerId_createdAt_idx" ON "audit" ("ownerId", "createdAt");`.execute(db);
}
@@ -0,0 +1,11 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "deviceAssetId";`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "deviceId";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD "deviceAssetId" character varying NOT NULL;`.execute(db);
await sql`ALTER TABLE "asset" ADD "deviceId" character varying NOT NULL;`.execute(db);
}
-6
View File
@@ -65,15 +65,9 @@ export class AssetTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column()
deviceAssetId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column()
deviceId!: string;
@Column()
type!: AssetType;
-24
View File
@@ -1,24 +0,0 @@
import { Column, CreateDateColumn, Generated, Index, PrimaryColumn, Table, Timestamp } from '@immich/sql-tools';
import { DatabaseAction, EntityType } from 'src/enum';
@Table('audit')
@Index({ columns: ['ownerId', 'createdAt'] })
export class AuditTable {
@PrimaryColumn({ type: 'serial', synchronize: false })
id!: Generated<number>;
@Column()
entityType!: EntityType;
@Column({ type: 'uuid' })
entityId!: string;
@Column()
action!: DatabaseAction;
@Column({ type: 'uuid' })
ownerId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
+7 -7
View File
@@ -563,9 +563,9 @@ describe(AlbumService.name, () => {
},
]);
await sut.get(AuthFactory.create(album.owner), album.id, {});
await sut.get(AuthFactory.create(album.owner), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id]));
});
@@ -584,9 +584,9 @@ describe(AlbumService.name, () => {
]);
const auth = AuthFactory.from().sharedLink().build();
await sut.get(auth, album.id, {});
await sut.get(auth, album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalledWith(auth.sharedLink!.id, new Set([album.id]));
});
@@ -605,9 +605,9 @@ describe(AlbumService.name, () => {
},
]);
await sut.get(AuthFactory.create(user), album.id, {});
await sut.get(AuthFactory.create(user), album.id);
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true });
expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false });
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
user.id,
new Set([album.id]),
@@ -617,7 +617,7 @@ describe(AlbumService.name, () => {
it('should throw an error for no access', async () => {
const auth = AuthFactory.create();
await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.get(auth, 'album-123')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['album-123']));
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
+18 -11
View File
@@ -1,7 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import {
AddUsersDto,
AlbumInfoDto,
AlbumResponseDto,
AlbumsAddAssetsDto,
AlbumsAddAssetsResponseDto,
@@ -10,13 +9,12 @@ import {
GetAlbumsDto,
mapAlbum,
MapAlbumDto,
mapAlbumWithAssets,
mapAlbumWithoutAssets,
UpdateAlbumDto,
UpdateAlbumUserDto,
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
@@ -63,7 +61,7 @@ export class AlbumService extends BaseService {
}
return albums.map((album) => ({
...mapAlbumWithoutAssets(album),
...mapAlbum(album),
sharedLinks: undefined,
startDate: asDateString(albumMetadata[album.id]?.startDate ?? undefined),
endDate: asDateString(albumMetadata[album.id]?.endDate ?? undefined),
@@ -73,11 +71,10 @@ export class AlbumService extends BaseService {
}));
}
async get(auth: AuthDto, id: string, dto: AlbumInfoDto): Promise<AlbumResponseDto> {
async get(auth: AuthDto, id: string): Promise<AlbumResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
await this.albumRepository.updateThumbnails();
const withAssets = dto.withoutAssets === undefined ? true : !dto.withoutAssets;
const album = await this.findOrFail(id, { withAssets });
const album = await this.findOrFail(id, { withAssets: false });
const [albumMetadataForIds] = await this.albumRepository.getMetadataForIds([album.id]);
const hasSharedUsers = album.albumUsers && album.albumUsers.length > 0;
@@ -85,7 +82,7 @@ export class AlbumService extends BaseService {
const isShared = hasSharedUsers || hasSharedLink;
return {
...mapAlbum(album, withAssets, auth),
...mapAlbum(album),
startDate: asDateString(albumMetadataForIds?.startDate ?? undefined),
endDate: asDateString(albumMetadataForIds?.endDate ?? undefined),
assetCount: albumMetadataForIds?.assetCount ?? 0,
@@ -94,6 +91,16 @@ export class AlbumService extends BaseService {
};
}
async getMapMarkers(auth: AuthDto, id: string): Promise<MapMarkerResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] });
if (auth.sharedLink && !auth.sharedLink.showExif) {
return [];
}
return this.mapRepository.getAlbumMapMarkers(id);
}
async create(auth: AuthDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
const albumUsers = dto.albumUsers || [];
@@ -133,7 +140,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId });
}
return mapAlbumWithAssets(album);
return mapAlbum(album);
}
async update(auth: AuthDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
@@ -156,7 +163,7 @@ export class AlbumService extends BaseService {
order: dto.order,
});
return mapAlbumWithoutAssets({ ...updatedAlbum, assets: album.assets });
return mapAlbum({ ...updatedAlbum, assets: album.assets });
}
async delete(auth: AuthDto, id: string): Promise<void> {
@@ -294,7 +301,7 @@ export class AlbumService extends BaseService {
await this.eventRepository.emit('AlbumInvite', { id, userId });
}
return this.findOrFail(id, { withAssets: true }).then(mapAlbumWithoutAssets);
return this.findOrFail(id, { withAssets: true }).then(mapAlbum);
}
async removeUser(auth: AuthDto, id: string, userId: string | 'me'): Promise<void> {
+7 -7
View File
@@ -1,6 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/common';
import { ApiKey } from 'src/database';
import { APIKeyCreateDto, APIKeyCreateResponseDto, APIKeyResponseDto, APIKeyUpdateDto } from 'src/dtos/api-key.dto';
import { ApiKeyCreateDto, ApiKeyCreateResponseDto, ApiKeyResponseDto, ApiKeyUpdateDto } from 'src/dtos/api-key.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { BaseService } from 'src/services/base.service';
@@ -8,7 +8,7 @@ import { isGranted } from 'src/utils/access';
@Injectable()
export class ApiKeyService extends BaseService {
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
async create(auth: AuthDto, dto: ApiKeyCreateDto): Promise<ApiKeyCreateResponseDto> {
const token = this.cryptoRepository.randomBytesAsText(32);
const hashed = this.cryptoRepository.hashSha256(token);
@@ -26,7 +26,7 @@ export class ApiKeyService extends BaseService {
return { secret: token, apiKey: this.map(entity) };
}
async update(auth: AuthDto, id: string, dto: APIKeyUpdateDto): Promise<APIKeyResponseDto> {
async update(auth: AuthDto, id: string, dto: ApiKeyUpdateDto): Promise<ApiKeyResponseDto> {
const exists = await this.apiKeyRepository.getById(auth.user.id, id);
if (!exists) {
throw new BadRequestException('API Key not found');
@@ -54,7 +54,7 @@ export class ApiKeyService extends BaseService {
await this.apiKeyRepository.delete(auth.user.id, id);
}
async getMine(auth: AuthDto): Promise<APIKeyResponseDto> {
async getMine(auth: AuthDto): Promise<ApiKeyResponseDto> {
if (!auth.apiKey) {
throw new ForbiddenException('Not authenticated with an API Key');
}
@@ -67,7 +67,7 @@ export class ApiKeyService extends BaseService {
return this.map(key);
}
async getById(auth: AuthDto, id: string): Promise<APIKeyResponseDto> {
async getById(auth: AuthDto, id: string): Promise<ApiKeyResponseDto> {
const key = await this.apiKeyRepository.getById(auth.user.id, id);
if (!key) {
throw new BadRequestException('API Key not found');
@@ -75,12 +75,12 @@ export class ApiKeyService extends BaseService {
return this.map(key);
}
async getAll(auth: AuthDto): Promise<APIKeyResponseDto[]> {
async getAll(auth: AuthDto): Promise<ApiKeyResponseDto[]> {
const keys = await this.apiKeyRepository.getByUserId(auth.user.id);
return keys.map((key) => this.map(key));
}
private map(entity: ApiKey): APIKeyResponseDto {
private map(entity: ApiKey): ApiKeyResponseDto {
return {
id: entity.id,
name: entity.name,
@@ -145,8 +145,6 @@ const uploadTests = [
];
const createDto = Object.freeze({
deviceAssetId: 'deviceAssetId',
deviceId: 'deviceId',
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
isFavorite: false,
@@ -156,8 +154,6 @@ const createDto = Object.freeze({
const assetEntity = Object.freeze({
id: 'id_1',
ownerId: 'user_id_1',
deviceAssetId: 'device_asset_id_1',
deviceId: 'device_id_1',
type: AssetType.Video,
originalPath: 'fake_path/asset_1.jpeg',
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
@@ -765,17 +761,6 @@ describe(AssetMediaService.name, () => {
});
});
describe('checkExistingAssets', () => {
it('should get existing asset ids', async () => {
mocks.asset.getByDeviceIds.mockResolvedValue(['42']);
await expect(
sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }),
).resolves.toEqual({ existingIds: ['42'] });
expect(mocks.asset.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']);
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
@@ -9,14 +9,12 @@ import {
AssetMediaStatus,
AssetRejectReason,
AssetUploadAction,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto';
import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
@@ -251,18 +249,6 @@ export class AssetMediaService extends BaseService {
});
}
async checkExistingAssets(
auth: AuthDto,
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
const existingIds = await this.assetRepository.getByDeviceIds(
auth.user.id,
checkExistingAssetsDto.deviceId,
checkExistingAssetsDto.deviceAssetIds,
);
return { existingIds };
}
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
@@ -340,9 +326,6 @@ export class AssetMediaService extends BaseService {
checksumAlgorithm: ChecksumAlgorithm.sha1File,
originalPath: file.originalPath,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
-14
View File
@@ -685,20 +685,6 @@ describe(AssetService.name, () => {
});
});
describe('getUserAssetsByDeviceId', () => {
it('get assets by device id', async () => {
const assets = [AssetFactory.create(), AssetFactory.create()];
mocks.asset.getAllByDeviceId.mockResolvedValue(assets.map((asset) => asset.deviceAssetId));
const deviceId = 'device-id';
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
expect(result.length).toEqual(2);
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
});
describe('upsertMetadata', () => {
it('should throw a bad request exception if duplicate keys are sent', async () => {
const asset = AssetFactory.create();
-4
View File
@@ -59,10 +59,6 @@ export class AssetService extends BaseService {
return mapStats(stats);
}
async getUserAssetsByDeviceId(auth: AuthDto, deviceId: string) {
return this.assetRepository.getAllByDeviceId(auth.user.id, deviceId);
}
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
-26
View File
@@ -1,26 +0,0 @@
import { JobStatus } from 'src/enum';
import { AuditService } from 'src/services/audit.service';
import { newTestService, ServiceMocks } from 'test/utils';
describe(AuditService.name, () => {
let sut: AuditService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(AuditService));
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('handleCleanup', () => {
it('should delete old audit entries', async () => {
mocks.audit.removeBefore.mockResolvedValue();
await expect(sut.handleCleanup()).resolves.toBe(JobStatus.Success);
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
});
});
});
-15
View File
@@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { OnJob } from 'src/decorators';
import { JobName, JobStatus, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class AuditService extends BaseService {
@OnJob({ name: JobName.AuditLogCleanup, queue: QueueName.BackgroundTask })
async handleCleanup(): Promise<JobStatus> {
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return JobStatus.Success;
}
}
-3
View File
@@ -14,7 +14,6 @@ import { AppRepository } from 'src/repositories/app.repository';
import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { AuditRepository } from 'src/repositories/audit.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
@@ -72,7 +71,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
AssetRepository,
AssetEditRepository,
AssetJobRepository,
AuditRepository,
ConfigRepository,
CronRepository,
CryptoRepository,
@@ -131,7 +129,6 @@ export class BaseService {
protected assetRepository: AssetRepository,
protected assetEditRepository: AssetEditRepository,
protected assetJobRepository: AssetJobRepository,
protected auditRepository: AuditRepository,
protected configRepository: ConfigRepository,
protected cronRepository: CronRepository,
protected cryptoRepository: CryptoRepository,
-2
View File
@@ -4,7 +4,6 @@ import { ApiKeyService } from 'src/services/api-key.service';
import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthAdminService } from 'src/services/auth-admin.service';
import { AuthService } from 'src/services/auth.service';
import { CliService } from 'src/services/cli.service';
@@ -55,7 +54,6 @@ export const services = [
ApiService,
AssetMediaService,
AssetService,
AuditService,
AuthService,
AuthAdminService,
CliService,
@@ -570,7 +570,6 @@ describe(LibraryService.name, () => {
ownerId: library.ownerId,
libraryId: library.id,
originalPath: '/data/user1/photo.jpg',
deviceId: 'Library Import',
type: AssetType.Image,
originalFileName: 'photo.jpg',
isExternal: true,
+1 -4
View File
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely';
import { R_OK } from 'node:constants';
import { Stats } from 'node:fs';
import path, { basename, isAbsolute, parse } from 'node:path';
import path, { isAbsolute, parse } from 'node:path';
import picomatch from 'picomatch';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
@@ -416,9 +416,6 @@ export class LibraryService extends BaseService {
fileCreatedAt: stat.mtime,
fileModifiedAt: stat.mtime,
localDateTime: stat.mtime,
// TODO: device asset id is deprecated, remove it
deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''),
deviceId: 'Library Import',
type: mimeTypes.isVideo(assetPath) ? AssetType.Video : AssetType.Image,
originalFileName: parse(assetPath).base,
isExternal: true,
@@ -654,8 +654,6 @@ describe(MetadataService.name, () => {
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
id: motionAsset.id,
@@ -708,8 +706,6 @@ describe(MetadataService.name, () => {
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
id: motionAsset.id,
@@ -762,8 +758,6 @@ describe(MetadataService.name, () => {
expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer),
checksumAlgorithm: ChecksumAlgorithm.sha1File,
deviceAssetId: 'NONE',
deviceId: 'NONE',
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
id: motionAsset.id,
-2
View File
@@ -681,8 +681,6 @@ export class MetadataService extends BaseService {
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${parse(asset.originalFileName).name}.mp4`,
visibility: AssetVisibility.Hidden,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
isNewMotionAsset = true;
@@ -42,7 +42,6 @@ describe(QueueService.name, () => {
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.AuditLogCleanup },
{ name: JobName.MemoryGenerate },
{ name: JobName.UserSyncUsage },
{ name: JobName.AssetGenerateThumbnailsQueueAll, data: { force: false } },
-1
View File
@@ -270,7 +270,6 @@ export class QueueService extends BaseService {
{ name: JobName.MemoryCleanup },
{ name: JobName.SessionCleanup },
{ name: JobName.AuditTableCleanup },
{ name: JobName.AuditLogCleanup },
);
}
-98
View File
@@ -1,98 +0,0 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SyncService } from 'src/services/sync.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { PartnerFactory } from 'test/factories/partner.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const untilDate = new Date(2024);
const mapAssetOpts = { auth: authStub.user1, stripMetadata: false, withStack: true };
describe(SyncService.name, () => {
let sut: SyncService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(SyncService));
});
it('should exist', () => {
expect(sut).toBeDefined();
});
describe('getAllAssetsForUserFullSync', () => {
it('should return a list of all assets owned by the user', async () => {
const [asset1, asset2] = [
AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(),
AssetFactory.from().owner(authStub.user1.user).build(),
];
mocks.asset.getAllForUserFullSync.mockResolvedValue([getForAsset(asset1), getForAsset(asset2)]);
await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
mapAsset(getForAsset(asset1), mapAssetOpts),
mapAsset(getForAsset(asset2), mapAssetOpts),
]);
expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({
ownerId: authStub.user1.user.id,
updatedUntil: untilDate,
limit: 2,
});
});
});
describe('getChangesForDeltaSync', () => {
it('should return a response requiring a full sync when partners are out of sync', async () => {
const partner = PartnerFactory.create();
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [auth.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0);
});
it('should return a response requiring a full sync when last sync was too long ago', async () => {
mocks.partner.getAll.mockResolvedValue([]);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(2000), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(0);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0);
});
it('should return a response requiring a full sync when there are too many changes', async () => {
const asset = AssetFactory.create();
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue(
Array.from<ReturnType<typeof getForAsset>>({ length: 10_000 }).fill(getForAsset(asset)),
);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(0);
});
it('should return a response with changes and deletions', async () => {
const asset = AssetFactory.create({ ownerId: authStub.user1.user.id });
const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue([getForAsset(asset)]);
mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({
needsFullSync: false,
upserted: [mapAsset(getForAsset(asset), mapAssetOpts)],
deleted: [deletedAsset.id],
});
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1);
});
});
});
+1 -82
View File
@@ -2,14 +2,9 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com
import { Insertable } from 'kysely';
import { DateTime, Duration } from 'luxon';
import { Writable } from 'node:stream';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { OnJob } from 'src/decorators';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetDeltaSyncDto,
AssetDeltaSyncResponseDto,
AssetFullSyncDto,
SyncAckDeleteDto,
SyncAckSetDto,
syncAssetFaceV2ToV1,
@@ -17,23 +12,12 @@ import {
SyncItem,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import {
AssetVisibility,
DatabaseAction,
EntityType,
JobName,
Permission,
QueueName,
SyncEntityType,
SyncRequestType,
} from 'src/enum';
import { JobName, QueueName, SyncEntityType, SyncRequestType } from 'src/enum';
import { SyncQueryOptions } from 'src/repositories/sync.repository';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { BaseService } from 'src/services/base.service';
import { SyncAck } from 'src/types';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { setIsEqual } from 'src/utils/set';
import { fromAck, serialize, SerializeOptions, toAck } from 'src/utils/sync';
type CheckpointMap = Partial<Record<SyncEntityType, SyncAck>>;
@@ -66,7 +50,6 @@ const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityTy
send(response, { type: SyncEntityType.SyncAckV1, data: {}, ackType, ids: [id, COMPLETE_ID] });
};
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
export const SYNC_TYPES_ORDER = [
SyncRequestType.AuthUsersV1,
SyncRequestType.UsersV1,
@@ -887,68 +870,4 @@ export class SyncService extends BaseService {
},
]);
}
async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
// mobile implementation is faster if this is a single id
const userId = dto.userId || auth.user.id;
await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: [userId] });
const assets = await this.assetRepository.getAllForUserFullSync({
ownerId: userId,
updatedUntil: dto.updatedUntil,
lastId: dto.lastId,
limit: dto.limit,
});
return assets.map((a) => mapAsset(a, { auth, stripMetadata: false, withStack: true }));
}
async getDeltaSync(auth: AuthDto, dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
// app has not synced in the last 100 days
const duration = DateTime.now().diff(DateTime.fromJSDate(dto.updatedAfter));
if (duration > AUDIT_LOG_MAX_DURATION) {
return FULL_SYNC;
}
// app does not have the correct partners synced
const partnerIds = await getMyPartnerIds({ userId: auth.user.id, repository: this.partnerRepository });
const userIds = [auth.user.id, ...partnerIds];
if (!setIsEqual(new Set(userIds), new Set(dto.userIds))) {
return FULL_SYNC;
}
await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: dto.userIds });
const limit = 10_000;
const upserted = await this.assetRepository.getChangedDeltaSync({ limit, updatedAfter: dto.updatedAfter, userIds });
// too many changes, need to do a full sync
if (upserted.length === limit) {
return FULL_SYNC;
}
const deleted = await this.auditRepository.getAfter(dto.updatedAfter, {
userIds,
entityType: EntityType.Asset,
action: DatabaseAction.Delete,
});
const result = {
needsFullSync: false,
upserted: upserted
// do not return archived assets for partner users
.filter(
(a) =>
a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && a.visibility === AssetVisibility.Timeline),
)
.map((a) =>
mapAsset(a, {
auth,
stripMetadata: false,
// ignore stacks for non partner users
withStack: a.ownerId === auth.user.id,
}),
),
deleted,
};
return result;
}
}
-1
View File
@@ -351,7 +351,6 @@ export type JobItem =
| { name: JobName.FileDelete; data: IDeleteFilesJob }
// Cleanup
| { name: JobName.AuditLogCleanup; data?: IBaseJob }
| { name: JobName.SessionCleanup; data?: IBaseJob }
// Tags
-2
View File
@@ -351,8 +351,6 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.where('asset_exif.rating', options.rating === null ? 'is' : '=', options.rating!),
)
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
.$if(!!options.deviceAssetId, (qb) => qb.where('asset.deviceAssetId', '=', options.deviceAssetId!))
.$if(!!options.deviceId, (qb) => qb.where('asset.deviceId', '=', options.deviceId!))
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
-2
View File
@@ -21,8 +21,6 @@ const createAsset = (
width: 1920,
height: 1080,
createdAt: new Date().toISOString(),
deviceAssetId: 'device-asset-1',
deviceId: 'device-1',
ownerId: 'owner-1',
originalPath: '/path/to/asset',
originalFileName: 'asset.jpg',