diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index bfdac06c4..11c80ae26 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -96,6 +96,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | *AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | +*AssetApi* | [**getAssetDuplicates**](doc//AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | *AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} | *AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | *AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics | diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index 0778485c3..836914116 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -14,6 +14,7 @@ Method | HTTP request | Description [**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | [**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} | +[**getAssetDuplicates**](AssetApi.md#getassetduplicates) | **GET** /asset/duplicates | [**getAssetInfo**](AssetApi.md#getassetinfo) | **GET** /asset/{id} | [**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms | [**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics | @@ -328,6 +329,57 @@ Name | Type | Description | Notes [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) +# **getAssetDuplicates** +> List getAssetDuplicates() + + + +### Example +```dart +import 'package:openapi/api.dart'; +// TODO Configure API key authorization: cookie +//defaultApiClient.getAuthentication('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('bearer').setAccessToken(yourTokenGeneratorFunction); + +final api_instance = AssetApi(); + +try { + final result = api_instance.getAssetDuplicates(); + print(result); +} catch (e) { + print('Exception when calling AssetApi->getAssetDuplicates: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](AssetResponseDto.md) + +### Authorization + +[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + # **getAssetInfo** > AssetResponseDto getAssetInfo(id, key) diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index e16ccc73e..b9974d46c 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -326,6 +326,50 @@ class AssetApi { return null; } + /// Performs an HTTP 'GET /asset/duplicates' operation and returns the [Response]. + Future getAssetDuplicatesWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/asset/duplicates'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAssetDuplicates() async { + final response = await getAssetDuplicatesWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 41d0ac8f5..f56685686 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -50,6 +50,11 @@ void main() { // TODO }); + //Future> getAssetDuplicates() async + test('test getAssetDuplicates', () async { + // TODO + }); + //Future getAssetInfo(String id, { String key }) async test('test getAssetInfo', () async { // TODO diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index cd304e941..df360f9a6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1213,6 +1213,41 @@ ] } }, + "/asset/duplicates": { + "get": { + "operationId": "getAssetDuplicates", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Asset" + ] + } + }, "/asset/exist": { "post": { "description": "Checks if multiple assets exist on the server and returns all existing - used by background backup", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 57806d72b..929b25a48 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1323,6 +1323,14 @@ export function getAllUserAssetsByDeviceId({ deviceId }: { ...opts })); } +export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>("/asset/duplicates", { + ...opts + })); +} /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8e446d23f..b985bf19e 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -70,6 +70,11 @@ export class AssetController { return this.service.getStatistics(auth, dto); } + @Get('duplicates') + getAssetDuplicates(@Auth() auth: AuthDto): Promise { + return this.service.getDuplicates(auth); + } + @Post('jobs') @HttpCode(HttpStatus.NO_CONTENT) runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise { diff --git a/server/src/cores/system-config.core.ts b/server/src/cores/system-config.core.ts index d01fe4324..3f03c3d82 100644 --- a/server/src/cores/system-config.core.ts +++ b/server/src/cores/system-config.core.ts @@ -71,7 +71,7 @@ export const defaults = Object.freeze({ clip: { enabled: true, modelName: 'ViT-B-32__openai', - duplicateThreshold: 0.01, + duplicateThreshold: 0.03, }, facialRecognition: { enabled: true, diff --git a/server/src/entities/asset-duplicate.entity.ts b/server/src/entities/asset-duplicate.entity.ts index e395556c9..811ba1f00 100644 --- a/server/src/entities/asset-duplicate.entity.ts +++ b/server/src/entities/asset-duplicate.entity.ts @@ -4,7 +4,7 @@ import { Entity, Index, JoinColumn, OneToMany, PrimaryColumn } from 'typeorm'; @Entity('asset_duplicates') @Index('asset_duplicates_assetId_uindex', ['assetId'], { unique: true }) export class AssetDuplicateEntity { - @OneToMany(() => AssetEntity, (asset) => asset.duplicates) + @OneToMany(() => AssetEntity, (asset) => asset.duplicates, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) @JoinColumn({ name: 'assetId', referencedColumnName: 'id' }) assets!: AssetEntity; diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts index eabe1ccb5..4ff5f34cf 100644 --- a/server/src/entities/asset.entity.ts +++ b/server/src/entities/asset.entity.ts @@ -1,4 +1,5 @@ import { AlbumEntity } from 'src/entities/album.entity'; +import { AssetDuplicateEntity } from 'src/entities/asset-duplicate.entity'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetStackEntity } from 'src/entities/asset-stack.entity'; @@ -24,7 +25,6 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; -import { AssetDuplicateEntity } from './asset-duplicate.entity'; export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum'; @@ -173,7 +173,7 @@ export class AssetEntity { @Column({ nullable: true }) duplicateId?: string | null; - @ManyToOne(() => AssetDuplicateEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) + @ManyToOne(() => AssetDuplicateEntity, { nullable: true }) @JoinColumn({ name: 'duplicateId' }) duplicates?: AssetDuplicateEntity | null; } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index dd00e40cb..04ed7d1d9 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -61,6 +61,7 @@ export interface AssetBuilderOptions { isArchived?: boolean; isFavorite?: boolean; isTrashed?: boolean; + isDuplicate?: boolean; albumId?: string; personId?: string; userIds?: string[]; @@ -172,8 +173,9 @@ export interface IAssetRepository { getTimeBuckets(options: TimeBucketOptions): Promise; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; upsertExif(exif: Partial): Promise; - upsertJobStatus(jobStatus: Partial): Promise; + upsertJobStatus(jobStatus: Partial | Partial[]): Promise; getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise>; getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise>; + getDuplicates(options: AssetBuilderOptions): Promise; searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise; } diff --git a/server/src/interfaces/search.interface.ts b/server/src/interfaces/search.interface.ts index c2c2ac6f6..3b06e0f4f 100644 --- a/server/src/interfaces/search.interface.ts +++ b/server/src/interfaces/search.interface.ts @@ -179,6 +179,7 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { export interface AssetDuplicateSearch { assetId: string; + embedding: Embedding; userIds: string[]; maxDistance?: number; } diff --git a/server/src/migrations/1711989989911-CreateAssetDuplicateTable.ts b/server/src/migrations/1711989989911-CreateAssetDuplicateTable.ts new file mode 100644 index 000000000..c7a2b2c7a --- /dev/null +++ b/server/src/migrations/1711989989911-CreateAssetDuplicateTable.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateAssetDuplicateTable1711989989911 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE asset_duplicates ( + id uuid, + "assetId" uuid REFERENCES assets ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (id, "assetId") + ); + `); + + await queryRunner.query(`ALTER TABLE assets ADD COLUMN "duplicateId" uuid`); + + await queryRunner.query(`ALTER TABLE asset_job_status ADD COLUMN "duplicatesDetectedAt" timestamptz`); + + await queryRunner.query(` + ALTER TABLE assets + ADD CONSTRAINT asset_duplicates_id + FOREIGN KEY ("duplicateId", id) + REFERENCES asset_duplicates DEFERRABLE INITIALLY DEFERRED + `); + + await queryRunner.query(` + CREATE UNIQUE INDEX "asset_duplicates_assetId_uindex" + ON asset_duplicates ("assetId") + `); + + await queryRunner.query(`CREATE INDEX "IDX_assets_duplicateId" ON assets ("duplicateId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE assets DROP COLUMN "duplicateId"`); + await queryRunner.query(`DROP TABLE asset_duplicates`); + } +} diff --git a/server/src/repositories/asset-duplicate.repository.ts b/server/src/repositories/asset-duplicate.repository.ts index 6da47e5dd..ea3e6e5b7 100644 --- a/server/src/repositories/asset-duplicate.repository.ts +++ b/server/src/repositories/asset-duplicate.repository.ts @@ -22,7 +22,7 @@ export class AssetDuplicateRepository implements IAssetDuplicateRepository { await manager.update(AssetDuplicateEntity, { id: In(oldDuplicateIds) }, { id }); } await manager.update(AssetEntity, { id: In(assetIds) }, { duplicateId: id }); - await manager.update(AssetEntity, { duplicateId: In(oldDuplicateIds) }, { duplicateId: id }); // TODO: cascade should handle this, but it doesn't seem to + await manager.update(AssetEntity, { duplicateId: In(oldDuplicateIds) }, { duplicateId: id }); }); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index bfcb525f2..7a4011c9f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -67,7 +67,7 @@ export class AssetRepository implements IAssetRepository { await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] }); } - async upsertJobStatus(jobStatus: Partial): Promise { + async upsertJobStatus(jobStatus: Partial | Partial[]): Promise { await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] }); } @@ -201,6 +201,7 @@ export class AssetRepository implements IAssetRepository { let builder = this.repository.createQueryBuilder('asset'); builder = searchAssetBuilder(builder, options); builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC'); + builder.innerJoin('asset.smartSearch', 'smartSearch'); return paginatedBuilder(builder, { mode: PaginationMode.SKIP_TAKE, skip: pagination.skip, @@ -591,6 +592,13 @@ export class AssetRepository implements IAssetRepository { ); } + @GenerateSql({ params: [{ userIds: [DummyValue.UUID, DummyValue.UUID] }] }) + getDuplicates(options: AssetBuilderOptions): Promise { + return this.getBuilder({ ...options, isDuplicate: true }) + .orderBy('asset.duplicateId') + .getMany(); + } + @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) async getAssetIdByCity( ownerId: string, @@ -650,16 +658,14 @@ export class AssetRepository implements IAssetRepository { } private getBuilder(options: AssetBuilderOptions) { - const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options; - let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true'); - if (assetType !== undefined) { - builder = builder.andWhere('asset.type = :assetType', { assetType }); + if (options.assetType !== undefined) { + builder = builder.andWhere('asset.type = :assetType', { assetType: options.assetType }); } let stackJoined = false; - if (exifInfo !== false) { + if (options.exifInfo !== false) { stackJoined = true; builder = builder .leftJoinAndSelect('asset.exifInfo', 'exifInfo') @@ -667,34 +673,38 @@ export class AssetRepository implements IAssetRepository { .leftJoinAndSelect('stack.assets', 'stackedAssets'); } - if (albumId) { - builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId }); + if (options.albumId) { + builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId: options.albumId }); } - if (userIds) { - builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds }); + if (options.userIds) { + builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds: options.userIds }); } - if (isArchived !== undefined) { - builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived }); + if (options.isArchived !== undefined) { + builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived: options.isArchived }); } - if (isFavorite !== undefined) { - builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite }); + if (options.isFavorite !== undefined) { + builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite: options.isFavorite }); } - if (isTrashed !== undefined) { - builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); + if (options.isTrashed !== undefined) { + builder = builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted(); } - if (personId !== undefined) { + if (options.isDuplicate !== undefined) { + builder = builder.andWhere(`asset.duplicateId ${options.isDuplicate ? 'IS NOT NULL' : 'IS NULL'}`); + } + + if (options.personId !== undefined) { builder = builder .innerJoin('asset.faces', 'faces') .innerJoin('faces.person', 'person') - .andWhere('person.id = :personId', { personId }); + .andWhere('person.id = :personId', { personId: options.personId }); } - if (withStacked) { + if (options.withStacked) { if (!stackJoined) { builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets'); } diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index b8a9dec87..bb02c53ac 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -248,6 +248,11 @@ export class AssetService { return data; } + async getDuplicates(auth: AuthDto): Promise { + const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] }); + return res.map((a) => mapAsset(a, { auth })); + } + async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise { await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 66e9a5cd3..e3a07c595 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -179,7 +179,11 @@ export class SearchService { async handleSearchDuplicates({ id }: IEntityJob): Promise { const { machineLearning } = await this.configCore.getConfig(); - const asset = await this.assetRepository.getById(id); + const [asset] = await this.assetRepository.getByIds( + [id], + { smartSearch: true }, + { smartSearch: { assetId: true, embedding: true } }, + ); if (!asset) { this.logger.error(`Asset ${id} not found`); return JobStatus.FAILED; @@ -187,10 +191,6 @@ export class SearchService { if (!asset.isVisible) { this.logger.debug(`Asset ${id} is not visible, skipping`); - await this.assetRepository.upsertJobStatus({ - assetId: asset.id, - duplicatesDetectedAt: new Date(), - }); return JobStatus.SKIPPED; } @@ -204,28 +204,35 @@ export class SearchService { return JobStatus.FAILED; } + if (!asset.smartSearch?.embedding) { + this.logger.debug(`Asset ${id} is missing embedding`); + return JobStatus.FAILED; + } + const duplicateAssets = await this.searchRepository.searchDuplicates({ assetId: asset.id, + embedding: asset.smartSearch.embedding, maxDistance: machineLearning.clip.duplicateThreshold, userIds: [asset.ownerId], }); + const duplicateAssetIds = [asset.id]; + if (duplicateAssets.length > 0) { - this.logger.debug(`Found ${duplicateAssets.length} duplicates for asset ${asset.id}`); + this.logger.debug( + `Found ${duplicateAssets.length} duplicate${duplicateAssets.length === 1 ? '' : 's'} for asset ${asset.id}`, + ); const duplicateIds = duplicateAssets.map((duplicate) => duplicate.duplicateId).filter(Boolean); const duplicateId = duplicateIds[0] || this.cryptoRepository.randomUUID(); - const duplicateAssetIds = duplicateAssets.map((duplicate) => duplicate.assetId); - duplicateAssetIds.push(asset.id); + duplicateAssetIds.push(...duplicateAssets.map((duplicate) => duplicate.assetId)); await this.assetDuplicateRepository.upsert(duplicateId, duplicateAssetIds, duplicateIds); } - await this.assetRepository.upsertJobStatus({ - assetId: asset.id, - duplicatesDetectedAt: new Date(), - }); + const duplicatesDetectedAt = new Date(); + await this.assetRepository.upsertJobStatus(duplicateAssetIds.map((assetId) => ({ assetId, duplicatesDetectedAt }))); return JobStatus.SUCCESS; } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 4101a524e..f90337d62 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -74,7 +74,7 @@ const updatedConfig = Object.freeze({ clip: { enabled: true, modelName: 'ViT-B-32__openai', - duplicateThreshold: 0.01, + duplicateThreshold: 0.03, }, facialRecognition: { enabled: true, diff --git a/web/src/lib/components/shared-components/side-bar/side-bar.svelte b/web/src/lib/components/shared-components/side-bar/side-bar.svelte index cfb0a6bde..ed0568adb 100644 --- a/web/src/lib/components/shared-components/side-bar/side-bar.svelte +++ b/web/src/lib/components/shared-components/side-bar/side-bar.svelte @@ -131,6 +131,19 @@ + + + {#await getStats({ isArchived: true })} + + {:then data} +
+

{data.videos.toLocaleString($locale)} Videos

+

{data.images.toLocaleString($locale)} Photos

+
+ {/await} +
+
+ {#if $featureFlags.trash} diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index af5558c26..5ff2782bf 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -20,6 +20,7 @@ export enum AppRoute { ALBUMS = '/albums', LIBRARIES = '/libraries', ARCHIVE = '/archive', + DUPLICATES = '/duplicates', FAVORITES = '/favorites', PEOPLE = '/people', PLACES = '/places', diff --git a/web/src/routes/(user)/duplicates/+page.svelte b/web/src/routes/(user)/duplicates/+page.svelte new file mode 100644 index 000000000..32e1da937 --- /dev/null +++ b/web/src/routes/(user)/duplicates/+page.svelte @@ -0,0 +1,21 @@ + + +
+ +
diff --git a/web/src/routes/(user)/duplicates/+page.ts b/web/src/routes/(user)/duplicates/+page.ts new file mode 100644 index 000000000..b311904ab --- /dev/null +++ b/web/src/routes/(user)/duplicates/+page.ts @@ -0,0 +1,12 @@ +import { authenticate } from '$lib/utils/auth'; +import type { PageLoad } from './$types'; + +export const load = (async () => { + await authenticate(); + + return { + meta: { + title: 'Duplicates', + }, + }; +}) satisfies PageLoad; diff --git a/web/src/routes/(user)/duplicates/photos/[assetId]/+page.svelte b/web/src/routes/(user)/duplicates/photos/[assetId]/+page.svelte new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/routes/(user)/duplicates/photos/[assetId]/+page.ts b/web/src/routes/(user)/duplicates/photos/[assetId]/+page.ts new file mode 100644 index 000000000..9c7beef4f --- /dev/null +++ b/web/src/routes/(user)/duplicates/photos/[assetId]/+page.ts @@ -0,0 +1,7 @@ +import { AppRoute } from '$lib/constants'; +import { redirect } from '@sveltejs/kit'; +import type { PageLoad } from './$types'; + +export const load: PageLoad = () => { + redirect(302, AppRoute.DUPLICATES); +};