From ae1d60e259aa0d913b0395eca24bb94234b35f11 Mon Sep 17 00:00:00 2001 From: Alwin Lohrie <46248939+niwla23@users.noreply.github.com> Date: Tue, 29 Jul 2025 00:48:39 +0200 Subject: [PATCH] feat: find large files utility (#18040) feat: large asset utility Co-authored-by: Jason Rasmussen --- i18n/en.json | 2 + mobile/openapi/README.md | Bin 38919 -> 39029 bytes mobile/openapi/lib/api/search_api.dart | Bin 16725 -> 25498 bytes open-api/immich-openapi-specs.json | 317 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 73 ++++ server/src/controllers/search.controller.ts | 8 + server/src/dtos/search.dto.ts | 9 + server/src/queries/search.repository.sql | 21 ++ server/src/repositories/search.repository.ts | 27 +- server/src/services/search.service.ts | 11 + .../specs/services/search.service.spec.ts | 55 +++ .../large-assets/large-asset-data.svelte | 58 ++++ .../utilities-page/utilities-menu.svelte | 11 +- web/src/lib/constants.ts | 1 + web/src/lib/utils/asset-utils.ts | 4 +- .../[[assetId=id]]/+page.svelte | 89 +++++ .../[[photos=photos]]/[[assetId=id]]/+page.ts | 17 + 17 files changed, 699 insertions(+), 4 deletions(-) create mode 100644 server/test/medium/specs/services/search.service.spec.ts create mode 100644 web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte create mode 100644 web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte create mode 100644 web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts diff --git a/i18n/en.json b/i18n/en.json index 39baff338..5f7f16622 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1154,6 +1154,7 @@ "language_no_results_title": "No languages found", "language_search_hint": "Search languages...", "language_setting_description": "Select your preferred language", + "large_files": "Large Files", "last_seen": "Last seen", "latest_version": "Latest Version", "latitude": "Latitude", @@ -1588,6 +1589,7 @@ "resume": "Resume", "retry_upload": "Retry upload", "review_duplicates": "Review duplicates", + "review_large_files": "Review large files", "role": "Role", "role_editor": "Editor", "role_viewer": "Viewer", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 3181b03a47cfeb926cd116ee97dcc099e1802176..058524479cbfa6a703aeded16fa6c47ea3d1d29e 100644 GIT binary patch delta 63 zcmZqQ!1Q$k(}r)RTt10K>8Xyz#i=Ee4NPTOb3ok54T2)V#i@x!$r<`!0o_E9g5t>+ Mj72wpFcnV%02=8SjQ{`u delta 14 VcmeymfvJ51(}r)Rn^VjLlK?j827CYj diff --git a/mobile/openapi/lib/api/search_api.dart b/mobile/openapi/lib/api/search_api.dart index 5c7a8de59d2f9fe22d40872bce8523e9489df78f..1b58702c40dfc727b79bff7e3f97588c628f4213 100644 GIT binary patch literal 25498 zcmeHPTXWks7Jm1yK>JY1JF1*?`p{7mYn|9>Jd?!U*xk-f#^cfwB(tSRm4xhgGx_g( z4i2saDcO|lby7aq0zlw=1bFTMxwEsiv%~g&Jw5vVmxFf)@Apm*_Svh$HwQhof5hG$ zowBz_`-iXo^9^*vp8p(2xp@9V|LIR#SdFJ?mhK?9O3#0Kaz46@@?vWX-~`JiESvK*n#X(kR?*7@b&l(vh4xW9G3B z*gWSGSV;K{&c_^BP@etERChm|{p{SyC6C5Dw;fMJ9;eeDqu={7q9-QwP7KQ0btb8*9lEL|iCFsye~3LK5w#%1}86H!d_CN9J?$t;?E_C>Zk z40x!-H}tS_#f2yp*HJ8%GafyWc*44RTpy%eEQ*)en6usI40-fKw)8Ke@q17pyb#-p zQyDORo?W9@9ef<|Im$&9p`N-GWMQC-4EEwi@WS-nmTQ2kfKL+y;I~T-5(dw8mI=-Z zo?r3YNL!k*3n0O+L583c-7spFAD;Z!W7p9CGJ+LPGXc5)0a+|iCMD%PA*EHsGmb;9 za}exw3T<)GW51*J9l(PEIPp6G02!JhD1~o-P5H+;hzTDv4#E$k;}q2Dx0>`TVChJh zm0;I636bAO`kB5Lpu{C(6jD6s#=IwhlLPj7mkHva@4kbnlWuXJ&NtrENwb$ECHCR? z41ud|n4Jk%y_>k)Dk2ySzI7;IG@;y{q<{2XW9_v&yZwFNz!)x1eweVr| zM^P1@cby(0!y(-%Fj;hg=5GQDivzPrBE&7EgQbueL0YZP;YlW7S=UqO2Sj8)64CwY zbA7k1LjQ6VI(ayXWkSX7@Y1dIx--V-zgDfcYTY-m8&T`EEL^YNYll3HnlBd9DL5?P zm)ya3m)66?Mgy=l&_e&&KiCJF(utvrZs{2l@E^S>imV@cmFP9}lcjh``7&5$l zr-t`ytMUeW?DNK>Czolye2a^&rD8e;E+Ymb)_Smb}`Kv>Xeh<*!egG3Q~` zKS0z?KmdPmJjgo3XtWUc|8Eglw<w?D|}J!Ek*wYuP`m4T~cNFxlCy&=0uqO8PjKx@}yDpBFZ>nNCQ#-z=d$Yd~U zmN8LMxWti`ZrSerxy{E%CpO)UEDf2Lc(g&c*^ngRg`6g7B*HO`B_}%YJ`36@z+C!? zA?drHrr8p;6EbbR%{z_;xYOKk1T|N2vx?p$Psn$o`8!{#VHSTSd^wAEZ{w zO?R+rzAqaP4{4V~djtE&`u5i|W15Otm)3}xjdZd19I6qgMyXdgdnBlDzINxA&OG-b1xfYpJ;W>{9UJV=y&d zBB^o+V+>?N7&9JVOt}I^8{MWbdOX7DatMQlydeS(1>*QqLDzcbP03pdh+=CMVCZc$ zg~721fWIMW16t08S13Y77uf-c55O$p;z zq>R5IX>#})&^9Yi6aaw=RS-J3Y5)UtJJto2D4JRg(prYmiSaa6ol$gs-KG1mL*UTD-M5!wWtvVjXwb=(*tp zEdcw9RoueJyJZo$Piv+AYv9jhBGl}-!63lENYT-}Wn zbI{LAJ<&@PK_3h0nw8bvmO_j3u$4;K=9nR`hS#+Hu|+3eF|er%C%piz+T@hZklW}Tj+NVr z3cI0=_6tmK3?F;YTXH(09hm(Bj6>lFI{Yu6N2e%nprSpD`gA;oZMv-5^;$a$-VbwN zjHA`fH8akQdEhGUgQ@KTPc?h~zljDk70-2do#}z&SbL7|!1aRZ3i_(ljHUKme)}bZ z;F}T~dF3^*;Pof;gRb{?R^R9gG zD8Em)Hp`L~lYCQc#pET=iur8ot0416IOWuiLjjQXsv0heAl*pNxQ^rw`AfKJ32(PTcW*!kC)mrBIQX*?n_(yYTH-+YMaj;4;gR;xy*XY z7Ib|e!&c1TTfG~zYbQv}STXoygIB+uRg*M4arLQ|h;H7b$(vr!qWSY~4ceFS!nV(6 ux8q)7cE@`)XA>l~u1|A0@A`_W6qZRso>&x~aJ5G4t2Sbf^Zo44TmJ)77cDRV delta 18 acmbProbhTC;|4*s$@lzuHz#|TO9KE)Mh71N diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8b305aa8b..b8089083d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5307,6 +5307,323 @@ "x-immich-permission": "asset.read" } }, + "/search/large-assets": { + "post": { + "operationId": "searchLargeAssets", + "parameters": [ + { + "name": "albumIds", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "city", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "country", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "createdAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "createdBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "deviceId", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "isEncoded", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isMotion", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isNotInAlbum", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isOffline", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "lensModel", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "libraryId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "nullable": true, + "type": "string" + } + }, + { + "name": "make", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "minFileSize", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "type": "integer" + } + }, + { + "name": "model", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "personIds", + "required": false, + "in": "query", + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "rating", + "required": false, + "in": "query", + "schema": { + "minimum": -1, + "maximum": 5, + "type": "number" + } + }, + { + "name": "size", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 1000, + "type": "number" + } + }, + { + "name": "state", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "string" + } + }, + { + "name": "tagIds", + "required": false, + "in": "query", + "schema": { + "nullable": true, + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + } + }, + { + "name": "takenAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "takenBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "trashedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetTypeEnum" + } + }, + { + "name": "updatedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "updatedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "visibility", + "required": false, + "in": "query", + "schema": { + "$ref": "#/components/schemas/AssetVisibility" + } + }, + { + "name": "withDeleted", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withExif", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Search" + ] + } + }, "/search/metadata": { "post": { "operationId": "searchAssets", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 063fc8678..33f0da8a0 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2954,6 +2954,79 @@ export function getExploreData(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function searchLargeAssets({ albumIds, city, country, createdAfter, createdBefore, deviceId, isEncoded, isFavorite, isMotion, isNotInAlbum, isOffline, lensModel, libraryId, make, minFileSize, model, personIds, rating, size, state, tagIds, takenAfter, takenBefore, trashedAfter, trashedBefore, $type, updatedAfter, updatedBefore, visibility, withDeleted, withExif }: { + albumIds?: string[]; + city?: string | null; + country?: string | null; + createdAfter?: string; + createdBefore?: string; + deviceId?: string; + isEncoded?: boolean; + isFavorite?: boolean; + isMotion?: boolean; + isNotInAlbum?: boolean; + isOffline?: boolean; + lensModel?: string | null; + libraryId?: string | null; + make?: string; + minFileSize?: number; + model?: string | null; + personIds?: string[]; + rating?: number; + size?: number; + state?: string | null; + tagIds?: string[] | null; + takenAfter?: string; + takenBefore?: string; + trashedAfter?: string; + trashedBefore?: string; + $type?: AssetTypeEnum; + updatedAfter?: string; + updatedBefore?: string; + visibility?: AssetVisibility; + withDeleted?: boolean; + withExif?: boolean; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetResponseDto[]; + }>(`/search/large-assets${QS.query(QS.explode({ + albumIds, + city, + country, + createdAfter, + createdBefore, + deviceId, + isEncoded, + isFavorite, + isMotion, + isNotInAlbum, + isOffline, + lensModel, + libraryId, + make, + minFileSize, + model, + personIds, + rating, + size, + state, + tagIds, + takenAfter, + takenBefore, + trashedAfter, + trashedBefore, + "type": $type, + updatedAfter, + updatedBefore, + visibility, + withDeleted, + withExif + }))}`, { + ...opts, + method: "POST" + })); +} export function searchAssets({ metadataSearchDto }: { metadataSearchDto: MetadataSearchDto; }, opts?: Oazapfts.RequestOpts) { diff --git a/server/src/controllers/search.controller.ts b/server/src/controllers/search.controller.ts index fefa916fe..15f8bc3a5 100644 --- a/server/src/controllers/search.controller.ts +++ b/server/src/controllers/search.controller.ts @@ -4,6 +4,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { PersonResponseDto } from 'src/dtos/person.dto'; import { + LargeAssetSearchDto, MetadataSearchDto, PlacesResponseDto, RandomSearchDto, @@ -46,6 +47,13 @@ export class SearchController { return this.service.searchRandom(auth, dto); } + @Post('large-assets') + @HttpCode(HttpStatus.OK) + @Authenticated({ permission: Permission.AssetRead }) + searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise { + return this.service.searchLargeAssets(auth, dto); + } + @Post('smart') @HttpCode(HttpStatus.OK) @Authenticated({ permission: Permission.AssetRead }) diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index aef78e51e..f709ad94a 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -126,6 +126,15 @@ export class RandomSearchDto extends BaseSearchWithResultsDto { withPeople?: boolean; } +export class LargeAssetSearchDto extends BaseSearchWithResultsDto { + @Optional() + @IsInt() + @Min(0) + @Type(() => Number) + @ApiProperty({ type: 'integer' }) + minFileSize?: number; +} + export class MetadataSearchDto extends RandomSearchDto { @ValidateUUID({ optional: true }) id?: string; diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index ef5363126..be2245a74 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -77,6 +77,27 @@ union all limit $15 +-- SearchRepository.searchLargeAssets +select + "asset".*, + to_json("asset_exif") as "exifInfo" +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" +where + "asset"."visibility" = $1 + and "asset"."fileCreatedAt" >= $2 + and "asset_exif"."lensModel" = $3 + and "asset"."ownerId" = any ($4::uuid[]) + and "asset"."isFavorite" = $5 + and "asset"."deletedAt" is null + and "asset_exif"."fileSizeInByte" > $6 +order by + "asset_exif"."fileSizeInByte" desc +limit + $7 + -- SearchRepository.searchSmart begin set diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 61e0cc1e2..36ef7a27f 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -8,7 +8,7 @@ import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; import { probes } from 'src/repositories/database.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; -import { anyUuid, searchAssetBuilder } from 'src/utils/database'; +import { anyUuid, searchAssetBuilder, withExif } from 'src/utils/database'; import { paginationHelper } from 'src/utils/pagination'; import { isValidInteger } from 'src/validation'; @@ -129,6 +129,8 @@ export type SmartSearchOptions = SearchDateOptions & SearchPeopleOptions & SearchTagOptions; +export type LargeAssetSearchOptions = AssetSearchOptions & { minFileSize?: number }; + export interface FaceEmbeddingSearch extends SearchEmbeddingOptions { hasPerson?: boolean; numResults: number; @@ -237,6 +239,29 @@ export class SearchRepository { return rows; } + @GenerateSql({ + params: [ + 100, + { + takenAfter: DummyValue.DATE, + lensModel: DummyValue.STRING, + withStacked: true, + isFavorite: true, + userIds: [DummyValue.UUID], + }, + ], + }) + searchLargeAssets(size: number, options: LargeAssetSearchOptions) { + const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirection; + return searchAssetBuilder(this.db, options) + .selectAll('asset') + .$call(withExif) + .where('asset_exif.fileSizeInByte', '>', options.minFileSize || 0) + .orderBy('asset_exif.fileSizeInByte', orderDirection) + .limit(size) + .execute(); + } + @GenerateSql({ params: [ { page: 1, size: 200 }, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 1c75c4a43..b9391fed9 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -4,6 +4,7 @@ import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/ import { AuthDto } from 'src/dtos/auth.dto'; import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { + LargeAssetSearchDto, mapPlaces, MetadataSearchDto, PlacesResponseDto, @@ -91,6 +92,16 @@ export class SearchService extends BaseService { return items.map((item) => mapAsset(item, { auth })); } + async searchLargeAssets(auth: AuthDto, dto: LargeAssetSearchDto): Promise { + if (dto.visibility === AssetVisibility.Locked) { + requireElevatedPermission(auth); + } + + const userIds = await this.getUserIdsToSearch(auth); + const items = await this.searchRepository.searchLargeAssets(dto.size || 250, { ...dto, userIds }); + return items.map((item) => mapAsset(item, { auth })); + } + async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise { if (dto.visibility === AssetVisibility.Locked) { requireElevatedPermission(auth); diff --git a/server/test/medium/specs/services/search.service.spec.ts b/server/test/medium/specs/services/search.service.spec.ts new file mode 100644 index 000000000..517e6cc27 --- /dev/null +++ b/server/test/medium/specs/services/search.service.spec.ts @@ -0,0 +1,55 @@ +import { Kysely } from 'kysely'; +import { AccessRepository } from 'src/repositories/access.repository'; +import { DatabaseRepository } from 'src/repositories/database.repository'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { PartnerRepository } from 'src/repositories/partner.repository'; +import { PersonRepository } from 'src/repositories/person.repository'; +import { SearchRepository } from 'src/repositories/search.repository'; +import { DB } from 'src/schema'; +import { SearchService } from 'src/services/search.service'; +import { newMediumService } from 'test/medium.factory'; +import { factory } from 'test/small.factory'; +import { getKyselyDB } from 'test/utils'; + +let defaultDatabase: Kysely; + +const setup = (db?: Kysely) => { + return newMediumService(SearchService, { + database: db || defaultDatabase, + real: [AccessRepository, DatabaseRepository, SearchRepository, PartnerRepository, PersonRepository], + mock: [LoggingRepository], + }); +}; + +beforeAll(async () => { + defaultDatabase = await getKyselyDB(); +}); + +describe(SearchService.name, () => { + it('should work', () => { + const { sut } = setup(); + expect(sut).toBeDefined(); + }); + + it('should return assets', async () => { + const { sut, ctx } = setup(); + const { user } = await ctx.newUser(); + + const assets = []; + const sizes = [12_334, 599, 123_456]; + + for (let i = 0; i < sizes.length; i++) { + const { asset } = await ctx.newAsset({ ownerId: user.id }); + await ctx.newExif({ assetId: asset.id, fileSizeInByte: sizes[i] }); + assets.push(asset); + } + + const auth = factory.auth({ user: { id: user.id } }); + + await expect(sut.searchLargeAssets(auth, {})).resolves.toEqual([ + expect.objectContaining({ id: assets[2].id }), + expect.objectContaining({ id: assets[0].id }), + expect.objectContaining({ id: assets[1].id }), + ]); + }); +}); diff --git a/web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte b/web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte new file mode 100644 index 000000000..71f3dbb5c --- /dev/null +++ b/web/src/lib/components/utilities-page/large-assets/large-asset-data.svelte @@ -0,0 +1,58 @@ + + +
+
+ +
+ +
+
+
{asset.originalFileName}
+ {getAssetResolution(asset)} +
+
+ {getFileSize(asset, 1)} +
+
+
diff --git a/web/src/lib/components/utilities-page/utilities-menu.svelte b/web/src/lib/components/utilities-page/utilities-menu.svelte index 7deddc6be..5484ce4ea 100644 --- a/web/src/lib/components/utilities-page/utilities-menu.svelte +++ b/web/src/lib/components/utilities-page/utilities-menu.svelte @@ -1,7 +1,7 @@ @@ -17,4 +17,13 @@ {$t('review_duplicates')} + + + + {$t('review_large_files')} + diff --git a/web/src/lib/constants.ts b/web/src/lib/constants.ts index b354989e1..f2de6d5de 100644 --- a/web/src/lib/constants.ts +++ b/web/src/lib/constants.ts @@ -51,6 +51,7 @@ export enum AppRoute { UTILITIES = '/utilities', DUPLICATES = '/utilities/duplicates', + LARGE_FILES = '/utilities/large-files', FOLDERS = '/folders', TAGS = '/tags', diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index d3feff830..726752054 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -275,9 +275,9 @@ export function isFlipped(orientation?: string | null) { return value && (isRotated270CW(value) || isRotated90CW(value)); } -export function getFileSize(asset: AssetResponseDto): string { +export function getFileSize(asset: AssetResponseDto, maxPrecision = 4): string { const size = asset.exifInfo?.fileSizeInByte || 0; - return size > 0 ? getByteUnitString(size, undefined, 4) : 'Invalid Data'; + return size > 0 ? getByteUnitString(size, undefined, maxPrecision) : 'Invalid Data'; } export function getAssetResolution(asset: AssetResponseDto): string { diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte new file mode 100644 index 000000000..75ac4fab9 --- /dev/null +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -0,0 +1,89 @@ + + + +
+ {#if assets && data.assets.length > 0} + {#each assets as asset (asset.id)} + setAsset(asset)} /> + {/each} + {:else} +

+ {$t('no_assets_to_show')} +

+ {/if} +
+
+ +{#if $showAssetViewer} + {#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }} + + 1} + {onNext} + {onPrevious} + {onRandom} + {onAction} + onClose={() => { + assetViewingStore.showAssetViewer(false); + handlePromiseError(navigate({ targetRoute: 'current', assetId: null })); + }} + /> + + {/await} +{/if} diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts new file mode 100644 index 000000000..6780fdb02 --- /dev/null +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/+page.ts @@ -0,0 +1,17 @@ +import { authenticate } from '$lib/utils/auth'; +import { getFormatter } from '$lib/utils/i18n'; +import { searchLargeAssets } from '@immich/sdk'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const assets = await searchLargeAssets({ minFileSize: 0 }); + const $t = await getFormatter(); + + return { + assets, + meta: { + title: $t('large_files'), + }, + }; +}) satisfies PageLoad;