1
0
mirror of https://github.com/immich-app/immich.git synced 2025-03-11 15:09:45 +02:00

Merge remote-tracking branch 'origin/main' into fix-search-scrollbar

This commit is contained in:
Snowknight26 2025-03-09 23:22:27 -05:00
commit c94a47e18e
85 changed files with 3599 additions and 4529 deletions

View File

@ -41,8 +41,8 @@ jobs:
with:
token: ${{ steps.generate-token.outputs.token }}
- name: Install Poetry
run: pipx install poetry
- name: Install uv
uses: astral-sh/setup-uv@v5
- name: Bump version
run: misc/release/pump-version.sh -s "${{ inputs.serverBump }}" -m "${{ inputs.mobileBump }}"
@ -74,7 +74,7 @@ jobs:
with:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:

View File

@ -380,27 +380,28 @@ jobs:
working-directory: ./machine-learning
steps:
- uses: actions/checkout@v4
- name: Install poetry
run: pipx install poetry
- name: Install uv
uses: astral-sh/setup-uv@v5
- uses: actions/setup-python@v5
with:
python-version: 3.11
cache: 'poetry'
# TODO: add caching when supported (https://github.com/actions/setup-python/pull/818)
# with:
# python-version: 3.11
# cache: 'uv'
- name: Install dependencies
run: |
poetry install --with dev --with cpu
uv sync --extra cpu
- name: Lint with ruff
run: |
poetry run ruff check --output-format=github app export
uv run ruff check --output-format=github app export
- name: Check black formatting
run: |
poetry run black --check app export
uv run black --check app export
- name: Run mypy type checking
run: |
poetry run mypy --install-types --non-interactive --strict app/
uv run mypy --strict app/
- name: Run tests and coverage
run: |
poetry run pytest app --cov=app --cov-report term-missing
uv run pytest app --cov=app --cov-report term-missing
shellcheck:
name: ShellCheck

View File

@ -25,7 +25,7 @@ services:
context: ../
dockerfile: server/Dockerfile
target: dev
restart: always
restart: unless-stopped
volumes:
- ../server:/usr/src/app
- ../open-api:/usr/src/open-api

View File

