feat: find large files utility (#18040)

feat: large asset utility

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Alwin Lohrie 2025-07-29 00:48:39 +02:00 committed by GitHub
parent 7d759edfcc
commit ae1d60e259
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 964 additions and 4 deletions

View File

@ -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",

View File

@ -184,6 +184,7 @@ Class | Method | HTTP request | Description
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions |
*SearchApi* | [**searchAssetStatistics**](doc//SearchApi.md#searchassetstatistics) | **POST** /search/statistics |
*SearchApi* | [**searchAssets**](doc//SearchApi.md#searchassets) | **POST** /search/metadata |
*SearchApi* | [**searchLargeAssets**](doc//SearchApi.md#searchlargeassets) | **POST** /search/large-assets |
*SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person |
*SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places |
*SearchApi* | [**searchRandom**](doc//SearchApi.md#searchrandom) | **POST** /search/random |

View File

@ -287,6 +287,270 @@ class SearchApi {
return null;
}
/// Performs an HTTP 'POST /search/large-assets' operation and returns the [Response].
/// Parameters:
///
/// * [List<String>] albumIds:
///
/// * [String] city:
///
/// * [String] country:
///
/// * [DateTime] createdAfter:
///
/// * [DateTime] createdBefore:
///
/// * [String] deviceId:
///
/// * [bool] isEncoded:
///
/// * [bool] isFavorite:
///
/// * [bool] isMotion:
///
/// * [bool] isNotInAlbum:
///
/// * [bool] isOffline:
///
/// * [String] lensModel:
///
/// * [String] libraryId:
///
/// * [String] make:
///
/// * [int] minFileSize:
///
/// * [String] model:
///
/// * [List<String>] personIds:
///
/// * [num] rating:
///
/// * [num] size:
///
/// * [String] state:
///
/// * [List<String>] tagIds:
///
/// * [DateTime] takenAfter:
///
/// * [DateTime] takenBefore:
///
/// * [DateTime] trashedAfter:
///
/// * [DateTime] trashedBefore:
///
/// * [AssetTypeEnum] type:
///
/// * [DateTime] updatedAfter:
///
/// * [DateTime] updatedBefore:
///
/// * [AssetVisibility] visibility:
///
/// * [bool] withDeleted:
///
/// * [bool] withExif:
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/search/large-assets';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (albumIds != null) {
queryParams.addAll(_queryParams('multi', 'albumIds', albumIds));
}
if (city != null) {
queryParams.addAll(_queryParams('', 'city', city));
}
if (country != null) {
queryParams.addAll(_queryParams('', 'country', country));
}
if (createdAfter != null) {
queryParams.addAll(_queryParams('', 'createdAfter', createdAfter));
}
if (createdBefore != null) {
queryParams.addAll(_queryParams('', 'createdBefore', createdBefore));
}
if (deviceId != null) {
queryParams.addAll(_queryParams('', 'deviceId', deviceId));
}
if (isEncoded != null) {
queryParams.addAll(_queryParams('', 'isEncoded', isEncoded));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
if (isMotion != null) {
queryParams.addAll(_queryParams('', 'isMotion', isMotion));
}
if (isNotInAlbum != null) {
queryParams.addAll(_queryParams('', 'isNotInAlbum', isNotInAlbum));
}
if (isOffline != null) {
queryParams.addAll(_queryParams('', 'isOffline', isOffline));
}
if (lensModel != null) {
queryParams.addAll(_queryParams('', 'lensModel', lensModel));
}
if (libraryId != null) {
queryParams.addAll(_queryParams('', 'libraryId', libraryId));
}
if (make != null) {
queryParams.addAll(_queryParams('', 'make', make));
}
if (minFileSize != null) {
queryParams.addAll(_queryParams('', 'minFileSize', minFileSize));
}
if (model != null) {
queryParams.addAll(_queryParams('', 'model', model));
}
if (personIds != null) {
queryParams.addAll(_queryParams('multi', 'personIds', personIds));
}
if (rating != null) {
queryParams.addAll(_queryParams('', 'rating', rating));
}
if (size != null) {
queryParams.addAll(_queryParams('', 'size', size));
}
if (state != null) {
queryParams.addAll(_queryParams('', 'state', state));
}
if (tagIds != null) {
queryParams.addAll(_queryParams('multi', 'tagIds', tagIds));
}
if (takenAfter != null) {
queryParams.addAll(_queryParams('', 'takenAfter', takenAfter));
}
if (takenBefore != null) {
queryParams.addAll(_queryParams('', 'takenBefore', takenBefore));
}
if (trashedAfter != null) {
queryParams.addAll(_queryParams('', 'trashedAfter', trashedAfter));
}
if (trashedBefore != null) {
queryParams.addAll(_queryParams('', 'trashedBefore', trashedBefore));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (updatedAfter != null) {
queryParams.addAll(_queryParams('', 'updatedAfter', updatedAfter));
}
if (updatedBefore != null) {
queryParams.addAll(_queryParams('', 'updatedBefore', updatedBefore));
}
if (visibility != null) {
queryParams.addAll(_queryParams('', 'visibility', visibility));
}
if (withDeleted != null) {
queryParams.addAll(_queryParams('', 'withDeleted', withDeleted));
}
if (withExif != null) {
queryParams.addAll(_queryParams('', 'withExif', withExif));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [List<String>] albumIds:
///
/// * [String] city:
///
/// * [String] country:
///
/// * [DateTime] createdAfter:
///
/// * [DateTime] createdBefore:
///
/// * [String] deviceId:
///
/// * [bool] isEncoded:
///
/// * [bool] isFavorite:
///
/// * [bool] isMotion:
///
/// * [bool] isNotInAlbum:
///
/// * [bool] isOffline:
///
/// * [String] lensModel:
///
/// * [String] libraryId:
///
/// * [String] make:
///
/// * [int] minFileSize:
///
/// * [String] model:
///
/// * [List<String>] personIds:
///
/// * [num] rating:
///
/// * [num] size:
///
/// * [String] state:
///
/// * [List<String>] tagIds:
///
/// * [DateTime] takenAfter:
///
/// * [DateTime] takenBefore:
///
/// * [DateTime] trashedAfter:
///
/// * [DateTime] trashedBefore:
///
/// * [AssetTypeEnum] type:
///
/// * [DateTime] updatedAfter:
///
/// * [DateTime] updatedBefore:
///
/// * [AssetVisibility] visibility:
///
/// * [bool] withDeleted:
///
/// * [bool] withExif:
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, String? deviceId, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, deviceId: deviceId, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
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<AssetResponseDto>') as List)
.cast<AssetResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /search/person' operation and returns the [Response].
/// Parameters:
///

View File

@ -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",

View File

@ -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) {

View File

@ -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<AssetResponseDto[]> {
return this.service.searchLargeAssets(auth, dto);
}
@Post('smart')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetRead })

View File

@ -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;

View File

@ -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

View File

@ -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 },

View File

@ -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<AssetResponseDto[]> {
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<SearchResponseDto> {
if (dto.visibility === AssetVisibility.Locked) {
requireElevatedPermission(auth);

View File

@ -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<DB>;
const setup = (db?: Kysely<DB>) => {
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 }),
]);
});
});

