mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 05:05:22 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68fc8c5008 |
@@ -0,0 +1 @@
|
||||
custom: ['https://buy.immich.app', 'https://immich.store']
|
||||
@@ -0,0 +1,134 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation
|
||||
in our community a harassment-free experience for everyone, regardless
|
||||
of age, body size, visible or invisible disability, ethnicity, sex
|
||||
characteristics, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance,
|
||||
race, religion, or sexual identity and orientation.
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open,
|
||||
welcoming, diverse, inclusive, and healthy community.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment for
|
||||
our community include:
|
||||
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our
|
||||
mistakes, and learning from the experience
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
- Trolling, insulting or derogatory comments, and personal or
|
||||
political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
- Other conduct which could reasonably be considered inappropriate in
|
||||
a professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our
|
||||
standards of acceptable behavior and will take appropriate and fair
|
||||
corrective action in response to any behavior that they deem
|
||||
inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit,
|
||||
or reject comments, commits, code, wiki edits, issues, and other
|
||||
contributions that are not aligned to this Code of Conduct, and will
|
||||
communicate reasons for moderation decisions when appropriate.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also
|
||||
applies when an individual is officially representing the community in
|
||||
public spaces. Examples of representing our community include using an
|
||||
official e-mail address, posting via an official social media account,
|
||||
or acting as an appointed representative at an online or offline
|
||||
event.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior
|
||||
may be reported to the community leaders responsible for enforcement
|
||||
at our Discord channel. All complaints
|
||||
will be reviewed and investigated promptly and fairly.
|
||||
|
||||
All community leaders are obligated to respect the privacy and
|
||||
security of the reporter of any incident.
|
||||
|
||||
## Enforcement Guidelines
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in
|
||||
determining the consequences for any action they deem in violation of
|
||||
this Code of Conduct:
|
||||
|
||||
### 1. Correction
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior
|
||||
deemed unprofessional or unwelcome in the community.
|
||||
|
||||
**Consequence**: A private, written warning from community leaders,
|
||||
providing clarity around the nature of the violation and an
|
||||
explanation of why the behavior was inappropriate. A public apology
|
||||
may be requested.
|
||||
|
||||
### 2. Warning
|
||||
|
||||
**Community Impact**: A violation through a single incident or series
|
||||
of actions.
|
||||
|
||||
**Consequence**: A warning with consequences for continued
|
||||
behavior. No interaction with the people involved, including
|
||||
unsolicited interaction with those enforcing the Code of Conduct, for
|
||||
a specified period of time. This includes avoiding interactions in
|
||||
community spaces as well as external channels like social
|
||||
media. Violating these terms may lead to a temporary or permanent ban.
|
||||
|
||||
### 3. Temporary Ban
|
||||
|
||||
**Community Impact**: A serious violation of community standards,
|
||||
including sustained inappropriate behavior.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or
|
||||
public communication with the community for a specified period of
|
||||
time. No public or private interaction with the people involved,
|
||||
including unsolicited interaction with those enforcing the Code of
|
||||
Conduct, is allowed during this period. Violating these terms may lead
|
||||
to a permanent ban.
|
||||
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of
|
||||
community standards, including sustained inappropriate behavior,
|
||||
harassment of an individual, or aggression toward or disparagement of
|
||||
classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction
|
||||
within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor
|
||||
Covenant][homepage], version 2.0, available at
|
||||
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of
|
||||
conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the
|
||||
FAQ at https://www.contributor-covenant.org/faq. Translations are
|
||||
available at https://www.contributor-covenant.org/translations.
|
||||
@@ -0,0 +1,5 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security issues to `security@immich.app`
|
||||
@@ -1,6 +1,6 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
opentofu = "1.12.1"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
run = "terragrunt hclfmt"
|
||||
|
||||
@@ -141,7 +141,6 @@ describe('/server', () => {
|
||||
maintenanceMode: false,
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
minFaces: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,21 +230,6 @@ describe('/users', () => {
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ download: { includeEmbeddedVideos: true } });
|
||||
});
|
||||
|
||||
it('should update minimum face count to display people', async () => {
|
||||
const before = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(before).toMatchObject({ people: { minimumFaces: 3 } });
|
||||
|
||||
const { status, body } = await request(app)
|
||||
.put('/users/me/preferences')
|
||||
.send({ people: { minimumFaces: 2 } })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(200);
|
||||
expect(body).toMatchObject({ people: { minimumFaces: 2 } });
|
||||
|
||||
const after = await getMyPreferences({ headers: asBearerAuth(admin.accessToken) });
|
||||
expect(after).toMatchObject({ people: { minimumFaces: 2 } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
|
||||
@@ -1592,8 +1592,6 @@
|
||||
"merge_people_prompt": "Do you want to merge these people? This action is irreversible.",
|
||||
"merge_people_successfully": "Merge people successfully",
|
||||
"merged_people_count": "Merged {count, plural, one {# person} other {# people}}",
|
||||
"minFaces": "Minimum faces",
|
||||
"minFaces_description": "The minimum number of recognized faces for a person to be displayed",
|
||||
"minimize": "Minimize",
|
||||
"minute": "Minute",
|
||||
"minutes": "Minutes",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
ARG DEVICE=cpu
|
||||
|
||||
FROM python:3.11-bookworm@sha256:121d86b6d08752968a7dddbc708849e5f3a839bbff47f32212b46d2a1d842bab AS builder-cpu
|
||||
FROM python:3.11-bookworm@sha256:970c99f886b839fc8829289040c1845dadaf2cae46b37acc7710333158ec29b4 AS builder-cpu
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS builder-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS builder-openvino
|
||||
|
||||
FROM builder-cpu AS builder-cuda
|
||||
|
||||
@@ -39,12 +39,12 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
|
||||
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress --active --link-mode copy
|
||||
|
||||
FROM python:3.11-slim-bookworm@sha256:8dca233de9f3d9bb410665f00a4da6dd06f331083137e0e98ccf227236fcc438 AS prod-cpu
|
||||
FROM python:3.11-slim-bookworm@sha256:9c6f90801e6b68e772b7c0ca74260cbf7af9f320acec894e26fccdaccfbe3b47 AS prod-cpu
|
||||
|
||||
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2 \
|
||||
MACHINE_LEARNING_MODEL_ARENA=false
|
||||
|
||||
FROM python:3.13-slim-trixie@sha256:b04b5d7233d2ad9c379e22ea8927cd1378cd15c60d4ef876c065b25ea8fb3bf3 AS prod-openvino
|
||||
FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec5fb2f8f6403244621664 AS prod-openvino
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
|
||||
@@ -49,7 +49,6 @@ try:
|
||||
str(settings.http_keepalive_timeout_s),
|
||||
"--graceful-timeout",
|
||||
"10",
|
||||
"--no-control-socket",
|
||||
],
|
||||
) as cmd:
|
||||
cmd.wait()
|
||||
|
||||
@@ -12,7 +12,7 @@ from zipfile import BadZipFile
|
||||
|
||||
import orjson
|
||||
from fastapi import Depends, FastAPI, File, Form, HTTPException
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.responses import ORJSONResponse, PlainTextResponse
|
||||
from onnxruntime.capi.onnxruntime_pybind11_state import InvalidProtobuf, NoSuchFile
|
||||
from PIL.Image import Image
|
||||
from pydantic import ValidationError
|
||||
@@ -32,7 +32,6 @@ from .schemas import (
|
||||
ModelIdentity,
|
||||
ModelTask,
|
||||
ModelType,
|
||||
ORJSONResponse,
|
||||
PipelineRequest,
|
||||
T,
|
||||
)
|
||||
|
||||
@@ -89,9 +89,7 @@ class OpenClipTextualEncoder(BaseCLIPTextualEncoder):
|
||||
|
||||
tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
|
||||
|
||||
pad_id = tokenizer.token_to_id(pad_token)
|
||||
if pad_id is None:
|
||||
raise ValueError(f"Pad token '{pad_token}' not found in tokenizer vocab")
|
||||
pad_id: int = tokenizer.token_to_id(pad_token)
|
||||
tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
|
||||
tokenizer.enable_truncation(max_length=context_length)
|
||||
|
||||
|
||||
@@ -3,16 +3,9 @@ from typing import Any, Literal, Protocol, TypeGuard, TypeVar
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import orjson
|
||||
from fastapi.responses import JSONResponse
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
|
||||
class ORJSONResponse(JSONResponse):
|
||||
def render(self, content: Any) -> bytes:
|
||||
return orjson.dumps(content, option=orjson.OPT_SERIALIZE_NUMPY)
|
||||
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
value: str
|
||||
|
||||
|
||||
Generated
+450
-498
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ node = "24.15.0"
|
||||
"aqua:flutter/flutter" = "3.44.0"
|
||||
pnpm = "10.33.4"
|
||||
terragrunt = "1.0.3"
|
||||
opentofu = "1.11.6"
|
||||
opentofu = "1.12.1"
|
||||
java = "21.0.2"
|
||||
"npm:oazapfts" = "7.5.0"
|
||||
"github:extism/cli" = "1.6.3"
|
||||
|
||||
@@ -63,19 +63,16 @@ class SheetTile extends ConsumerWidget {
|
||||
subtitleWidget = null;
|
||||
}
|
||||
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
),
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: GestureDetector(onLongPress: () => copyTitle(context, ref), child: titleWidget),
|
||||
titleAlignment: ListTileTitleAlignment.center,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
contentPadding: leading == null ? null : const EdgeInsets.only(left: 25),
|
||||
subtitle: subtitleWidget,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-22
@@ -14,52 +14,32 @@ class PeopleResponse {
|
||||
/// Returns a new [PeopleResponse] instance.
|
||||
PeopleResponse({
|
||||
required this.enabled,
|
||||
this.minimumFaces,
|
||||
required this.sidebarWeb,
|
||||
});
|
||||
|
||||
/// Whether people are enabled
|
||||
bool enabled;
|
||||
|
||||
/// People face threshold
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
int? minimumFaces;
|
||||
|
||||
/// Whether people appear in web sidebar
|
||||
bool sidebarWeb;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PeopleResponse &&
|
||||
other.enabled == enabled &&
|
||||
other.minimumFaces == minimumFaces &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
|
||||
(sidebarWeb.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PeopleResponse[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
|
||||
String toString() => 'PeopleResponse[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
if (this.minimumFaces != null) {
|
||||
json[r'minimumFaces'] = this.minimumFaces;
|
||||
} else {
|
||||
// json[r'minimumFaces'] = null;
|
||||
}
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
return json;
|
||||
}
|
||||
@@ -74,7 +54,6 @@ class PeopleResponse {
|
||||
|
||||
return PeopleResponse(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb')!,
|
||||
);
|
||||
}
|
||||
|
||||
+1
-22
@@ -14,7 +14,6 @@ class PeopleUpdate {
|
||||
/// Returns a new [PeopleUpdate] instance.
|
||||
PeopleUpdate({
|
||||
this.enabled,
|
||||
this.minimumFaces,
|
||||
this.sidebarWeb,
|
||||
});
|
||||
|
||||
@@ -27,18 +26,6 @@ class PeopleUpdate {
|
||||
///
|
||||
bool? enabled;
|
||||
|
||||
/// People face threshold
|
||||
///
|
||||
/// Minimum value: 1
|
||||
/// Maximum value: 9007199254740991
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
int? minimumFaces;
|
||||
|
||||
/// Whether people appear in web sidebar
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -51,18 +38,16 @@ class PeopleUpdate {
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdate &&
|
||||
other.enabled == enabled &&
|
||||
other.minimumFaces == minimumFaces &&
|
||||
other.sidebarWeb == sidebarWeb;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled == null ? 0 : enabled!.hashCode) +
|
||||
(minimumFaces == null ? 0 : minimumFaces!.hashCode) +
|
||||
(sidebarWeb == null ? 0 : sidebarWeb!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'PeopleUpdate[enabled=$enabled, minimumFaces=$minimumFaces, sidebarWeb=$sidebarWeb]';
|
||||
String toString() => 'PeopleUpdate[enabled=$enabled, sidebarWeb=$sidebarWeb]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -71,11 +56,6 @@ class PeopleUpdate {
|
||||
} else {
|
||||
// json[r'enabled'] = null;
|
||||
}
|
||||
if (this.minimumFaces != null) {
|
||||
json[r'minimumFaces'] = this.minimumFaces;
|
||||
} else {
|
||||
// json[r'minimumFaces'] = null;
|
||||
}
|
||||
if (this.sidebarWeb != null) {
|
||||
json[r'sidebarWeb'] = this.sidebarWeb;
|
||||
} else {
|
||||
@@ -94,7 +74,6 @@ class PeopleUpdate {
|
||||
|
||||
return PeopleUpdate(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled'),
|
||||
minimumFaces: mapValueOfType<int>(json, r'minimumFaces'),
|
||||
sidebarWeb: mapValueOfType<bool>(json, r'sidebarWeb'),
|
||||
);
|
||||
}
|
||||
|
||||
+1
-13
@@ -20,7 +20,6 @@ class ServerConfigDto {
|
||||
required this.maintenanceMode,
|
||||
required this.mapDarkStyleUrl,
|
||||
required this.mapLightStyleUrl,
|
||||
required this.minFaces,
|
||||
required this.oauthButtonText,
|
||||
required this.publicUsers,
|
||||
required this.trashDays,
|
||||
@@ -48,12 +47,6 @@ class ServerConfigDto {
|
||||
/// Map light style URL
|
||||
String mapLightStyleUrl;
|
||||
|
||||
/// People min faces server default
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int minFaces;
|
||||
|
||||
/// OAuth button text
|
||||
String oauthButtonText;
|
||||
|
||||
@@ -81,7 +74,6 @@ class ServerConfigDto {
|
||||
other.maintenanceMode == maintenanceMode &&
|
||||
other.mapDarkStyleUrl == mapDarkStyleUrl &&
|
||||
other.mapLightStyleUrl == mapLightStyleUrl &&
|
||||
other.minFaces == minFaces &&
|
||||
other.oauthButtonText == oauthButtonText &&
|
||||
other.publicUsers == publicUsers &&
|
||||
other.trashDays == trashDays &&
|
||||
@@ -97,14 +89,13 @@ class ServerConfigDto {
|
||||
(maintenanceMode.hashCode) +
|
||||
(mapDarkStyleUrl.hashCode) +
|
||||
(mapLightStyleUrl.hashCode) +
|
||||
(minFaces.hashCode) +
|
||||
(oauthButtonText.hashCode) +
|
||||
(publicUsers.hashCode) +
|
||||
(trashDays.hashCode) +
|
||||
(userDeleteDelay.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, minFaces=$minFaces, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
|
||||
String toString() => 'ServerConfigDto[externalDomain=$externalDomain, isInitialized=$isInitialized, isOnboarded=$isOnboarded, loginPageMessage=$loginPageMessage, maintenanceMode=$maintenanceMode, mapDarkStyleUrl=$mapDarkStyleUrl, mapLightStyleUrl=$mapLightStyleUrl, oauthButtonText=$oauthButtonText, publicUsers=$publicUsers, trashDays=$trashDays, userDeleteDelay=$userDeleteDelay]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -115,7 +106,6 @@ class ServerConfigDto {
|
||||
json[r'maintenanceMode'] = this.maintenanceMode;
|
||||
json[r'mapDarkStyleUrl'] = this.mapDarkStyleUrl;
|
||||
json[r'mapLightStyleUrl'] = this.mapLightStyleUrl;
|
||||
json[r'minFaces'] = this.minFaces;
|
||||
json[r'oauthButtonText'] = this.oauthButtonText;
|
||||
json[r'publicUsers'] = this.publicUsers;
|
||||
json[r'trashDays'] = this.trashDays;
|
||||
@@ -139,7 +129,6 @@ class ServerConfigDto {
|
||||
maintenanceMode: mapValueOfType<bool>(json, r'maintenanceMode')!,
|
||||
mapDarkStyleUrl: mapValueOfType<String>(json, r'mapDarkStyleUrl')!,
|
||||
mapLightStyleUrl: mapValueOfType<String>(json, r'mapLightStyleUrl')!,
|
||||
minFaces: mapValueOfType<int>(json, r'minFaces')!,
|
||||
oauthButtonText: mapValueOfType<String>(json, r'oauthButtonText')!,
|
||||
publicUsers: mapValueOfType<bool>(json, r'publicUsers')!,
|
||||
trashDays: mapValueOfType<int>(json, r'trashDays')!,
|
||||
@@ -198,7 +187,6 @@ class ServerConfigDto {
|
||||
'maintenanceMode',
|
||||
'mapDarkStyleUrl',
|
||||
'mapLightStyleUrl',
|
||||
'minFaces',
|
||||
'oauthButtonText',
|
||||
'publicUsers',
|
||||
'trashDays',
|
||||
|
||||
@@ -19907,12 +19907,6 @@
|
||||
"description": "Whether people are enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumFaces": {
|
||||
"description": "People face threshold",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"description": "Whether people appear in web sidebar",
|
||||
"type": "boolean"
|
||||
@@ -19974,12 +19968,6 @@
|
||||
"description": "Whether people are enabled",
|
||||
"type": "boolean"
|
||||
},
|
||||
"minimumFaces": {
|
||||
"description": "People face threshold",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"sidebarWeb": {
|
||||
"description": "Whether people appear in web sidebar",
|
||||
"type": "boolean"
|
||||
@@ -21616,12 +21604,6 @@
|
||||
"description": "Map light style URL",
|
||||
"type": "string"
|
||||
},
|
||||
"minFaces": {
|
||||
"description": "People min faces server default",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"oauthButtonText": {
|
||||
"description": "OAuth button text",
|
||||
"type": "string"
|
||||
@@ -21651,7 +21633,6 @@
|
||||
"maintenanceMode",
|
||||
"mapDarkStyleUrl",
|
||||
"mapLightStyleUrl",
|
||||
"minFaces",
|
||||
"oauthButtonText",
|
||||
"publicUsers",
|
||||
"trashDays",
|
||||
|
||||
@@ -298,8 +298,6 @@ export type MemoriesResponse = {
|
||||
export type PeopleResponse = {
|
||||
/** Whether people are enabled */
|
||||
enabled: boolean;
|
||||
/** People face threshold */
|
||||
minimumFaces?: number;
|
||||
/** Whether people appear in web sidebar */
|
||||
sidebarWeb: boolean;
|
||||
};
|
||||
@@ -377,8 +375,6 @@ export type MemoriesUpdate = {
|
||||
export type PeopleUpdate = {
|
||||
/** Whether people are enabled */
|
||||
enabled?: boolean;
|
||||
/** People face threshold */
|
||||
minimumFaces?: number;
|
||||
/** Whether people appear in web sidebar */
|
||||
sidebarWeb?: boolean;
|
||||
};
|
||||
@@ -1967,8 +1963,6 @@ export type ServerConfigDto = {
|
||||
mapDarkStyleUrl: string;
|
||||
/** Map light style URL */
|
||||
mapLightStyleUrl: string;
|
||||
/** People min faces server default */
|
||||
minFaces: number;
|
||||
/** OAuth button text */
|
||||
oauthButtonText: string;
|
||||
/** Whether public user registration is enabled */
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202605121138@sha256:127cc323590e1d64d765016492e1de1e9355da8f658f078aef7be6bede0fdd0f AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
mise //:plugins
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202606021219@sha256:6ef9ef5859492149af770a6c884b5e2ddbaeef99f8885ea5f2d9f73625a3d9ec
|
||||
FROM ghcr.io/immich-app/base-server-prod:202605121138@sha256:b346d42d86f42799fede6b6c9f6feed0018c5ee46e4ac31d1165f50737cee842
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202606021219@sha256:63fa91aa011f6f2921dd32fe6d1be8d637e9bd7f3e3dd0c8e446afb31b282af4 AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202605121138@sha256:127cc323590e1d64d765016492e1de1e9355da8f658f078aef7be6bede0fdd0f AS dev
|
||||
|
||||
|
||||
COPY --from=ghcr.io/jdx/mise:2026.5.18@sha256:5bb3311994fa78cef307ca3077cdb18f9551da0886371fc26ea91ab56220ffc5 /usr/local/bin/mise /usr/local/bin/mise
|
||||
|
||||
@@ -124,7 +124,6 @@ const ServerConfigSchema = z
|
||||
mapDarkStyleUrl: z.string().describe('Map dark style URL'),
|
||||
mapLightStyleUrl: z.string().describe('Map light style URL'),
|
||||
maintenanceMode: z.boolean().describe('Whether maintenance mode is active'),
|
||||
minFaces: z.int().describe('People min faces server default'),
|
||||
})
|
||||
.meta({ id: 'ServerConfigDto' });
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ const PeopleUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional().describe('Whether people are enabled'),
|
||||
sidebarWeb: z.boolean().optional().describe('Whether people appear in web sidebar'),
|
||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||
})
|
||||
.optional()
|
||||
.meta({ id: 'PeopleUpdate' });
|
||||
@@ -139,7 +138,6 @@ const PeopleResponseSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().describe('Whether people are enabled'),
|
||||
sidebarWeb: z.boolean().describe('Whether people appear in web sidebar'),
|
||||
minimumFaces: z.int().min(1).optional().describe('People face threshold'),
|
||||
})
|
||||
.meta({ id: 'PeopleResponse' });
|
||||
|
||||
|
||||
@@ -42,18 +42,7 @@ group by
|
||||
having
|
||||
(
|
||||
"person"."name" != $3
|
||||
or count("asset_face"."assetId") >= COALESCE(
|
||||
(
|
||||
SELECT
|
||||
value -> 'people' ->> 'minimumFaces'
|
||||
FROM
|
||||
user_metadata
|
||||
WHERE
|
||||
"userId" = $4
|
||||
AND key = 'preferences'
|
||||
),
|
||||
'3'
|
||||
)::int
|
||||
or count("asset_face"."assetId") >= $4
|
||||
)
|
||||
order by
|
||||
"person"."isHidden" asc,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
|
||||
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
|
||||
import { DB } from 'src/schema';
|
||||
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
|
||||
@@ -13,6 +13,7 @@ import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database';
|
||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
minimumFaceCount: number;
|
||||
withHidden: boolean;
|
||||
closestFaceAssetId?: string;
|
||||
}
|
||||
@@ -167,17 +168,7 @@ export class PersonRepository {
|
||||
.having((eb) =>
|
||||
eb.or([
|
||||
eb('person.name', '!=', ''),
|
||||
eb(
|
||||
(innerEb) => innerEb.fn.count('asset_face.assetId'),
|
||||
'>=',
|
||||
sql<number>`COALESCE(
|
||||
(SELECT value -> 'people' ->> 'minimumFaces'
|
||||
FROM user_metadata
|
||||
WHERE "userId" = ${userId}
|
||||
AND key = ${sql.lit(UserMetadataKey.Preferences)}),
|
||||
'3'
|
||||
)::int `,
|
||||
),
|
||||
eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1),
|
||||
]),
|
||||
)
|
||||
.groupBy('person.id')
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Delete unauthorized cross-owner asset faces
|
||||
await sql`
|
||||
DELETE FROM asset_face
|
||||
USING person, asset
|
||||
WHERE asset_face."personId" = person.id
|
||||
AND asset_face."assetId" = asset.id
|
||||
AND person."ownerId" != asset."ownerId"
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented: the deleted rows were unauthorized cross-owner entries
|
||||
}
|
||||
@@ -57,6 +57,7 @@ describe(PersonService.name, () => {
|
||||
],
|
||||
});
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: true,
|
||||
});
|
||||
});
|
||||
@@ -83,6 +84,7 @@ describe(PersonService.name, () => {
|
||||
],
|
||||
});
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: false,
|
||||
});
|
||||
});
|
||||
@@ -452,30 +454,6 @@ describe(PersonService.name, () => {
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject creating a face on an asset the user does not own', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create({ faceAssetId: null });
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(
|
||||
sut.createFace(auth, {
|
||||
assetId: asset.id,
|
||||
personId: person.id,
|
||||
imageHeight: 500,
|
||||
imageWidth: 400,
|
||||
x: 10,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 110,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.person.createAssetFace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createNewFeaturePhoto', () => {
|
||||
|
||||
@@ -63,7 +63,9 @@ export class PersonService extends BaseService {
|
||||
}
|
||||
closestFaceAssetId = person.faceAssetId;
|
||||
}
|
||||
const { machineLearning } = await this.getConfig({ withCache: false });
|
||||
const { items, hasNextPage } = await this.personRepository.getAllForUser(pagination, auth.user.id, {
|
||||
minimumFaceCount: machineLearning.facialRecognition.minFaces,
|
||||
withHidden,
|
||||
closestFaceAssetId,
|
||||
});
|
||||
@@ -625,7 +627,7 @@ export class PersonService extends BaseService {
|
||||
// TODO return a asset face response
|
||||
async createFace(auth: AuthDto, dto: AssetFaceCreateDto): Promise<void> {
|
||||
await Promise.all([
|
||||
this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.assetId] }),
|
||||
this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }),
|
||||
this.requireAccess({ auth, permission: Permission.PersonRead, ids: [dto.personId] }),
|
||||
]);
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ describe(ServerService.name, () => {
|
||||
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
|
||||
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
|
||||
maintenanceMode: false,
|
||||
minFaces: 3,
|
||||
});
|
||||
expect(mocks.systemMetadata.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -128,7 +128,6 @@ export class ServerService extends BaseService {
|
||||
mapDarkStyleUrl: config.map.darkStyle,
|
||||
mapLightStyleUrl: config.map.lightStyle,
|
||||
maintenanceMode: false,
|
||||
minFaces: config.machineLearning.facialRecognition.minFaces,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -539,7 +539,6 @@ export type UserPreferences = {
|
||||
people: {
|
||||
enabled: boolean;
|
||||
sidebarWeb: boolean;
|
||||
minimumFaces: number;
|
||||
};
|
||||
ratings: {
|
||||
enabled: boolean;
|
||||
|
||||
@@ -21,7 +21,6 @@ const getDefaultPreferences = (): UserPreferences => {
|
||||
people: {
|
||||
enabled: true,
|
||||
sidebarWeb: false,
|
||||
minimumFaces: 3,
|
||||
},
|
||||
sharedLinks: {
|
||||
enabled: true,
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
mdiLink,
|
||||
mdiLockOutline,
|
||||
mdiMagnify,
|
||||
mdiMapMarkerOutline,
|
||||
mdiMapOutline,
|
||||
mdiServer,
|
||||
mdiStateMachine,
|
||||
@@ -94,11 +93,6 @@ export const getPagesProvider = ($t: MessageFormatter) => {
|
||||
onAction: () => goto(Route.people()),
|
||||
$if: () => authManager.authenticated && authManager.preferences.people.enabled,
|
||||
},
|
||||
{
|
||||
title: $t('places'),
|
||||
icon: mdiMapMarkerOutline,
|
||||
onAction: () => goto(Route.places()),
|
||||
},
|
||||
{
|
||||
title: $t('shared_links'),
|
||||
icon: mdiLink,
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||
import { assetCacheManager } from '$lib/managers/AssetCacheManager.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { editManager, EditToolType } from '$lib/managers/edit/edit-manager.svelte';
|
||||
@@ -103,10 +102,9 @@
|
||||
const stackSelectedThumbnailSize = 65;
|
||||
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let stack: StackResponseDto | undefined = $state();
|
||||
let selectedStackAsset: AssetResponseDto | undefined = $state();
|
||||
let stack: StackResponseDto | null = $state(null);
|
||||
|
||||
const asset = $derived(previewStackedAsset ?? selectedStackAsset ?? cursor.current);
|
||||
const asset = $derived(previewStackedAsset ?? cursor.current);
|
||||
const nextAsset = $derived(cursor.nextAsset);
|
||||
const previousAsset = $derived(cursor.previousAsset);
|
||||
let sharedLink = getSharedLink();
|
||||
@@ -119,29 +117,17 @@
|
||||
playOriginalVideo = value;
|
||||
};
|
||||
|
||||
const selectStackedAsset = async (id: string) => {
|
||||
ocrManager.clear();
|
||||
selectedStackAsset = await assetCacheManager.getAsset({ id });
|
||||
if (!sharedLink) {
|
||||
await ocrManager.getAssetOcr(id);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshStack = async () => {
|
||||
if (authManager.isSharedLink || !withStacked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cursor.current.stack) {
|
||||
stack = undefined;
|
||||
selectedStackAsset = undefined;
|
||||
return;
|
||||
if (asset.stack) {
|
||||
stack = await getStack({ id: asset.stack.id });
|
||||
}
|
||||
|
||||
stack = await getStack({ id: cursor.current.stack.id });
|
||||
const primaryAsset = stack?.assets.find(({ id }) => id === stack?.primaryAssetId);
|
||||
if (primaryAsset) {
|
||||
await selectStackedAsset(primaryAsset.id);
|
||||
if (!stack?.assets.some(({ id }) => id === asset.id)) {
|
||||
stack = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -199,21 +185,11 @@
|
||||
onClose?.(asset.id);
|
||||
};
|
||||
|
||||
const refreshPreservingSelection = async () => {
|
||||
const id = asset.id;
|
||||
assetCacheManager.invalidateAsset(id);
|
||||
if (selectedStackAsset) {
|
||||
await selectStackedAsset(id);
|
||||
} else {
|
||||
const refreshedAsset = await assetCacheManager.getAsset({ id });
|
||||
assetViewerManager.setAsset(refreshedAsset);
|
||||
}
|
||||
onAssetChange?.(asset);
|
||||
};
|
||||
|
||||
const closeEditor = async () => {
|
||||
if (editManager.hasAppliedEdits) {
|
||||
await refreshPreservingSelection();
|
||||
const refreshedAsset = await getAssetInfo({ id: asset.id });
|
||||
onAssetChange?.(refreshedAsset);
|
||||
assetViewerManager.setAsset(refreshedAsset);
|
||||
}
|
||||
assetViewerManager.closeEditor();
|
||||
};
|
||||
@@ -328,6 +304,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleStackedAssetMouseEvent = (isMouseOver: boolean, stackedAsset: AssetResponseDto) => {
|
||||
previewStackedAsset = isMouseOver ? stackedAsset : undefined;
|
||||
};
|
||||
|
||||
const handlePreAction = (action: Action) => {
|
||||
preAction?.(action);
|
||||
};
|
||||
@@ -340,7 +320,7 @@
|
||||
break;
|
||||
}
|
||||
case AssetAction.REMOVE_ASSET_FROM_STACK: {
|
||||
stack = action.stack ?? undefined;
|
||||
stack = action.stack;
|
||||
if (stack) {
|
||||
cursor.current = stack.assets[0];
|
||||
}
|
||||
@@ -348,7 +328,7 @@
|
||||
}
|
||||
case AssetAction.STACK:
|
||||
case AssetAction.SET_STACK_PRIMARY_ASSET: {
|
||||
stack = action.stack ?? undefined;
|
||||
stack = action.stack;
|
||||
break;
|
||||
}
|
||||
case AssetAction.SET_PERSON_FEATURED_PHOTO: {
|
||||
@@ -411,7 +391,7 @@
|
||||
|
||||
$effect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
cursor.current;
|
||||
asset;
|
||||
untrack(() => handlePromiseError(refresh()));
|
||||
});
|
||||
|
||||
@@ -580,12 +560,7 @@
|
||||
{:else if viewerKind === 'CropArea'}
|
||||
<CropArea {asset} />
|
||||
{:else if viewerKind === 'PhotoViewer'}
|
||||
<PhotoViewer
|
||||
cursor={{ ...cursor, current: asset }}
|
||||
{sharedLink}
|
||||
{onSwipe}
|
||||
onTagFace={refreshPreservingSelection}
|
||||
/>
|
||||
<PhotoViewer cursor={{ ...cursor, current: asset }} {sharedLink} {onSwipe} />
|
||||
{:else if viewerKind === 'VideoViewer'}
|
||||
<VideoViewer
|
||||
{asset}
|
||||
@@ -642,7 +617,7 @@
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<DetailPanel {asset} currentAlbum={album} onRefreshPeople={refreshPreservingSelection} />
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
{/if}
|
||||
@@ -654,24 +629,27 @@
|
||||
<div id="stack-slideshow" class="pointer-events-none absolute bottom-0 col-span-4 col-start-1 w-full">
|
||||
<div class="no-wrap horizontal-scrollbar relative flex flex-row overflow-x-auto overflow-y-hidden">
|
||||
{#each stackedAssets as stackedAsset (stackedAsset.id)}
|
||||
{@const isSelected = stackedAsset.id === (selectedStackAsset?.id ?? cursor.current.id)}
|
||||
<div
|
||||
class={['pointer-events-auto relative inline-block px-1 pb-2 transition-all']}
|
||||
style:bottom={isSelected ? '0' : '-10px'}
|
||||
style:bottom={stackedAsset.id === asset.id ? '0' : '-10px'}
|
||||
>
|
||||
<Thumbnail
|
||||
imageClass={{ 'border-2 border-white': isSelected }}
|
||||
imageClass={{ 'border-2 border-white': stackedAsset.id === asset.id }}
|
||||
brokenAssetClass="text-xs"
|
||||
dimmed={!isSelected}
|
||||
dimmed={stackedAsset.id !== asset.id}
|
||||
asset={toTimelineAsset(stackedAsset)}
|
||||
onClick={() => selectStackedAsset(stackedAsset.id)}
|
||||
onClick={() => {
|
||||
cursor.current = stackedAsset;
|
||||
previewStackedAsset = undefined;
|
||||
}}
|
||||
onMouseEvent={({ isMouseOver }) => handleStackedAssetMouseEvent(isMouseOver, stackedAsset)}
|
||||
readonly
|
||||
thumbnailSize={isSelected ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
thumbnailSize={stackedAsset.id === asset.id ? stackSelectedThumbnailSize : stackThumbnailSize}
|
||||
showStackedIcon={false}
|
||||
disableLinkMouseOver
|
||||
/>
|
||||
|
||||
{#if isSelected}
|
||||
{#if stackedAsset.id === asset.id}
|
||||
<div class="flex w-full place-content-center place-items-center">
|
||||
<div class="mt-0.5 flex size-2 rounded-full bg-white"></div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,13 @@
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { getParentPath } from '$lib/utils/tree-utils';
|
||||
import { AssetMediaSize, getAllAlbums, type AlbumResponseDto, type AssetResponseDto } from '@immich/sdk';
|
||||
import {
|
||||
AssetMediaSize,
|
||||
getAllAlbums,
|
||||
getAssetInfo,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
@@ -31,10 +37,9 @@
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
currentAlbum?: AlbumResponseDto | null;
|
||||
onRefreshPeople?: () => Promise<void>;
|
||||
}
|
||||
|
||||
let { asset, currentAlbum = null, onRefreshPeople }: Props = $props();
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
|
||||
let latlng = $derived(
|
||||
@@ -89,6 +94,11 @@
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const handleRefreshPeople = async () => {
|
||||
asset = await getAssetInfo({ id: asset.id });
|
||||
assetViewerManager.closeEditFacesPanel();
|
||||
};
|
||||
|
||||
const getAssetFolderHref = (asset: AssetResponseDto) => {
|
||||
// Remove the last part of the path to get the parent path
|
||||
return Route.folders({ path: getParentPath(asset.originalPath) });
|
||||
@@ -375,6 +385,6 @@
|
||||
assetId={asset.id}
|
||||
assetType={asset.type}
|
||||
onClose={() => assetViewerManager.closeEditFacesPanel()}
|
||||
onRefresh={() => void onRefreshPeople?.()}
|
||||
onRefresh={handleRefreshPeople}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -31,10 +31,9 @@
|
||||
onReady?: () => void;
|
||||
onError?: () => void;
|
||||
onSwipe?: (event: SwipeCustomEvent) => void;
|
||||
onTagFace?: () => Promise<void>;
|
||||
};
|
||||
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe, onTagFace }: Props = $props();
|
||||
let { cursor, element = $bindable(), sharedLink, onReady, onError, onSwipe }: Props = $props();
|
||||
|
||||
const { slideshowState, slideshowLook } = slideshowStore;
|
||||
const asset = $derived(cursor.current);
|
||||
@@ -288,12 +287,6 @@
|
||||
</AdaptiveImage>
|
||||
|
||||
{#if assetViewerManager.isFaceEditMode && assetViewerManager.imgRef}
|
||||
<FaceEditor
|
||||
htmlElement={assetViewerManager.imgRef}
|
||||
{containerWidth}
|
||||
{containerHeight}
|
||||
assetId={asset.id}
|
||||
{onTagFace}
|
||||
/>
|
||||
<FaceEditor htmlElement={assetViewerManager.imgRef} {containerWidth} {containerHeight} assetId={asset.id} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -18,10 +18,9 @@
|
||||
containerWidth: number;
|
||||
containerHeight: number;
|
||||
assetId: string;
|
||||
onTagFace?: () => Promise<void>;
|
||||
};
|
||||
|
||||
let { htmlElement, containerWidth, containerHeight, assetId, onTagFace }: Props = $props();
|
||||
let { htmlElement, containerWidth, containerHeight, assetId }: Props = $props();
|
||||
|
||||
let canvasEl: HTMLCanvasElement | undefined = $state();
|
||||
let canvas: Canvas | undefined = $state();
|
||||
@@ -326,7 +325,7 @@
|
||||
},
|
||||
});
|
||||
|
||||
await onTagFace?.();
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, 'Error tagging face');
|
||||
} finally {
|
||||
|
||||
@@ -178,10 +178,7 @@
|
||||
|
||||
peopleWithFaces = peopleWithFaces.filter((f) => f.id !== face.id);
|
||||
|
||||
onRefresh();
|
||||
if (peopleWithFaces.length === 0) {
|
||||
onClose();
|
||||
}
|
||||
await assetViewerManager.setAssetId(assetId);
|
||||
} catch (error) {
|
||||
handleError(error, $t('error_delete_face'));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { serverConfigManager } from '$lib/managers/server-config-manager.svelte';
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -22,7 +21,6 @@
|
||||
// People
|
||||
let peopleEnabled = $state(authManager.preferences.people?.enabled ?? false);
|
||||
let peopleSidebar = $state(authManager.preferences.people?.sidebarWeb ?? false);
|
||||
let peopleMinFaces = $state(authManager.preferences.people?.minimumFaces ?? serverConfigManager.value.minFaces);
|
||||
|
||||
// Ratings
|
||||
let ratingsEnabled = $state(authManager.preferences.ratings?.enabled ?? false);
|
||||
@@ -45,7 +43,7 @@
|
||||
albums: { defaultAssetOrder },
|
||||
folders: { enabled: foldersEnabled, sidebarWeb: foldersSidebar },
|
||||
memories: { enabled: memoriesEnabled, duration: memoriesDuration },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar, minimumFaces: peopleMinFaces },
|
||||
people: { enabled: peopleEnabled, sidebarWeb: peopleSidebar },
|
||||
ratings: { enabled: ratingsEnabled },
|
||||
sharedLinks: { enabled: sharedLinksEnabled, sidebarWeb: sharedLinkSidebar },
|
||||
tags: { enabled: tagsEnabled, sidebarWeb: tagsSidebar },
|
||||
@@ -119,9 +117,6 @@
|
||||
<Field label={$t('sidebar')} description={$t('sidebar_display_description')}>
|
||||
<Switch bind:checked={peopleSidebar} />
|
||||
</Field>
|
||||
<Field label={$t('minFaces')} description={$t('minFaces_description')}>
|
||||
<NumberInput bind:value={peopleMinFaces} />
|
||||
</Field>
|
||||
{/if}
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
Reference in New Issue
Block a user