This commit is contained in:
Alwin Lohrie 2025-05-23 13:20:26 +02:00 committed by GitHub
commit 91ffe68d46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 570 additions and 3 deletions
i18n
mobile/openapi
open-api
server
web/src
lib
routes/(user)/utilities/large-assets/[[photos=photos]]/[[assetId=id]]

View file

@ -1125,6 +1125,7 @@
"language_setting_description": "Select your preferred language",
"last_seen": "Last seen",
"latest_version": "Latest Version",
"large_assets": "Large Assets",
"latitude": "Latitude",
"leave": "Leave",
"lens_model": "Lens model",

View file

@ -130,6 +130,7 @@ Class | Method | HTTP request | Description
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs |
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
*LargeAssetsApi* | [**getLargeAssets**](doc//LargeAssetsApi.md#getlargeassets) | **GET** /large-assets |
*LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries |
*LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} |
*LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries |
@ -349,6 +350,7 @@ Class | Method | HTTP request | Description
- [JobName](doc//JobName.md)
- [JobSettingsDto](doc//JobSettingsDto.md)
- [JobStatusDto](doc//JobStatusDto.md)
- [LargeAssetsResponseDto](doc//LargeAssetsResponseDto.md)
- [LibraryResponseDto](doc//LibraryResponseDto.md)
- [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md)
- [LicenseKeyDto](doc//LicenseKeyDto.md)

View file

@ -40,6 +40,7 @@ part 'api/download_api.dart';
part 'api/duplicates_api.dart';
part 'api/faces_api.dart';
part 'api/jobs_api.dart';
part 'api/large_assets_api.dart';
part 'api/libraries_api.dart';
part 'api/map_api.dart';
part 'api/memories_api.dart';
@ -144,6 +145,7 @@ part 'model/job_create_dto.dart';
part 'model/job_name.dart';
part 'model/job_settings_dto.dart';
part 'model/job_status_dto.dart';
part 'model/large_assets_response_dto.dart';
part 'model/library_response_dto.dart';
part 'model/library_stats_response_dto.dart';
part 'model/license_key_dto.dart';

View file

@ -0,0 +1,67 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class LargeAssetsApi {
LargeAssetsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'GET /large-assets' operation and returns the [Response].
/// Parameters:
///
/// * [num] take (required):
Future<Response> getLargeAssetsWithHttpInfo(num take,) async {
// ignore: prefer_const_declarations
final apiPath = r'/large-assets';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'take', take));
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [num] take (required):
Future<LargeAssetsResponseDto?> getLargeAssets(num take,) async {
final response = await getLargeAssetsWithHttpInfo(take,);
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) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'LargeAssetsResponseDto',) as LargeAssetsResponseDto;
}
return null;
}
}

View file

@ -344,6 +344,8 @@ class ApiClient {
return JobSettingsDto.fromJson(value);
case 'JobStatusDto':
return JobStatusDto.fromJson(value);
case 'LargeAssetsResponseDto':
return LargeAssetsResponseDto.fromJson(value);
case 'LibraryResponseDto':
return LibraryResponseDto.fromJson(value);
case 'LibraryStatsResponseDto':

View file

@ -0,0 +1,99 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class LargeAssetsResponseDto {
/// Returns a new [LargeAssetsResponseDto] instance.
LargeAssetsResponseDto({
this.assets = const [],
});
List<AssetResponseDto> assets;
@override
bool operator ==(Object other) => identical(this, other) || other is LargeAssetsResponseDto &&
_deepEquality.equals(other.assets, assets);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(assets.hashCode);
@override
String toString() => 'LargeAssetsResponseDto[assets=$assets]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'assets'] = this.assets;
return json;
}
/// Returns a new [LargeAssetsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static LargeAssetsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "LargeAssetsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return LargeAssetsResponseDto(
assets: AssetResponseDto.listFromJson(json[r'assets']),
);
}
return null;
}
static List<LargeAssetsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <LargeAssetsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = LargeAssetsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, LargeAssetsResponseDto> mapFromJson(dynamic json) {
final map = <String, LargeAssetsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = LargeAssetsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of LargeAssetsResponseDto-objects as value to a dart map
static Map<String, List<LargeAssetsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<LargeAssetsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = LargeAssetsResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'assets',
};
}

