mirror of
https://github.com/immich-app/immich.git
synced 2026-05-13 19:12:13 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1ffdfe223 | |||
| 44a77892f4 | |||
| 31fb7f6aa8 | |||
| 96b6165bd3 | |||
| 2624f3884f | |||
| f9b7ce9407 | |||
| 013ea37a0d | |||
| b2b4385271 | |||
| 081c75bb21 | |||
| da337578fb | |||
| acf4109171 | |||
| 66601a1fdc | |||
| 02ff077367 | |||
| 94bb6c1a5e | |||
| fe9e5afcf4 | |||
| 5e89efba64 | |||
| 5a457d72c9 | |||
| 45ccdb37fb | |||
| 9263e2f2e1 | |||
| a3ee615c5b | |||
| 6cd33de1bb | |||
| ac3eea80d2 |
@@ -143,9 +143,9 @@ jobs:
|
||||
ALIAS: ${{ secrets.ALIAS }}
|
||||
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
ANDROID_STORE_PASSWORD: ${{ secrets.ANDROID_STORE_PASSWORD }}
|
||||
IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }}
|
||||
IS_MAIN: ${{ github.ref == 'refs/heads/main' }}
|
||||
run: |
|
||||
if [[ $IS_RELEASE == 'true' ]]; then
|
||||
if [[ $IS_MAIN == 'true' ]]; then
|
||||
flutter build apk --release
|
||||
flutter build apk --release --split-per-abi --target-platform android-arm,android-arm64,android-x64
|
||||
else
|
||||
@@ -268,20 +268,20 @@ jobs:
|
||||
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }}
|
||||
ENVIRONMENT: ${{ inputs.environment || 'development' }}
|
||||
BUNDLE_ID_SUFFIX: ${{ inputs.environment == 'production' && '' || 'development' }}
|
||||
IS_RELEASE: ${{ inputs.environment == 'production' || github.ref == 'refs/heads/main' }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT: 120
|
||||
FASTLANE_XCODEBUILD_SETTINGS_RETRIES: 6
|
||||
working-directory: ./mobile/ios
|
||||
run: |
|
||||
# Upload to TestFlight on main or when explicitly invoked as a production release.
|
||||
if [[ "$IS_RELEASE" == "true" ]]; then
|
||||
# Only upload to TestFlight on main branch
|
||||
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
if [[ "$ENVIRONMENT" == "development" ]]; then
|
||||
bundle exec fastlane gha_testflight_dev
|
||||
else
|
||||
bundle exec fastlane gha_release_prod
|
||||
fi
|
||||
else
|
||||
# Build only, no TestFlight upload
|
||||
# Build only, no TestFlight upload for non-main branches
|
||||
bundle exec fastlane gha_build_only
|
||||
fi
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Docker
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -53,8 +53,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Retag sources from the :main image, so only retag for PRs and main-branch pushes.
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).machine-learning == false && !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -84,8 +83,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# Retag sources from the :main image, so only retag for PRs and main-branch pushes.
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork && (github.event_name == 'pull_request' || github.ref == 'refs/heads/main') }}
|
||||
if: ${{ fromJSON(needs.pre-job.outputs.should_run).server == false && !github.event.pull_request.head.repo.fork }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Docs build
|
||||
on:
|
||||
push:
|
||||
branches: [main, 'release/**']
|
||||
branches: [main]
|
||||
pull_request:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
force-filters: |
|
||||
- '.github/workflows/docs-build.yml'
|
||||
force-events: 'release'
|
||||
force-branches: 'main,release/**'
|
||||
force-branches: 'main'
|
||||
|
||||
build:
|
||||
name: Docs Build
|
||||
|
||||
@@ -3,13 +3,8 @@ name: Prepare new release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: 'Branch to release from (must be main or release/*)'
|
||||
required: true
|
||||
default: 'main'
|
||||
type: string
|
||||
serverBump:
|
||||
description: 'Bump server version (only patch allowed on release/* branches)'
|
||||
description: 'Bump server version'
|
||||
required: true
|
||||
default: 'false'
|
||||
type: choice
|
||||
@@ -34,31 +29,10 @@ concurrency:
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
validate_inputs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Validate branch and bump combination
|
||||
env:
|
||||
BRANCH: ${{ inputs.branch }}
|
||||
SERVER_BUMP: ${{ inputs.serverBump }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$BRANCH" != "main" && "$BRANCH" != release/* ]]; then
|
||||
echo "::error::branch must be 'main' or start with 'release/' (got '$BRANCH')"
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$BRANCH" != "main" && "$SERVER_BUMP" != "false" && "$SERVER_BUMP" != "patch" ]]; then
|
||||
echo "::error::only 'patch' (or 'false') serverBump is allowed on '$BRANCH'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
merge_translations:
|
||||
needs: [validate_inputs]
|
||||
uses: ./.github/workflows/merge-translations.yml
|
||||
with:
|
||||
# Weblate tracks main only, so skip translations when releasing from a release/* branch.
|
||||
skip: ${{ inputs.skipTranslations || inputs.branch != 'main' }}
|
||||
skip: ${{ inputs.skipTranslations }}
|
||||
permissions:
|
||||
pull-requests: write
|
||||
secrets:
|
||||
@@ -86,7 +60,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: true
|
||||
ref: ${{ inputs.branch }}
|
||||
ref: main
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
|
||||
@@ -120,10 +94,6 @@ jobs:
|
||||
push: true
|
||||
|
||||
build_mobile:
|
||||
# Mobile build numbers are monotonic per store; releasing from a release/* branch
|
||||
# would collide with build numbers already shipped from main. Skip mobile on patch
|
||||
# releases — handle mobile patches on main instead.
|
||||
if: ${{ inputs.branch == 'main' }}
|
||||
uses: ./.github/workflows/build-mobile.yml
|
||||
needs: bump_version
|
||||
permissions:
|
||||
@@ -148,8 +118,6 @@ jobs:
|
||||
prepare_release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_mobile, bump_version]
|
||||
# Run even when build_mobile is skipped (patch release from release/* branch).
|
||||
if: ${{ always() && needs.bump_version.result == 'success' && (needs.build_mobile.result == 'success' || needs.build_mobile.result == 'skipped') }}
|
||||
permissions:
|
||||
actions: read # To download the app artifact
|
||||
# No content permissions are needed because it uses the app-token
|
||||
@@ -166,83 +134,26 @@ jobs:
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: ${{ needs.bump_version.outputs.ref }}
|
||||
|
||||
- name: Download APK
|
||||
if: ${{ needs.build_mobile.result == 'success' }}
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Assemble release assets
|
||||
id: assets
|
||||
env:
|
||||
HAS_APK: ${{ needs.build_mobile.result == 'success' }}
|
||||
run: |
|
||||
{
|
||||
echo 'files<<EOF'
|
||||
echo 'docker/docker-compose.yml'
|
||||
echo 'docker/docker-compose.rootless.yml'
|
||||
echo 'docker/example.env'
|
||||
echo 'docker/hwaccel.ml.yml'
|
||||
echo 'docker/hwaccel.transcoding.yml'
|
||||
echo 'docker/prometheus.yml'
|
||||
if [[ "$HAS_APK" == "true" ]]; then
|
||||
echo '*.apk'
|
||||
fi
|
||||
echo 'EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create draft release
|
||||
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
|
||||
with:
|
||||
draft: true
|
||||
tag_name: ${{ needs.bump_version.outputs.version }}
|
||||
target_commitish: ${{ inputs.branch }}
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
generate_release_notes: true
|
||||
body_path: misc/release/notes.tmpl
|
||||
files: ${{ steps.assets.outputs.files }}
|
||||
|
||||
backport_archived_versions:
|
||||
# When releasing from a release/* branch, the archived-versions.json update
|
||||
# lives on that branch only. Open a PR to mirror the new entry onto main so
|
||||
# main's docs keep a complete archive list.
|
||||
if: ${{ inputs.branch != 'main' && needs.bump_version.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: [bump_version, prepare_release]
|
||||
permissions: {} # uses the app token
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
|
||||
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
persist-credentials: false
|
||||
ref: main
|
||||
|
||||
- name: Update archived versions on main
|
||||
env:
|
||||
VERSION: ${{ needs.bump_version.outputs.version }}
|
||||
run: ./misc/release/archive-version.js "${VERSION#v}"
|
||||
|
||||
- name: Open backport PR
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
branch: backport/archived-versions-${{ needs.bump_version.outputs.version }}
|
||||
base: main
|
||||
commit-message: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}'
|
||||
title: 'chore(docs): archive ${{ needs.bump_version.outputs.version }}'
|
||||
body: |
|
||||
Backports the `archived-versions.json` entry for ${{ needs.bump_version.outputs.version }},
|
||||
released from `${{ inputs.branch }}`, so main's docs archive list stays complete.
|
||||
add-paths: docs/static/archived-versions.json
|
||||
delete-branch: true
|
||||
files: |
|
||||
docker/docker-compose.yml
|
||||
docker/docker-compose.rootless.yml
|
||||
docker/example.env
|
||||
docker/hwaccel.ml.yml
|
||||
docker/hwaccel.transcoding.yml
|
||||
docker/prometheus.yml
|
||||
*.apk
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
terragrunt = "1.0.1"
|
||||
terragrunt = "1.0.2"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
[tasks."tg:fmt"]
|
||||
|
||||
@@ -85,7 +85,7 @@ services:
|
||||
container_name: immich_prometheus
|
||||
ports:
|
||||
- 9090:9090
|
||||
image: prom/prometheus@sha256:5550dc63da361dc30f6fe02ac0e4dfc736ededfef3c8d12a634db04a67824d78
|
||||
image: prom/prometheus@sha256:e4254400b85610324913f0dc4acf92603d9984e7519414c5a12811aa6146acc3
|
||||
volumes:
|
||||
- ./prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus-data:/prometheus
|
||||
|
||||
@@ -5,79 +5,66 @@ export const errorDto = {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
unauthorizedWithMessage: (message: string) => ({
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: `Missing required permission: ${permission}`,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
passwordRequired: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Password required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: message ?? expect.anything(),
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
noPermission: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidEmail: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: ['email must be an email'],
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -48,14 +48,14 @@ FROM python:3.13-slim-trixie@sha256:d168b8d9eb761f4d3fe305ebd04aeb7e7f2de0297cec
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-core-2_2.28.4+20760_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.28.4/intel-igc-opencl-2_2.28.4+20760_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/intel-opencl-icd_26.05.37020.3-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-core-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/v2.32.7/intel-igc-opencl-2_2.32.7+21184_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/intel-opencl-icd_26.14.37833.4-0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-core_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17537.24/intel-igc-opencl_1.0.17537.24_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/24.35.30872.36/intel-opencl-icd-legacy1_24.35.30872.36_amd64.deb && \
|
||||
# TODO: Figure out how to get renovate to manage this differently versioned libigdgmm file
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.05.37020.3/libigdgmm12_22.9.0_amd64.deb && \
|
||||
wget -nv https://github.com/intel/compute-runtime/releases/download/26.14.37833.4/libigdgmm12_22.9.0_amd64.deb && \
|
||||
dpkg -i *.deb && \
|
||||
rm *.deb && \
|
||||
apt-get remove wget -yqq && \
|
||||
|
||||
@@ -183,7 +183,10 @@ async def predict(
|
||||
text: str | None = Form(default=None),
|
||||
) -> Any:
|
||||
if image is not None:
|
||||
inputs: Image | str = await run(lambda: decode_pil(image))
|
||||
decoded = await run(lambda: decode_pil(image))
|
||||
if decoded.width == 0 or decoded.height == 0:
|
||||
raise HTTPException(400, "Image has zero width or height")
|
||||
inputs: Image | str = decoded
|
||||
elif text is not None:
|
||||
inputs = text
|
||||
else:
|
||||
|
||||
@@ -9,12 +9,12 @@ dependencies = [
|
||||
"aiocache>=0.12.1,<1.0",
|
||||
"fastapi>=0.95.2,<1.0",
|
||||
"gunicorn>=21.1.0",
|
||||
"huggingface-hub>=0.20.1,<1.0",
|
||||
"huggingface-hub>=1.0,<2.0",
|
||||
"insightface>=0.7.3,<1.0",
|
||||
"numpy<2.4.0",
|
||||
"opencv-python-headless>=4.7.0.72,<5.0",
|
||||
"orjson>=3.9.5",
|
||||
"pillow>=12.2,<12.3",
|
||||
"pillow>=12.2,<13",
|
||||
"pydantic>=2.0.0,<3",
|
||||
"pydantic-settings>=2.5.2,<3",
|
||||
"python-multipart>=0.0.6,<1.0",
|
||||
|
||||
@@ -1198,6 +1198,19 @@ class TestLoad:
|
||||
mock_model.model_format = ModelFormat.ONNX
|
||||
|
||||
|
||||
@pytest.mark.parametrize("size", [(0, 100), (100, 0), (0, 0)])
|
||||
def test_predict_rejects_empty_image(size: tuple[int, int], deployed_app: TestClient) -> None:
|
||||
with mock.patch("immich_ml.main.decode_pil", return_value=Image.new("RGB", size)):
|
||||
response = deployed_app.post(
|
||||
"http://localhost:3003/predict",
|
||||
data={"entries": json.dumps({"clip": {"visual": {"modelName": "ViT-B-32__openai"}}})},
|
||||
files={"image": b"fake image bytes"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "zero" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_root_endpoint(deployed_app: TestClient) -> None:
|
||||
response = deployed_app.get("http://localhost:3003")
|
||||
|
||||
|
||||
@@ -16,8 +16,8 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
flutter = "3.41.6"
|
||||
pnpm = "10.33.0"
|
||||
terragrunt = "1.0.1"
|
||||
pnpm = "10.33.1"
|
||||
terragrunt = "1.0.2"
|
||||
opentofu = "1.11.6"
|
||||
java = "21.0.2"
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/models/sync_event.model.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
@@ -191,17 +192,22 @@ class SyncStreamService {
|
||||
case SyncEntityType.assetV1:
|
||||
final remoteSyncAssets = data.cast<SyncAssetV1>();
|
||||
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
|
||||
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (hasPermission) {
|
||||
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
|
||||
await _runWithManageMediaPermission(
|
||||
logContext: "Trashed Assets",
|
||||
action: () async {
|
||||
await _handleRemoteDeleted(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.id));
|
||||
await _applyRemoteRestoreToLocal();
|
||||
} else {
|
||||
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
return;
|
||||
case SyncEntityType.assetDeleteV1:
|
||||
await _runWithManageMediaPermission(
|
||||
logContext: "Deleted Assets",
|
||||
action: () async {
|
||||
final remoteSyncAssets = data.cast<SyncAssetDeleteV1>();
|
||||
await _handleRemoteDeleted(remoteSyncAssets.map((e) => e.assetId));
|
||||
},
|
||||
);
|
||||
return _syncStreamRepository.deleteAssetsV1(data.cast());
|
||||
case SyncEntityType.assetExifV1:
|
||||
return _syncStreamRepository.updateAssetsExifV1(data.cast());
|
||||
@@ -382,28 +388,32 @@ class SyncStreamService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return Future.value();
|
||||
} else {
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
|
||||
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(remoteIds);
|
||||
if (localAssetsToTrash.isNotEmpty) {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
await _trashLocalAssets(localAssetsToTrash);
|
||||
} else {
|
||||
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
|
||||
_logger.info("No assets found in backup-enabled albums for remote assets: $remoteIds");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _trashLocalAssets(Map<String, List<LocalAsset>> localAssetsToTrash) async {
|
||||
final mediaUrls = await Future.wait(
|
||||
localAssetsToTrash.values
|
||||
.expand((e) => e)
|
||||
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
|
||||
);
|
||||
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
|
||||
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
|
||||
if (result) {
|
||||
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _applyRemoteRestoreToLocal() async {
|
||||
final assetsToRestore = await _trashedLocalAssetRepository.getToRestore();
|
||||
if (assetsToRestore.isNotEmpty) {
|
||||
@@ -413,4 +423,21 @@ class SyncStreamService {
|
||||
_logger.info("No remote assets found for restoration");
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runWithManageMediaPermission({
|
||||
required String logContext,
|
||||
required Future<void> Function() action,
|
||||
}) async {
|
||||
if (!CurrentPlatform.isAndroid || !Store.get(StoreKey.manageLocalMediaAndroid, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission = await _localFilesManager.hasManageMediaPermission();
|
||||
if (!hasPermission) {
|
||||
_logger.warning("sync $logContext cannot proceed because MANAGE_MEDIA permission is missing");
|
||||
return;
|
||||
}
|
||||
|
||||
await action();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,31 +109,40 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
||||
return query.map((localAlbum) => localAlbum.toDto()).get();
|
||||
}
|
||||
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
|
||||
if (checksums.isEmpty) {
|
||||
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> remoteIds) async {
|
||||
if (remoteIds.isEmpty) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final result = <String, List<LocalAsset>>{};
|
||||
|
||||
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
|
||||
for (final slice in remoteIds.toSet().slices(kDriftMaxChunk)) {
|
||||
final rows =
|
||||
await (_db.select(_db.localAlbumAssetEntity).join([
|
||||
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
|
||||
innerJoin(
|
||||
_db.localAlbumEntity,
|
||||
_db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
innerJoin(_db.localAssetEntity, _db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id)),
|
||||
innerJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||
useColumns: false,
|
||||
),
|
||||
])..where(
|
||||
_db.localAlbumEntity.backupSelection.equalsValue(BackupSelection.selected) &
|
||||
_db.localAssetEntity.checksum.isIn(slice),
|
||||
_db.remoteAssetEntity.id.isIn(slice),
|
||||
))
|
||||
.get();
|
||||
|
||||
for (final row in rows) {
|
||||
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
|
||||
final assetData = row.readTable(_db.localAssetEntity);
|
||||
final asset = assetData.toDto();
|
||||
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||
(result[albumId] ??= <LocalAsset>[]).add(asset);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
Generated
+6
-6
@@ -183,15 +183,15 @@ class PeopleApi {
|
||||
/// * [String] closestPersonId:
|
||||
/// Closest person ID for similarity search
|
||||
///
|
||||
/// * [num] page:
|
||||
/// * [int] page:
|
||||
/// Page number for pagination
|
||||
///
|
||||
/// * [num] size:
|
||||
/// * [int] size:
|
||||
/// Number of items per page
|
||||
///
|
||||
/// * [bool] withHidden:
|
||||
/// Include hidden people
|
||||
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
|
||||
Future<Response> getAllPeopleWithHttpInfo({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/people';
|
||||
|
||||
@@ -244,15 +244,15 @@ class PeopleApi {
|
||||
/// * [String] closestPersonId:
|
||||
/// Closest person ID for similarity search
|
||||
///
|
||||
/// * [num] page:
|
||||
/// * [int] page:
|
||||
/// Page number for pagination
|
||||
///
|
||||
/// * [num] size:
|
||||
/// * [int] size:
|
||||
/// Number of items per page
|
||||
///
|
||||
/// * [bool] withHidden:
|
||||
/// Include hidden people
|
||||
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, num? page, num? size, bool? withHidden, }) async {
|
||||
Future<PeopleResponseDto?> getAllPeople({ String? closestAssetId, String? closestPersonId, int? page, int? size, bool? withHidden, }) async {
|
||||
final response = await getAllPeopleWithHttpInfo( closestAssetId: closestAssetId, closestPersonId: closestPersonId, page: page, size: size, withHidden: withHidden, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
|
||||
Generated
+6
-6
@@ -404,10 +404,10 @@ class SearchApi {
|
||||
/// * [List<String>] personIds:
|
||||
/// Filter by person IDs
|
||||
///
|
||||
/// * [num] rating:
|
||||
/// * [int] rating:
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// * [num] size:
|
||||
/// * [int] size:
|
||||
/// Number of results to return
|
||||
///
|
||||
/// * [String] state:
|
||||
@@ -443,7 +443,7 @@ class SearchApi {
|
||||
///
|
||||
/// * [bool] withExif:
|
||||
/// Include EXIF data in response
|
||||
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
Future<Response> searchLargeAssetsWithHttpInfo({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/search/large-assets';
|
||||
|
||||
@@ -619,10 +619,10 @@ class SearchApi {
|
||||
/// * [List<String>] personIds:
|
||||
/// Filter by person IDs
|
||||
///
|
||||
/// * [num] rating:
|
||||
/// * [int] rating:
|
||||
/// Filter by rating [1-5], or null for unrated
|
||||
///
|
||||
/// * [num] size:
|
||||
/// * [int] size:
|
||||
/// Number of results to return
|
||||
///
|
||||
/// * [String] state:
|
||||
@@ -658,7 +658,7 @@ class SearchApi {
|
||||
///
|
||||
/// * [bool] withExif:
|
||||
/// Include EXIF data in response
|
||||
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, num? rating, num? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
Future<List<AssetResponseDto>?> searchLargeAssets({ List<String>? albumIds, String? city, String? country, DateTime? createdAfter, DateTime? createdBefore, bool? isEncoded, bool? isFavorite, bool? isMotion, bool? isNotInAlbum, bool? isOffline, String? lensModel, String? libraryId, String? make, int? minFileSize, String? model, String? ocr, List<String>? personIds, int? rating, int? size, String? state, List<String>? tagIds, DateTime? takenAfter, DateTime? takenBefore, DateTime? trashedAfter, DateTime? trashedBefore, AssetTypeEnum? type, DateTime? updatedAfter, DateTime? updatedBefore, AssetVisibility? visibility, bool? withDeleted, bool? withExif, }) async {
|
||||
final response = await searchLargeAssetsWithHttpInfo( albumIds: albumIds, city: city, country: country, createdAfter: createdAfter, createdBefore: createdBefore, isEncoded: isEncoded, isFavorite: isFavorite, isMotion: isMotion, isNotInAlbum: isNotInAlbum, isOffline: isOffline, lensModel: lensModel, libraryId: libraryId, make: make, minFileSize: minFileSize, model: model, ocr: ocr, personIds: personIds, rating: rating, size: size, state: state, tagIds: tagIds, takenAfter: takenAfter, takenBefore: takenBefore, trashedAfter: trashedAfter, trashedBefore: trashedBefore, type: type, updatedAfter: updatedAfter, updatedBefore: updatedBefore, visibility: visibility, withDeleted: withDeleted, withExif: withExif, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
|
||||
Generated
+24
-6
@@ -64,9 +64,12 @@ class TimelineApi {
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withSharedAlbums:
|
||||
/// Include assets from shared albums
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
Future<Response> getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/timeline/bucket';
|
||||
|
||||
@@ -117,6 +120,9 @@ class TimelineApi {
|
||||
if (withPartners != null) {
|
||||
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
|
||||
}
|
||||
if (withSharedAlbums != null) {
|
||||
queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums));
|
||||
}
|
||||
if (withStacked != null) {
|
||||
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
|
||||
}
|
||||
@@ -181,10 +187,13 @@ class TimelineApi {
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withSharedAlbums:
|
||||
/// Include assets from shared albums
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
|
||||
Future<TimeBucketAssetResponseDto?> getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withSharedAlbums: withSharedAlbums, withStacked: withStacked, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
@@ -243,9 +252,12 @@ class TimelineApi {
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withSharedAlbums:
|
||||
/// Include assets from shared albums
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
Future<Response> getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/timeline/buckets';
|
||||
|
||||
@@ -295,6 +307,9 @@ class TimelineApi {
|
||||
if (withPartners != null) {
|
||||
queryParams.addAll(_queryParams('', 'withPartners', withPartners));
|
||||
}
|
||||
if (withSharedAlbums != null) {
|
||||
queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums));
|
||||
}
|
||||
if (withStacked != null) {
|
||||
queryParams.addAll(_queryParams('', 'withStacked', withStacked));
|
||||
}
|
||||
@@ -356,10 +371,13 @@ class TimelineApi {
|
||||
/// * [bool] withPartners:
|
||||
/// Include assets shared by partners
|
||||
///
|
||||
/// * [bool] withSharedAlbums:
|
||||
/// Include assets from shared albums
|
||||
///
|
||||
/// * [bool] withStacked:
|
||||
/// Include stacked assets in the response. When true, only primary assets from stacks are returned.
|
||||
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, );
|
||||
Future<List<TimeBucketsResponseDto>?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async {
|
||||
final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withSharedAlbums: withSharedAlbums, withStacked: withStacked, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
+5
-2
@@ -37,12 +37,15 @@ class AssetBulkUpdateDto {
|
||||
|
||||
/// Relative time offset in seconds
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// 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.
|
||||
///
|
||||
num? dateTimeRelative;
|
||||
int? dateTimeRelative;
|
||||
|
||||
/// Asset description
|
||||
///
|
||||
@@ -213,7 +216,7 @@ class AssetBulkUpdateDto {
|
||||
|
||||
return AssetBulkUpdateDto(
|
||||
dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'),
|
||||
dateTimeRelative: num.parse('${json[r'dateTimeRelative']}'),
|
||||
dateTimeRelative: mapValueOfType<int>(json, r'dateTimeRelative'),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
duplicateId: mapValueOfType<String>(json, r'duplicateId'),
|
||||
ids: json[r'ids'] is Iterable
|
||||
|
||||
@@ -24,22 +24,26 @@ class AssetEditActionItemDtoParameters {
|
||||
/// Height of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num height;
|
||||
/// Maximum value: 9007199254740991
|
||||
int height;
|
||||
|
||||
/// Width of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num width;
|
||||
/// Maximum value: 9007199254740991
|
||||
int width;
|
||||
|
||||
/// Top-Left X coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num x;
|
||||
/// Maximum value: 9007199254740991
|
||||
int x;
|
||||
|
||||
/// Top-Left Y coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num y;
|
||||
/// Maximum value: 9007199254740991
|
||||
int y;
|
||||
|
||||
/// Rotation angle in degrees
|
||||
num angle;
|
||||
@@ -88,10 +92,10 @@ class AssetEditActionItemDtoParameters {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return AssetEditActionItemDtoParameters(
|
||||
height: num.parse('${json[r'height']}'),
|
||||
width: num.parse('${json[r'width']}'),
|
||||
x: num.parse('${json[r'x']}'),
|
||||
y: num.parse('${json[r'y']}'),
|
||||
height: mapValueOfType<int>(json, r'height')!,
|
||||
width: mapValueOfType<int>(json, r'width')!,
|
||||
x: mapValueOfType<int>(json, r'x')!,
|
||||
y: mapValueOfType<int>(json, r'y')!,
|
||||
angle: num.parse('${json[r'angle']}'),
|
||||
axis: MirrorAxis.fromJson(json[r'axis'])!,
|
||||
);
|
||||
|
||||
+6
-8
@@ -80,7 +80,8 @@ class AssetResponseDto {
|
||||
/// Asset height
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num? height;
|
||||
/// Maximum value: 9007199254740991
|
||||
int? height;
|
||||
|
||||
/// Asset ID
|
||||
String id;
|
||||
@@ -165,7 +166,8 @@ class AssetResponseDto {
|
||||
/// Asset width
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num? width;
|
||||
/// Maximum value: 9007199254740991
|
||||
int? width;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is AssetResponseDto &&
|
||||
@@ -346,9 +348,7 @@ class AssetResponseDto {
|
||||
fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!,
|
||||
fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!,
|
||||
hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
|
||||
height: json[r'height'] == null
|
||||
? null
|
||||
: num.parse('${json[r'height']}'),
|
||||
height: mapValueOfType<int>(json, r'height'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
isArchived: mapValueOfType<bool>(json, r'isArchived')!,
|
||||
isEdited: mapValueOfType<bool>(json, r'isEdited')!,
|
||||
@@ -372,9 +372,7 @@ class AssetResponseDto {
|
||||
unassignedFaces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'unassignedFaces']),
|
||||
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
|
||||
visibility: AssetVisibility.fromJson(json[r'visibility'])!,
|
||||
width: json[r'width'] == null
|
||||
? null
|
||||
: num.parse('${json[r'width']}'),
|
||||
width: mapValueOfType<int>(json, r'width'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
+12
-8
@@ -22,22 +22,26 @@ class CropParameters {
|
||||
/// Height of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num height;
|
||||
/// Maximum value: 9007199254740991
|
||||
int height;
|
||||
|
||||
/// Width of the crop
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num width;
|
||||
/// Maximum value: 9007199254740991
|
||||
int width;
|
||||
|
||||
/// Top-Left X coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num x;
|
||||
/// Maximum value: 9007199254740991
|
||||
int x;
|
||||
|
||||
/// Top-Left Y coordinate of crop
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num y;
|
||||
/// Maximum value: 9007199254740991
|
||||
int y;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is CropParameters &&
|
||||
@@ -75,10 +79,10 @@ class CropParameters {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return CropParameters(
|
||||
height: num.parse('${json[r'height']}'),
|
||||
width: num.parse('${json[r'width']}'),
|
||||
x: num.parse('${json[r'x']}'),
|
||||
y: num.parse('${json[r'y']}'),
|
||||
height: mapValueOfType<int>(json, r'height')!,
|
||||
width: mapValueOfType<int>(json, r'width')!,
|
||||
x: mapValueOfType<int>(json, r'x')!,
|
||||
y: mapValueOfType<int>(json, r'y')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
+3
-2
@@ -27,7 +27,8 @@ class DatabaseBackupConfig {
|
||||
/// Keep last amount
|
||||
///
|
||||
/// Minimum value: 1
|
||||
num keepLastAmount;
|
||||
/// Maximum value: 9007199254740991
|
||||
int keepLastAmount;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig &&
|
||||
@@ -64,7 +65,7 @@ class DatabaseBackupConfig {
|
||||
return DatabaseBackupConfig(
|
||||
cronExpression: mapValueOfType<String>(json, r'cronExpression')!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
keepLastAmount: num.parse('${json[r'keepLastAmount']}'),
|
||||
keepLastAmount: mapValueOfType<int>(json, r'keepLastAmount')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
+5
-2
@@ -22,7 +22,10 @@ class DatabaseBackupDto {
|
||||
String filename;
|
||||
|
||||
/// Backup file size
|
||||
num filesize;
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int filesize;
|
||||
|
||||
/// Backup timezone
|
||||
String timezone;
|
||||
@@ -61,7 +64,7 @@ class DatabaseBackupDto {
|
||||
|
||||
return DatabaseBackupDto(
|
||||
filename: mapValueOfType<String>(json, r'filename')!,
|
||||
filesize: num.parse('${json[r'filesize']}'),
|
||||
filesize: mapValueOfType<int>(json, r'filesize')!,
|
||||
timezone: mapValueOfType<String>(json, r'timezone')!,
|
||||
);
|
||||
}
|
||||
|
||||
+16
-16
@@ -52,12 +52,14 @@ class ExifResponseDto {
|
||||
/// Image height in pixels
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num? exifImageHeight;
|
||||
/// Maximum value: 9007199254740991
|
||||
int? exifImageHeight;
|
||||
|
||||
/// Image width in pixels
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num? exifImageWidth;
|
||||
/// Maximum value: 9007199254740991
|
||||
int? exifImageWidth;
|
||||
|
||||
/// Exposure time
|
||||
String? exposureTime;
|
||||
@@ -75,7 +77,10 @@ class ExifResponseDto {
|
||||
num? focalLength;
|
||||
|
||||
/// ISO sensitivity
|
||||
num? iso;
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int? iso;
|
||||
|
||||
/// GPS latitude
|
||||
num? latitude;
|
||||
@@ -102,7 +107,10 @@ class ExifResponseDto {
|
||||
String? projectionType;
|
||||
|
||||
/// Rating
|
||||
num? rating;
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int? rating;
|
||||
|
||||
/// State/province name
|
||||
String? state;
|
||||
@@ -292,12 +300,8 @@ class ExifResponseDto {
|
||||
country: mapValueOfType<String>(json, r'country'),
|
||||
dateTimeOriginal: mapDateTime(json, r'dateTimeOriginal', r''),
|
||||
description: mapValueOfType<String>(json, r'description'),
|
||||
exifImageHeight: json[r'exifImageHeight'] == null
|
||||
? null
|
||||
: num.parse('${json[r'exifImageHeight']}'),
|
||||
exifImageWidth: json[r'exifImageWidth'] == null
|
||||
? null
|
||||
: num.parse('${json[r'exifImageWidth']}'),
|
||||
exifImageHeight: mapValueOfType<int>(json, r'exifImageHeight'),
|
||||
exifImageWidth: mapValueOfType<int>(json, r'exifImageWidth'),
|
||||
exposureTime: mapValueOfType<String>(json, r'exposureTime'),
|
||||
fNumber: json[r'fNumber'] == null
|
||||
? null
|
||||
@@ -306,9 +310,7 @@ class ExifResponseDto {
|
||||
focalLength: json[r'focalLength'] == null
|
||||
? null
|
||||
: num.parse('${json[r'focalLength']}'),
|
||||
iso: json[r'iso'] == null
|
||||
? null
|
||||
: num.parse('${json[r'iso']}'),
|
||||
iso: mapValueOfType<int>(json, r'iso'),
|
||||
latitude: json[r'latitude'] == null
|
||||
? null
|
||||
: num.parse('${json[r'latitude']}'),
|
||||
@@ -321,9 +323,7 @@ class ExifResponseDto {
|
||||
modifyDate: mapDateTime(json, r'modifyDate', r''),
|
||||
orientation: mapValueOfType<String>(json, r'orientation'),
|
||||
projectionType: mapValueOfType<String>(json, r'projectionType'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
timeZone: mapValueOfType<String>(json, r'timeZone'),
|
||||
);
|
||||
|
||||
@@ -21,9 +21,13 @@ class MachineLearningAvailabilityChecksDto {
|
||||
/// Enabled
|
||||
bool enabled;
|
||||
|
||||
num interval;
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int interval;
|
||||
|
||||
num timeout;
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int timeout;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MachineLearningAvailabilityChecksDto &&
|
||||
@@ -59,8 +63,8 @@ class MachineLearningAvailabilityChecksDto {
|
||||
|
||||
return MachineLearningAvailabilityChecksDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
interval: num.parse('${json[r'interval']}'),
|
||||
timeout: num.parse('${json[r'timeout']}'),
|
||||
interval: mapValueOfType<int>(json, r'interval')!,
|
||||
timeout: mapValueOfType<int>(json, r'timeout')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
+5
-2
@@ -20,7 +20,10 @@ class MaintenanceDetectInstallStorageFolderDto {
|
||||
});
|
||||
|
||||
/// Number of files in the folder
|
||||
num files;
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int files;
|
||||
|
||||
StorageFolder folder;
|
||||
|
||||
@@ -66,7 +69,7 @@ class MaintenanceDetectInstallStorageFolderDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return MaintenanceDetectInstallStorageFolderDto(
|
||||
files: num.parse('${json[r'files']}'),
|
||||
files: mapValueOfType<int>(json, r'files')!,
|
||||
folder: StorageFolder.fromJson(json[r'folder'])!,
|
||||
readable: mapValueOfType<bool>(json, r'readable')!,
|
||||
writable: mapValueOfType<bool>(json, r'writable')!,
|
||||
|
||||
@@ -32,13 +32,15 @@ class MaintenanceStatusResponseDto {
|
||||
///
|
||||
String? error;
|
||||
|
||||
/// Minimum value: -9007199254740991
|
||||
/// 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.
|
||||
///
|
||||
num? progress;
|
||||
int? progress;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
@@ -102,7 +104,7 @@ class MaintenanceStatusResponseDto {
|
||||
action: MaintenanceAction.fromJson(json[r'action'])!,
|
||||
active: mapValueOfType<bool>(json, r'active')!,
|
||||
error: mapValueOfType<String>(json, r'error'),
|
||||
progress: num.parse('${json[r'progress']}'),
|
||||
progress: mapValueOfType<int>(json, r'progress'),
|
||||
task: mapValueOfType<String>(json, r'task'),
|
||||
);
|
||||
}
|
||||
|
||||
+7
-8
@@ -215,13 +215,14 @@ class MetadataSearchDto {
|
||||
/// Page number
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
num? page;
|
||||
int? page;
|
||||
|
||||
/// Filter by person IDs
|
||||
List<String> personIds;
|
||||
@@ -239,7 +240,7 @@ class MetadataSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
num? rating;
|
||||
int? rating;
|
||||
|
||||
/// Number of results to return
|
||||
///
|
||||
@@ -251,7 +252,7 @@ class MetadataSearchDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? size;
|
||||
int? size;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -724,15 +725,13 @@ class MetadataSearchDto {
|
||||
order: AssetOrder.fromJson(json[r'order']),
|
||||
originalFileName: mapValueOfType<String>(json, r'originalFileName'),
|
||||
originalPath: mapValueOfType<String>(json, r'originalPath'),
|
||||
page: num.parse('${json[r'page']}'),
|
||||
page: mapValueOfType<int>(json, r'page'),
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
previewPath: mapValueOfType<String>(json, r'previewPath'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
size: mapValueOfType<int>(json, r'size'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
+4
-6
@@ -147,7 +147,7 @@ class RandomSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
num? rating;
|
||||
int? rating;
|
||||
|
||||
/// Number of results to return
|
||||
///
|
||||
@@ -159,7 +159,7 @@ class RandomSearchDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? size;
|
||||
int? size;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -549,10 +549,8 @@ class RandomSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
size: mapValueOfType<int>(json, r'size'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
+3
-2
@@ -39,13 +39,14 @@ class SessionCreateDto {
|
||||
/// Session duration in seconds
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
num? duration;
|
||||
int? duration;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SessionCreateDto &&
|
||||
@@ -94,7 +95,7 @@ class SessionCreateDto {
|
||||
return SessionCreateDto(
|
||||
deviceOS: mapValueOfType<String>(json, r'deviceOS'),
|
||||
deviceType: mapValueOfType<String>(json, r'deviceType'),
|
||||
duration: num.parse('${json[r'duration']}'),
|
||||
duration: mapValueOfType<int>(json, r'duration'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
+7
-8
@@ -154,13 +154,14 @@ class SmartSearchDto {
|
||||
/// Page number
|
||||
///
|
||||
/// 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.
|
||||
///
|
||||
num? page;
|
||||
int? page;
|
||||
|
||||
/// Filter by person IDs
|
||||
List<String> personIds;
|
||||
@@ -187,7 +188,7 @@ class SmartSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
num? rating;
|
||||
int? rating;
|
||||
|
||||
/// Number of results to return
|
||||
///
|
||||
@@ -199,7 +200,7 @@ class SmartSearchDto {
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
num? size;
|
||||
int? size;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -583,16 +584,14 @@ class SmartSearchDto {
|
||||
make: mapValueOfType<String>(json, r'make'),
|
||||
model: mapValueOfType<String>(json, r'model'),
|
||||
ocr: mapValueOfType<String>(json, r'ocr'),
|
||||
page: num.parse('${json[r'page']}'),
|
||||
page: mapValueOfType<int>(json, r'page'),
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
query: mapValueOfType<String>(json, r'query'),
|
||||
queryAssetId: mapValueOfType<String>(json, r'queryAssetId'),
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
size: num.parse('${json[r'size']}'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
size: mapValueOfType<int>(json, r'size'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
+2
-4
@@ -152,7 +152,7 @@ class StatisticsSearchDto {
|
||||
///
|
||||
/// Minimum value: -1
|
||||
/// Maximum value: 5
|
||||
num? rating;
|
||||
int? rating;
|
||||
|
||||
/// Filter by state/province name
|
||||
String? state;
|
||||
@@ -479,9 +479,7 @@ class StatisticsSearchDto {
|
||||
personIds: json[r'personIds'] is Iterable
|
||||
? (json[r'personIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
: const [],
|
||||
rating: json[r'rating'] == null
|
||||
? null
|
||||
: num.parse('${json[r'rating']}'),
|
||||
rating: mapValueOfType<int>(json, r'rating'),
|
||||
state: mapValueOfType<String>(json, r'state'),
|
||||
tagIds: json[r'tagIds'] is Iterable
|
||||
? (json[r'tagIds'] as Iterable).cast<String>().toList(growable: false)
|
||||
|
||||
+3
-4
@@ -57,7 +57,8 @@ class SystemConfigOAuthDto {
|
||||
/// Default storage quota
|
||||
///
|
||||
/// Minimum value: 0
|
||||
num? defaultStorageQuota;
|
||||
/// Maximum value: 9007199254740991
|
||||
int? defaultStorageQuota;
|
||||
|
||||
/// Enabled
|
||||
bool enabled;
|
||||
@@ -200,9 +201,7 @@ class SystemConfigOAuthDto {
|
||||
buttonText: mapValueOfType<String>(json, r'buttonText')!,
|
||||
clientId: mapValueOfType<String>(json, r'clientId')!,
|
||||
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
|
||||
defaultStorageQuota: json[r'defaultStorageQuota'] == null
|
||||
? null
|
||||
: num.parse('${json[r'defaultStorageQuota']}'),
|
||||
defaultStorageQuota: mapValueOfType<int>(json, r'defaultStorageQuota'),
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
endSessionEndpoint: mapValueOfType<String>(json, r'endSessionEndpoint')!,
|
||||
issuerUrl: mapValueOfType<String>(json, r'issuerUrl')!,
|
||||
|
||||
@@ -34,7 +34,7 @@ class SystemConfigSmtpTransportDto {
|
||||
///
|
||||
/// Minimum value: 0
|
||||
/// Maximum value: 65535
|
||||
num port;
|
||||
int port;
|
||||
|
||||
/// Whether to use secure connection (TLS/SSL)
|
||||
bool secure;
|
||||
@@ -87,7 +87,7 @@ class SystemConfigSmtpTransportDto {
|
||||
host: mapValueOfType<String>(json, r'host')!,
|
||||
ignoreCert: mapValueOfType<bool>(json, r'ignoreCert')!,
|
||||
password: mapValueOfType<String>(json, r'password')!,
|
||||
port: num.parse('${json[r'port']}'),
|
||||
port: mapValueOfType<int>(json, r'port')!,
|
||||
secure: mapValueOfType<bool>(json, r'secure')!,
|
||||
username: mapValueOfType<String>(json, r'username')!,
|
||||
);
|
||||
|
||||
+5
-2
@@ -26,7 +26,10 @@ class WorkflowActionResponseDto {
|
||||
String id;
|
||||
|
||||
/// Action order
|
||||
num order;
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int order;
|
||||
|
||||
/// Plugin action ID
|
||||
String pluginActionId;
|
||||
@@ -79,7 +82,7 @@ class WorkflowActionResponseDto {
|
||||
return WorkflowActionResponseDto(
|
||||
actionConfig: mapCastOfType<String, Object>(json, r'actionConfig'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
order: num.parse('${json[r'order']}'),
|
||||
order: mapValueOfType<int>(json, r'order')!,
|
||||
pluginActionId: mapValueOfType<String>(json, r'pluginActionId')!,
|
||||
workflowId: mapValueOfType<String>(json, r'workflowId')!,
|
||||
);
|
||||
|
||||
+5
-2
@@ -26,7 +26,10 @@ class WorkflowFilterResponseDto {
|
||||
String id;
|
||||
|
||||
/// Filter order
|
||||
num order;
|
||||
///
|
||||
/// Minimum value: -9007199254740991
|
||||
/// Maximum value: 9007199254740991
|
||||
int order;
|
||||
|
||||
/// Plugin filter ID
|
||||
String pluginFilterId;
|
||||
@@ -79,7 +82,7 @@ class WorkflowFilterResponseDto {
|
||||
return WorkflowFilterResponseDto(
|
||||
filterConfig: mapCastOfType<String, Object>(json, r'filterConfig'),
|
||||
id: mapValueOfType<String>(json, r'id')!,
|
||||
order: num.parse('${json[r'order']}'),
|
||||
order: mapValueOfType<int>(json, r'order')!,
|
||||
pluginFilterId: mapValueOfType<String>(json, r'pluginFilterId')!,
|
||||
workflowId: mapValueOfType<String>(json, r'workflowId')!,
|
||||
);
|
||||
|
||||
@@ -419,8 +419,8 @@ void main() {
|
||||
'album-b': [mergedAsset],
|
||||
};
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-1', 'remote-2', 'remote-3'}));
|
||||
return assetsByAlbum;
|
||||
});
|
||||
|
||||
@@ -482,12 +482,18 @@ void main() {
|
||||
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
|
||||
});
|
||||
|
||||
test("does not request local deletions for permanent remote delete events", () async {
|
||||
test("requests local deletions lookup by remote ids for permanent remote delete events", () async {
|
||||
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
|
||||
final Iterable<String> requestedRemoteIds = invocation.positionalArguments.first as Iterable<String>;
|
||||
expect(requestedRemoteIds.toSet(), equals({'remote-asset'}));
|
||||
return {};
|
||||
});
|
||||
|
||||
final events = [SyncStreamStub.assetDeleteV1];
|
||||
|
||||
await simulateEvents(events);
|
||||
|
||||
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
|
||||
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
|
||||
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
|
||||
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
|
||||
});
|
||||
|
||||
@@ -7964,8 +7964,9 @@
|
||||
"description": "Page number for pagination",
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 9007199254740991,
|
||||
"default": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -7977,7 +7978,7 @@
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"default": 500,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -9372,7 +9373,7 @@
|
||||
],
|
||||
"x-immich-state": "Stable",
|
||||
"schema": {
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"minimum": -1,
|
||||
"maximum": 5,
|
||||
"nullable": true
|
||||
@@ -9386,7 +9387,7 @@
|
||||
"schema": {
|
||||
"minimum": 1,
|
||||
"maximum": 1000,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -13390,6 +13391,15 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withSharedAlbums",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Include assets from shared albums",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withStacked",
|
||||
"required": false,
|
||||
@@ -13570,6 +13580,15 @@
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withSharedAlbums",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Include assets from shared albums",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "withStacked",
|
||||
"required": false,
|
||||
@@ -15636,7 +15655,9 @@
|
||||
},
|
||||
"dateTimeRelative": {
|
||||
"description": "Relative time offset in seconds",
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"description": {
|
||||
"description": "Asset description",
|
||||
@@ -16650,9 +16671,10 @@
|
||||
},
|
||||
"height": {
|
||||
"description": "Asset height",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"description": "Asset ID",
|
||||
@@ -16795,9 +16817,10 @@
|
||||
},
|
||||
"width": {
|
||||
"description": "Asset width",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17214,23 +17237,27 @@
|
||||
"properties": {
|
||||
"height": {
|
||||
"description": "Height of the crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"width": {
|
||||
"description": "Width of the crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"x": {
|
||||
"description": "Top-Left X coordinate of crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"y": {
|
||||
"description": "Top-Left Y coordinate of crop",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17254,8 +17281,9 @@
|
||||
},
|
||||
"keepLastAmount": {
|
||||
"description": "Keep last amount",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -17288,7 +17316,9 @@
|
||||
},
|
||||
"filesize": {
|
||||
"description": "Backup file size",
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Backup timezone",
|
||||
@@ -17627,16 +17657,18 @@
|
||||
"exifImageHeight": {
|
||||
"default": null,
|
||||
"description": "Image height in pixels",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"exifImageWidth": {
|
||||
"default": null,
|
||||
"description": "Image width in pixels",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"exposureTime": {
|
||||
"default": null,
|
||||
@@ -17667,8 +17699,10 @@
|
||||
"iso": {
|
||||
"default": null,
|
||||
"description": "ISO sensitivity",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"latitude": {
|
||||
"default": null,
|
||||
@@ -17722,8 +17756,10 @@
|
||||
"rating": {
|
||||
"default": null,
|
||||
"description": "Rating",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"default": null,
|
||||
@@ -18150,10 +18186,14 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"interval": {
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"timeout": {
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -18203,7 +18243,9 @@
|
||||
"properties": {
|
||||
"files": {
|
||||
"description": "Number of files in the folder",
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"folder": {
|
||||
"$ref": "#/components/schemas/StorageFolder"
|
||||
@@ -18246,7 +18288,9 @@
|
||||
"type": "string"
|
||||
},
|
||||
"progress": {
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"task": {
|
||||
"type": "string"
|
||||
@@ -18723,8 +18767,9 @@
|
||||
},
|
||||
"page": {
|
||||
"description": "Page number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"personIds": {
|
||||
"description": "Filter by person IDs",
|
||||
@@ -18744,7 +18789,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -18766,7 +18811,7 @@
|
||||
"description": "Number of results to return",
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -20597,7 +20642,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -20619,7 +20664,7 @@
|
||||
"description": "Number of results to return",
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -21437,8 +21482,9 @@
|
||||
},
|
||||
"duration": {
|
||||
"description": "Session duration in seconds",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -21952,8 +21998,9 @@
|
||||
},
|
||||
"page": {
|
||||
"description": "Page number",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"personIds": {
|
||||
"description": "Filter by person IDs",
|
||||
@@ -21979,7 +22026,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -22001,7 +22048,7 @@
|
||||
"description": "Number of results to return",
|
||||
"maximum": 1000,
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"state": {
|
||||
"description": "Filter by state/province name",
|
||||
@@ -22239,7 +22286,7 @@
|
||||
"maximum": 5,
|
||||
"minimum": -1,
|
||||
"nullable": true,
|
||||
"type": "number",
|
||||
"type": "integer",
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v1",
|
||||
@@ -24371,9 +24418,10 @@
|
||||
},
|
||||
"defaultStorageQuota": {
|
||||
"description": "Default storage quota",
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": 0,
|
||||
"nullable": true,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"enabled": {
|
||||
"description": "Enabled",
|
||||
@@ -24548,7 +24596,7 @@
|
||||
"description": "SMTP server port",
|
||||
"maximum": 65535,
|
||||
"minimum": 0,
|
||||
"type": "number"
|
||||
"type": "integer"
|
||||
},
|
||||
"secure": {
|
||||
"description": "Whether to use secure connection (TLS/SSL)",
|
||||
@@ -25966,7 +26014,9 @@
|
||||
},
|
||||
"order": {
|
||||
"description": "Action order",
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"pluginActionId": {
|
||||
"description": "Plugin action ID",
|
||||
@@ -26065,7 +26115,9 @@
|
||||
},
|
||||
"order": {
|
||||
"description": "Filter order",
|
||||
"type": "number"
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"pluginFilterId": {
|
||||
"description": "Plugin filter ID",
|
||||
|
||||
@@ -6286,7 +6286,7 @@ export function tagAssets({ id, bulkIdsDto }: {
|
||||
/**
|
||||
* Get time bucket
|
||||
*/
|
||||
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withSharedAlbums, withStacked }: {
|
||||
albumId?: string;
|
||||
bbox?: string;
|
||||
isFavorite?: boolean;
|
||||
@@ -6301,6 +6301,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
withPartners?: boolean;
|
||||
withSharedAlbums?: boolean;
|
||||
withStacked?: boolean;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
@@ -6321,6 +6322,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
|
||||
visibility,
|
||||
withCoordinates,
|
||||
withPartners,
|
||||
withSharedAlbums,
|
||||
withStacked
|
||||
}))}`, {
|
||||
...opts
|
||||
@@ -6329,7 +6331,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order
|
||||
/**
|
||||
* Get time buckets
|
||||
*/
|
||||
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: {
|
||||
export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withSharedAlbums, withStacked }: {
|
||||
albumId?: string;
|
||||
bbox?: string;
|
||||
isFavorite?: boolean;
|
||||
@@ -6343,6 +6345,7 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
withPartners?: boolean;
|
||||
withSharedAlbums?: boolean;
|
||||
withStacked?: boolean;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
@@ -6362,6 +6365,7 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde
|
||||
visibility,
|
||||
withCoordinates,
|
||||
withPartners,
|
||||
withSharedAlbums,
|
||||
withStacked
|
||||
}))}`, {
|
||||
...opts
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
"version": "2.7.5",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
|
||||
"packageManager": "pnpm@10.33.1+sha512.05ba3c1d5d1c18f68df06470d74055e62d41fc110a0c660db1b2dfb2785327f04cf0f68345d4609bc52089e7fa0343c31593b2f9594e2c5d5da426230acc9820",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
Generated
+960
-998
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -46,15 +46,15 @@
|
||||
"@nestjs/platform-express": "^11.0.4",
|
||||
"@nestjs/platform-socket.io": "^11.0.4",
|
||||
"@nestjs/schedule": "^6.0.0",
|
||||
"@nestjs/swagger": "11.2.6",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.215.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.215.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.62.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.60.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.66.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.63.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.61.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.67.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.215.0",
|
||||
|
||||
@@ -49,7 +49,7 @@ describe(SearchController.name, () => {
|
||||
});
|
||||
|
||||
it('should reject an invalid size', async () => {
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1.5 });
|
||||
const { status, body } = await request(ctx.getHttpServer()).post('/search/metadata').send({ size: -1 });
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest(['[size] Too small: expected number to be >=1']));
|
||||
});
|
||||
|
||||
@@ -50,8 +50,8 @@ const SanitizedAssetResponseSchema = z
|
||||
duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'),
|
||||
livePhotoVideoId: z.string().nullish().describe('Live photo video ID'),
|
||||
hasMetadata: z.boolean().describe('Whether asset has metadata'),
|
||||
width: z.number().min(0).nullable().describe('Asset width'),
|
||||
height: z.number().min(0).nullable().describe('Asset height'),
|
||||
width: z.int().min(0).nullable().describe('Asset width'),
|
||||
height: z.int().min(0).nullable().describe('Asset height'),
|
||||
})
|
||||
.meta({ id: 'SanitizedAssetResponseDto' });
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const UpdateAssetBaseSchema = z
|
||||
const AssetBulkUpdateBaseSchema = UpdateAssetBaseSchema.extend({
|
||||
ids: z.array(z.uuidv4()).describe('Asset IDs to update'),
|
||||
duplicateId: z.string().nullish().describe('Duplicate ID'),
|
||||
dateTimeRelative: z.number().optional().describe('Relative time offset in seconds'),
|
||||
dateTimeRelative: z.int().optional().describe('Relative time offset in seconds'),
|
||||
timeZone: z.string().optional().describe('Time zone (IANA timezone)'),
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import z from 'zod';
|
||||
const DatabaseBackupSchema = z
|
||||
.object({
|
||||
filename: z.string().describe('Backup filename'),
|
||||
filesize: z.number().describe('Backup file size'),
|
||||
filesize: z.int().describe('Backup file size'),
|
||||
timezone: z.string().describe('Backup timezone'),
|
||||
})
|
||||
.meta({ id: 'DatabaseBackupDto' });
|
||||
|
||||
@@ -21,10 +21,10 @@ const MirrorAxisSchema = z.enum(['horizontal', 'vertical']).describe('Axis to mi
|
||||
|
||||
const CropParametersSchema = z
|
||||
.object({
|
||||
x: z.number().min(0).describe('Top-Left X coordinate of crop'),
|
||||
y: z.number().min(0).describe('Top-Left Y coordinate of crop'),
|
||||
width: z.number().min(1).describe('Width of the crop'),
|
||||
height: z.number().min(1).describe('Height of the crop'),
|
||||
x: z.int().min(0).describe('Top-Left X coordinate of crop'),
|
||||
y: z.int().min(0).describe('Top-Left Y coordinate of crop'),
|
||||
width: z.int().min(1).describe('Width of the crop'),
|
||||
height: z.int().min(1).describe('Height of the crop'),
|
||||
})
|
||||
.meta({ id: 'CropParameters' });
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ export const ExifResponseSchema = z
|
||||
.object({
|
||||
make: z.string().nullish().default(null).describe('Camera make'),
|
||||
model: z.string().nullish().default(null).describe('Camera model'),
|
||||
exifImageWidth: z.number().min(0).nullish().default(null).describe('Image width in pixels'),
|
||||
exifImageHeight: z.number().min(0).nullish().default(null).describe('Image height in pixels'),
|
||||
exifImageWidth: z.int().min(0).nullish().default(null).describe('Image width in pixels'),
|
||||
exifImageHeight: z.int().min(0).nullish().default(null).describe('Image height in pixels'),
|
||||
fileSizeInByte: z.int().min(0).nullish().default(null).describe('File size in bytes'),
|
||||
orientation: z.string().nullish().default(null).describe('Image orientation'),
|
||||
// TODO: use `isoDatetimeToDate` when using `ZodSerializerDto` on the controllers.
|
||||
@@ -20,7 +20,7 @@ export const ExifResponseSchema = z
|
||||
lensModel: z.string().nullish().default(null).describe('Lens model'),
|
||||
fNumber: z.number().nullish().default(null).describe('F-number (aperture)'),
|
||||
focalLength: z.number().nullish().default(null).describe('Focal length in mm'),
|
||||
iso: z.number().nullish().default(null).describe('ISO sensitivity'),
|
||||
iso: z.int().nullish().default(null).describe('ISO sensitivity'),
|
||||
exposureTime: z.string().nullish().default(null).describe('Exposure time'),
|
||||
latitude: z.number().nullish().default(null).describe('GPS latitude'),
|
||||
longitude: z.number().nullish().default(null).describe('GPS longitude'),
|
||||
@@ -29,7 +29,7 @@ export const ExifResponseSchema = z
|
||||
country: z.string().nullish().default(null).describe('Country name'),
|
||||
description: z.string().nullish().default(null).describe('Image description'),
|
||||
projectionType: z.string().nullish().default(null).describe('Projection type'),
|
||||
rating: z.number().nullish().default(null).describe('Rating'),
|
||||
rating: z.int().nullish().default(null).describe('Rating'),
|
||||
})
|
||||
.describe('EXIF response')
|
||||
.meta({ id: 'ExifResponseDto' });
|
||||
|
||||
@@ -29,7 +29,7 @@ const MaintenanceStatusResponseSchema = z
|
||||
.object({
|
||||
active: z.boolean(),
|
||||
action: MaintenanceActionSchema,
|
||||
progress: z.number().optional(),
|
||||
progress: z.int().optional(),
|
||||
task: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
@@ -40,7 +40,7 @@ const MaintenanceDetectInstallStorageFolderSchema = z
|
||||
folder: StorageFolderSchema,
|
||||
readable: z.boolean().describe('Whether the folder is readable'),
|
||||
writable: z.boolean().describe('Whether the folder is writable'),
|
||||
files: z.number().describe('Number of files in the folder'),
|
||||
files: z.int().describe('Number of files in the folder'),
|
||||
})
|
||||
.meta({ id: 'MaintenanceDetectInstallStorageFolderDto' });
|
||||
|
||||
|
||||
@@ -51,8 +51,8 @@ const PersonSearchSchema = z
|
||||
withHidden: stringToBool.optional().describe('Include hidden people'),
|
||||
closestPersonId: z.uuidv4().optional().describe('Closest person ID for similarity search'),
|
||||
closestAssetId: z.uuidv4().optional().describe('Closest asset ID for similarity search'),
|
||||
page: z.coerce.number().min(1).default(1).describe('Page number for pagination'),
|
||||
size: z.coerce.number().min(1).max(1000).default(500).describe('Number of items per page'),
|
||||
page: z.coerce.number().int().min(1).default(1).describe('Page number for pagination'),
|
||||
size: z.coerce.number().int().min(1).max(1000).default(500).describe('Number of items per page'),
|
||||
})
|
||||
.meta({ id: 'PersonSearchDto' });
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ const BaseSearchSchema = z.object({
|
||||
tagIds: z.array(z.uuidv4()).nullish().describe('Filter by tag IDs'),
|
||||
albumIds: z.array(z.uuidv4()).optional().describe('Filter by album IDs'),
|
||||
rating: z
|
||||
.number()
|
||||
.int()
|
||||
.min(-1)
|
||||
.max(5)
|
||||
.nullish()
|
||||
@@ -52,7 +52,7 @@ const BaseSearchSchema = z.object({
|
||||
const BaseSearchWithResultsSchema = BaseSearchSchema.extend({
|
||||
withDeleted: z.boolean().optional().describe('Include deleted assets'),
|
||||
withExif: z.boolean().optional().describe('Include EXIF data in response'),
|
||||
size: z.number().min(1).max(1000).optional().describe('Number of results to return'),
|
||||
size: z.int().min(1).max(1000).optional().describe('Number of results to return'),
|
||||
});
|
||||
|
||||
const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
@@ -62,7 +62,7 @@ const RandomSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
|
||||
const LargeAssetSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
minFileSize: z.coerce.number().int().min(0).optional().describe('Minimum file size in bytes'),
|
||||
size: z.coerce.number().min(1).max(1000).optional().describe('Number of results to return'),
|
||||
size: z.coerce.number().int().min(1).max(1000).optional().describe('Number of results to return'),
|
||||
}).meta({ id: 'LargeAssetSearchDto' });
|
||||
|
||||
const MetadataSearchSchema = RandomSearchSchema.extend({
|
||||
@@ -75,7 +75,7 @@ const MetadataSearchSchema = RandomSearchSchema.extend({
|
||||
thumbnailPath: z.string().optional().describe('Filter by thumbnail file path'),
|
||||
encodedVideoPath: z.string().optional().describe('Filter by encoded video file path'),
|
||||
order: AssetOrderSchema.default(AssetOrder.Desc).optional().describe('Sort order'),
|
||||
page: z.number().min(1).optional().describe('Page number'),
|
||||
page: z.int().min(1).optional().describe('Page number'),
|
||||
}).meta({ id: 'MetadataSearchDto' });
|
||||
|
||||
const StatisticsSearchSchema = BaseSearchSchema.extend({
|
||||
@@ -86,7 +86,7 @@ const SmartSearchSchema = BaseSearchWithResultsSchema.extend({
|
||||
query: z.string().trim().optional().describe('Natural language search query'),
|
||||
queryAssetId: z.uuidv4().optional().describe('Asset ID to use as search reference'),
|
||||
language: z.string().optional().describe('Search language code'),
|
||||
page: z.number().min(1).optional().describe('Page number'),
|
||||
page: z.int().min(1).optional().describe('Page number'),
|
||||
}).meta({ id: 'SmartSearchDto' });
|
||||
|
||||
const SearchPlacesSchema = z
|
||||
|
||||
@@ -4,7 +4,7 @@ import z from 'zod';
|
||||
|
||||
const SessionCreateSchema = z
|
||||
.object({
|
||||
duration: z.number().min(1).optional().describe('Session duration in seconds'),
|
||||
duration: z.int().min(1).optional().describe('Session duration in seconds'),
|
||||
deviceType: z.string().optional().describe('Device type'),
|
||||
deviceOS: z.string().optional().describe('Device OS'),
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ const DatabaseBackupSchema = z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled'),
|
||||
cronExpression: cronExpressionSchema,
|
||||
keepLastAmount: z.number().min(1).describe('Keep last amount'),
|
||||
keepLastAmount: z.int().min(1).describe('Keep last amount'),
|
||||
})
|
||||
.meta({ id: 'DatabaseBackupConfig' });
|
||||
|
||||
@@ -130,8 +130,8 @@ const SystemConfigLoggingSchema = z
|
||||
const MachineLearningAvailabilityChecksSchema = z
|
||||
.object({
|
||||
enabled: configBool.describe('Enabled'),
|
||||
timeout: z.number(),
|
||||
interval: z.number(),
|
||||
timeout: z.int(),
|
||||
interval: z.int(),
|
||||
})
|
||||
.meta({ id: 'MachineLearningAvailabilityChecksDto' });
|
||||
|
||||
@@ -180,7 +180,7 @@ const SystemConfigOAuthSchema = z
|
||||
tokenEndpointAuthMethod: OAuthTokenEndpointAuthMethodSchema,
|
||||
timeout: z.int().min(1).describe('Timeout'),
|
||||
allowInsecureRequests: configBool.describe('Allow insecure requests'),
|
||||
defaultStorageQuota: z.number().min(0).nullable().describe('Default storage quota'),
|
||||
defaultStorageQuota: z.int().min(0).nullable().describe('Default storage quota'),
|
||||
enabled: configBool.describe('Enabled'),
|
||||
issuerUrl: z
|
||||
.string()
|
||||
@@ -254,7 +254,7 @@ const SystemConfigSmtpTransportSchema = z
|
||||
.object({
|
||||
ignoreCert: configBool.describe('Whether to ignore SSL certificate errors'),
|
||||
host: z.string().describe('SMTP server hostname'),
|
||||
port: z.number().min(0).max(65_535).describe('SMTP server port'),
|
||||
port: z.int().min(0).max(65_535).describe('SMTP server port'),
|
||||
secure: configBool.describe('Whether to use secure connection (TLS/SSL)'),
|
||||
username: z.string().describe('SMTP username'),
|
||||
password: z.string().describe('SMTP password'),
|
||||
|
||||
@@ -27,6 +27,8 @@ const TimeBucketQueryBaseSchema = z
|
||||
'Filter by asset visibility status (ARCHIVE, TIMELINE, HIDDEN, LOCKED)',
|
||||
),
|
||||
withCoordinates: stringToBool.optional().describe('Include location data in the response'),
|
||||
// TODO: Remove this after #12614 is resolved
|
||||
withSharedAlbums: z.boolean().optional().describe('Include assets from shared albums (do not use!)'),
|
||||
key: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
bbox: z
|
||||
|
||||
@@ -46,7 +46,7 @@ const WorkflowFilterResponseSchema = z
|
||||
workflowId: z.string().describe('Workflow ID'),
|
||||
pluginFilterId: z.string().describe('Plugin filter ID'),
|
||||
filterConfig: FilterConfigSchema.nullable(),
|
||||
order: z.number().describe('Filter order'),
|
||||
order: z.int().describe('Filter order'),
|
||||
})
|
||||
.meta({ id: 'WorkflowFilterResponseDto' });
|
||||
|
||||
@@ -56,7 +56,7 @@ const WorkflowActionResponseSchema = z
|
||||
workflowId: z.string().describe('Workflow ID'),
|
||||
pluginActionId: z.string().describe('Plugin action ID'),
|
||||
actionConfig: ActionConfigSchema.nullable(),
|
||||
order: z.number().describe('Action order'),
|
||||
order: z.int().describe('Action order'),
|
||||
})
|
||||
.meta({ id: 'WorkflowActionResponseDto' });
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ export enum ImmichHeader {
|
||||
SharedLinkKey = 'x-immich-share-key',
|
||||
SharedLinkSlug = 'x-immich-share-slug',
|
||||
Checksum = 'x-immich-checksum',
|
||||
Cid = 'x-immich-cid',
|
||||
}
|
||||
|
||||
export enum ImmichQuery {
|
||||
|
||||
@@ -20,14 +20,16 @@ export class GlobalExceptionFilter implements ExceptionFilter<Error> {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const { status, body } = this.fromError(error);
|
||||
if (!response.headersSent) {
|
||||
response.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
|
||||
response.header('X-Correlation-ID', this.cls.getId());
|
||||
response.status(status).json({ ...body, statusCode: status });
|
||||
}
|
||||
}
|
||||
|
||||
handleError(res: Response, error: Error) {
|
||||
const { status, body } = this.fromError(error);
|
||||
if (!res.headersSent) {
|
||||
res.status(status).json({ ...body, statusCode: status, correlationId: this.cls.getId() });
|
||||
res.header('X-Correlation-ID', this.cls.getId());
|
||||
res.status(status).json({ ...body, statusCode: status });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ interface AssetBuilderOptions {
|
||||
assetType?: AssetType;
|
||||
visibility?: AssetVisibility;
|
||||
withCoordinates?: boolean;
|
||||
withSharedAlbums?: boolean;
|
||||
bbox?: BoundingBox;
|
||||
}
|
||||
|
||||
@@ -673,7 +674,41 @@ export class AssetRepository {
|
||||
)
|
||||
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
|
||||
)
|
||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(!!options.userIds, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||
// TODO: Rework, this is inefficient, temporary solution until #12614 is resolved
|
||||
...(options.withSharedAlbums
|
||||
? [
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_asset')
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('album.ownerId', '=', anyUuid(options.userIds!)),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', anyUuid(options.userIds!)),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('shared_link')
|
||||
.whereRef('shared_link.albumId', '=', 'album.id')
|
||||
.where('shared_link.userId', '=', anyUuid(options.userIds!)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
|
||||
.$if(options.isDuplicate !== undefined, (qb) =>
|
||||
@@ -757,7 +792,40 @@ export class AssetRepository {
|
||||
),
|
||||
)
|
||||
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
|
||||
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
|
||||
.$if(!!options.userIds, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb('asset.ownerId', '=', anyUuid(options.userIds!)),
|
||||
...(options.withSharedAlbums
|
||||
? [
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_asset')
|
||||
.innerJoin('album', 'album.id', 'album_asset.albumId')
|
||||
.whereRef('album_asset.assetId', '=', 'asset.id')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('album.ownerId', '=', anyUuid(options.userIds!)),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('album_user')
|
||||
.whereRef('album_user.albumId', '=', 'album.id')
|
||||
.where('album_user.userId', '=', anyUuid(options.userIds!)),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom('shared_link')
|
||||
.whereRef('shared_link.albumId', '=', 'album.id')
|
||||
.where('shared_link.userId', '=', anyUuid(options.userIds!)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
|
||||
.$if(!!options.withStacked, (qb) =>
|
||||
qb
|
||||
|
||||
@@ -15,7 +15,6 @@ import { EnvSchema } from 'src/dtos/env.dto';
|
||||
import {
|
||||
DatabaseExtension,
|
||||
ImmichEnvironment,
|
||||
ImmichHeader,
|
||||
ImmichTelemetry,
|
||||
ImmichWorker,
|
||||
LogFormat,
|
||||
@@ -301,11 +300,11 @@ const getEnv = (): EnvData => {
|
||||
mount: true,
|
||||
generateId: true,
|
||||
setup: (cls, req: Request, res: Response) => {
|
||||
const headerValues = req.headers[ImmichHeader.Cid];
|
||||
const headerValues = req.headers['x-correlation-id'];
|
||||
const headerValue = Array.isArray(headerValues) ? headerValues[0] : headerValues;
|
||||
const cid = headerValue || cls.get(CLS_ID);
|
||||
cls.set(CLS_ID, cid);
|
||||
res.header(ImmichHeader.Cid, cid);
|
||||
res.header('X-Correlation-ID', cid);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -196,6 +196,7 @@ describe(AlbumService.name, () => {
|
||||
expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {});
|
||||
expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id);
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([assetId]), false);
|
||||
expect(mocks.event.emit).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', {
|
||||
id: album.id,
|
||||
userId: albumUser.userId,
|
||||
|
||||
@@ -114,7 +114,6 @@ export class AlbumService extends BaseService {
|
||||
throw new BadRequestException('Cannot share album with owner');
|
||||
}
|
||||
}
|
||||
albumUsers.unshift({ userId: auth.user.id, role: AlbumUserRole.Owner });
|
||||
|
||||
const allowedAssetIdsSet = await this.checkAccess({
|
||||
auth,
|
||||
@@ -133,7 +132,7 @@ export class AlbumService extends BaseService {
|
||||
order: getPreferences(userMetadata).albums.defaultAssetOrder,
|
||||
},
|
||||
assetIds,
|
||||
albumUsers,
|
||||
[{ userId: auth.user.id, role: AlbumUserRole.Owner }, ...albumUsers],
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export class TimelineService extends BaseService {
|
||||
const partnerIds = await getMyPartnerIds({
|
||||
userId: auth.user.id,
|
||||
repository: this.partnerRepository,
|
||||
timelineEnabled: true,
|
||||
timelineEnabled: dto.bbox === undefined, // ignore this option in map view
|
||||
});
|
||||
userIds.push(...partnerIds);
|
||||
}
|
||||
|
||||
@@ -5,43 +5,36 @@ export const errorDto = {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Authentication required',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
forbidden: {
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: expect.any(String),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
error: 'Forbidden',
|
||||
statusCode: 403,
|
||||
message: `Missing required permission: ${permission}`,
|
||||
correlationId: expect.any(String),
|
||||
}),
|
||||
wrongPassword: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'Wrong password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidToken: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid user token',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidShareKey: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid share key',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
invalidSharePassword: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Invalid password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
badRequest: (message: any = null) => ({
|
||||
error: 'Bad Request',
|
||||
@@ -52,18 +45,15 @@ export const errorDto = {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
statusCode: 401,
|
||||
message: 'Incorrect email or password',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
alreadyHasAdmin: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: 'The server already has an admin',
|
||||
correlationId: expect.any(String),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
const setSelectedDate = (value: DateTime | undefined) => {
|
||||
selectedPresetValue = null; // Clear preset when manually setting date
|
||||
expiresAt = value ? value.toISO() : null;
|
||||
expiresAt = value ? value.toUTC().toISO() : null;
|
||||
};
|
||||
|
||||
const selectPreset = (value: number) => {
|
||||
@@ -44,8 +44,8 @@
|
||||
expiresAt = null;
|
||||
return;
|
||||
}
|
||||
const newDate = DateTime.now().plus(value);
|
||||
expiresAt = newDate.toISO();
|
||||
const newDate = DateTime.now().plus({ milliseconds: value });
|
||||
expiresAt = newDate.toUTC().toISO();
|
||||
};
|
||||
|
||||
const isSelected = (value: number) => {
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { delay, getDimensions } from '$lib/utils/asset-utils';
|
||||
import { getByteUnitString } from '$lib/utils/byte-units';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
@@ -25,26 +24,15 @@
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
|
||||
import {
|
||||
mdiCamera,
|
||||
mdiCameraIris,
|
||||
mdiClose,
|
||||
mdiEye,
|
||||
mdiEyeOff,
|
||||
mdiImageOutline,
|
||||
mdiInformationOutline,
|
||||
mdiPencil,
|
||||
mdiPlus,
|
||||
} from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { slide } from 'svelte/transition';
|
||||
import ImageThumbnail from '../assets/thumbnail/ImageThumbnail.svelte';
|
||||
import PersonSidePanel from '../faces-page/PersonSidePanel.svelte';
|
||||
import OnEvents from '../OnEvents.svelte';
|
||||
import UserAvatar from '../shared-components/UserAvatar.svelte';
|
||||
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
|
||||
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
|
||||
|
||||
interface Props {
|
||||
asset: AssetResponseDto;
|
||||
@@ -54,9 +42,6 @@
|
||||
let { asset, currentAlbum = null }: Props = $props();
|
||||
|
||||
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
|
||||
let people = $derived(asset.people || []);
|
||||
let unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
let showingHiddenPeople = $state(false);
|
||||
let latlng = $derived(
|
||||
(() => {
|
||||
const lat = asset.exifInfo?.latitude;
|
||||
@@ -164,108 +149,7 @@
|
||||
|
||||
<DetailPanelDescription {asset} {isOwner} />
|
||||
<DetailPanelRating {asset} {isOwner} />
|
||||
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
aria-label={$t('show_hidden_people')}
|
||||
icon={showingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => (showingHiddenPeople = !showingHiddenPeople)}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
aria-label={$t('tag_people')}
|
||||
icon={mdiPlus}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
<IconButton
|
||||
aria-label={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.openEditFacesPanel()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
{#each people as person, index (person.id)}
|
||||
{#if showingHiddenPeople || !person.isHidden}
|
||||
{@const isHighlighted = people[index].faces.some((f) => $boundingBoxesArray.some((b) => b.id === f.id))}
|
||||
<a
|
||||
class="group w-22 outline-none"
|
||||
href={Route.viewPerson(person, { previousRoute })}
|
||||
onfocus={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onblur={() => ($boundingBoxesArray = [])}
|
||||
onmouseover={() => ($boundingBoxesArray = people[index].faces)}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
>
|
||||
<div class="relative">
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="90px"
|
||||
heightStyle="90px"
|
||||
hidden={person.isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class="group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-immich-primary dark:group-focus-visible:outline-immich-dark-primary"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
{#if person.birthDate}
|
||||
{@const personBirthDate = DateTime.fromISO(person.birthDate)}
|
||||
{@const age = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years)}
|
||||
{@const ageInMonths = Math.floor(
|
||||
DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months,
|
||||
)}
|
||||
{#if age >= 0}
|
||||
<p
|
||||
class="font-light"
|
||||
title={personBirthDate.toLocaleString(
|
||||
{
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
)}
|
||||
>
|
||||
{#if ageInMonths <= 11}
|
||||
{$t('age_months', { values: { months: ageInMonths } })}
|
||||
{:else if ageInMonths > 12 && ageInMonths <= 23}
|
||||
{$t('age_year_months', { values: { months: ageInMonths - 12 } })}
|
||||
{:else}
|
||||
{$t('age_years', { values: { years: age } })}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
|
||||
|
||||
<div class="px-4 py-4">
|
||||
{#if asset.exifInfo}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/ImageThumbnail.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { Route } from '$lib/route';
|
||||
import { locale } from '$lib/stores/preferences.store';
|
||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||
import { type AssetResponseDto } from '@immich/sdk';
|
||||
import { IconButton, Text } from '@immich/ui';
|
||||
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
|
||||
import { DateTime } from 'luxon';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
type Props = {
|
||||
asset: AssetResponseDto;
|
||||
isOwner: boolean;
|
||||
previousRoute: string;
|
||||
};
|
||||
|
||||
const { asset, isOwner, previousRoute }: Props = $props();
|
||||
|
||||
const unassignedFaces = $derived(asset.unassignedFaces || []);
|
||||
const people = $derived(asset.people || []);
|
||||
const visiblePeople = $derived(
|
||||
people
|
||||
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
|
||||
.map((person) => {
|
||||
if (!person.birthDate) {
|
||||
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
|
||||
}
|
||||
const personBirthDate = DateTime.fromISO(person.birthDate);
|
||||
const ageInYears = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'years').years);
|
||||
const ageInMonths = Math.floor(DateTime.fromISO(asset.localDateTime).diff(personBirthDate, 'months').months);
|
||||
|
||||
let formattedAge;
|
||||
if (ageInYears < 0) {
|
||||
return { formattedBirthDate: undefined, formattedAge: undefined, ...person };
|
||||
} else if (ageInMonths < 12) {
|
||||
formattedAge = $t('age_months', { values: { months: ageInMonths } });
|
||||
} else if (ageInMonths > 12 && ageInMonths < 24) {
|
||||
formattedAge = $t('age_year_months', { values: { months: ageInMonths - 12 } });
|
||||
} else {
|
||||
formattedAge = $t('age_years', { values: { years: ageInYears } });
|
||||
}
|
||||
|
||||
const formattedBirthDate = personBirthDate.toLocaleString(
|
||||
{
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
},
|
||||
{ locale: $locale },
|
||||
);
|
||||
return { formattedBirthDate, formattedAge, ...person };
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if !authManager.isSharedLink && isOwner}
|
||||
<section class="px-4 pt-4 text-sm">
|
||||
<div class="flex h-10 w-full items-center justify-between">
|
||||
<Text size="small" color="muted">{$t('people')}</Text>
|
||||
<div class="flex gap-2 items-center">
|
||||
{#if people.some((person) => person.isHidden)}
|
||||
<IconButton
|
||||
aria-label={$t('show_hidden_people')}
|
||||
icon={assetViewerManager.isShowingHiddenPeople ? mdiEyeOff : mdiEye}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleHiddenPeople()}
|
||||
/>
|
||||
{/if}
|
||||
<IconButton
|
||||
aria-label={$t('tag_people')}
|
||||
icon={mdiPlus}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.toggleFaceEditMode()}
|
||||
/>
|
||||
|
||||
{#if people.length > 0 || unassignedFaces.length > 0}
|
||||
<IconButton
|
||||
aria-label={$t('edit_people')}
|
||||
icon={mdiPencil}
|
||||
size="medium"
|
||||
shape="round"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onclick={() => assetViewerManager.openEditFacesPanel()}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 grid {visiblePeople.length <= 6 ? 'grid-cols-3 gap-3' : 'grid-cols-4 gap-2'}">
|
||||
{#each visiblePeople as person (person.id)}
|
||||
{@const isHighlighted = person.faces.some((f) =>
|
||||
assetViewerManager.highlightedFaces.some((b) => b.id === f.id),
|
||||
)}
|
||||
<a
|
||||
class="group outline-none"
|
||||
href={Route.viewPerson(person, { previousRoute })}
|
||||
onfocus={() => assetViewerManager.setHighlightedFaces(person.faces)}
|
||||
onblur={() => assetViewerManager.clearHighlightedFaces()}
|
||||
onpointerenter={() => assetViewerManager.setHighlightedFaces(person.faces)}
|
||||
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
||||
>
|
||||
<ImageThumbnail
|
||||
curve
|
||||
shadow
|
||||
url={getPeopleThumbnailUrl(person)}
|
||||
altText={person.name}
|
||||
title={person.name}
|
||||
widthStyle="100%"
|
||||
hidden={person.isHidden}
|
||||
highlighted={isHighlighted}
|
||||
class="group-focus-visible:outline-2 outline-offset-2 outline-immich-primary dark:outline-immich-dark-primary"
|
||||
/>
|
||||
<p class="mt-1 truncate font-medium" title={person.name}>{person.name}</p>
|
||||
{#if person.birthDate && person.formattedAge}
|
||||
<p class="font-light {visiblePeople.length > 6 ? 'text-xs' : ''}" title={person.formattedBirthDate!}>
|
||||
{person.formattedAge}
|
||||
</p>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { shortcuts } from '$lib/actions/shortcut';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { ocrManager, type OcrBoundingBox } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { alwaysLoadOriginalFile } from '$lib/stores/preferences.store';
|
||||
import { calculateBoundingBoxMatrix, getOcrBoundingBoxes, type Point } from '$lib/utils/ocr-utils';
|
||||
import {
|
||||
@@ -55,14 +54,9 @@
|
||||
let viewer: Viewer;
|
||||
|
||||
let animationInProgress: { cancel: () => void } | undefined;
|
||||
let previousFaces: Faces[] = [];
|
||||
|
||||
const boundingBoxesUnsubscribe = boundingBoxesArray.subscribe((faces: Faces[]) => {
|
||||
// Debounce; don't do anything when the data didn't actually change.
|
||||
if (faces === previousFaces) {
|
||||
return;
|
||||
}
|
||||
previousFaces = faces;
|
||||
$effect(() => {
|
||||
const faces: Faces[] = assetViewerManager.highlightedFaces;
|
||||
|
||||
if (animationInProgress) {
|
||||
animationInProgress.cancel();
|
||||
@@ -105,7 +99,7 @@
|
||||
textureX: x,
|
||||
textureY: y,
|
||||
zoom: Math.min(viewer.getZoomLevel(), 75),
|
||||
speed: 500, // duration in ms
|
||||
speed: 500,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -247,7 +241,8 @@
|
||||
if (viewer) {
|
||||
viewer.destroy();
|
||||
}
|
||||
boundingBoxesUnsubscribe();
|
||||
assetViewerManager.clearHighlightedFaces();
|
||||
assetViewerManager.hideHiddenPeople();
|
||||
assetViewerManager.zoom = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
import Thumbhash from '$lib/components/Thumbhash.svelte';
|
||||
import OcrBoundingBox from '$lib/components/asset-viewer/OcrBoundingBox.svelte';
|
||||
import AssetViewerEvents from '$lib/components/AssetViewerEvents.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { assetViewerManager, type Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { castManager } from '$lib/managers/cast-manager.svelte';
|
||||
import { ocrManager } from '$lib/stores/ocr.svelte';
|
||||
import { boundingBoxesArray, type Faces } from '$lib/stores/people.store';
|
||||
import { SlideshowLook, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
|
||||
@@ -50,12 +49,13 @@
|
||||
untrack(() => {
|
||||
assetViewerManager.resetZoomState();
|
||||
visibleImageReady = false;
|
||||
$boundingBoxesArray = [];
|
||||
assetViewerManager.clearHighlightedFaces();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
$boundingBoxesArray = [];
|
||||
assetViewerManager.clearHighlightedFaces();
|
||||
assetViewerManager.hideHiddenPeople();
|
||||
});
|
||||
|
||||
let containerWidth = $state(0);
|
||||
@@ -74,15 +74,13 @@
|
||||
return scaleToFit(getNaturalSize(assetViewerManager.imgRef), { width: containerWidth, height: containerHeight });
|
||||
});
|
||||
|
||||
const highlightedBoxes = $derived(getBoundingBox($boundingBoxesArray, overlaySize));
|
||||
const highlightedBoxes = $derived(getBoundingBox(assetViewerManager.highlightedFaces, overlaySize));
|
||||
const isHighlighting = $derived(highlightedBoxes.length > 0);
|
||||
|
||||
let visibleBoxes = $state<BoundingBox[]>([]);
|
||||
let visibleBoundingBoxes = $state<Faces[]>([]);
|
||||
$effect(() => {
|
||||
if (isHighlighting) {
|
||||
visibleBoxes = highlightedBoxes;
|
||||
visibleBoundingBoxes = $boundingBoxesArray;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,6 +158,9 @@
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
||||
const map = new Map<Faces, string>();
|
||||
for (const person of asset.people ?? []) {
|
||||
if (person.isHidden && !assetViewerManager.isShowingHiddenPeople) {
|
||||
continue;
|
||||
}
|
||||
for (const face of person.faces ?? []) {
|
||||
map.set(face, person.name);
|
||||
}
|
||||
@@ -169,35 +170,31 @@
|
||||
|
||||
const faces = $derived(Array.from(faceToNameMap.keys()));
|
||||
|
||||
const handleImageMouseMove = (event: MouseEvent) => {
|
||||
$boundingBoxesArray = [];
|
||||
if (!assetViewerManager.imgRef || !element || assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
|
||||
return;
|
||||
const boundingBoxes = $derived.by(() => {
|
||||
if (assetViewerManager.isFaceEditMode || ocrManager.showOverlay) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const natural = getNaturalSize(assetViewerManager.imgRef);
|
||||
const scaled = scaleToFit(natural, container);
|
||||
const { currentZoom, currentPositionX, currentPositionY } = assetViewerManager.zoomState;
|
||||
const knownBoxes = getBoundingBox(faces, overlaySize);
|
||||
const result = knownBoxes.map((box, index) => ({
|
||||
...box,
|
||||
face: faces[index],
|
||||
name: faceToNameMap.get(faces[index]),
|
||||
}));
|
||||
|
||||
const contentOffsetX = (container.width - scaled.width) / 2;
|
||||
const contentOffsetY = (container.height - scaled.height) / 2;
|
||||
|
||||
const containerRect = element.getBoundingClientRect();
|
||||
const mouseX = (event.clientX - containerRect.left - contentOffsetX * currentZoom - currentPositionX) / currentZoom;
|
||||
const mouseY = (event.clientY - containerRect.top - contentOffsetY * currentZoom - currentPositionY) / currentZoom;
|
||||
|
||||
const faceBoxes = getBoundingBox(faces, overlaySize);
|
||||
|
||||
for (const [index, box] of faceBoxes.entries()) {
|
||||
if (mouseX >= box.left && mouseX <= box.left + box.width && mouseY >= box.top && mouseY <= box.top + box.height) {
|
||||
$boundingBoxesArray.push(faces[index]);
|
||||
}
|
||||
if (assetViewerManager.highlightedFaces.length === 0) {
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageMouseLeave = () => {
|
||||
$boundingBoxesArray = [];
|
||||
};
|
||||
const knownIds = new Set(faces.map((f) => f.id));
|
||||
const unassignedFaces = assetViewerManager.highlightedFaces.filter((f) => !knownIds.has(f.id));
|
||||
const unassignedBoxes = getBoundingBox(unassignedFaces, overlaySize);
|
||||
for (let i = 0; i < unassignedBoxes.length; i++) {
|
||||
result.push({ ...unassignedBoxes[i], face: unassignedFaces[i], name: undefined });
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<AssetViewerEvents {onCopy} {onZoom} {onFaceEditModeChange} />
|
||||
@@ -218,8 +215,6 @@
|
||||
bind:clientHeight={containerHeight}
|
||||
role="presentation"
|
||||
ondblclick={onZoom}
|
||||
onmousemove={handleImageMouseMove}
|
||||
onmouseleave={handleImageMouseLeave}
|
||||
use:zoomImageAction={{ zoomTarget: adaptiveImage }}
|
||||
{...useSwipe((event) => onSwipe?.(event))}
|
||||
>
|
||||
@@ -261,22 +256,27 @@
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="rgba(0,0,0,0.4)" mask="url(#face-dim-mask)" />
|
||||
</svg>
|
||||
{#each visibleBoxes as boundingbox, index (boundingbox.id)}
|
||||
<div
|
||||
class="absolute border-solid border-white border-3 rounded-lg"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
></div>
|
||||
{#if faceToNameMap.get(visibleBoundingBoxes[index])}
|
||||
</div>
|
||||
{#each boundingBoxes as boundingbox (boundingbox.id)}
|
||||
{@const isActive = assetViewerManager.highlightedFaces.some((f) => f.id === boundingbox.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="absolute pointer-events-auto rounded-lg {isActive && 'border-solid border-white border-3'}"
|
||||
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"
|
||||
onpointerenter={() => assetViewerManager.setHighlightedFaces([boundingbox.face])}
|
||||
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
||||
>
|
||||
{#if isActive && boundingbox.name}
|
||||
<div
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap pointer-events-none shadow-lg"
|
||||
style="top: {boundingbox.top + boundingbox.height + 4}px; left: {boundingbox.left +
|
||||
boundingbox.width}px; transform: translateX(-100%);"
|
||||
aria-hidden="true"
|
||||
class="absolute bg-white/90 text-black px-2 py-1 rounded text-sm font-medium whitespace-nowrap shadow-lg"
|
||||
style="top: {boundingbox.height + 4}px; right: 0;"
|
||||
>
|
||||
{faceToNameMap.get(visibleBoundingBoxes[index])}
|
||||
{boundingbox.name}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each ocrBoxes as ocrBox (ocrBox.id)}
|
||||
<OcrBoundingBox {ocrBox} />
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||
import { boundingBoxesArray } from '$lib/stores/people.store';
|
||||
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { zoomImageToBase64 } from '$lib/utils/people-utils';
|
||||
@@ -239,15 +238,15 @@
|
||||
{:else}
|
||||
{#each peopleWithFaces as face, index (face.id)}
|
||||
{@const personName = face.person ? face.person?.name : $t('face_unassigned')}
|
||||
{@const isHighlighted = $boundingBoxesArray.some((b) => b.id === face.id)}
|
||||
{@const isHighlighted = assetViewerManager.highlightedFaces.some((b) => b.id === face.id)}
|
||||
<div class="relative h-29 w-24">
|
||||
<div
|
||||
role="button"
|
||||
tabindex={index}
|
||||
class="absolute start-0 top-0 h-22.5 w-22.5 cursor-default"
|
||||
onfocus={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onmouseover={() => ($boundingBoxesArray = [peopleWithFaces[index]])}
|
||||
onmouseleave={() => ($boundingBoxesArray = [])}
|
||||
onfocus={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
|
||||
onpointerenter={() => assetViewerManager.setHighlightedFaces([peopleWithFaces[index]])}
|
||||
onpointerleave={() => assetViewerManager.clearHighlightedFaces()}
|
||||
>
|
||||
<div class="relative">
|
||||
{#if selectedPersonToCreate[face.id]}
|
||||
|
||||
@@ -45,10 +45,7 @@
|
||||
|
||||
await deleteAssets(
|
||||
force,
|
||||
(assetIds) => {
|
||||
timelineManager.removeAssets(assetIds);
|
||||
eventManager.emit('AssetsDelete', assetIds);
|
||||
},
|
||||
(assetIds) => timelineManager.removeAssets(assetIds),
|
||||
selectedAssets,
|
||||
force ? undefined : (assets) => timelineManager.upsertAssets(assets),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,16 @@ import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||
import type { AssetGridRouteSearchParams } from '$lib/utils/navigation';
|
||||
import { PersistedLocalStorage } from '$lib/utils/persisted';
|
||||
|
||||
export interface Faces {
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
}
|
||||
|
||||
const isShowDetailPanel = new PersistedLocalStorage<boolean>('asset-viewer-state', false);
|
||||
const isShowAssetPath = new PersistedLocalStorage<boolean>('asset-viewer-show-path', false);
|
||||
|
||||
@@ -48,6 +58,8 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
#isEditFacesPanelOpen = $state(false);
|
||||
#viewingAssetStoreState = $state<AssetResponseDto>();
|
||||
#viewState = $state<boolean>(false);
|
||||
#highlightedFaces = $state<Faces[]>([]);
|
||||
#showingHiddenPeople = $state(false);
|
||||
gridScrollTarget = $state<AssetGridRouteSearchParams | null | undefined>();
|
||||
|
||||
get asset() {
|
||||
@@ -209,6 +221,31 @@ class AssetViewerManager extends BaseEventManager<Events> {
|
||||
this.closeFaceEditMode();
|
||||
this.closeEditFacesPanel();
|
||||
}
|
||||
|
||||
get highlightedFaces() {
|
||||
return this.#highlightedFaces;
|
||||
}
|
||||
|
||||
setHighlightedFaces(faces: Faces[]) {
|
||||
this.#highlightedFaces = faces;
|
||||
}
|
||||
|
||||
clearHighlightedFaces() {
|
||||
this.#highlightedFaces = [];
|
||||
}
|
||||
|
||||
get isShowingHiddenPeople() {
|
||||
return this.#showingHiddenPeople;
|
||||
}
|
||||
|
||||
toggleHiddenPeople() {
|
||||
this.#showingHiddenPeople = !this.#showingHiddenPeople;
|
||||
}
|
||||
|
||||
hideHiddenPeople() {
|
||||
this.#showingHiddenPeople = false;
|
||||
}
|
||||
|
||||
setAsset(asset: AssetResponseDto) {
|
||||
this.#viewingAssetStoreState = asset;
|
||||
this.#viewState = true;
|
||||
|
||||
@@ -33,6 +33,8 @@ class MemoryManager {
|
||||
if (authManager.authenticated) {
|
||||
void this.initialize();
|
||||
}
|
||||
|
||||
this.scheduleHourlyRefresh();
|
||||
}
|
||||
|
||||
ready() {
|
||||
@@ -132,6 +134,29 @@ class MemoryManager {
|
||||
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
|
||||
this.memories = memories.filter((memory) => memory.assets.length > 0);
|
||||
}
|
||||
|
||||
private scheduleHourlyRefresh() {
|
||||
const now = DateTime.utc();
|
||||
let nextEvent = now.set({ minute: 0, second: 5 });
|
||||
|
||||
if (nextEvent <= now) {
|
||||
nextEvent = nextEvent.plus({ hours: 1 });
|
||||
}
|
||||
|
||||
const initialDelay = nextEvent.diff(now).as('milliseconds');
|
||||
|
||||
setTimeout(() => {
|
||||
this.#loading = this.load();
|
||||
|
||||
// Schedule subsequent events hourly
|
||||
setInterval(
|
||||
() => {
|
||||
this.#loading = this.load();
|
||||
},
|
||||
60 * 60 * 1000,
|
||||
);
|
||||
}, initialDelay);
|
||||
}
|
||||
}
|
||||
|
||||
export const memoryManager = new MemoryManager();
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface Faces {
|
||||
id: string;
|
||||
imageHeight: number;
|
||||
imageWidth: number;
|
||||
boundingBoxX1: number;
|
||||
boundingBoxX2: number;
|
||||
boundingBoxY1: number;
|
||||
boundingBoxY2: number;
|
||||
}
|
||||
|
||||
export const boundingBoxesArray = writable<Faces[]>([]);
|
||||
@@ -80,6 +80,8 @@ websocket
|
||||
.on('on_new_release', (event) => eventManager.emit('ReleaseEvent', event))
|
||||
.on('on_session_delete', () => eventManager.emit('SessionDelete'))
|
||||
.on('on_user_delete', (id) => eventManager.emit('UserAdminDeleted', { id }))
|
||||
.on('on_asset_delete', (asset) => eventManager.emit('AssetsDelete', [asset]))
|
||||
.on('on_asset_trash', (assets) => eventManager.emit('AssetsDelete', assets))
|
||||
.on('on_asset_update', (asset) => eventManager.emit('AssetUpdate', asset))
|
||||
.on('on_person_thumbnail', (id) => eventManager.emit('PersonThumbnailReady', { id }))
|
||||
.on('on_notification', () => notificationManager.refresh())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import type { Size } from '$lib/utils/container-utils';
|
||||
import { getBoundingBox } from '$lib/utils/people-utils';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
|
||||
import type { Faces } from '$lib/stores/people.store';
|
||||
import type { Faces } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { getAssetMediaUrl } from '$lib/utils';
|
||||
import { mapNormalizedRectToContent, type Rect, type Size } from '$lib/utils/container-utils';
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@
|
||||
visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline,
|
||||
isFavorite: $mapSettings.onlyFavorites || undefined,
|
||||
withPartners: $mapSettings.withPartners || undefined,
|
||||
withSharedAlbums: $mapSettings.withSharedAlbums || undefined,
|
||||
assetFilter: selectedClusterIds,
|
||||
});
|
||||
|
||||
|
||||
+16
-7
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||
import UserPageLayout from '$lib/components/layouts/UserPageLayout.svelte';
|
||||
import OnEvents from '$lib/components/OnEvents.svelte';
|
||||
import LargeAssetData from './LargeAssetData.svelte';
|
||||
import Portal from '$lib/elements/Portal.svelte';
|
||||
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { getNextAsset, getPreviousAsset } from '$lib/utils/asset-utils';
|
||||
import { getNextAsset, getPreviousAsset, navigateToAsset } from '$lib/utils/asset-utils';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import type { AssetResponseDto } from '@immich/sdk';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -17,7 +18,7 @@
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let assets = $derived(data.assets);
|
||||
let assets = $state(data.assets);
|
||||
let asset = $derived(data.asset);
|
||||
|
||||
$effect(() => {
|
||||
@@ -36,13 +37,19 @@
|
||||
return asset;
|
||||
};
|
||||
|
||||
const onAction = (payload: Action) => {
|
||||
const preAction = async (payload: Action) => {
|
||||
if (payload.type == 'trash') {
|
||||
assets = assets.filter((a) => a.id != payload.asset.id);
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
(await navigateToAsset(assetCursor?.nextAsset)) ||
|
||||
(await navigateToAsset(assetCursor?.previousAsset)) ||
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAssetsDelete = (assetIds: string[]) => {
|
||||
assets = assets.filter(({ id }) => !assetIds.includes(id));
|
||||
};
|
||||
|
||||
const onViewAsset = async (asset: AssetResponseDto) => {
|
||||
await navigate({ targetRoute: 'current', assetId: asset.id });
|
||||
};
|
||||
@@ -54,9 +61,11 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<OnEvents {onAssetsDelete} />
|
||||
|
||||
<UserPageLayout title={data.meta.title} scrollbar={true}>
|
||||
<div class="grid gap-2 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
|
||||
{#if assets && data.assets.length > 0}
|
||||
{#if assets && assets.length > 0}
|
||||
{#each assets as asset (asset.id)}
|
||||
<LargeAssetData {asset} {onViewAsset} />
|
||||
{/each}
|
||||
@@ -75,7 +84,7 @@
|
||||
cursor={assetCursor}
|
||||
showNavigation={assets.length > 1}
|
||||
{onRandom}
|
||||
{onAction}
|
||||
{preAction}
|
||||
onClose={() => {
|
||||
assetViewerManager.showAssetViewer(false);
|
||||
handlePromiseError(navigate({ targetRoute: 'current', assetId: null }));
|
||||
|
||||
Reference in New Issue
Block a user