@ -53,7 +53,7 @@ docker compose create # Create Docker containers for Immich apps witho
docker start immich_postgres # Start Postgres server
sleep 10 # Wait for Postgres server to start up
# Check the database user if you deviated from the default
gunzip < "/path/to/backup/dump.sql.gz" \
gunzip --stdout "/path/to/backup/dump.sql.gz" \
| sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" \
| docker exec -i immich_postgres psql --dbname=postgres --username=<DB_USERNAME> # Restore Backup
docker compose up -d # Start remainder of Immich apps
@ -76,8 +76,8 @@ docker compose create # Create Docker containers for
docker start immich_postgres # Start Postgres server
sleep 10 # Wait for Postgres server to start up
docker exec -it immich_postgres bash # Enter the Docker shell and run the following command
# Check the database user if you deviated from the default. If your backup ends in `.gz`, replace `cat` with `gunzip`
cat < "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=postgres --username=<DB_USERNAME>
# Check the database user if you deviated from the default. If your backup ends in `.gz`, replace `cat` with `gunzip --stdout`
cat "/dump.sql" | sed "s/SELECT pg_catalog.set_config('search_path', '', false);/SELECT pg_catalog.set_config('search_path', 'public, pg_catalog', true);/g" | psql --dbname=postgres --username=<DB_USERNAME>
exit # Exit the Docker shell
docker compose up -d # Start remainder of Immich apps
```

View File

@ -201,7 +201,7 @@ describe('/people', () => {
expect(body).toMatchObject({
id: expect.any(String),
name: 'New Person',
birthDate: '1990-01-01T00:00:00.000Z',
birthDate: '1990-01-01',
});
});
@ -262,7 +262,7 @@ describe('/people', () => {
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ birthDate: '1990-01-01' });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01T00:00:00.000Z' });
expect(body).toMatchObject({ birthDate: '1990-01-01' });
});
it('should clear a date of birth', async () => {

View File

@ -19,20 +19,16 @@ FROM builder-${DEVICE} AS builder
ARG DEVICE
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=true \
VIRTUAL_ENV="/opt/venv" \
PATH="/opt/venv/bin:${PATH}"
PYTHONUNBUFFERED=1
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y --no-install-recommends g++
RUN pip install --upgrade pip && pip install poetry
RUN poetry config installer.max-workers 10 && \
poetry config virtualenvs.create false
RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --frozen --extra ${DEVICE} --no-dev --no-editable --no-install-project --compile-bytecode --no-progress
FROM python:3.11-slim-bookworm@sha256:614c8691ab74150465ec9123378cd4dde7a6e57be9e558c3108df40664667a4c AS prod-cpu
@ -93,7 +89,7 @@ WORKDIR /usr/src/app
ENV TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
PATH="/usr/src/app/.venv/bin:$PATH" \
PYTHONPATH=/usr/src \
DEVICE=${DEVICE}
@ -102,7 +98,7 @@ RUN echo "hard core 0" >> /etc/security/limits.conf && \
echo "fs.suid_dumpable 0" >> /etc/sysctl.conf && \
echo 'ulimit -S -c 0 > /dev/null 2>&1' >> /etc/profile
COPY --from=builder /opt/venv /opt/venv
COPY --from=builder /usr/src/app/.venv /usr/src/app/.venv
COPY ann/ann.py /usr/src/ann/ann.py
COPY start.sh log_conf.json gunicorn_conf.py ./
COPY app .

View File

@ -5,13 +5,12 @@
# Setup
This project uses [Poetry](https://python-poetry.org/docs/#installation), so be sure to install it first.
Running `poetry install --no-root --with dev --with cpu` will install everything you need in an isolated virtual environment.
CUDA and OpenVINO are supported as acceleration APIs. To use them, you can replace `--with cpu` with either of `--with cuda` or `--with openvino`. In the case of CUDA, a [compute capability](https://developer.nvidia.com/cuda-gpus) of 5.2 or higher is required.
To add or remove dependencies, you can use the commands `poetry add $PACKAGE_NAME` and `poetry remove $PACKAGE_NAME`, respectively.
Be sure to commit the `poetry.lock` and `pyproject.toml` files with `poetry lock --no-update` to reflect any changes in dependencies.
This project uses [uv](https://docs.astral.sh/uv/getting-started/installation/), so be sure to install it first.
Running `uv sync --extra cpu` will install everything you need in an isolated virtual environment.
CUDA and OpenVINO are supported as acceleration APIs. To use them, you can replace `--group cpu` with either of `--group cuda` or `--group openvino`. In the case of CUDA, a [compute capability](https://developer.nvidia.com/cuda-gpus) of 5.2 or higher is required.
To add or remove dependencies, you can use the commands `uv add $PACKAGE_NAME` and `uv remove $PACKAGE_NAME`, respectively.
Be sure to commit the `uv.lock` and `pyproject.toml` files with `uv lock` to reflect any changes in dependencies.
# Load Testing
@ -19,22 +18,25 @@ To measure inference throughput and latency, you can use [Locust](https://locust
Locust works by querying the model endpoints and aggregating their statistics, meaning the app must be deployed.
You can change the models or adjust options like score thresholds through the Locust UI.
To get started, you can simply run `locust --web-host 127.0.0.1` and open `localhost:8089` in a browser to access the UI. See the [Locust documentation](https://docs.locust.io/en/stable/index.html) for more info on running Locust.
To get started, you can simply run `locust --web-host 127.0.0.1` and open `localhost:8089` in a browser to access the UI. See the [Locust documentation](https://docs.locust.io/en/stable/index.html) for more info on running Locust.
Note that in Locust's jargon, concurrency is measured in `users`, and each user runs one task at a time. To achieve a particular per-endpoint concurrency, multiply that number by the number of endpoints to be queried. For example, if there are 3 endpoints and you want each of them to receive 8 requests at a time, you should set the number of users to 24.
# Facial Recognition
## Acknowledgements
This project utilizes facial recognition models from the [InsightFace](https://github.com/deepinsight/insightface/tree/master/model_zoo) project. We appreciate the work put into developing these models, which have been beneficial to the machine learning part of this project.
### Used Models
* antelopev2
* buffalo_l
* buffalo_m
* buffalo_s
- antelopev2
- buffalo_l
- buffalo_m
- buffalo_s
## License and Use Restrictions
We have received permission to use the InsightFace facial recognition models in our project, as granted via email by Jia Guo (guojia@insightface.ai) on 18th March 2023. However, it's important to note that this permission does not extend to the redistribution or commercial use of their models by third parties. Users and developers interested in using these models should review the licensing terms provided in the InsightFace GitHub repository.
For more information on the capabilities of the InsightFace models and to ensure compliance with their license, please refer to their [official repository](https://github.com/deepinsight/insightface). Adhering to the specified licensing terms is crucial for the respectful and lawful use of their work.
For more information on the capabilities of the InsightFace models and to ensure compliance with their license, please refer to their [official repository](https://github.com/deepinsight/insightface). Adhering to the specified licensing terms is crucial for the respectful and lawful use of their work.

File diff suppressed because it is too large Load Diff

View File

@ -1,72 +1,77 @@
[tool.poetry]
[project]
name = "machine-learning"
version = "1.129.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.10,<4.0"
readme = "README.md"
packages = [{include = "app"}]
dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"ftfy>=6.1.1",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=9.5.0,<11.0",
"pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0",
"rich>=13.4.2",
"tokenizers>=0.15.0,<1.0",
"uvicorn[standard]>=0.22.0,<1.0",
]
[tool.poetry.dependencies]
python = ">=3.10,<4.0"
insightface = ">=0.7.3,<1.0"
opencv-python-headless = ">=4.7.0.72,<5.0"
pillow = ">=9.5.0,<11.0"
fastapi = ">=0.95.2,<1.0"
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
pydantic = "^2.0.0"
pydantic-settings = "^2.5.2"
aiocache = ">=0.12.1,<1.0"
rich = ">=13.4.2"
ftfy = ">=6.1.1"
python-multipart = ">=0.0.6,<1.0"
orjson = ">=3.9.5"
gunicorn = ">=21.1.0"
huggingface-hub = ">=0.20.1,<1.0"
tokenizers = ">=0.15.0,<1.0"
[dependency-groups]
test = [
"httpx>=0.24.1",
"pytest>=7.3.1",
"pytest-asyncio>=0.21.0",
"pytest-cov>=4.1.0",
"pytest-mock>=3.11.1",
]
types = [
"types-pyyaml>=6.0.12.20241230",
"types-requests>=2.32.0.20250306",
"types-setuptools>=75.8.2.20250305",
"types-simplejson>=3.20.0.20250218",
"types-ujson>=5.10.0.20240515",
]
lint = [
"black>=23.3.0",
"mypy>=1.3.0",
"ruff>=0.0.272",
{ include-group = "types" },
]
dev = ["locust>=2.15.1", { include-group = "test" }, { include-group = "lint" }]
[tool.poetry.group.dev.dependencies]
mypy = ">=1.3.0"
black = ">=23.3.0"
pytest = ">=7.3.1"
locust = ">=2.15.1"
httpx = ">=0.24.1"
pytest-asyncio = ">=0.21.0"
pytest-cov = ">=4.1.0"
ruff = ">=0.0.272"
pytest-mock = ">=3.11.1"
[project.optional-dependencies]
cpu = ["onnxruntime>=1.15.0,<2"]
cuda = ["onnxruntime-gpu>=1.17.0,<2"]
openvino = ["onnxruntime-openvino>=1.17.1,<1.19.0"]
armnn = ["onnxruntime>=1.15.0,<2"]
[tool.poetry.group.cpu]
optional = true
[tool.uv]
compile-bytecode = true
[tool.poetry.group.cpu.dependencies]
onnxruntime = "^1.15.0"
[tool.poetry.group.cuda]
optional = true
[tool.poetry.group.cuda.dependencies]
onnxruntime-gpu = {version = "^1.17.0", source = "cuda12"}
[tool.poetry.group.openvino]
optional = true
[tool.poetry.group.openvino.dependencies]
onnxruntime-openvino = ">=1.17.1,<1.19.0"
[tool.poetry.group.armnn]
optional = true
[tool.poetry.group.armnn.dependencies]
onnxruntime = "^1.15.0"
[[tool.poetry.source]]
[[tool.uv.index]]
name = "cuda12"
url = "https://aiinfra.pkgs.visualstudio.com/PublicPackages/_packaging/onnxruntime-cuda-12/pypi/simple/"
priority = "explicit"
explicit = true
[tool.uv.sources]
onnxruntime-gpu = { index = "cuda12" }
[tool.hatch.build.targets.sdist]
include = ["app"]
[tool.hatch.build.targets.wheel]
include = ["app"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.mypy]
python_version = "3.11"

2648
machine-learning/uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -73,7 +73,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
npm --prefix web i --package-lock-only
npm --prefix e2e version "$SERVER_PUMP"
npm --prefix e2e i --package-lock-only
poetry --directory machine-learning version "$SERVER_PUMP"
uvx --from=toml-cli toml set --toml-path=pyproject.toml project.version "$SERVER_PUMP"
fi
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then

View File

@ -93,9 +93,9 @@
<activity
android:name="com.linusu.flutter_web_auth.CallbackActivity"
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true">
<intent-filter android:label="flutter_web_auth">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

View File

@ -264,6 +264,7 @@
"exif_bottom_sheet_location_add": "Add a location",
"exif_bottom_sheet_people": "PEOPLE",
"exif_bottom_sheet_person_add_person": "Add name",
"exif_bottom_sheet_person_age": "Age {}",
"experimental_settings_new_asset_list_subtitle": "Work in progress",
"experimental_settings_new_asset_list_title": "Enable experimental photo grid",
"experimental_settings_subtitle": "Use at your own risk!",

View File

@ -48,7 +48,7 @@ PODS:
- flutter_udid (0.0.1):
- Flutter
- SAMKeychain
- flutter_web_auth (0.6.0):
- flutter_web_auth_2 (3.0.0):
- Flutter
- fluttertoast (0.0.2):
- Flutter
@ -117,7 +117,7 @@ DEPENDENCIES:
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
- flutter_web_auth_2 (from `.symlinks/plugins/flutter_web_auth_2/ios`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
@ -166,8 +166,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_udid:
:path: ".symlinks/plugins/flutter_udid/ios"
flutter_web_auth:
:path: ".symlinks/plugins/flutter_web_auth/ios"
flutter_web_auth_2:
:path: ".symlinks/plugins/flutter_web_auth_2/ios"
fluttertoast:
:path: ".symlinks/plugins/fluttertoast/ios"
geolocator_apple:
@ -220,7 +220,7 @@ SPEC CHECKSUMS:
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
flutter_web_auth: acc15a8fd7bba796a933c724a6dffc3d00f07c27
flutter_web_auth_2: 06d500582775790a0d4c323222fcb6d7990f9603
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
geolocator_apple: 6cbaf322953988e009e5ecb481f07efece75c450
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1

View File

@ -8,20 +8,26 @@ class SearchCuratedContent {
/// The label to show associated with this curated object
final String label;
/// The subtitle to show below the label
final String? subtitle;
/// The id to lookup the asset from the server
final String id;
SearchCuratedContent({
required this.label,
required this.id,
this.subtitle,
});
SearchCuratedContent copyWith({
String? label,
String? subtitle,
String? id,
}) {
return SearchCuratedContent(
label: label ?? this.label,
subtitle: subtitle ?? this.subtitle,
id: id ?? this.id,
);
}
@ -29,6 +35,7 @@ class SearchCuratedContent {
Map<String, dynamic> toMap() {
return <String, dynamic>{
'label': label,
'subtitle': subtitle,
'id': id,
};
}
@ -36,6 +43,7 @@ class SearchCuratedContent {
factory SearchCuratedContent.fromMap(Map<String, dynamic> map) {
return SearchCuratedContent(
label: map['label'] as String,
subtitle: map['subtitle'] as String?,
id: map['id'] as String,
);
}
@ -46,13 +54,14 @@ class SearchCuratedContent {
SearchCuratedContent.fromMap(json.decode(source) as Map<String, dynamic>);
@override
String toString() => 'CuratedContent(label: $label, id: $id)';
String toString() =>
'CuratedContent(label: $label, subtitle: $subtitle, id: $id)';
@override
bool operator ==(covariant SearchCuratedContent other) {
if (identical(this, other)) return true;
return other.label == label && other.id == id;
return other.label == label && other.subtitle == subtitle && other.id == id;
}
@override

View File

@ -187,6 +187,8 @@ class AlbumActivityProvider extends AutoDisposeAsyncNotifierProviderImpl<
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin AlbumActivityRef on AutoDisposeAsyncNotifierProviderRef<List<Activity>> {
/// The parameter `albumId` of this provider.
String get albumId;
@ -206,4 +208,4 @@ class _AlbumActivityProviderElement
String? get assetId => (origin as AlbumActivityProvider).assetId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/repositories/activity_api.repository.dart';
import 'package:immich_mobile/services/activity.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -5,5 +6,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'activity_service.provider.g.dart';
@riverpod
ActivityService activityService(ActivityServiceRef ref) =>
ActivityService activityService(Ref ref) =>
ActivityService(ref.watch(activityApiRepositoryProvider));

View File

@ -20,6 +20,8 @@ final activityServiceProvider = AutoDisposeProvider<ActivityService>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ActivityServiceRef = AutoDisposeProviderRef<ActivityService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -186,6 +186,8 @@ class ActivityStatisticsProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin ActivityStatisticsRef on AutoDisposeNotifierProviderRef<int> {
/// The parameter `albumId` of this provider.
String get albumId;
@ -205,4 +207,4 @@ class _ActivityStatisticsProviderElement
String? get assetId => (origin as ActivityStatisticsProvider).assetId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -40,4 +40,4 @@ final albumSortOrderProvider =
typedef _$AlbumSortOrder = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -22,4 +22,4 @@ final currentAlbumProvider =
typedef _$CurrentAlbum = AutoDisposeNotifier<Album?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,7 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'api.provider.g.dart';
@Riverpod(keepAlive: true)
ApiService apiService(ApiServiceRef ref) => ApiService();
ApiService apiService(Ref ref) => ApiService();

View File

@ -19,6 +19,8 @@ final apiServiceProvider = Provider<ApiService>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ApiServiceRef = ProviderRef<ApiService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,8 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'app_settings.provider.g.dart';
@Riverpod(keepAlive: true)
AppSettingsService appSettingsService(AppSettingsServiceRef ref) =>
AppSettingsService();
AppSettingsService appSettingsService(Ref ref) => AppSettingsService();

View File

@ -21,6 +21,8 @@ final appSettingsServiceProvider = Provider<AppSettingsService>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef AppSettingsServiceRef = ProviderRef<AppSettingsService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -31,7 +31,7 @@ class AssetNotifier extends StateNotifier<bool> {
final SyncService _syncService;
final ETagService _etagService;
final ExifService _exifService;
final StateNotifierProviderRef _ref;
final Ref _ref;
final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;

View File

@ -171,6 +171,8 @@ class AssetPeopleNotifierProvider extends AutoDisposeAsyncNotifierProviderImpl<
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin AssetPeopleNotifierRef
on AutoDisposeAsyncNotifierProviderRef<List<PersonWithFacesResponseDto>> {
/// The parameter `asset` of this provider.
@ -186,4 +188,4 @@ class _AssetPeopleNotifierProviderElement
Asset get asset => (origin as AssetPeopleNotifierProvider).asset;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -39,6 +39,6 @@ final assetStackStateProvider = StateNotifierProvider.autoDispose
);
@riverpod
int assetStackIndex(AssetStackIndexRef ref, Asset asset) {
int assetStackIndex(Ref ref, Asset asset) {
return -1;
}

View File

@ -142,6 +142,8 @@ class AssetStackIndexProvider extends AutoDisposeProvider<int> {
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin AssetStackIndexRef on AutoDisposeProviderRef<int> {
/// The parameter `asset` of this provider.
Asset get asset;
@ -155,4 +157,4 @@ class _AssetStackIndexProviderElement extends AutoDisposeProviderElement<int>
Asset get asset => (origin as AssetStackIndexProvider).asset;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -22,4 +22,4 @@ final currentAssetProvider =
typedef _$CurrentAsset = AutoDisposeNotifier<Asset?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -24,4 +24,4 @@ final backupVerificationProvider =
typedef _$BackupVerification = AutoDisposeNotifier<bool>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,12 +1,13 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'immich_logo_provider.g.dart';
@riverpod
Future<Uint8List> immichLogo(ImmichLogoRef ref) async {
Future<Uint8List> immichLogo(Ref ref) async {
final json = await rootBundle.loadString('assets/immich-logo.json');
final j = jsonDecode(json);
return base64Decode(j['content']);

View File

@ -19,6 +19,8 @@ final immichLogoProvider = AutoDisposeFutureProvider<Uint8List>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ImmichLogoRef = AutoDisposeFutureProviderRef<Uint8List>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,7 +1,8 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:isar/isar.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'db.provider.g.dart';
@Riverpod(keepAlive: true)
Isar isar(IsarRef ref) => throw UnimplementedError('isar');
Isar isar(Ref ref) => throw UnimplementedError('isar');

View File

@ -19,6 +19,8 @@ final isarProvider = Provider<Isar>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef IsarRef = ProviderRef<Isar>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/interfaces/exif.interface.dart';
import 'package:immich_mobile/infrastructure/repositories/exif.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@ -6,5 +7,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'exif.provider.g.dart';
@Riverpod(keepAlive: true)
IExifInfoRepository exifRepository(ExifRepositoryRef ref) =>
IExifInfoRepository exifRepository(Ref ref) =>
IsarExifRepository(ref.watch(isarProvider));

View File

@ -20,6 +20,8 @@ final exifRepositoryProvider = Provider<IExifInfoRepository>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef ExifRepositoryRef = ProviderRef<IExifInfoRepository>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
@ -6,5 +7,5 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'store.provider.g.dart';
@riverpod
IStoreRepository storeRepository(StoreRepositoryRef ref) =>
IStoreRepository storeRepository(Ref ref) =>
IsarStoreRepository(ref.watch(isarProvider));

View File

@ -20,6 +20,8 @@ final storeRepositoryProvider = AutoDisposeProvider<IStoreRepository>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef StoreRepositoryRef = AutoDisposeProviderRef<IStoreRepository>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/map/map_service.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
@ -6,7 +7,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'map_marker.provider.g.dart';
@riverpod
Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async {
Future<List<MapMarker>> mapMarkers(Ref ref) async {
final service = ref.read(mapServiceProvider);
final mapState = ref.read(mapStateNotifierProvider);
DateTime? fileCreatedAfter;

View File

@ -19,6 +19,8 @@ final mapMarkersProvider = AutoDisposeFutureProvider<List<MapMarker>>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef MapMarkersRef = AutoDisposeFutureProviderRef<List<MapMarker>>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/map.service.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -5,5 +6,4 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'map_service.provider.g.dart';
@riverpod
MapSerivce mapService(MapServiceRef ref) =>
MapSerivce(ref.watch(apiServiceProvider));
MapSerivce mapService(Ref ref) => MapSerivce(ref.watch(apiServiceProvider));

View File

@ -19,6 +19,8 @@ final mapServiceProvider = AutoDisposeProvider<MapSerivce>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef MapServiceRef = AutoDisposeProviderRef<MapSerivce>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -23,4 +23,4 @@ final mapStateNotifierProvider =
typedef _$MapStateNotifier = Notifier<MapState>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -45,7 +45,7 @@ class PaginatedSearchNotifier extends StateNotifier<SearchResult> {
@riverpod
Future<RenderList> paginatedSearchRenderList(
PaginatedSearchRenderListRef ref,
Ref ref,
) {
final result = ref.watch(paginatedSearchProvider);
final timelineService = ref.watch(timelineServiceProvider);

View File

@ -22,6 +22,8 @@ final paginatedSearchRenderListProvider =
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PaginatedSearchRenderListRef = AutoDisposeFutureProviderRef<RenderList>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/interfaces/person_api.interface.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/services/person.service.dart';
@ -9,7 +10,7 @@ part 'people.provider.g.dart';
@riverpod
Future<List<Person>> getAllPeople(
GetAllPeopleRef ref,
Ref ref,
) async {
final PersonService personService = ref.read(personServiceProvider);
@ -19,7 +20,7 @@ Future<List<Person>> getAllPeople(
}
@riverpod
Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async {
Future<RenderList> personAssets(Ref ref, String personId) async {
final PersonService personService = ref.read(personServiceProvider);
final assets = await personService.getPersonAssets(personId);
@ -31,7 +32,7 @@ Future<RenderList> personAssets(PersonAssetsRef ref, String personId) async {
@riverpod
Future<bool> updatePersonName(
UpdatePersonNameRef ref,
Ref ref,
String personId,
String updatedName,
) async {

View File

@ -19,6 +19,8 @@ final getAllPeopleProvider = AutoDisposeFutureProvider<List<Person>>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef GetAllPeopleRef = AutoDisposeFutureProviderRef<List<Person>>;
String _$personAssetsHash() => r'3dfecb67a54d07e4208bcb9581b2625acd2e1832';
@ -156,6 +158,8 @@ class PersonAssetsProvider extends AutoDisposeFutureProvider<RenderList> {
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin PersonAssetsRef on AutoDisposeFutureProviderRef<RenderList> {
/// The parameter `personId` of this provider.
String get personId;
@ -296,6 +300,8 @@ class UpdatePersonNameProvider extends AutoDisposeFutureProvider<bool> {
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin UpdatePersonNameRef on AutoDisposeFutureProviderRef<bool> {
/// The parameter `personId` of this provider.
String get personId;
@ -314,4 +320,4 @@ class _UpdatePersonNameProviderElement
String get updatedName => (origin as UpdatePersonNameProvider).updatedName;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/services/search.service.dart';
import 'package:openapi/api.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -6,7 +7,7 @@ part 'search_filter.provider.g.dart';
@riverpod
Future<List<String>> getSearchSuggestions(
GetSearchSuggestionsRef ref,
Ref ref,
SearchSuggestionType type, {
String? locationCountry,
String? locationState,

View File

@ -189,6 +189,8 @@ class GetSearchSuggestionsProvider
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin GetSearchSuggestionsRef on AutoDisposeFutureProviderRef<List<String>> {
/// The parameter `type` of this provider.
SearchSuggestionType get type;
@ -226,4 +228,4 @@ class _GetSearchSuggestionsProviderElement
String? get model => (origin as GetSearchSuggestionsProvider).model;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -788,6 +788,53 @@ class FilterImageRouteArgs {
}
}
/// generated route for
/// [FolderPage]
class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
FolderRoute({
Key? key,
RecursiveFolder? folder,
List<PageRouteInfo>? children,
}) : super(
FolderRoute.name,
args: FolderRouteArgs(
key: key,
folder: folder,
),
initialChildren: children,
);
static const String name = 'FolderRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args =
data.argsAs<FolderRouteArgs>(orElse: () => const FolderRouteArgs());
return FolderPage(
key: args.key,
folder: args.folder,
);
},
);
}
class FolderRouteArgs {
const FolderRouteArgs({
this.key,
this.folder,
});
final Key? key;
final RecursiveFolder? folder;
@override
String toString() {
return 'FolderRouteArgs{key: $key, folder: $folder}';
}
}
/// generated route for
/// [GalleryViewerPage]
class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
@ -1175,40 +1222,6 @@ class PartnerRoute extends PageRouteInfo<void> {
);
}
/// manually written (with love) route for
/// [FolderPage]
class FolderRoute extends PageRouteInfo<FolderRouteArgs> {
FolderRoute({
RecursiveFolder? folder,
List<PageRouteInfo>? children,
}) : super(
FolderRoute.name,
args: FolderRouteArgs(folder: folder),
initialChildren: children,
);
static const String name = 'FolderRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<FolderRouteArgs>();
return FolderPage(folder: args.folder);
},
);
}
class FolderRouteArgs {
const FolderRouteArgs({this.folder});
final RecursiveFolder? folder;
@override
String toString() {
return 'FolderRouteArgs{folder: $folder}';
}
}
/// generated route for
/// [PeopleCollectionPage]
class PeopleCollectionRoute extends PageRouteInfo<void> {

View File

@ -1,7 +1,7 @@
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:flutter_web_auth/flutter_web_auth.dart';
// Redirect URL = app.immich:///oauth-callback
@ -32,7 +32,7 @@ class OAuthService {
}
Future<LoginResponseDto?> oAuthLogin(String oauthUrl) async {
String result = await FlutterWebAuth.authenticate(
String result = await FlutterWebAuth2.authenticate(
url: oauthUrl,
callbackUrlScheme: callbackUrlScheme,
);

View File

@ -1,3 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/interfaces/asset.interface.dart';
import 'package:immich_mobile/interfaces/asset_api.interface.dart';
@ -11,7 +12,7 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'person.service.g.dart';
@riverpod
PersonService personService(PersonServiceRef ref) => PersonService(
PersonService personService(Ref ref) => PersonService(
ref.watch(personApiRepositoryProvider),
ref.watch(assetApiRepositoryProvider),
ref.read(assetRepositoryProvider),

View File

@ -20,6 +20,8 @@ final personServiceProvider = AutoDisposeProvider<PersonService>.internal(
allTransitiveDependencies: null,
);
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef PersonServiceRef = AutoDisposeProviderRef<PersonService>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -332,7 +332,7 @@ class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
);
}
if (index != -1 && index < widget.renderList.elements.length) {
if (index < widget.renderList.elements.length) {
// Not sure why the index is shifted, but it works. :3
_scrollToIndex(index + 1);
} else {

View File

@ -44,7 +44,19 @@ class PeopleInfo extends ConsumerWidget {
}
final curatedPeople = people
?.map((p) => SearchCuratedContent(id: p.id, label: p.name))
?.map(
(p) => SearchCuratedContent(
id: p.id,
label: p.name,
subtitle: p.birthDate != null
? "exif_bottom_sheet_person_age".tr(
args: [
_calculateAge(p.birthDate!).toString(),
],
)
: null,
),
)
.toList() ??
[];
@ -99,4 +111,17 @@ class PeopleInfo extends ConsumerWidget {
),
);
}
int _calculateAge(DateTime birthDate) {
DateTime today = DateTime.now();
int age = today.year - birthDate.year;
// Check if the birthday has occurred this year
if (today.month < birthDate.month ||
(today.month == birthDate.month && today.day < birthDate.day)) {
age--;
}
return age;
}
}

View File

@ -5,6 +5,8 @@ import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
@ -95,6 +97,16 @@ class GalleryAppBar extends ConsumerWidget {
ref.read(downloadStateProvider.notifier).downloadAsset(asset, context);
}
handleLocateAsset() async {
// Go back to the gallery
await context.maybePop();
await context
.navigateTo(const TabControllerRoute(children: [PhotosRoute()]));
ref.read(tabProvider.notifier).update((state) => state = TabEnum.home);
// Scroll to the asset's date
scrollToDateNotifierProvider.scrollToDate(asset.fileCreatedAt);
}
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
@ -107,6 +119,7 @@ class GalleryAppBar extends ConsumerWidget {
isPartner: isPartner,
asset: asset,
onMoreInfoPressed: showInfo,
onLocatePressed: handleLocateAsset,
onFavorite: toggleFavorite,
onRestorePressed: () => handleRestore(asset),
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
class TopControlAppBar extends HookConsumerWidget {
@ -13,6 +14,7 @@ class TopControlAppBar extends HookConsumerWidget {
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onLocatePressed,
required this.onAddToAlbumPressed,
required this.onRestorePressed,
required this.onFavorite,
@ -26,6 +28,7 @@ class TopControlAppBar extends HookConsumerWidget {
final Function onMoreInfoPressed;
final VoidCallback? onUploadPressed;
final VoidCallback? onDownloadPressed;
final VoidCallback onLocatePressed;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onRestorePressed;
final VoidCallback onActivitiesPressed;
@ -54,6 +57,18 @@ class TopControlAppBar extends HookConsumerWidget {
);
}
Widget buildLocateButton() {
return IconButton(
onPressed: () {
onLocatePressed();
},
icon: Icon(
Icons.image_search,
color: Colors.grey[200],
),
);
}
Widget buildMoreInfoButton() {
return IconButton(
onPressed: () {
@ -159,6 +174,8 @@ class TopControlAppBar extends HookConsumerWidget {
shape: const Border(),
actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (isOwner && ref.read(tabProvider.notifier).state != TabEnum.home)
buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),

View File

@ -86,12 +86,22 @@ class CuratedPeopleRow extends StatelessWidget {
).tr(),
);
}
return Text(
person.label,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 2,
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.label,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 2,
),
if (person.subtitle != null)
Text(
person.subtitle!,
textAlign: TextAlign.center,
),
],
);
}
}

View File

@ -358,6 +358,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.10"
desktop_webview_window:
dependency: transitive
description:
name: desktop_webview_window
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
url: "https://pub.dev"
source: hosted
version: "0.2.3"
device_info_plus:
dependency: "direct main"
description:
@ -508,10 +516,10 @@ packages:
dependency: "direct main"
description:
name: flutter_hooks
sha256: cde36b12f7188c85286fba9b38cc5a902e7279f36dd676967106c041dc9dde70
sha256: b772e710d16d7a20c0740c4f855095026b31c7eb5ba3ab67d2bd52021cd9461d
url: "https://pub.dev"
source: hosted
version: "0.20.5"
version: "0.21.2"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -577,10 +585,10 @@ packages:
dependency: transitive
description:
name: flutter_riverpod
sha256: "0f1974eff5bbe774bf1d870e406fc6f29e3d6f1c46bd9c58e7172ff68a785d7d"
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
version: "2.6.1"
flutter_svg:
dependency: "direct main"
description:
@ -602,14 +610,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
flutter_web_auth:
flutter_web_auth_2:
dependency: "direct main"
description:
name: flutter_web_auth
sha256: "95e4856e24fb6ac1678f5ff334743b63f782d839ab324543d29ccbd295176209"
name: flutter_web_auth_2
sha256: "561c32d32ed537853de43852c35849cf1d37f3482f41f22b718ab6112f96b333"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
version: "5.0.0-alpha.0"
flutter_web_auth_2_platform_interface:
dependency: transitive
description:
name: flutter_web_auth_2_platform_interface
sha256: "45927587ebb2364cd273675ec95f6f67b81725754b416cef2b65cdc63fd3e853"
url: "https://pub.dev"
source: hosted
version: "5.0.0-alpha.0"
flutter_web_plugins:
dependency: transitive
description: flutter
@ -712,10 +728,10 @@ packages:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "45b2030a18bcd6dbd680c2c91bc3b33e3fe7c323e3acb5ecec93a613e2fbaa8a"
sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
version: "2.6.1"
hotreloader:
dependency: transitive
description:
@ -1276,42 +1292,42 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: f21b32ffd26a36555e501b04f4a5dca43ed59e16343f1a30c13632b2351dfa4d
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
version: "2.6.1"
riverpod_analyzer_utils:
dependency: transitive
description:
name: riverpod_analyzer_utils
sha256: ee72770090078e6841d51355292335f1bc254907c6694283389dcb8156d99a4d
sha256: "0dcb0af32d561f8fa000c6a6d95633c9fb08ea8a8df46e3f9daca59f11218167"
url: "https://pub.dev"
source: hosted
version: "0.5.3"
version: "0.5.6"
riverpod_annotation:
dependency: "direct main"
description:
name: riverpod_annotation
sha256: e5e796c0eba4030c704e9dae1b834a6541814963292839dcf9638d53eba84f5c
sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8
url: "https://pub.dev"
source: hosted
version: "2.3.5"
version: "2.6.1"
riverpod_generator:
dependency: "direct dev"
description:
name: riverpod_generator
sha256: "1ad626afbd8b01d168870b13c0b036f8a5bdb57c14cd426dc5b4595466bd6e2f"
sha256: "851aedac7ad52693d12af3bf6d92b1626d516ed6b764eb61bf19e968b5e0b931"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
version: "2.6.1"
riverpod_lint:
dependency: "direct dev"
description:
name: riverpod_lint
sha256: b95a8cdc6102397f7d51037131c25ce7e51be900be021af4bf0c2d6f1b8f7aa7
sha256: "0684c21a9a4582c28c897d55c7b611fa59a351579061b43f8c92c005804e63a8"
url: "https://pub.dev"
source: hosted
version: "2.3.12"
version: "2.6.1"
rxdart:
dependency: transitive
description:
@ -1813,6 +1829,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.4"
window_to_front:
dependency: transitive
description:
name: window_to_front
sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee"
url: "https://pub.dev"
source: hosted
version: "0.0.3"
xdg_directories:
dependency: transitive
description:
@ -1846,5 +1870,5 @@ packages:
source: hosted
version: "3.1.2"
sdks:
dart: ">=3.5.3 <4.0.0"
dart: ">=3.5.0 <4.0.0"
flutter: ">=3.24.5"

View File

@ -17,9 +17,9 @@ dependencies:
path_provider_ios:
photo_manager: ^3.6.1
photo_manager_image_provider: ^2.2.0
flutter_hooks: ^0.20.4
hooks_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
flutter_hooks: ^0.21.2
hooks_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
cached_network_image: ^3.3.1
flutter_cache_manager: ^3.3.1
intl: ^0.19.0
@ -42,7 +42,7 @@ dependencies:
path_provider: ^2.1.2
collection: ^1.18.0
http_parser: ^4.0.2
flutter_web_auth: 0.6.0
flutter_web_auth_2: ^5.0.0-alpha.0
easy_image_viewer: ^1.4.0
isar:
version: *isar_version
@ -108,8 +108,8 @@ dev_dependencies:
integration_test:
sdk: flutter
custom_lint: ^0.6.4
riverpod_lint: ^2.3.7
riverpod_generator: ^2.3.9
riverpod_lint: ^2.6.1
riverpod_generator: ^2.6.1
mocktail: ^1.0.3
immich_mobile_immich_lint:
path: './immich_lint'

View File

@ -32,7 +32,8 @@
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@ -81,8 +82,7 @@
"@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.3",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/js-yaml": "^4.0.9",
@ -5612,13 +5612,6 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/cookie-parser": {
"version": "1.4.8",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
@ -8071,12 +8064,12 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
"node": ">=18"
}
},
"node_modules/cookie-parser": {
@ -8092,6 +8085,15 @@
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@ -8728,6 +8730,15 @@
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/engine.io/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",

View File

@ -58,7 +58,8 @@
"chokidar": "^3.5.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"cookie": "^1.0.2",
"cookie-parser": "^1.4.7",
"exiftool-vendored": "^28.3.1",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
@ -107,8 +108,7 @@
"@types/archiver": "^6.0.0",
"@types/async-lock": "^1.4.2",
"@types/bcrypt": "^5.0.0",
"@types/cookie": "^0.6.0",
"@types/cookie-parser": "^1.4.3",
"@types/cookie-parser": "^1.4.8",
"@types/express": "^4.17.17",
"@types/fluent-ffmpeg": "^2.1.21",
"@types/js-yaml": "^4.0.9",

View File

@ -10,6 +10,20 @@ export type AuthUser = {
quotaSizeInBytes: number | null;
};
export type Library = {
id: string;
ownerId: string;
createdAt: Date;
updatedAt: Date;
updateId: string;
name: string;
importPaths: string[];
exclusionPatterns: string[];
deletedAt: Date | null;
refreshedAt: Date | null;
assets?: Asset[];
};
export type AuthApiKey = {
id: string;
permissions: Permission[];

View File

@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, ArrayUnique, IsNotEmpty, IsString } from 'class-validator';
import { LibraryEntity } from 'src/entities/library.entity';
import { Library } from 'src/database';
import { Optional, ValidateUUID } from 'src/validation';
export class CreateLibraryDto {
@ -120,7 +120,7 @@ export class LibraryStatsResponseDto {
usage = 0;
}
export function mapLibrary(entity: LibraryEntity): LibraryResponseDto {
export function mapLibrary(entity: Library): LibraryResponseDto {
let assetCount = 0;
if (entity.assets) {
assetCount = entity.assets.length;

View File

@ -7,6 +7,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { asDateString } from 'src/utils/date';
import {
IsDateStringFormat,
MaxDateString,
@ -32,7 +33,7 @@ export class PersonCreateDto {
@MaxDateString(() => DateTime.now(), { message: 'Birth date cannot be in the future' })
@IsDateStringFormat('yyyy-MM-dd')
@Optional({ nullable: true })
birthDate?: string | null;
birthDate?: Date | null;
/**
* Person visibility
@ -222,7 +223,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
return {
id: person.id,
name: person.name,
birthDate: person.birthDate,
birthDate: asDateString(person.birthDate),
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,

View File

@ -26,7 +26,7 @@ export class LibraryEntity {
assets!: AssetEntity[];
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity;
owner?: UserEntity;
@Column()
ownerId!: string;

View File

@ -38,7 +38,7 @@ export class PersonEntity {
name!: string;
@Column({ type: 'date', nullable: true })
birthDate!: string | null;
birthDate!: Date | string | null;
@Column({ default: '' })
thumbnailPath!: string;

View File

@ -2,34 +2,7 @@
-- LibraryRepository.get
select
"libraries".*,
(
select
to_json(obj)
from
(
select
"users"."id",
"users"."email",
"users"."createdAt",
"users"."profileImagePath",
"users"."isAdmin",
"users"."shouldChangePassword",
"users"."deletedAt",
"users"."oauthId",
"users"."updatedAt",
"users"."storageLabel",
"users"."name",
"users"."quotaSizeInBytes",
"users"."quotaUsageInBytes",
"users"."status",
"users"."profileChangedAt"
from
"users"
where
"users"."id" = "libraries"."ownerId"
) as obj
) as "owner"
"libraries".*
from
"libraries"
where
@ -38,34 +11,7 @@ where
-- LibraryRepository.getAll
select
"libraries".*,
(
select
to_json(obj)
from
(
select
"users"."id",
"users"."email",
"users"."createdAt",
"users"."profileImagePath",
"users"."isAdmin",
"users"."shouldChangePassword",
"users"."deletedAt",
"users"."oauthId",
"users"."updatedAt",
"users"."storageLabel",
"users"."name",
"users"."quotaSizeInBytes",
"users"."quotaUsageInBytes",
"users"."status",
"users"."profileChangedAt"
from
"users"
where
"users"."id" = "libraries"."ownerId"
) as obj
) as "owner"
"libraries".*
from
"libraries"
where
@ -75,34 +21,7 @@ order by
-- LibraryRepository.getAllDeleted
select
"libraries".*,
(
select
to_json(obj)
from
(
select
"users"."id",
"users"."email",
"users"."createdAt",
"users"."profileImagePath",
"users"."isAdmin",
"users"."shouldChangePassword",
"users"."deletedAt",
"users"."oauthId",
"users"."updatedAt",
"users"."storageLabel",
"users"."name",
"users"."quotaSizeInBytes",
"users"."quotaUsageInBytes",
"users"."status",
"users"."profileChangedAt"
from
"users"
where
"users"."id" = "libraries"."ownerId"
) as obj
) as "owner"
"libraries".*
from
"libraries"
where

View File

@ -1,31 +1,11 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, sql, Updateable } from 'kysely';
import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB, Libraries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType } from 'src/enum';
const userColumns = [
'users.id',
'users.email',
'users.createdAt',
'users.profileImagePath',
'users.isAdmin',
'users.shouldChangePassword',
'users.deletedAt',
'users.oauthId',
'users.updatedAt',
'users.storageLabel',
'users.name',
'users.quotaSizeInBytes',
'users.quotaUsageInBytes',
'users.status',
'users.profileChangedAt',
] as const;
export enum AssetSyncResult {
DO_NOTHING,
UPDATE,
@ -33,72 +13,59 @@ export enum AssetSyncResult {
CHECK_OFFLINE,
}
const withOwner = (eb: ExpressionBuilder<DB, 'libraries'>) => {
return jsonObjectFrom(eb.selectFrom('users').whereRef('users.id', '=', 'libraries.ownerId').select(userColumns)).as(
'owner',
);
};
@Injectable()
export class LibraryRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [DummyValue.UUID] })
get(id: string, withDeleted = false): Promise<LibraryEntity | undefined> {
get(id: string, withDeleted = false) {
return this.db
.selectFrom('libraries')
.selectAll('libraries')
.select(withOwner)
.where('libraries.id', '=', id)
.$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null))
.executeTakeFirst() as Promise<LibraryEntity | undefined>;
.executeTakeFirst();
}
@GenerateSql({ params: [] })
getAll(withDeleted = false): Promise<LibraryEntity[]> {
getAll(withDeleted = false) {
return this.db
.selectFrom('libraries')
.selectAll('libraries')
.select(withOwner)
.orderBy('createdAt', 'asc')
.$if(!withDeleted, (qb) => qb.where('libraries.deletedAt', 'is', null))
.execute() as unknown as Promise<LibraryEntity[]>;
.execute();
}
@GenerateSql()
getAllDeleted(): Promise<LibraryEntity[]> {
getAllDeleted() {
return this.db
.selectFrom('libraries')
.selectAll('libraries')
.select(withOwner)
.where('libraries.deletedAt', 'is not', null)
.orderBy('createdAt', 'asc')
.execute() as unknown as Promise<LibraryEntity[]>;
.execute();
}
create(library: Insertable<Libraries>): Promise<LibraryEntity> {
return this.db
.insertInto('libraries')
.values(library)
.returningAll()
.executeTakeFirstOrThrow() as Promise<LibraryEntity>;
create(library: Insertable<Libraries>) {
return this.db.insertInto('libraries').values(library).returningAll().executeTakeFirstOrThrow();
}
async delete(id: string): Promise<void> {
async delete(id: string) {
await this.db.deleteFrom('libraries').where('libraries.id', '=', id).execute();
}
async softDelete(id: string): Promise<void> {
async softDelete(id: string) {
await this.db.updateTable('libraries').set({ deletedAt: new Date() }).where('libraries.id', '=', id).execute();
}
update(id: string, library: Updateable<Libraries>): Promise<LibraryEntity> {
update(id: string, library: Updateable<Libraries>) {
return this.db
.updateTable('libraries')
.set(library)
.where('libraries.id', '=', id)
.returningAll()
.executeTakeFirstOrThrow() as Promise<LibraryEntity>;
.executeTakeFirstOrThrow();
}
@GenerateSql({ params: [DummyValue.UUID] })

View File

@ -63,6 +63,14 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
Name?: string;
}[];
};
Device?: {
Manufacturer?: string;
ModelName?: string;
};
AndroidMake?: string;
AndroidModel?: string;
}
@Injectable()

View File

@ -1,6 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { isString } from 'class-validator';
import cookieParser from 'cookie';
import { parse } from 'cookie';
import { DateTime } from 'luxon';
import { IncomingHttpHeaders } from 'node:http';
import { LOGIN_URL, MOBILE_REDIRECT, SALT_ROUNDS } from 'src/constants';
@ -287,7 +287,7 @@ export class AuthService extends BaseService {
}
private getCookieToken(headers: IncomingHttpHeaders): string | null {
const cookies = cookieParser.parse(headers.cookie || '');
const cookies = parse(headers.cookie || '');
return cookies[ImmichCookie.ACCESS_TOKEN] || null;
}

File diff suppressed because it is too large Load Diff

View File

@ -1081,6 +1081,7 @@ describe(MetadataService.name, () => {
}),
);
});
it('should handle valid negative rating value', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags({ Rating: -1 });
@ -1193,6 +1194,23 @@ describe(MetadataService.name, () => {
type: 'VIDEO',
});
});
it.each([
{ Make: '1', Model: '2', Device: { Manufacturer: '3', ModelName: '4' }, AndroidMake: '4', AndroidModel: '5' },
{ Device: { Manufacturer: '1', ModelName: '2' }, AndroidMake: '3', AndroidModel: '4' },
{ AndroidMake: '1', AndroidModel: '2' },
])('should read camera make and model correct place %s', async (metaData) => {
mocks.asset.getByIds.mockResolvedValue([assetStub.image]);
mockReadTags(metaData);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
make: '1',
model: '2',
}),
);
});
});
describe('handleQueueSidecar', () => {

View File

@ -221,8 +221,8 @@ export class MetadataService extends BaseService {
colorspace: exifTags.ColorSpace ?? null,
// camera
make: exifTags.Make ?? null,
model: exifTags.Model ?? null,
make: exifTags.Make ?? exifTags?.Device?.Manufacturer ?? exifTags.AndroidMake ?? null,
model: exifTags.Model ?? exifTags?.Device?.ModelName ?? exifTags.AndroidModel ?? null,
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
iso: validate(exifTags.ISO) as number,
exposureTime: exifTags.ExposureTime ?? null,

View File

@ -222,7 +222,7 @@ describe(PersonService.name, () => {
mocks.person.update.mockResolvedValue(personStub.withBirthDate);
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { birthDate: '1976-06-30' })).resolves.toEqual({
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
id: 'person-1',
name: 'Person 1',
birthDate: '1976-06-30',
@ -231,7 +231,7 @@ describe(PersonService.name, () => {
isFavorite: false,
updatedAt: expect.any(Date),
});
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));

3
server/src/utils/date.ts Normal file
View File

@ -0,0 +1,3 @@
export const asDateString = (x: Date | string | null): string | null => {
return x instanceof Date ? x.toISOString().split('T')[0] : x;
};

View File

@ -6,7 +6,6 @@ import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { libraryStub } from 'test/fixtures/library.stub';
import { userStub } from 'test/fixtures/user.stub';
const previewFile: AssetFileEntity = {
@ -396,7 +395,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.jpg',
@ -751,7 +749,6 @@ export const assetStub = {
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
library: libraryStub.externalLibrary1,
tags: [],
sharedLinks: [],
originalFileName: 'photo.jpg',

View File

@ -1,77 +0,0 @@
import { LibraryEntity } from 'src/entities/library.entity';
import { userStub } from 'test/fixtures/user.stub';
export const libraryStub = {
externalLibrary1: Object.freeze<LibraryEntity>({
id: 'library-id',
name: 'test_library',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: [],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibrary2: Object.freeze<LibraryEntity>({
id: 'library-id2',
name: 'test_library2',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: [],
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2022-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibraryWithImportPaths1: Object.freeze<LibraryEntity>({
id: 'library-id-with-paths1',
name: 'library-with-import-paths1',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: ['/foo', '/bar'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
externalLibraryWithImportPaths2: Object.freeze<LibraryEntity>({
id: 'library-id-with-paths2',
name: 'library-with-import-paths2',
assets: [],
owner: userStub.admin,
ownerId: 'admin_id',
importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: [],
}),
patternPath: Object.freeze<LibraryEntity>({
id: 'library-id1337',
name: 'importpath-exclusion-library1',
assets: [],
owner: userStub.admin,
ownerId: 'user-id',
importPaths: ['/xyz', '/asdf'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: ['**/dir1/**'],
}),
hasImmichPaths: Object.freeze<LibraryEntity>({
id: 'library-id1337',
name: 'importpath-exclusion-library1',
assets: [],
owner: userStub.admin,
ownerId: 'user-id',
importPaths: ['upload/thumbs', 'xyz', 'upload/library'],
createdAt: new Date('2023-01-01'),
updatedAt: new Date('2023-01-01'),
refreshedAt: null,
exclusionPatterns: ['**/dir1/**'],
}),
};

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Asset, AuthUser, User } from 'src/database';
import { Asset, AuthUser, Library, User } from 'src/database';
import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetStatus, AssetType, MemoryType } from 'src/enum';
import { ActivityItem, MemoryItem } from 'src/types';
@ -13,7 +13,11 @@ export const newDate = () => new Date();
export const newUpdateId = () => 'uuid-v7';
export const newSha1 = () => Buffer.from('this is a fake hash');
const authUser = (authUser: Partial<AuthUser>) => ({
const authFactory = (user: Partial<AuthUser> = {}) => ({
user: authUserFactory(user),
});
const authUserFactory = (authUser: Partial<AuthUser>) => ({
id: newUuid(),
isAdmin: false,
name: 'Test User',
@ -23,7 +27,7 @@ const authUser = (authUser: Partial<AuthUser>) => ({
...authUser,
});
const user = (user: Partial<User>) => ({
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
name: 'Test User',
email: 'test@immich.cloud',
@ -32,75 +36,95 @@ const user = (user: Partial<User>) => ({
...user,
});
export const factory = {
auth: (user: Partial<AuthUser> = {}) => ({
user: authUser(user),
}),
authUser,
user,
asset: (asset: Partial<Asset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUpdateId(),
status: AssetStatus.ACTIVE,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isArchived: false,
isExternal: false,
isFavorite: false,
isOffline: false,
isVisible: true,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `upload/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.IMAGE,
...asset,
}),
activity: (activity: Partial<ActivityItem> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: user({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
...activity,
};
},
memory: (memory: Partial<MemoryItem> = {}) => ({
const assetFactory = (asset: Partial<Asset> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUpdateId(),
status: AssetStatus.ACTIVE,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isArchived: false,
isExternal: false,
isFavorite: false,
isOffline: false,
isVisible: true,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName: 'IMG_123.jpg',
originalPath: `upload/12/34/IMG_123.jpg`,
ownerId: newUuid(),
sidecarPath: null,
stackId: null,
thumbhash: null,
type: AssetType.IMAGE,
...asset,
});
const activityFactory = (activity: Partial<ActivityItem> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
}),
...activity,
};
};
const libraryFactory = (library: Partial<Library> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
deletedAt: null,
refreshedAt: null,
name: 'Library',
assets: [],
ownerId: newUuid(),
importPaths: [],
exclusionPatterns: [],
...library,
});
const memoryFactory = (memory: Partial<MemoryItem> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUpdateId(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.ON_THIS_DAY,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
});
export const factory = {
activity: activityFactory,
asset: assetFactory,
auth: authFactory,
authUser: authUserFactory,
library: libraryFactory,
memory: memoryFactory,
user: userFactory,
};

