Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros
8e9bec75ac fix(mobile): map unresponsive after viewing asset 2026-03-19 12:41:56 +01:00
46 changed files with 939 additions and 401 deletions

View File

@@ -6,9 +6,6 @@ on:
permissions: {}
env:
LABEL_ID: 'LA_kwDOGyI-8M8AAAACcAeOfg' # auto-closed:template
jobs:
parse:
runs-on: ubuntu-latest
@@ -49,10 +46,9 @@ jobs:
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f body="This PR has been automatically closed as the description doesn't follow our template. After you edit it to match the template, the PR will automatically be reopened." \
-f query='
mutation CommentAndClosePR($prId: ID!, $body: String!, $labelId: ID!) {
mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: {
subjectId: $prId,
body: $body
@@ -64,34 +60,21 @@ jobs:
}) {
__typename
}
addLabelsToLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'
- name: Reopen PR (sections now present, PR was auto-closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' && contains(github.event.pull_request.labels.*.node_id, env.LABEL_ID) }}
- name: Reopen PR (sections now present, PR closed)
if: ${{ needs.parse.outputs.uses_template == 'true' && github.event.pull_request.state == 'closed' }}
env:
GH_TOKEN: ${{ github.token }}
NODE_ID: ${{ github.event.pull_request.node_id }}
run: |
gh api graphql \
-f prId="$NODE_ID" \
-f labelId="$LABEL_ID" \
-f query='
mutation ReopenPR($prId: ID!, $labelId: ID!) {
mutation ReopenPR($prId: ID!) {
reopenPullRequest(input: {
pullRequestId: $prId
}) {
__typename
}
removeLabelsFromLabelable(input: {
labelableId: $prId,
labelIds: [$labelId]
}) {
__typename
}
}'

View File

@@ -151,7 +151,6 @@ jobs:
body_path: misc/release/notes.tmpl
files: |
docker/docker-compose.yml
docker/docker-compose.rootless.yml
docker/example.env
docker/hwaccel.ml.yml
docker/hwaccel.transcoding.yml

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.1",
"version": "2.6.0",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",

View File

@@ -1,7 +1,7 @@
[
{
"label": "v2.6.1",
"url": "https://docs.v2.6.1.archive.immich.app"
"label": "v2.6.0",
"url": "https://docs.v2.6.0.archive.immich.app"
},
{
"label": "v2.5.6",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.1",
"version": "2.6.0",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.1",
"version": "2.6.0",
"private": true,
"scripts": {
"format": "prettier --cache --check .",

View File

@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.6.1"
version = "2.6.0"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"

View File

@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.1"
version = "2.6.0"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3039,
"android.injected.version.name" => "2.6.1",
"android.injected.version.code" => 3038,
"android.injected.version.name" => "2.6.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.6.1</string>
<string>2.6.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>

View File

@@ -7,7 +7,7 @@ const Map<String, Locale> locales = {
'Arabic (ar)': Locale('ar'),
'Bulgarian (bg)': Locale('bg'),
'Catalan (ca)': Locale('ca'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans'),
'Chinese Simplified (zh_CN)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'SIMPLIFIED'),
'Chinese Traditional (zh_TW)': Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'),
'Croatian (hr)': Locale('hr'),
'Czech (cs)': Locale('cs'),

View File

@@ -28,6 +28,17 @@ class AppNavigationObserver extends AutoRouterObserver {
});
}
@override
void didPop(Route route, Route? previousRoute) {
_handleLockedViewState(previousRoute ?? route, null);
_handleDriftLockedFolderState(previousRoute ?? route, null);
Future(() {
ref.read(currentRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteNameProvider.notifier).state = ref.read(previousRouteNameProvider);
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
});
}
_handleLockedViewState(Route route, Route? previousRoute) {
final isInLockedView = ref.read(inLockedViewProvider);
final isFromLockedViewToDetailView =

View File

@@ -6,7 +6,6 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/infrastructure/repositories/network.repository.dart';
import 'package:immich_mobile/models/auth/auxilary_endpoint.model.dart';
import 'package:immich_mobile/utils/debug_print.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:logging/logging.dart';
@@ -185,8 +184,8 @@ class ApiService {
if (externalJson != null) {
final List<dynamic> list = jsonDecode(externalJson);
for (final entry in list) {
final url = AuxilaryEndpoint.fromJson(entry).url;
if (url.isNotEmpty) urls.add(url);
final url = entry['url'] as String?;
if (url != null && url.isNotEmpty) urls.add(url);
}
}
return urls;

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.1
- API version: 2.6.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -113,6 +113,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata
@@ -148,6 +149,7 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information

View File

@@ -1115,6 +1115,154 @@ class AssetsApi {
}
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
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), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run an asset job
///
/// Run a specific job on a set of assets.

View File

@@ -363,6 +363,154 @@ class DeprecatedApi {
return null;
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
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), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run jobs
///
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.

View File

@@ -41,6 +41,7 @@ class Permission {
static const assetPeriodView = Permission._(r'asset.view');
static const assetPeriodDownload = Permission._(r'asset.download');
static const assetPeriodUpload = Permission._(r'asset.upload');
static const assetPeriodReplace = Permission._(r'asset.replace');
static const assetPeriodCopy = Permission._(r'asset.copy');
static const assetPeriodDerive = Permission._(r'asset.derive');
static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get');
@@ -199,6 +200,7 @@ class Permission {
assetPeriodView,
assetPeriodDownload,
assetPeriodUpload,
assetPeriodReplace,
assetPeriodCopy,
assetPeriodDerive,
assetPeriodEditPeriodGet,
@@ -392,6 +394,7 @@ class PermissionTypeTransformer {
case r'asset.view': return Permission.assetPeriodView;
case r'asset.download': return Permission.assetPeriodDownload;
case r'asset.upload': return Permission.assetPeriodUpload;
case r'asset.replace': return Permission.assetPeriodReplace;
case r'asset.copy': return Permission.assetPeriodCopy;
case r'asset.derive': return Permission.assetPeriodDerive;
case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet;

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.6.1+3039
version: 2.6.0+3038
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -4216,6 +4216,89 @@
],
"x-immich-permission": "asset.download",
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Replace the asset with new file, without changing its id.",
"operationId": "replaceAsset",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AssetMediaReplaceDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMediaResponseDto"
}
}
},
"description": "Asset replaced successfully"
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Replace asset",
"tags": [
"Assets",
"Deprecated"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v1",
"state": "Deprecated",
"replacementId": "copyAsset"
}
],
"x-immich-permission": "asset.replace",
"x-immich-state": "Deprecated"
}
},
"/assets/{id}/thumbnail": {
@@ -15083,7 +15166,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.1",
"version": "2.6.0",
"contact": {}
},
"tags": [
@@ -16527,6 +16610,49 @@
],
"type": "object"
},
"AssetMediaReplaceDto": {
"properties": {
"assetData": {
"description": "Asset file data",
"format": "binary",
"type": "string"
},
"deviceAssetId": {
"description": "Device asset ID",
"type": "string"
},
"deviceId": {
"description": "Device ID",
"type": "string"
},
"duration": {
"description": "Duration (for videos)",
"type": "string"
},
"fileCreatedAt": {
"description": "File creation date",
"format": "date-time",
"type": "string"
},
"fileModifiedAt": {
"description": "File modification date",
"format": "date-time",
"type": "string"
},
"filename": {
"description": "Filename",
"type": "string"
}
},
"required": [
"assetData",
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt"
],
"type": "object"
},
"AssetMediaResponseDto": {
"properties": {
"id": {
@@ -19573,6 +19699,7 @@
"asset.view",
"asset.download",
"asset.upload",
"asset.replace",
"asset.copy",
"asset.derive",
"asset.edit.get",

View File

@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.6.1",
"version": "2.6.0",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 2.6.1
* 2.6.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -1028,6 +1028,22 @@ export type AssetOcrResponseDto = {
/** Normalized y coordinate of box corner 4 (0-1) */
y4: number;
};
export type AssetMediaReplaceDto = {
/** Asset file data */
assetData: Blob;
/** Device asset ID */
deviceAssetId: string;
/** Device ID */
deviceId: string;
/** Duration (for videos) */
duration?: string;
/** File creation date */
fileCreatedAt: string;
/** File modification date */
fileModifiedAt: string;
/** Filename */
filename?: string;
};
export type SignUpDto = {
/** User email */
email: string;
@@ -4254,6 +4270,27 @@ export function downloadAsset({ edited, id, key, slug }: {
...opts
}));
}
/**
* Replace asset
*/
export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
id: string;
key?: string;
slug?: string;
assetMediaReplaceDto: AssetMediaReplaceDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMediaResponseDto;
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.multipart({
...opts,
method: "PUT",
body: assetMediaReplaceDto
})));
}
/**
* View asset thumbnail
*/
@@ -6883,6 +6920,7 @@ export enum Permission {
AssetView = "asset.view",
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AssetDerive = "asset.derive",
AssetEditGet = "asset.edit.get",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.6.1",
"version": "2.6.0",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",

10
pnpm-lock.yaml generated
View File

@@ -747,8 +747,8 @@ importers:
specifier: workspace:*
version: link:../open-api/typescript-sdk
'@immich/ui':
specifier: ^0.65.3
version: 0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
specifier: ^0.64.0
version: 0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)
'@mapbox/mapbox-gl-rtl-text':
specifier: 0.3.0
version: 0.3.0
@@ -3035,8 +3035,8 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/ui@0.65.3':
resolution: {integrity: sha512-jMXzCzMNTcCdWXt9IUP7GkALE5oEvPQk/jCOuI2bfxsxCZFzMkUfUS+AV83Vg1vQ6l+g39PbKSPKBEzv125ATQ==}
'@immich/ui@0.64.0':
resolution: {integrity: sha512-jbPN1x9KAAcW18h4RO7skbFYjkR4Lg+mEVjSDzsPC2NBNzSi4IA0PIHhFEwnD5dk4OS7+UjRG8m5/QTyotrm4A==}
peerDependencies:
svelte: ^5.0.0
@@ -14955,7 +14955,7 @@ snapshots:
node-emoji: 2.2.0
svelte: 5.53.7
'@immich/ui@0.65.3(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)':
'@immich/ui@0.64.0(@sveltejs/kit@2.53.4(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.53.7)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)(typescript@5.9.3)(vite@7.3.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.7)':
dependencies:
'@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.53.7)
'@internationalized/date': 3.10.0

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.6.1",
"version": "2.6.0",
"description": "",
"author": "",
"private": true,