View file

@ -3021,6 +3021,49 @@
]
}
},
"/large-assets": {
"get": {
"operationId": "getLargeAssets",
"parameters": [
{
"name": "take",
"required": true,
"in": "query",
"schema": {
"minimum": 1,
"maximum": 1000,
"type": "number"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LargeAssetsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"LargeAssets"
]
}
},
"/libraries": {
"get": {
"operationId": "getAllLibraries",
@ -10129,6 +10172,20 @@
],
"type": "object"
},
"LargeAssetsResponseDto": {
"properties": {
"assets": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
}
},
"required": [
"assets"
],
"type": "object"
},
"LibraryResponseDto": {
"properties": {
"assetCount": {

View file

@ -640,6 +640,9 @@ export type JobCommandDto = {
command: JobCommand;
force?: boolean;
};
export type LargeAssetsResponseDto = {
assets: AssetResponseDto[];
};
export type LibraryResponseDto = {
assetCount: number;
createdAt: string;
@ -2268,6 +2271,18 @@ export function sendJobCommand({ id, jobCommandDto }: {
body: jobCommandDto
})));
}
export function getLargeAssets({ take }: {
take: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: LargeAssetsResponseDto;
}>(`/large-assets${QS.query(QS.explode({
take
}))}`, {
...opts
}));
}
export function getAllLibraries(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View file

@ -9,6 +9,7 @@ import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller';
import { JobController } from 'src/controllers/job.controller';
import { LargeAssetsController } from 'src/controllers/large-assets.controller';
import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller';
import { MemoryController } from 'src/controllers/memory.controller';
@ -42,6 +43,7 @@ export const controllers = [
AuthController,
DownloadController,
DuplicateController,
LargeAssetsController,
FaceController,
JobController,
LibraryController,

View file

@ -0,0 +1,19 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { GetLargeAssetsRequestDto, LargeAssetsResponseDto } from 'src/dtos/large-assets.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { LargeAssetsService } from 'src/services/large-assets.service';
@ApiTags('LargeAssets')
@Controller('large-assets')
export class LargeAssetsController {
constructor(private service: LargeAssetsService) {}
@Get()
@Authenticated()
getLargeAssets(@Auth() auth: AuthDto, @Query() dto: GetLargeAssetsRequestDto): Promise<LargeAssetsResponseDto> {
console.log('take', dto);
return this.service.getLargeAssets(auth, dto.take);
}
}

View file

@ -0,0 +1,15 @@
import { Type } from 'class-transformer';
import { IsInt, Max, Min } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
export class LargeAssetsResponseDto {
assets!: AssetResponseDto[];
}
export class GetLargeAssetsRequestDto {
@IsInt()
@Min(1)
@Max(1000)
@Type(() => Number)
take!: number;
}

View file

@ -231,6 +231,21 @@ where
limit
$3
-- AssetRepository.getLargeAssets
select
"assets".*,
to_json("exif") as "exifInfo"
from
"assets"
left join "exif" on "assets"."id" = "exif"."assetId"
where
"ownerId" = $1
and "deletedAt" is null
order by
"exif"."fileSizeInByte" desc
limit
$2
-- AssetRepository.getTimeBuckets
with
"assets" as (

View file

@ -531,6 +531,19 @@ export class AssetRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, 5] })
getLargeAssets(ownerId: string, take: number) {
return this.db
.selectFrom('assets')
.selectAll('assets')
.$call(withExif)
.where('ownerId', '=', ownerId)
.where('deletedAt', 'is', null)
.orderBy('exif.fileSizeInByte', 'desc')
.limit(take)
.execute();
}
@GenerateSql({ params: [{ size: TimeBucketSize.MONTH }] })
async getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]> {
return (

View file

@ -12,6 +12,7 @@ import { DatabaseService } from 'src/services/database.service';
import { DownloadService } from 'src/services/download.service';
import { DuplicateService } from 'src/services/duplicate.service';
import { JobService } from 'src/services/job.service';
import { LargeAssetsService } from 'src/services/large-assets.service';
import { LibraryService } from 'src/services/library.service';
import { MapService } from 'src/services/map.service';
import { MediaService } from 'src/services/media.service';
@ -54,6 +55,7 @@ export const services = [
DatabaseService,
DownloadService,
DuplicateService,
LargeAssetsService,
JobService,
LibraryService,
MapService,

View file

@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { LargeAssetsResponseDto } from 'src/dtos/large-assets.dto';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class LargeAssetsService extends BaseService {
async getLargeAssets(auth: AuthDto, take: number): Promise<LargeAssetsResponseDto> {
const largeAssets = await this.assetRepository.getLargeAssets(auth.user.id, take);
return { assets: largeAssets.map((asset) => mapAsset(asset, { auth })) };
}
}

View file

@ -0,0 +1,67 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { AssetRepository } from 'src/repositories/asset.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { LargeAssetsService } from 'src/services/large-assets.service';
import { mediumFactory, newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
vitest.useFakeTimers();
describe(LargeAssetsService.name, () => {
let defaultDatabase: Kysely<DB>;
let assetRepo: AssetRepository;
let userRepo: UserRepository;
const createSut = (db?: Kysely<DB>) => {
return newMediumService(LargeAssetsService, {
database: db || defaultDatabase,
repos: {
asset: 'real',
},
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
assetRepo = new AssetRepository(defaultDatabase);
userRepo = new UserRepository(defaultDatabase);
});
beforeEach(() => {});
it('should work', () => {
const { sut } = createSut();
expect(sut).toBeDefined();
});
it('should return assets', async () => {
const { sut } = createSut();
const user = mediumFactory.userInsert();
await userRepo.create(user);
const assets = [];
const sizes = [12_334, 599, 123_456];
for (let i = 0; i < sizes.length; i++) {
const asset = mediumFactory.assetInsert({ ownerId: user.id });
await assetRepo.create(asset);
await assetRepo.upsertExif({ assetId: asset.id, fileSizeInByte: sizes[i] });
assets.push(asset);
}
const auth = factory.auth({ user: { id: user.id } });
await expect(sut.getLargeAssets(auth, 50)).resolves.toEqual({
assets: [
expect.objectContaining({ id: assets[2].id }),
expect.objectContaining({ id: assets[0].id }),
expect.objectContaining({ id: assets[1].id }),
],
});
});
});

View file

@ -35,6 +35,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getAllForUserFullSync: vitest.fn(),
getChangedDeltaSync: vitest.fn(),
getDuplicates: vitest.fn(),
getLargeAssets: vitest.fn(),
upsertFile: vitest.fn(),
upsertFiles: vitest.fn(),
deleteFiles: vitest.fn(),

View file

@ -0,0 +1,57 @@
<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 { 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(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,5 +1,5 @@
<script lang="ts">
import { mdiContentDuplicate } from '@mdi/js';
import { mdiContentDuplicate, mdiImageSizeSelectLarge } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute } from '$lib/constants';
import { t } from 'svelte-i18n';
@ -17,4 +17,14 @@
</span>
{$t('review_duplicates')}
</a>
<a
href={AppRoute.SHOW_LARGE_ASSETS}
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>
Show large assets
</a>
</div>

View file

@ -49,6 +49,7 @@ export enum AppRoute {
UTILITIES = '/utilities',
DUPLICATES = '/utilities/duplicates',
SHOW_LARGE_ASSETS = '/utilities/large-assets',
FOLDERS = '/folders',
TAGS = '/tags',

View file

@ -304,9 +304,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,88 @@
<script lang="ts">
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import LargeAssetData from '$lib/components/utilities-page/large-assets/large-asset-data.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { handlePromiseError } from '$lib/utils';
import { navigate } from '$lib/utils/navigation';
import type { Action } from '$lib/components/asset-viewer/actions/action';
interface Props {
data: PageData;
}
let { data = $bindable() }: Props = $props();
let assets = $state(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,18 @@
import { authenticate } from '$lib/utils/auth';
import { getFormatter } from '$lib/utils/i18n';
import { getLargeAssets } from '@immich/sdk';
import type { PageLoad } from './$types';
export const load = (async () => {
await authenticate();
// const asset = await getAssetInfoFromParam(params);
const assets = await getLargeAssets({ take: 200 });
const $t = await getFormatter();
return {
assets: assets.assets,
meta: {
title: $t('large_assets'),
},
};
}) satisfies PageLoad;