View File

@ -65,7 +65,7 @@
widthStyle="100%"
/>
{#if person.isFavorite}
<div class="absolute top-2 left-2">
<div class="absolute top-4 left-4">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}

View File

@ -50,9 +50,11 @@
<button
type="button"
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
title={$t('previous')}
aria-label={$t('previous')}
onclick={scrollLeft}
>
<Icon path={mdiChevronLeft} size="36" /></button
<Icon path={mdiChevronLeft} size="36" ariaLabel={$t('previous')} /></button
>
</div>
{/if}
@ -61,9 +63,11 @@
<button
type="button"
class="rounded-full border border-gray-500 bg-gray-100 p-2 text-gray-500 opacity-50 hover:opacity-100"
title={$t('next')}
aria-label={$t('next')}
onclick={scrollRight}
>
<Icon path={mdiChevronRight} size="36" /></button
<Icon path={mdiChevronRight} size="36" ariaLabel={$t('next')} /></button
>
</div>
{/if}

View File

@ -32,6 +32,7 @@
let showFilter = $state(false);
let isSearchSuggestions = $state(false);
let selectedId: string | undefined = $state();
let isFocus = $state(false);
const listboxId = generateId();
@ -98,7 +99,25 @@
};
const onSubmit = () => {
handlePromiseError(handleSearch({ query: value }));
const searchType = getSearchType();
let payload: SmartSearchDto | MetadataSearchDto = {} as SmartSearchDto | MetadataSearchDto;
switch (searchType) {
case 'smart': {
payload = { query: value } as SmartSearchDto;
break;
}
case 'metadata': {
payload = { originalFileName: value } as MetadataSearchDto;
break;
}
case 'description': {
payload = { description: value } as MetadataSearchDto;
break;
}
}
handlePromiseError(handleSearch(payload));
saveSearchTerm(value);
};
@ -132,10 +151,12 @@
const openDropdown = () => {
showSuggestions = true;
isFocus = true;
};
const closeDropdown = () => {
showSuggestions = false;
isFocus = false;
searchHistoryBox?.clearSelection();
};
@ -143,6 +164,26 @@
event.preventDefault();
onSubmit();
};
function getSearchType(): 'smart' | 'metadata' | 'description' {
const t = localStorage.getItem('searchQueryType');
return t === 'smart' || t === 'description' ? t : 'metadata';
}
function getSearchTypeText(): string {
const searchType = getSearchType();
switch (searchType) {
case 'smart': {
return $t('context');
}
case 'metadata': {
return $t('filename');
}
case 'description': {
return $t('description');
}
}
}
</script>
<svelte:window
@ -214,6 +255,21 @@
<div class="absolute inset-y-0 {showClearIcon ? 'right-14' : 'right-2'} flex items-center pl-6 transition-all">
<CircleIconButton title={$t('show_search_options')} icon={mdiTune} onclick={onFilterClick} size="20" />
</div>
{#if isFocus}
<div
class="absolute inset-y-0 flex items-center"
class:right-16={isFocus}
class:right-28={isFocus && value.length > 0}
>
<p
class="bg-immich-primary text-white dark:bg-immich-dark-primary/90 dark:text-black/75 rounded-full px-3 py-1 text-xs z-10"
>
{getSearchTypeText()}
</p>
</div>
{/if}
{#if showClearIcon}
<div class="absolute inset-y-0 right-0 flex items-center pr-2">
<CircleIconButton onclick={onClear} icon={mdiClose} title={$t('clear')} size="20" />

View File

@ -2,7 +2,7 @@
import type { SearchLocationFilter } from './search-location-section.svelte';
import type { SearchDisplayFilters } from './search-display-section.svelte';
import type { SearchDateFilter } from './search-date-section.svelte';
import { MediaType } from '$lib/constants';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
export type SearchFilter = {
query: string;
@ -55,9 +55,18 @@
return value === null ? undefined : value;
}
function storeQueryType(type: SearchFilter['queryType']) {
localStorage.setItem('searchQueryType', type);
}
function defaultQueryType(): QueryType {
const storedQueryType = localStorage.getItem('searchQueryType') as QueryType;
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
}
let filter: SearchFilter = $state({
query: 'query' in searchQuery ? searchQuery.query : searchQuery.originalFileName || '',
queryType: 'smart',
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds: new SvelteSet('tagIds' in searchQuery ? searchQuery.tagIds : []),
location: {
@ -90,7 +99,7 @@
const resetForm = () => {
filter = {
query: '',
queryType: 'smart',
queryType: defaultQueryType(), // retain from localStorage or default
personIds: new SvelteSet(),
tagIds: new SvelteSet(),
location: {},
@ -142,8 +151,14 @@
const onsubmit = (event: Event) => {
event.preventDefault();
storeQueryType(filter.queryType);
search();
};
// Will be called whenever queryType changes, not just onsubmit.
$effect(() => {
storeQueryType(filter.queryType);
});
</script>
<FullScreenModal icon={mdiTune} width="extra-wide" title={$t('search_options')} {onClose}>

View File

@ -119,6 +119,14 @@ export const fallbackLocale = {
name: 'English (US)',
};
export enum QueryType {
SMART = 'smart',
METADATA = 'metadata',
DESCRIPTION = 'description',
}
export const validQueryTypes = new Set([QueryType.SMART, QueryType.METADATA, QueryType.DESCRIPTION]);
export const locales = [
{ code: 'af-ZA', name: 'Afrikaans (South Africa)' },
{ code: 'sq-AL', name: 'Albanian (Albania)' },