View File

@@ -8,6 +8,7 @@ import {
Param,
ParseFilePipe,
Post,
Put,
Query,
Req,
Res,
@@ -27,8 +28,10 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -109,6 +112,36 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
}
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 200,
description: 'Asset replaced successfully',
type: AssetMediaResponseDto,
})
@Endpoint({
summary: 'Replace asset',
description: 'Replace the asset with new file, without changing its id.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }),
})
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })

View File

@@ -93,6 +93,8 @@ export class AssetMediaCreateDto extends AssetMediaBase {
[UploadFieldName.SIDECAR_DATA]?: any;
}
export class AssetMediaReplaceDto extends AssetMediaBase {}
export class AssetBulkUploadCheckItem {
@ApiProperty({ description: 'Asset ID' })
@IsString()

View File

@@ -105,6 +105,7 @@ export enum Permission {
AssetView = 'asset.view',
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',

View File

@@ -119,12 +119,8 @@ export class MetadataRepository {
}
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
// If exiftool assigns a field with ^= instead of =, empty values will be written too.
// Since exiftool-vendored doesn't support an option for this, we append the ^ to the name of the tag instead.
// https://exiftool.org/exiftool_pod.html#:~:text=is%20used%20to%20write%20an%20empty%20string
const tagsToWrite = Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key}^`, value]));
try {
await this.exiftool.write(path, tagsToWrite);
await this.exiftool.write(path, tags);
} catch (error) {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}

View File

@@ -64,9 +64,8 @@ export class UserTable {
@Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null;
// TODO remove default, make nullable, and convert empty spaces to null
@Column({ default: '' })
name!: string;
name!: Generated<string>;
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null;

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -14,13 +15,22 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import {
AssetFileType,
AssetStatus,
AssetVisibility,
CacheControl,
JobName,
Permission,
StorageFolder,
} from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
@@ -153,6 +163,40 @@ export class AssetMediaService extends BaseService {
}
}
async replaceAsset(
auth: AuthDto,
id: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
const asset = await this.assetRepository.getById(id);
if (!asset) {
throw new Error('Asset not found');
}
this.requireQuota(auth, file.size);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
// Next, create a backup copy of the existing record. The db record has already been updated above,
// but the local variable holds the original file data paths.
const copiedPhoto = await this.createCopy(asset);
// and immediate trash it
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
}
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -313,6 +357,82 @@ export class AssetMediaService extends BaseService {
throw error;
}
/**
* Updates the specified assetId to the specified photo data file properties: checksum, path,
* timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc
* are UNTOUCHED. The photo data files modification times on the filesysytem are updated to
* the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION
* job is queued to update these derived properties.
*/
private async replaceFileData(
assetId: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarPath?: string,
): Promise<void> {
await this.assetRepository.update({
id: assetId,
checksum: file.checksum,
originalPath: file.originalPath,
type: mimeTypes.assetType(file.originalPath),
originalFileName: file.originalName,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
duration: dto.duration || null,
livePhotoVideoId: null,
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
}
/**
* Create a 'shallow' copy of the specified asset record creating a new asset record in the database.
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
* and then queues a METADATA_EXTRACTION job.
*/
private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({
ownerId: asset.ownerId,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
libraryId: asset.libraryId,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
livePhotoVideoId: asset.livePhotoVideoId,
});
const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,

View File

@@ -8,7 +8,6 @@ import { AuthService } from 'src/services/auth.service';
import { UserMetadataItem } from 'src/types';
import { ApiKeyFactory } from 'test/factories/api-key.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { OAuthProfileFactory } from 'test/factories/oauth-profile.factory';
import { SessionFactory } from 'test/factories/session.factory';
import { UserFactory } from 'test/factories/user.factory';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
@@ -16,7 +15,31 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const oauthResponse = ({
id,
email,
name,
profileImagePath,
}: {
id: string;
email: string;
name: string;
profileImagePath?: string;
}) => ({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: id,
userEmail: email,
name,
profileImagePath,
isAdmin: false,
isOnboarded: false,
shouldChangePassword: false,
});
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
const email = 'test@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
@@ -25,9 +48,11 @@ const loginDetails = {
appVersion: null,
};
const dto = {
email,
password: 'password',
const fixtures = {
login: {
email,
password: 'password',
},
};
describe(AuthService.name, () => {
@@ -38,6 +63,7 @@ describe(AuthService.name, () => {
({ sut, mocks } = newTestService(AuthService));
mocks.oauth.authorize.mockResolvedValue({ url: 'http://test', state: 'state', codeVerifier: 'codeVerifier' });
mocks.oauth.getProfile.mockResolvedValue({ sub, email });
mocks.oauth.getLogoutEndpoint.mockResolvedValue('http://end-session-endpoint');
});
@@ -49,13 +75,13 @@ describe(AuthService.name, () => {
it('should throw an error if password login is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
});
it('should check the user exists', async () => {
mocks.user.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -63,7 +89,7 @@ describe(AuthService.name, () => {
it('should check the user has a password', async () => {
mocks.user.getByEmail.mockResolvedValue({} as UserAdmin);
await expect(sut.login(dto, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
});
@@ -74,7 +100,7 @@ describe(AuthService.name, () => {
mocks.user.getByEmail.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(session);
await expect(sut.login(dto, loginDetails)).resolves.toEqual({
await expect(sut.login(fixtures.login, loginDetails)).resolves.toEqual({
accessToken: 'cmFuZG9tLWJ5dGVz',
userId: user.id,
userEmail: user.email,
@@ -598,7 +624,6 @@ describe(AuthService.name, () => {
it('should not allow auto registering', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
await expect(
sut.callback(
@@ -613,31 +638,31 @@ describe(AuthService.name, () => {
it('should link an existing user', async () => {
const user = UserFactory.create();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByEmail.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(1);
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(user.id, { oauthId: sub });
});
it('should not link to a user with a different oauth sub', async () => {
const user = UserFactory.create({ oauthId: 'existing-sub' });
const user = UserFactory.create({ isAdmin: true, oauthId: 'existing-sub' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByEmail.mockResolvedValueOnce(user);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(user);
await expect(
sut.callback(
@@ -652,30 +677,35 @@ describe(AuthService.name, () => {
});
it('should allow auto registering by default', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foobar' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(mocks.user.create).toHaveBeenCalledTimes(1);
});
it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
const user = UserFactory.create({ isAdmin: true });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
mocks.oauth.getProfile.mockResolvedValue({ sub: 'sub' });
mocks.oauth.getProfile.mockResolvedValue({ sub, email: undefined });
await expect(
sut.callback(
@@ -695,9 +725,10 @@ describe(AuthService.name, () => {
'app.immich:///oauth-callback?code=abc123',
]) {
it(`should use the mobile redirect override for a url of ${url}`, async () => {
const user = UserFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithMobileOverride);
mocks.user.getByOAuthId.mockResolvedValue(UserFactory.create());
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback({ url, state: 'xyz789', codeVerifier: 'foo' }, {}, loginDetails);
@@ -712,136 +743,135 @@ describe(AuthService.name, () => {
}
it('should use the default quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should infer name from given and family names', async () => {
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({ name: undefined, given_name: 'Given', family_name: 'Family' }),
);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: 'Given Family' }));
});
it('should fallback to email when no username is provided', async () => {
const profile = OAuthProfileFactory.create({ name: undefined, given_name: undefined, family_name: undefined });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.create.mockResolvedValue(UserFactory.create());
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ name: profile.email }));
});
it('should ignore an invalid storage quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 'abc' }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 'abc' });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should ignore a negative quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: -5 }));
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: -5 });
mocks.user.getAdmin.mockResolvedValue(user);
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 1_073_741_824 }));
});
it('should set quota for 0 quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 0 }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 0 });
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 0 }));
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 0,
storageLabel: null,
});
});
it('should use a valid storage quota', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_quota: 5 }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_quota: 5 });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ quotaSizeInBytes: 5_368_709_120 }));
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
isAdmin: false,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: 5_368_709_120,
storageLabel: null,
});
});
it('should sync the profile picture', async () => {
const fileId = newUuid();
const user = UserFactory.create({ oauthId: 'oauth-id' });
const profile = OAuthProfileFactory.create({ picture: 'https://auth.immich.cloud/profiles/1.jpg' });
const pictureUrl = 'https://auth.immich.cloud/profiles/1.jpg';
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.oauth.getProfile.mockResolvedValue({
sub: user.oauthId,
email: user.email,
picture: pictureUrl,
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.crypto.randomUUID.mockReturnValue(fileId);
mocks.oauth.getProfilePicture.mockResolvedValue({
@@ -851,96 +881,131 @@ describe(AuthService.name, () => {
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).toHaveBeenCalledWith(user.id, {
profileImagePath: expect.stringContaining(`/data/profile/${user.id}/${fileId}.jpg`),
profileChangedAt: expect.any(Date),
});
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(profile.picture);
expect(mocks.oauth.getProfilePicture).toHaveBeenCalledWith(pictureUrl);
});
it('should not sync the profile picture if the user already has one', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id', profileImagePath: 'not-empty' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthEnabled);
mocks.oauth.getProfile.mockResolvedValue(
OAuthProfileFactory.create({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
}),
);
mocks.oauth.getProfile.mockResolvedValue({
sub: user.oauthId,
email: user.email,
picture: 'https://auth.immich.cloud/profiles/1.jpg',
});
mocks.user.getByOAuthId.mockResolvedValue(user);
mocks.user.update.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.update).not.toHaveBeenCalled();
expect(mocks.oauth.getProfilePicture).not.toHaveBeenCalled();
});
it('should only allow "admin" and "user" for the role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'foo' }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'foo' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getAdmin.mockResolvedValue(UserFactory.create({ isAdmin: true }));
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: false }));
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: false,
});
});
it('should create an admin user if the role claim is set to admin', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.oauthWithAutoRegister);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ immich_role: 'admin' }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, immich_role: 'admin' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: true,
});
});
it('should accept a custom role claim', async () => {
const user = UserFactory.create({ oauthId: 'oauth-id' });
mocks.systemMetadata.get.mockResolvedValue({
oauth: { ...systemConfigStub.oauthWithAutoRegister.oauth, roleClaim: 'my_role' },
oauth: { ...systemConfigStub.oauthWithAutoRegister, roleClaim: 'my_role' },
});
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create({ my_role: 'admin' }));
mocks.oauth.getProfile.mockResolvedValue({ sub: user.oauthId, email: user.email, my_role: 'admin' });
mocks.user.getByEmail.mockResolvedValue(void 0);
mocks.user.getByOAuthId.mockResolvedValue(void 0);
mocks.user.create.mockResolvedValue(UserFactory.create({ oauthId: 'oauth-id' }));
mocks.user.create.mockResolvedValue(user);
mocks.session.create.mockResolvedValue(SessionFactory.create());
await sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
);
await expect(
sut.callback(
{ url: 'http://immich/auth/login?code=abc123', state: 'xyz789', codeVerifier: 'foo' },
{},
loginDetails,
),
).resolves.toEqual(oauthResponse(user));
expect(mocks.user.create).toHaveBeenCalledWith(expect.objectContaining({ isAdmin: true }));
expect(mocks.user.create).toHaveBeenCalledWith({
email: user.email,
name: ' ',
oauthId: user.oauthId,
quotaSizeInBytes: null,
storageLabel: null,
isAdmin: true,
});
});
});
@@ -948,10 +1013,8 @@ describe(AuthService.name, () => {
it('should link an account', async () => {
const user = UserFactory.create();
const auth = AuthFactory.from(user).apiKey({ permissions: [] }).build();
const profile = OAuthProfileFactory.create();
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(profile);
mocks.user.update.mockResolvedValue(user);
await sut.link(
@@ -960,7 +1023,7 @@ describe(AuthService.name, () => {
{},
);
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: profile.sub });
expect(mocks.user.update).toHaveBeenCalledWith(auth.user.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
@@ -969,7 +1032,6 @@ describe(AuthService.name, () => {
const auth = { user: authUser, apiKey: authApiKey };
mocks.systemMetadata.get.mockResolvedValue(systemConfigStub.enabled);
mocks.oauth.getProfile.mockResolvedValue(OAuthProfileFactory.create());
mocks.user.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserAdmin);
await expect(

View File

@@ -261,11 +261,6 @@ export class AuthService extends BaseService {
}
async callback(dto: OAuthCallbackDto, headers: IncomingHttpHeaders, loginDetails: LoginDetails) {
const { oauth } = await this.getConfig({ withCache: false });
if (!oauth.enabled) {
throw new BadRequestException('OAuth is not enabled');
}
const expectedState = dto.state ?? this.getCookieOauthState(headers);
if (!expectedState?.length) {
throw new BadRequestException('OAuth state is missing');
@@ -276,6 +271,7 @@ export class AuthService extends BaseService {
throw new BadRequestException('OAuth code verifier is missing');
}
const { oauth } = await this.getConfig({ withCache: false });
const url = this.resolveRedirectUri(oauth, dto.url);
const profile = await this.oauthRepository.getProfile(oauth, url, expectedState, codeVerifier);
const { autoRegister, defaultStorageQuota, storageLabelClaim, storageQuotaClaim, roleClaim } = oauth;
@@ -302,8 +298,7 @@ export class AuthService extends BaseService {
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
const email = profile.email;
if (!email) {
if (!profile.email) {
throw new BadRequestException('OAuth profile does not have an email address');
}
@@ -325,13 +320,10 @@ export class AuthService extends BaseService {
isValid: (value: unknown) => isString(value) && ['admin', 'user'].includes(value),
});
const userName = profile.name ?? `${profile.given_name || ''} ${profile.family_name || ''}`;
user = await this.createUser({
name:
profile.name ||
`${profile.given_name || ''} ${profile.family_name || ''}`.trim() ||
profile.preferred_username ||
email,
email,
name: userName,
email: profile.email,
oauthId: profile.sub,
quotaSizeInBytes: storageQuota === null ? null : storageQuota * HumanReadableSize.GiB,
storageLabel: storageLabel || null,

View File

@@ -467,7 +467,7 @@ export class MetadataService extends BaseService {
GPSLatitude: latitude,
GPSLongitude: longitude,
Rating: rating,
TagsList: tags,
TagsList: tags?.length ? tags : undefined,
},
_.isUndefined,
);

View File

@@ -1,28 +0,0 @@
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { OAuthProfileLike } from 'test/factories/types';
import { newUuid } from 'test/small.factory';
export class OAuthProfileFactory {
private constructor(private value: OAuthProfile) {}
static create(dto: OAuthProfileLike = {}) {
return OAuthProfileFactory.from(dto).build();
}
static from(dto: OAuthProfileLike = {}) {
const sub = newUuid();
return new OAuthProfileFactory({
sub,
name: 'Name',
given_name: 'Given',
family_name: 'Family',
email: `oauth-${sub}@immich.cloud`,
email_verified: true,
...dto,
});
}
build() {
return { ...this.value };
}
}

View File

@@ -1,5 +1,4 @@
import { Selectable } from 'kysely';
import { OAuthProfile } from 'src/repositories/oauth.repository';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
@@ -35,4 +34,3 @@ export type PartnerLike = Partial<Selectable<PartnerTable>>;
export type ActivityLike = Partial<Selectable<ActivityTable>>;
export type ApiKeyLike = Partial<Selectable<ApiKeyTable>>;
export type SessionLike = Partial<Selectable<SessionTable>>;
export type OAuthProfileLike = Partial<OAuthProfile>;

View File

@@ -1,75 +0,0 @@
import { Kysely } from 'kysely';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { newDate } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let database: Kysely<DB>;
const setup = () => {
const { ctx } = newMediumService(BaseService, {
database,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(MetadataRepository) };
};
beforeAll(async () => {
database = await getKyselyDB();
});
describe(MetadataRepository.name, () => {
describe('writeTags', () => {
it('should write an empty description', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, { Description: '' });
expect(readFileSync(sidecarFile).toString()).toEqual(expect.stringContaining('rdf:Description'));
});
it('should write an empty tags list', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, { TagsList: [] });
const fileContent = readFileSync(sidecarFile).toString();
expect(fileContent).toEqual(expect.stringContaining('digiKam:TagsList'));
expect(fileContent).toEqual(expect.stringContaining('<rdf:li/>'));
});
});
it('should write tags', async () => {
const { sut } = setup();
const dir = mkdtempSync(join(tmpdir(), 'metadata-medium-write-tags'));
const sidecarFile = join(dir, 'sidecar.xmp');
await sut.writeTags(sidecarFile, {
Description: 'my-description',
ImageDescription: 'my-image-description',
DateTimeOriginal: newDate().toISOString(),
GPSLatitude: 42,
GPSLongitude: 69,
Rating: 3,
TagsList: ['tagA'],
});
const fileContent = readFileSync(sidecarFile).toString();
expect(fileContent).toEqual(expect.stringContaining('my-description'));
expect(fileContent).toEqual(expect.stringContaining('my-image-description'));
expect(fileContent).toEqual(expect.stringContaining('exif:DateTimeOriginal'));
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLatitude>42,0.0N</exif:GPSLatitude>'));
expect(fileContent).toEqual(expect.stringContaining('<exif:GPSLongitude>69,0.0E</exif:GPSLongitude>'));
expect(fileContent).toEqual(expect.stringContaining('<xmp:Rating>3</xmp:Rating>'));
expect(fileContent).toEqual(expect.stringContaining('tagA'));
});
});

View File

@@ -47,15 +47,15 @@ describe(UserService.name, () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const user = mediumFactory.userInsert();
await expect(sut.createUser({ name: 'Test', email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ name: 'Test', email: user.email })).rejects.toThrow('User exists');
await expect(sut.createUser({ email: user.email })).resolves.toMatchObject({ email: user.email });
await expect(sut.createUser({ email: user.email })).rejects.toThrow('User exists');
});
it('should not return password', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const dto = mediumFactory.userInsert({ password: 'password' });
const user = await sut.createUser({ name: 'Test', email: dto.email, password: 'password' });
const user = await sut.createUser({ email: dto.email, password: 'password' });
expect((user as any).password).toBeUndefined();
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.6.1",
"version": "2.6.0",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -27,7 +27,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.65.3",
"@immich/ui": "^0.64.0",
"@mapbox/mapbox-gl-rtl-text": "0.3.0",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.0",

View File

@@ -11,7 +11,7 @@
import { user } from '$lib/stores/user.store';
import { handleError } from '$lib/utils/handle-error';
import { sendTestEmailAdmin } from '@immich/sdk';
import { Button, toastManager } from '@immich/ui';
import { Button, LoadingSpinner, toastManager } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -142,7 +142,6 @@
<Button
size="small"
shape="round"
loading={isSending}
disabled={!configToEdit.notifications.smtp.enabled}
onclick={handleSendTestEmail}
>
@@ -152,6 +151,9 @@
{$t('admin.notification_email_sent_test_email_button')}
{/if}
</Button>
{#if isSending}
<LoadingSpinner />
{/if}
</div>
</div>
</SettingAccordion>

View File

@@ -33,11 +33,13 @@
{#if isOwned}
<Textarea
bind:value={description}
variant="ghost"
class="outline-none border-b max-h-32 border-transparent pl-0 bg-transparent ring-0 focus:ring-0 resize-none focus:border-b-2 focus:border-immich-primary dark:focus:border-immich-dark-primary dark:bg-transparent"
rows={1}
grow
shape="rectangle"
onfocusout={handleFocusOut}
placeholder={$t('add_a_description')}
data-testid="autogrow-textarea"
class="max-h-32"
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter', ctrl: true },
onShortcut: (e) => e.currentTarget.blur(),

View File

@@ -3,56 +3,59 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { Textarea } from '@immich/ui';
import { t } from 'svelte-i18n';
import { fromAction } from 'svelte/attachments';
import { tv } from 'tailwind-variants';
type Props = {
interface Props {
id: string;
albumName: string;
isOwned: boolean;
onUpdate: (albumName: string) => void;
};
}
let { id, albumName = $bindable(), isOwned, onUpdate }: Props = $props();
let newAlbumName = $derived(albumName);
const handleUpdate = async () => {
newAlbumName = newAlbumName.replaceAll('\n', ' ').trim();
const handleUpdateName = async () => {
if (newAlbumName === albumName) {
return;
}
try {
const response = await updateAlbumInfo({ id, updateAlbumDto: { albumName: newAlbumName } });
const response = await updateAlbumInfo({
id,
updateAlbumDto: {
albumName: newAlbumName,
},
});
({ albumName } = response);
eventManager.emit('AlbumUpdate', response);
onUpdate(albumName);
} catch (error) {
handleError(error, $t('errors.unable_to_save_album'));
return;
}
};
const textClasses = 'text-2xl lg:text-6xl text-primary';
const styles = tv({
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
variants: {
isOwned: {
true: 'hover:border-gray-400',
false: 'hover:border-transparent',
},
},
});
</script>
<div class="mb-2">
{#if isOwned}
<Textarea
bind:value={newAlbumName}
variant="ghost"
title={$t('edit_title')}
onblur={handleUpdate}
placeholder={$t('add_a_title')}
class={textClasses}
{@attach fromAction(shortcut, () => ({
shortcut: { key: 'Enter' },
onShortcut: (event) => event.currentTarget.blur(),
}))}
/>
{:else}
<div class={textClasses}>{newAlbumName}</div>
{/if}
</div>
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class={styles({ isOwned })}
type="text"
bind:value={newAlbumName}
disabled={!isOwned}
title={$t('edit_title')}
placeholder={$t('add_a_title')}
/>

View File

@@ -1,6 +1,5 @@
import { eventManager } from '$lib/managers/event-manager.svelte';
import { uploadAssetsStore } from '$lib/stores/upload';
import { cancelUploadRequests } from '$lib/utils';
import { getSupportedMediaTypes, type ServerMediaTypesResponseDto } from '@immich/sdk';
class UploadManager {
@@ -14,7 +13,6 @@ class UploadManager {
}
reset() {
cancelUploadRequests();
uploadAssetsStore.reset();
}

View File

@@ -273,7 +273,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
}
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_album'), { notify });
handleError(error, $t('errors.unable_to_delete_album'));
return false;
}
};

View File

@@ -78,40 +78,17 @@ export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};
let unsubscribeId = 0;
const uploads: Record<number, () => void> = {};
const trackUpload = (unsubscribe: () => void) => {
const id = unsubscribeId++;
uploads[id] = unsubscribe;
return () => {
delete uploads[id];
};
};
export const cancelUploadRequests = () => {
for (const unsubscribe of Object.values(uploads)) {
unsubscribe();
}
};
export const uploadRequest = async <T>(options: UploadRequestOptions): Promise<{ data: T; status: number }> => {
const { onUploadProgress: onProgress, data, url } = options;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const unsubscribe = trackUpload(() => xhr.abort());
xhr.addEventListener('error', (error) => {
unsubscribe();
reject(error);
});
xhr.addEventListener('error', (error) => reject(error));
xhr.addEventListener('load', () => {
if (xhr.readyState === 4 && xhr.status >= 200 && xhr.status < 300) {
unsubscribe();
resolve({ data: xhr.response as T, status: xhr.status });
} else {
unsubscribe();
reject(new ApiError(xhr.statusText, xhr.status, xhr.response));
}
});

View File

@@ -131,6 +131,7 @@ function getDeviceAssetId(asset: File) {
type FileUploaderParams = {
assetFile: File;
albumId?: string;
replaceAssetId?: string;
isLockedAssets?: boolean;
deviceAssetId: string;
};

View File

@@ -23,8 +23,7 @@ export function standardizeError(error: unknown) {
return error instanceof Error ? error : new Error(String(error));
}
export function handleError(error: unknown, localizedMessage: string, options?: { notify?: boolean }) {
const { notify = true } = options ?? {};
export function handleError(error: unknown, localizedMessage: string) {
const standardizedError = standardizeError(error);
if (standardizedError.name === 'AbortError') {
return;
@@ -40,9 +39,7 @@ export function handleError(error: unknown, localizedMessage: string, options?:
const errorMessage = serverMessage || localizedMessage;
if (notify) {
toastManager.danger(errorMessage);
}
toastManager.danger(errorMessage);
return errorMessage;
} catch (error) {