View File

@ -0,0 +1,58 @@
<script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetResolution, getFileSize } from '$lib/utils/asset-utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiHeart } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
onViewAsset: (asset: AssetResponseDto) => void;
}
let { asset, onViewAsset }: Props = $props();
let assetData = $derived(JSON.stringify(asset, null, 2));
</script>
<div
class="max-w-60 rounded-xl border-4 transition-colors font-semibold text-xs bg-gray-200 dark:bg-gray-800 border-gray-200 dark:border-gray-800"
>
<div class="relative w-full">
<button type="button" onclick={() => onViewAsset(asset)} class="block relative w-full" aria-label={$t('keep')}>
<!-- THUMBNAIL-->
<img
src={getAssetThumbnailUrl(asset.id)}
alt={$getAltText(toTimelineAsset(asset))}
title={assetData}
class="h-60 object-cover rounded-t-xl w-full"
draggable="false"
/>
<!-- OVERLAY CHIP -->
{#if !!asset.libraryId}
<div class="absolute bottom-1 end-3 px-4 py-1 rounded-xl text-xs transition-colors bg-red-300/90">External</div>
{/if}
<!-- FAVORITE ICON -->
{#if asset.isFavorite}
<div class="absolute bottom-2 start-2">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
</button>
</div>
<div class="flex justify-between items-center pl-2 pr-4 gap-2">
<div class="grid gap-y-2 py-2 text-xs transition-colors dark:text-white">
<div class="text-left text-ellipsis truncate">{asset.originalFileName}</div>
<span>{getAssetResolution(asset)}</span>
</div>
<div class="dark:text-white text-lg font-bold whitespace-nowrap w-max">
{getFileSize(asset, 1)}
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { mdiContentDuplicate } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { mdiContentDuplicate, mdiImageSizeSelectLarge } from '@mdi/js';
import { t } from 'svelte-i18n';
</script>
@ -17,4 +17,13 @@
</span>
{$t('review_duplicates')}
</a>
<a
href={AppRoute.LARGE_FILES}
class="w-full hover:bg-gray-100 dark:hover:bg-immich-dark-gray flex items-center gap-4 p-4"
>
<span
><Icon path={mdiImageSizeSelectLarge} class="text-immich-primary dark:text-immich-dark-primary" size="24" />
</span>
{$t('review_large_files')}
</a>
</div>

View File

@ -51,6 +51,7 @@ export enum AppRoute {
UTILITIES = '/utilities',
DUPLICATES = '/utilities/duplicates',
LARGE_FILES = '/utilities/large-files',
FOLDERS = '/folders',
TAGS = '/tags',

View File

@ -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 {

View File

@ -0,0 +1,89 @@
<script lang="ts">
import type { Action } from '$lib/components/asset-viewer/actions/action';
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
}
let { data }: Props = $props();
let assets = $derived(data.assets);
const { isViewing: showAssetViewer, asset: viewingAsset, setAsset } = assetViewingStore;
const getAssetIndex = (id: string) => assets.findIndex((asset) => asset.id === id);
const onNext = () => {
const index = getAssetIndex($viewingAsset.id) + 1;
if (index >= assets.length) {
return Promise.resolve(false);
}
setAsset(assets[index]);
return Promise.resolve(true);
};
const onPrevious = () => {
const index = getAssetIndex($viewingAsset.id) - 1;
if (index < 0) {
return Promise.resolve(false);
}
setAsset(assets[index]);
return Promise.resolve(true);
};
const onRandom = () => {
if (assets.length <= 0) {
return Promise.resolve(undefined);
}
const index = Math.floor(Math.random() * assets.length);
const asset = assets[index];
setAsset(asset);
return Promise.resolve(asset);
};
const onAction = (payload: Action) => {
if (payload.type == 'trash') {
assets = assets.filter((a) => a.id != payload.asset.id);
$showAssetViewer = false;
}
};
</script>
<UserPageLayout title={data.meta.title} scrollbar={true}>
<div class="flex gap-2 flex-wrap">
{#if assets && data.assets.length > 0}
{#each assets as asset (asset.id)}
<LargeAssetData {asset} onViewAsset={(asset) => setAsset(asset)} />
{/each}
{:else}
<p class="text-center text-lg dark:text-white flex place-items-center place-content-center">
{$t('no_assets_to_show')}
</p>
{/if}
</div>
</UserPageLayout>
{#if $showAssetViewer}
{#await import('$lib/components/asset-viewer/asset-viewer.svelte') then { default: AssetViewer }}
<Portal target="body">
<AssetViewer
asset={$viewingAsset}
showNavigation={assets.length > 1}
{onNext}
{onPrevious}
{onRandom}
{onAction}
onClose={() => {
assetViewingStore.showAssetViewer(false);
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
}}
/>
</Portal>
{/await}
{/if}

View File

@ -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;