Compare commits

...

25 Commits

Author SHA1 Message Date
github-actions
bede19a3ca chore: version v1.138.0 2025-08-13 17:08:29 +00:00
Alex
aefa62b234 fix: asset_viewer page viewing experience (#20889)
* fix: zoomed in effect on swiped when bottom sheet is open

* fix: memory leaked

* fix: asset out of range when swiping in asset_viewer
2025-08-13 11:35:42 -05:00
renovate[bot]
b3fb831994 fix(deps): update machine-learning (#20878)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 11:24:09 -04:00
Brandon Wees
0d60199514 fix(mobile): newest/oldest album sort (#20743)
* fix(mobile): newest/oldest album sort

* chore: use sqlite to determine album asset timestamps

* Fix missing future

Co-authored-by: Alex <alex.tran1502@gmail.com>

* fix: async handling of sort

* chore: tests

* chore: code review changes

* fix: use created at for newest asset

* fix: use localDateTime for sorting

* chore: cleanup

* chore: use final

* feat: loading indicator

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
2025-08-12 14:46:50 -05:00
Alex
54960157c0 chore: backup info card styling tweak (#20799)
* chore: backup info card styling tweak

* pr feedback
2025-08-12 16:08:31 +00:00
waclaw66
244d097d01 fix(mobile): enable person age pluralization (#20881)
Enable person age pluralization
2025-08-12 14:55:47 +00:00
renovate[bot]
adb55f3726 fix(deps): update machine-learning (#19803)
* fix(deps): update machine-learning

* typing fixes

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
2025-08-11 18:07:49 -04:00
Mirek
5d2777a5c6 feat: format date and time in /admin/users/ -> Profile section (#20811)
Matches the format used in the user settings page.

Added a formatting function in utils.
2025-08-11 16:50:34 -05:00
Alex
24db881c14 feat: swipe to delete album (#20765) 2025-08-11 16:49:53 -05:00
Alex
f09bed9ad2 fix: age info cut off (#20872) 2025-08-11 16:42:16 -05:00
Mert
e29cc66361 docs: vectorchord migration instructions, deprecation log on startup (#20867)
* deprecation log, migration docs

* update tests

* fix info boxes
2025-08-11 16:50:48 -04:00
Brandon Wees
669b765662 feat: edit image in beta timeline (#20709)
* feat: edit image in beta timeline

* delete album notifier pull

* feat: sync local after saving image

* feat: queue asset for manual upload after saving

* chore: clarify PlatformException catch
2025-08-11 15:01:31 -05:00
Gabriel Soldani
e7060dc292 fix(web): fix layout loop with single row grids in explore page (#20833) 2025-08-11 02:31:26 +00:00
Nicholas
03a8b6cb38 feat: add i18n formatting to make translation in mobile makefile (#20807)
add i18n formatting to `make translation` in mobile makefile
2025-08-10 21:26:23 -05:00
Min Idzelis
f317cbe221 fix: devcontainer broken by debian Trixie going stable (#20843) 2025-08-10 21:24:00 -05:00
Nicholas
d6d31c6695 fix: change all download icons to mdiDownload for clarity and consistency (#20821)
change all download icons to `mdiDownload` for clarity and consistency
2025-08-10 21:23:21 -05:00
Alex
4b9019e762 fix: return method correctly (#20831) 2025-08-09 23:01:47 -05:00
Jason Rasmussen
13563fc507 chore: update response codes (#20770)
* chore: update response codes

* chore: skip problematic test
2025-08-08 15:56:37 -04:00
Jason Rasmussen
2ce4f8dd3b fix(sql-tools): null default (#20796) 2025-08-08 15:44:39 -04:00
Jason Rasmussen
538d5c81ea feat: reset oauth ids (#20798) 2025-08-08 15:42:38 -04:00
Jason Rasmussen
9ecaa3fa9d feat: more cursed knowledge (#20794) 2025-08-08 10:05:59 -04:00
Alex
b1aacfdbd9 chore: log resume backup process (#20768) 2025-08-07 15:44:49 -05:00
Jason Rasmussen
cfbc24579d feat(web): reset pin code (#20766) 2025-08-07 15:07:31 -04:00
Alex
1d4d8e7a9a chore: bump @immich/ui to 24 (#20767)
chore: bump @ui 24
2025-08-07 14:43:56 -04:00
Alex
7b83b7b2d5 fix: don't show remove from album action from the main timeline (#20757)
* fix: don't show remove from album action from the main timeline

* pr feedback
2025-08-07 23:36:16 +05:30
117 changed files with 2671 additions and 701 deletions

6
cli/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@immich/cli",
"version": "2.2.77",
"version": "2.2.78",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/cli",
"version": "2.2.77",
"version": "2.2.78",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"chokidar": "^4.0.3",
@@ -54,7 +54,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.137.3",
"version": "1.138.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

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

View File

@@ -27,3 +27,102 @@ docker image prune
[watchtower]: https://containrrr.dev/watchtower/
[breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created
[releases]: https://github.com/immich-app/immich/releases
## Migrating to VectorChord
:::info
If you deploy Immich using Docker Compose, see `ghcr.io/immich-app/postgres` in the `docker-compose.yml` file and have not explicitly set the `DB_VECTOR_EXTENSION` environmental variable, your Immich database is already using VectorChord and this section does not apply to you.
:::
:::important
If you do not deploy Immich using Docker Compose and see a deprecation warning for pgvecto.rs on server startup, you should refer to the maintainers of the Immich distribution for guidance (if using a turnkey solution) or adapt the instructions for your specific setup.
:::
Immich has migrated off of the deprecated pgvecto.rs database extension to its successor, [VectorChord](https://github.com/tensorchord/VectorChord), which comes with performance improvements in almost every aspect. This section will guide you on how to make this change in a Docker Compose setup.
Before making any changes, please [back up your database](/docs/administration/backup-and-restore). While every effort has been made to make this migration as smooth as possible, theres always a chance that something can go wrong.
After making a backup, please modify your `docker-compose.yml` file with the following information.
```diff
[...]
database:
container_name: immich_postgres
- image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:739cdd626151ff1f796dc95a6591b55a714f341c737e27f045019ceabf8e8c52
+ image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
+ # Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
+ # DB_STORAGE_TYPE: 'HDD'
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
- healthcheck:
- test: >-
- pg_isready --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" || exit 1;
- Chksum="$$(psql --dbname="$${POSTGRES_DB}" --username="$${POSTGRES_USER}" --tuples-only --no-align
- --command='SELECT COALESCE(SUM(checksum_failures), 0) FROM pg_stat_database')";
- echo "checksum failure count is $$Chksum";
- [ "$$Chksum" = '0' ] || exit 1
- interval: 5m
- start_interval: 30s
- start_period: 5m
- command: >-
- postgres
- -c shared_preload_libraries=vectors.so
- -c 'search_path="$$user", public, vectors'
- -c logging_collector=on
- -c max_wal_size=2GB
- -c shared_buffers=512MB
- -c wal_compression=on
+ shm_size: 128mb
restart: always
[...]
```
:::important
If you deviated from the defaults of pg14 or pgvectors0.2.0, you must adjust the pg major version and pgvecto.rs version. If you are still using the default `docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0` image, you can just follow the changes above. For example, if the previous image is `docker.io/tensorchord/pgvecto-rs:pg16-v0.3.0`, the new image should be `ghcr.io/immich-app/postgres:16-vectorchord0.3.0-pgvectors0.3.0` instead of the image specified in the diff.
:::
After making these changes, you can start Immich as normal. Immich will make some changes to the DB during startup, which can take seconds to minutes to finish, depending on hardware and library size. In particular, its normal for the server logs to be seemingly stuck at `Reindexing clip_index` and `Reindexing face_index`for some time if you have over 100k assets in Immich and/or Immich is on a relatively weak server. If you see these logs and there are no errors, just give it time.
:::danger
After switching to VectorChord, you should not downgrade Immich below 1.133.0.
:::
Please dont hesitate to contact us on [GitHub](https://github.com/immich-app/immich/discussions) or [Discord](https://discord.immich.app/) if you encounter migration issues.
### VectorChord FAQ
#### I have a separate PostgreSQL instance shared with multiple services. How can I switch to VectorChord?
Please see the [standalone PostgreSQL documentation](/docs/administration/postgres-standalone#migrating-to-vectorchord) for migration instructions. The migration path will be different depending on whether youre currently using pgvecto.rs or pgvector, as well as whether Immich has superuser DB permissions.
#### Why are so many lines removed from the `docker-compose.yml` file? Does this mean the health check is removed?
These lines are now incorporated into the image itself along with some additional tuning.
#### What does this change mean for my existing DB backups?
The new DB image includes pgvector and pgvecto.rs in addition to VectorChord, so you can use this image to restore from existing backups that used either of these extensions. However, backups made after switching to VectorChord require an image containing VectorChord to restore successfully.
#### Do I still need pgvecto.rs installed after migrating to VectorChord?
pgvecto.rs only needs to be available during the migration, or if you need to restore from a backup that used pgvecto.rs. For a leaner DB and a smaller image, you can optionally switch to an image variant that doesnt have pgvecto.rs installed after youve performed the migration and started Immich: `ghcr.io/immich-app/postgres:14-vectorchord0.4.3`, changing the PostgreSQL version as appropriate.
#### Why does it matter whether my database is on an SSD or an HDD?
These storage mediums have different performance characteristics. As a result, the optimal settings for an SSD are not the same as those for an HDD. Either configuration is compatible with SSD and HDD, but using the right configuration will make Immich snappier. As a general tip, we recommend users store the database on an SSD whenever possible.
#### Can I use the new database image as a general PostgreSQL image outside of Immich?
Its a standard PostgreSQL container image that additionally contains the VectorChord, pgvector, and (optionally) pgvecto.rs extensions. If you were using the previous pgvecto.rs image for other purposes, you can similarly do so with this image.
#### If pgvecto.rs and pgvector still work, why should I switch to VectorChord?
VectorChord is faster, more stable, uses less RAM, and (with the settings Immich uses) offers higher-quality results than pgvector and pgvecto.rs. This translates to better search and facial recognition experiences. In addition, pgvecto.rs support will be dropped in the future, so changing it sooner will avoid disruption.

View File

@@ -16,6 +16,9 @@ import {
mdiCloudKeyOutline,
mdiRegex,
mdiCodeJson,
mdiClockOutline,
mdiAccountOutline,
mdiRestart,
} from '@mdi/js';
import Layout from '@theme/Layout';
import React from 'react';
@@ -26,6 +29,42 @@ const withLanguage = (date: Date) => (language: string) => date.toLocaleDateStri
type Item = Omit<TimelineItem, 'done' | 'getDateLabel'> & { date: Date };
const items: Item[] = [
{
icon: mdiClockOutline,
iconColor: 'gray',
title: 'setTimeout is cursed',
description:
'The setTimeout method in JavaScript is cursed when used with small values because the implementation may or may not actually wait the specified time.',
link: {
url: 'https://github.com/immich-app/immich/pull/20655',
text: '#20655',
},
date: new Date(2025, 7, 4),
},
{
icon: mdiAccountOutline,
iconColor: '#DAB1DA',
title: 'PostgreSQL USER is cursed',
description:
'The USER keyword in PostgreSQL is cursed because you can select from it like a table, which leads to confusion if you have a table name user as well.',
link: {
url: 'https://github.com/immich-app/immich/pull/19891',
text: '#19891',
},
date: new Date(2025, 7, 4),
},
{
icon: mdiRestart,
iconColor: '#8395e3',
title: 'PostgreSQL RESET is cursed',
description:
'PostgreSQL RESET is cursed because it is impossible to RESET a PostgreSQL extension parameter if the extension has been uninstalled.',
link: {
url: 'https://github.com/immich-app/immich/pull/19363',
text: '#19363',
},
date: new Date(2025, 5, 20),
},
{
icon: mdiRegex,
iconColor: 'purple',

View File

@@ -1,4 +1,8 @@
[
{
"label": "v1.138.0",
"url": "https://v1.138.0.archive.immich.app"
},
{
"label": "v1.137.3",
"url": "https://v1.137.3.archive.immich.app"

8
e2e/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "immich-e2e",
"version": "1.137.3",
"version": "1.138.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-e2e",
"version": "1.137.3",
"version": "1.138.0",
"license": "GNU Affero General Public License version 3",
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
@@ -46,7 +46,7 @@
},
"../cli": {
"name": "@immich/cli",
"version": "2.2.77",
"version": "2.2.78",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {
@@ -95,7 +95,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.137.3",
"version": "1.138.0",
"dev": true,
"license": "GNU Affero General Public License version 3",
"dependencies": {

View File

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

View File

@@ -683,7 +683,7 @@ describe('/albums', () => {
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ role: AlbumUserRole.Editor });
expect(status).toBe(200);
expect(status).toBe(204);
// Get album to verify the role change
const { body } = await request(app)

View File

@@ -555,7 +555,7 @@ describe('/asset', () => {
expect(body).toMatchObject({ id: user1Assets[0].id, livePhotoVideoId: null });
});
it('should update date time original when sidecar file contains DateTimeOriginal', async () => {
it.skip('should update date time original when sidecar file contains DateTimeOriginal', async () => {
const sidecarData = `<?xpacket begin='?' id='W5M0MpCehiHzreSzNTczkc9d'?>
<x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 12.40'>
<rdf:RDF xmlns:rdf='http://www.w3.org/1999/02/22-rdf-syntax-ns#'>

View File

@@ -116,7 +116,7 @@ describe('/partners', () => {
.delete(`/partners/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(status).toBe(204);
});
it('should throw a bad request if partner not found', async () => {

View File

@@ -485,7 +485,7 @@ describe('/shared-links', () => {
.delete(`/shared-links/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(status).toBe(204);
});
});
});

View File

@@ -304,7 +304,7 @@ describe('/users', () => {
const { status } = await request(app)
.delete(`/users/me/license`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(status).toBe(204);
});
});
});

View File

@@ -355,6 +355,9 @@
"trash_number_of_days_description": "Number of days to keep the assets in trash before permanently removing them",
"trash_settings": "Trash Settings",
"trash_settings_description": "Manage trash settings",
"unlink_all_oauth_accounts": "Unlink all OAuth accounts",
"unlink_all_oauth_accounts_description": "Remember to unlink all OAuth accounts before migrating to a new provider.",
"unlink_all_oauth_accounts_prompt": "Are you sure you want to unlink all OAuth accounts? This will reset the OAuth ID for each user and cannot be undone.",
"user_cleanup_job": "User cleanup",
"user_delete_delay": "<b>{user}</b>'s account and assets will be scheduled for permanent deletion in {delay, plural, one {# day} other {# days}}.",
"user_delete_delay_settings": "Delete delay",
@@ -912,6 +915,7 @@
"failed_to_load_notifications": "Failed to load notifications",
"failed_to_load_people": "Failed to load people",
"failed_to_remove_product_key": "Failed to remove product key",
"failed_to_reset_pin_code": "Failed to reset PIN code",
"failed_to_stack_assets": "Failed to stack assets",
"failed_to_unstack_assets": "Failed to un-stack assets",
"failed_to_update_notification_status": "Failed to update notification status",
@@ -920,6 +924,7 @@
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
"something_went_wrong": "Something went wrong",
"unable_to_add_album_users": "Unable to add users to album",
"unable_to_add_assets_to_shared_link": "Unable to add assets to shared link",
"unable_to_add_comment": "Unable to add comment",
@@ -1056,6 +1061,7 @@
"folder_not_found": "Folder not found",
"folders": "Folders",
"folders_feature_description": "Browsing the folder view for the photos and videos on the file system",
"forgot_pin_code_question": "Forgot your PIN?",
"forward": "Forward",
"gcast_enabled": "Google Cast",
"gcast_enabled_description": "This feature loads external resources from Google in order to work.",
@@ -1451,9 +1457,9 @@
"permission_onboarding_permission_limited": "Permission limited. To let Immich backup and manage your entire gallery collection, grant photo and video permissions in Settings.",
"permission_onboarding_request": "Immich requires permission to view your photos and videos.",
"person": "Person",
"person_age_months": "{months} months old",
"person_age_year_months": "1 year, {months} months old",
"person_age_years": "{years} years old",
"person_age_months": "{months, plural, one {# month} other {# months}} old",
"person_age_year_months": "1 year, {months, plural, one {# month} other {# months}} old",
"person_age_years": "{years, plural, other {# years}} old",
"person_birthdate": "Born on {date}",
"person_hidden": "{name}{hidden, select, true { (hidden)} other {}}",
"photo_shared_all_users": "Looks like you shared your photos with all users or you don't have any user to share with.",
@@ -1599,6 +1605,9 @@
"reset_password": "Reset password",
"reset_people_visibility": "Reset people visibility",
"reset_pin_code": "Reset PIN code",
"reset_pin_code_description": "If you forgot your PIN code, you can contact the server administrator to reset it",
"reset_pin_code_success": "Successfully reset PIN code",
"reset_pin_code_with_password": "You can always reset your PIN code with your password",
"reset_sqlite": "Reset SQLite Database",
"reset_sqlite_confirmation": "Are you sure you want to reset the SQLite database? You will need to log out and log in again to resync the data",
"reset_sqlite_success": "Successfully reset the SQLite database",
@@ -1847,6 +1856,7 @@
"sort_created": "Date created",
"sort_items": "Number of items",
"sort_modified": "Date modified",
"sort_newest": "Newest photo",
"sort_oldest": "Oldest photo",
"sort_people_by_similarity": "Sort people by similarity",
"sort_recent": "Most recent photo",

View File

@@ -1,6 +1,6 @@
ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:ce3b954c9285a7a145cba620bae03db836ab890b6b9e0d05a3ca522ea00dfbc9 AS builder-cpu
FROM python:3.11-bookworm@sha256:85c4ac66dea23fbd1beb5c48957c2589d104002f8b11c90a186be421117da5e0 AS builder-cpu
FROM builder-cpu AS builder-openvino
@@ -59,7 +59,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
RUN apt-get update && apt-get install -y --no-install-recommends g++
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:9653efd4380d5a0e5511e337dcfc3b8ba5bc4e6ea7fa3be7716598261d5503fa /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:latest@sha256:cda9608307dbbfc1769f3b6b1f9abf5f1360de0be720f544d29a7ae2863c47ef /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 \
@@ -68,11 +68,11 @@ RUN if [ "$DEVICE" = "rocm" ]; then \
uv pip install /opt/onnxruntime_rocm-*.whl; \
fi
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-cpu
FROM python:3.11-slim-bookworm@sha256:01f98e2d213e1cda58a21dabfd107c4a71c99caa0c932c593acfce05315b7251 AS prod-cpu
ENV LD_PRELOAD=/usr/lib/libmimalloc.so.2
FROM python:3.11-slim-bookworm@sha256:9e1912aab0a30bbd9488eb79063f68f42a68ab0946cbe98fecf197fe5b085506 AS prod-openvino
FROM python:3.11-slim-bookworm@sha256:01f98e2d213e1cda58a21dabfd107c4a71c99caa0c932c593acfce05315b7251 AS prod-openvino
RUN apt-get update && \
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \

View File

@@ -36,7 +36,7 @@ def to_numpy(img: Image.Image) -> NDArray[np.float32]:
def normalize(
img: NDArray[np.float32], mean: float | NDArray[np.float32], std: float | NDArray[np.float32]
) -> NDArray[np.float32]:
return np.divide(img - mean, std, dtype=np.float32)
return (img - mean) / std
def get_pil_resampling(resample: str) -> Image.Resampling:
@@ -58,11 +58,13 @@ def decode_pil(image_bytes: bytes | IO[bytes] | Image.Image) -> Image.Image:
def decode_cv2(image_bytes: NDArray[np.uint8] | bytes | Image.Image) -> NDArray[np.uint8]:
if isinstance(image_bytes, bytes):
image_bytes = decode_pil(image_bytes) # pillow is much faster than cv2
if isinstance(image_bytes, Image.Image):
return pil_to_cv2(image_bytes)
return image_bytes
match image_bytes:
case bytes() | memoryview() | bytearray():
return pil_to_cv2(decode_pil(image_bytes)) # pillow is much faster than cv2
case Image.Image():
return pil_to_cv2(image_bytes)
case _:
return image_bytes
def clean_text(text: str, canonicalize: bool = False) -> str:

View File

@@ -112,8 +112,4 @@ def has_profiling(obj: Any) -> TypeGuard[HasProfiling]:
return hasattr(obj, "profiling") and isinstance(obj.profiling, dict)
def is_ndarray(obj: Any, dtype: "type[np._DTypeScalar_co]") -> "TypeGuard[npt.NDArray[np._DTypeScalar_co]]":
return isinstance(obj, np.ndarray) and obj.dtype == dtype
T = TypeVar("T")

View File

@@ -12,6 +12,7 @@ dependencies = [
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
"numpy<2",
"opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5",
"pillow>=9.5.0,<11.0",

761
machine-learning/uv.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -22,7 +22,7 @@ platform :ios do
path: "./Runner.xcodeproj",
)
increment_version_number(
version_number: "1.137.3"
version_number: "1.138.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -1,12 +1,12 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
class RemoteAlbumService {
final DriftRemoteAlbumRepository _repository;
@@ -26,8 +26,21 @@ class RemoteAlbumService {
return _repository.get(albumId);
}
List<RemoteAlbum> sortAlbums(List<RemoteAlbum> albums, RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
return sortMode.sortFn(albums, isReverse);
Future<List<RemoteAlbum>> sortAlbums(
List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, {
bool isReverse = false,
}) async {
final List<RemoteAlbum> sorted = switch (sortMode) {
RemoteAlbumSortMode.created => albums.sortedBy((album) => album.createdAt),
RemoteAlbumSortMode.title => albums.sortedBy((album) => album.name),
RemoteAlbumSortMode.lastModified => albums.sortedBy((album) => album.updatedAt),
RemoteAlbumSortMode.assetCount => albums.sortedBy((album) => album.assetCount),
RemoteAlbumSortMode.mostRecent => await _sortByNewestAsset(albums),
RemoteAlbumSortMode.mostOldest => await _sortByOldestAsset(albums),
};
return (isReverse ? sorted.reversed : sorted).toList();
}
List<RemoteAlbum> searchAlbums(
@@ -143,4 +156,60 @@ class RemoteAlbumService {
Future<int> getCount() {
return _repository.getCount();
}
Future<List<RemoteAlbum>> _sortByNewestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their newest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {};
for (final album in albums) {
assetTimestampFutures[album.id] = _repository.getNewestAssetTimestamp(album.id);
}
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted;
}
Future<List<RemoteAlbum>> _sortByOldestAsset(List<RemoteAlbum> albums) async {
// map album IDs to their oldest asset dates
final Map<String, Future<DateTime?>> assetTimestampFutures = {
for (final album in albums) album.id: _repository.getOldestAssetTimestamp(album.id),
};
// await all database queries
final entries = await Future.wait(
assetTimestampFutures.entries.map((entry) async => MapEntry(entry.key, await entry.value)),
);
final assetTimestamps = Map.fromEntries(entries);
final sorted = albums.sorted((a, b) {
final aDate = assetTimestamps[a.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = assetTimestamps[b.id] ?? DateTime.fromMillisecondsSinceEpoch(0);
return aDate.compareTo(bDate);
});
return sorted.reversed.toList();
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title"),
assetCount("library_page_sort_asset_count"),
lastModified("library_page_sort_last_modified"),
created("library_page_sort_created"),
mostRecent("sort_newest"),
mostOldest("sort_oldest");
final String key;
const RemoteAlbumSortMode(this.key);
}

View File

@@ -169,6 +169,36 @@ class TimelineService {
return _buffer.elementAt(index - _bufferOffset);
}
/// Gets an asset at the given index, automatically loading the buffer if needed.
/// This is an async version that can handle out-of-range indices by loading the appropriate buffer.
Future<BaseAsset?> getAssetAsync(int index) async {
if (index < 0 || index >= _totalAssets) {
return null;
}
if (hasRange(index, 1)) {
return _buffer.elementAt(index - _bufferOffset);
}
// Load the buffer containing the requested index
try {
final assets = await loadAssets(index, 1);
return assets.isNotEmpty ? assets.first : null;
} catch (e) {
return null;
}
}
/// Safely gets an asset at the given index without throwing a RangeError.
/// Returns null if the index is out of bounds or not currently in the buffer.
/// For automatic buffer loading, use getAssetAsync instead.
BaseAsset? getAssetSafe(int index) {
if (index < 0 || index >= _totalAssets || !hasRange(index, 1)) {
return null;
}
return _buffer.elementAt(index - _bufferOffset);
}
Future<void> dispose() async {
await _bucketSubscription?.cancel();
_bucketSubscription = null;

View File

@@ -265,6 +265,28 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
}).watchSingleOrNull();
}
Future<DateTime?> getNewestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.max()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.max())).getSingleOrNull();
}
Future<DateTime?> getOldestAssetTimestamp(String albumId) {
final query = _db.remoteAlbumAssetEntity.selectOnly()
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
..addColumns([_db.remoteAssetEntity.localDateTime.min()])
..join([
innerJoin(_db.remoteAssetEntity, _db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId)),
]);
return query.map((row) => row.read(_db.remoteAssetEntity.localDateTime.min())).getSingleOrNull();
}
Future<int> getCount() {
return _db.managers.remoteAlbumEntity.count();
}

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/remote_album_bot
import 'package:immich_mobile/presentation/widgets/remote_album/drift_album_option.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@@ -215,22 +216,34 @@ class _RemoteAlbumPageState extends ConsumerState<RemoteAlbumPage> {
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context),
return PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
Future.microtask(() {
if (mounted) {
ref.read(currentRemoteAlbumProvider.notifier).dispose();
ref.read(remoteAlbumProvider.notifier).refresh();
}
});
}
},
child: ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final timelineService = ref.watch(timelineFactoryProvider).remoteAlbum(albumId: _album.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: RemoteAlbumSliverAppBar(
icon: Icons.photo_album_outlined,
onShowOptions: () => showOptionSheet(context),
onToggleAlbumOrder: () => toggleAlbumOrder(),
onEditTitle: () => showEditTitleAndDescription(context),
),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),
bottomSheet: RemoteAlbumBottomSheet(album: _album),
),
);
}

View File

@@ -0,0 +1,174 @@
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class DriftCropImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftCropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
context.pushRoute(DriftEditImageRoute(asset: asset, image: croppedImage, isEdited: true));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}

View File

@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:ui';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class DriftEditImagePage extends ConsumerWidget {
final BaseAsset asset;
final Image image;
final bool isEdited;
const DriftEditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<Uint8List> _imageToUint8List(Image image) async {
final Completer<Uint8List> completer = Completer();
image.image
.resolve(const ImageConfiguration())
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
info.image.toByteData(format: ImageByteFormat.png).then((byteData) {
if (byteData != null) {
completer.complete(byteData.buffer.asUint8List());
} else {
completer.completeError('Failed to convert image to bytes');
}
});
}, onError: (exception, stackTrace) => completer.completeError(exception)),
);
return completer.future;
}
Future<void> _saveEditedImage(BuildContext context, BaseAsset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await _imageToUint8List(image);
LocalAsset? localAsset;
try {
localAsset = await ref
.read(fileMediaRepositoryProvider)
.saveLocalAsset(imageData, title: "${p.withoutExtension(asset.name)}_edited.jpg");
} on PlatformException catch (e) {
// OS might not return the saved image back, so we handle that gracefully
// This can happen if app does not have full library access
Logger("SaveEditedImage").warning("Failed to retrieve the saved image back from OS", e);
}
ref.read(backgroundSyncProvider).syncLocal(full: true);
context.navigator.popUntil((route) => route.isFirst);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!');
if (localAsset == null) {
return;
}
await ref.read(uploadServiceProvider).manualBackup([localAsset]);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftCropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(DriftFilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class DriftFilterImagePage extends HookWidget {
final Image image;
final BaseAsset asset;
const DriftFilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
context.pushRoute(DriftEditImageRoute(asset: asset, image: filteredImage, isEdited: true));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class EditImageActionButton extends ConsumerWidget {
const EditImageActionButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentAsset = ref.watch(currentAssetNotifier);
onPress() {
if (currentAsset == null) {
return;
}
final image = Image(image: getFullImageProvider(currentAsset));
context.navigator.push(
MaterialPageRoute(
builder: (context) => DriftEditImagePage(asset: currentAsset, image: image, isEdited: false),
),
);
}
return BaseActionButton(
iconData: Icons.tune,
label: "edit".t(context: context),
onPressed: onPress,
);
}
}

View File

@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
@@ -18,7 +19,7 @@ import 'package:immich_mobile/providers/infrastructure/current_album.provider.da
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:sliver_tools/sliver_tools.dart';
@@ -137,21 +138,28 @@ class _SortButton extends ConsumerStatefulWidget {
class _SortButtonState extends ConsumerState<_SortButton> {
RemoteAlbumSortMode albumSortOption = RemoteAlbumSortMode.lastModified;
bool albumSortIsReverse = true;
bool isSorting = false;
void onMenuTapped(RemoteAlbumSortMode sortMode) {
Future<void> onMenuTapped(RemoteAlbumSortMode sortMode) async {
final selected = albumSortOption == sortMode;
// Switch direction
if (selected) {
setState(() {
albumSortIsReverse = !albumSortIsReverse;
isSorting = true;
});
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
} else {
setState(() {
albumSortOption = sortMode;
isSorting = true;
});
ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
await ref.read(remoteAlbumProvider.notifier).sortFilteredAlbums(sortMode, isReverse: albumSortIsReverse);
}
setState(() {
isSorting = false;
});
}
@override
@@ -229,6 +237,16 @@ class _SortButtonState extends ConsumerState<_SortButton> {
color: context.colorScheme.onSurface.withAlpha(225),
),
),
isSorting
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colorScheme.onSurface.withAlpha(225),
),
)
: const SizedBox.shrink(),
],
),
);
@@ -423,42 +441,72 @@ class _AlbumList extends ConsumerWidget {
sliver: SliverList.builder(
itemBuilder: (_, index) {
final album = albums[index];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
title: Text(
album.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => onAlbumSelected(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
),
final albumTile = LargeLeadingTile(
title: Text(
album.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'${'items_count'.t(context: context, args: {'count': album.assetCount})} • ${album.ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': album.ownerName}) : 'owned'.t(context: context)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => onAlbumSelected(album),
leadingPadding: const EdgeInsets.only(right: 16),
leading: album.thumbnailAssetId != null
? ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: SizedBox(width: 80, height: 80, child: Thumbnail(remoteId: album.thumbnailAssetId)),
)
: SizedBox(
width: 80,
height: 80,
child: Container(
decoration: BoxDecoration(
color: context.colorScheme.surfaceContainer,
borderRadius: const BorderRadius.all(Radius.circular(16)),
border: Border.all(color: context.colorScheme.outline.withAlpha(50), width: 1),
),
child: const Icon(Icons.photo_album_rounded, size: 24, color: Colors.grey),
),
),
);
final isOwner = album.ownerId == userId;
if (isOwner) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Dismissible(
key: ValueKey(album.id),
background: Container(
color: context.colorScheme.error,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.delete, color: context.colorScheme.onError),
),
direction: DismissDirection.endToStart,
confirmDismiss: (direction) {
return showDialog<bool>(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => true,
title: "delete_album".t(context: context),
content: "album_delete_confirmation".t(context: context, args: {'album': album.name}),
ok: "delete".t(context: context),
),
);
},
onDismissed: (direction) async {
await ref.read(remoteAlbumProvider.notifier).deleteAlbum(album.id);
},
child: albumTile,
),
);
} else {
return Padding(padding: const EdgeInsets.only(bottom: 8.0), child: albumTile);
}
},
itemCount: albums.length,
),

View File

@@ -127,20 +127,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_delayedOperations.clear();
}
// This is used to calculate the scale of the asset when the bottom sheet is showing.
// It is a small increment to ensure that the asset is slightly zoomed in when the
// bottom sheet is showing, which emulates the zoom effect.
double get _getScaleForBottomSheet => (viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) + 0.01;
double _getVerticalOffsetForBottomSheet(double extent) =>
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
Future<void> _precacheImage(int index) async {
if (!mounted || index < 0 || index >= totalAssets) {
if (!mounted) {
return;
}
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null || !mounted) {
return;
}
final asset = ref.read(timelineServiceProvider).getAsset(index);
final screenSize = Size(context.width, context.height);
// Precache both thumbnail and full image for smooth transitions
@@ -152,8 +153,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
);
}
void _onAssetChanged(int index) {
final asset = ref.read(timelineServiceProvider).getAsset(index);
void _onAssetChanged(int index) async {
// Validate index bounds and try to get asset, loading buffer if needed
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
if (asset == null) {
return;
}
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
@@ -217,19 +225,15 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
void _onPageChanged(int index, PhotoViewControllerBase? controller) {
_onAssetChanged(index);
viewController = controller;
// If the bottom sheet is showing, we need to adjust scale the asset to
// emulate the zoom effect
if (showingBottomSheet) {
initialScale = controller?.scale;
controller?.scale = _getScaleForBottomSheet;
}
}
void _onDragStart(
@@ -412,16 +416,22 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
}
void _onAssetReloadEvent() {
setState(() {
final index = pageController.page?.round() ?? 0;
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) {
return;
}
void _onAssetReloadEvent() async {
final index = pageController.page?.round() ?? 0;
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) {
return;
}
final currentAsset = ref.read(currentAssetNotifier);
// Do not reload / close the bottom sheet if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) {
return;
}
setState(() {
_onAssetChanged(pageController.page!.round());
sheetCloseController?.close();
});
@@ -430,7 +440,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale;
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
@@ -468,16 +478,29 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
Widget _placeholderBuilder(BuildContext ctx, ImageChunkEvent? progress, int index) {
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final timelineService = ref.read(timelineServiceProvider);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, show a loading container
if (asset == null) {
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
return Container(
width: double.infinity,
height: double.infinity,
color: backgroundColor,
child: Thumbnail(asset: asset, fit: BoxFit.contain),
child: Thumbnail(asset: displayAsset, fit: BoxFit.contain),
);
}
@@ -493,18 +516,34 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
scaffoldContext ??= ctx;
BaseAsset asset = ref.read(timelineServiceProvider).getAsset(index);
final timelineService = ref.read(timelineServiceProvider);
final asset = timelineService.getAssetSafe(index);
// If asset is not available in buffer, return a placeholder
if (asset == null) {
return PhotoViewGalleryPageOptions.customChild(
heroAttributes: PhotoViewHeroAttributes(tag: 'loading_$index'),
child: Container(
width: ctx.width,
height: ctx.height,
color: backgroundColor,
child: const Center(child: CircularProgressIndicator()),
),
);
}
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
asset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
if (asset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, asset);
if (displayAsset.isImage && !isPlayingMotionVideo) {
return _imageBuilder(ctx, displayAsset);
}
return _videoBuilder(ctx, asset);
return _videoBuilder(ctx, displayAsset);
}
PhotoViewGalleryPageOptions _imageBuilder(BuildContext ctx, BaseAsset asset) {
@@ -515,8 +554,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.999,
minScale: PhotoViewComputedScale.contained * 0.999,
disableScaleGestures: showingBottomSheet,
onDragStart: _onDragStart,
onDragUpdate: _onDragUpdate,
@@ -545,9 +582,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
onTapDown: _onTapDown,
heroAttributes: PhotoViewHeroAttributes(tag: '${asset.heroTag}_$heroOffset'),
filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center,
child: SizedBox(
width: ctx.width,

View File

@@ -6,6 +6,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -38,6 +39,7 @@ class ViewerBottomBar extends ConsumerWidget {
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote && isOwner) const ArchiveActionButton(source: ActionSource.viewer),
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)

View File

@@ -61,7 +61,7 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
),
),
SizedBox(
height: 150,
height: 160,
child: ListView(
padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal,

View File

@@ -11,7 +11,6 @@ import 'package:immich_mobile/domain/services/setting.service.dart';
import 'package:immich_mobile/extensions/codec_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_media.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/one_frame_multi_image_stream_completer.dart';
import 'package:immich_mobile/presentation/widgets/timeline/constants.dart';
import 'package:immich_mobile/providers/image/cache/thumbnail_image_cache_manager.dart';
@@ -107,7 +106,6 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
ImageStreamCompleter loadImage(LocalFullImageProvider key, ImageDecoderCallback decode) {
return OneFramePlaceholderImageStreamCompleter(
_codec(key, decode),
initialImage: getCachedImage(LocalThumbProvider(id: key.id, updatedAt: key.updatedAt)),
informationCollector: () => <DiagnosticsNode>[
DiagnosticsProperty<String>('Id', key.id),
DiagnosticsProperty<DateTime>('Updated at', key.updatedAt),
@@ -117,13 +115,30 @@ class LocalFullImageProvider extends ImageProvider<LocalFullImageProvider> {
}
// Streams in each stage of the image as we ask for it
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) {
Stream<ImageInfo> _codec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
try {
return switch (key.type) {
// First, yield the thumbnail image from LocalThumbProvider
final thumbProvider = LocalThumbProvider(id: key.id, updatedAt: key.updatedAt);
try {
final thumbCodec = await thumbProvider._codec(
thumbProvider,
thumbProvider.cacheManager ?? ThumbnailImageCacheManager(),
decode,
);
final thumbImageInfo = await thumbCodec.getImageInfo();
yield thumbImageInfo;
} catch (_) {}
// Then proceed with the main image loading stream
final mainStream = switch (key.type) {
AssetType.image => _decodeProgressive(key, decode),
AssetType.video => _getThumbnailCodec(key, decode),
_ => throw StateError('Unsupported asset type ${key.type}'),
};
await for (final imageInfo in mainStream) {
yield imageInfo;
}
} catch (error, stack) {
Logger('ImmichLocalImageProvider').severe('Error loading local image ${key.id}', error, stack);
throw const ImageLoadingException('Could not load image from local storage');

View File

@@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/services/upload.service.dart';
import 'package:logging/logging.dart';
class EnqueueStatus {
final int enqueueCount;
@@ -213,6 +214,7 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
final UploadService _uploadService;
StreamSubscription<TaskStatusUpdate>? _statusSubscription;
StreamSubscription<TaskProgressUpdate>? _progressSubscription;
final _logger = Logger("DriftBackupNotifier");
/// Remove upload item from state
void _removeUploadItem(String taskId) {
@@ -333,18 +335,18 @@ class DriftBackupNotifier extends StateNotifier<DriftBackupState> {
}
Future<void> handleBackupResume(String userId) async {
debugPrint("handleBackupResume");
_logger.info("Resuming backup tasks...");
final tasks = await _uploadService.getActiveTasks(kBackupGroup);
debugPrint("Found ${tasks.length} tasks");
_logger.info("Found ${tasks.length} tasks");
if (tasks.isEmpty) {
// Start a new backup queue
debugPrint("Start a new backup queue");
await startBackup(userId);
_logger.info("Start a new backup queue");
return startBackup(userId);
}
debugPrint("Tasks to resume: ${tasks.length}");
await _uploadService.resumeBackup();
_logger.info("Tasks to resume: ${tasks.length}");
return _uploadService.resumeBackup();
}
@override

View File

@@ -31,5 +31,6 @@ class CurrentAlbumNotifier extends AutoDisposeNotifier<RemoteAlbum?> {
void dispose() {
_keepAliveLink?.close();
_assetSubscription?.cancel();
state = null;
}
}

View File

@@ -5,7 +5,6 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/utils/remote_album.utils.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -71,8 +70,8 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
state = state.copyWith(filteredAlbums: state.albums);
}
void sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) {
final sortedAlbums = _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
Future<void> sortFilteredAlbums(RemoteAlbumSortMode sortMode, {bool isReverse = false}) async {
final sortedAlbums = await _remoteAlbumService.sortAlbums(state.filteredAlbums, sortMode, isReverse: isReverse);
state = state.copyWith(filteredAlbums: sortedAlbums);
}

View File

@@ -2,7 +2,8 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart' hide AssetType;
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:photo_manager/photo_manager.dart' hide AssetType;
@@ -15,6 +16,18 @@ class FileMediaRepository {
return AssetMediaRepository.toAsset(entity);
}
Future<LocalAsset?> saveLocalAsset(Uint8List data, {required String title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImage(data, filename: title, title: title, relativePath: relativePath);
return LocalAsset(
id: entity.id,
name: title,
type: AssetType.image,
createdAt: entity.createDateTime,
updatedAt: entity.modifiedDateTime,
);
}
Future<Asset?> saveImageWithFile(String filePath, {String? title, String? relativePath}) async {
final entity = await PhotoManager.editor.saveImageWithPath(filePath, title: title, relativePath: relativePath);
return AssetMediaRepository.toAsset(entity);

View File

@@ -101,6 +101,9 @@ import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_filter.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/search/drift_search.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
@@ -333,6 +336,9 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftBackupOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftAlbumOptionsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftMapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftEditImageRoute.page),
AutoRoute(page: DriftCropImageRoute.page),
AutoRoute(page: DriftFilterImageRoute.page),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -828,6 +828,112 @@ class DriftCreateAlbumRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftCropImagePage]
class DriftCropImageRoute extends PageRouteInfo<DriftCropImageRouteArgs> {
DriftCropImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftCropImageRoute.name,
args: DriftCropImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftCropImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftCropImageRouteArgs>();
return DriftCropImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftCropImageRouteArgs {
const DriftCropImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftCropImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftEditImagePage]
class DriftEditImageRoute extends PageRouteInfo<DriftEditImageRouteArgs> {
DriftEditImageRoute({
Key? key,
required BaseAsset asset,
required Image image,
required bool isEdited,
List<PageRouteInfo>? children,
}) : super(
DriftEditImageRoute.name,
args: DriftEditImageRouteArgs(
key: key,
asset: asset,
image: image,
isEdited: isEdited,
),
initialChildren: children,
);
static const String name = 'DriftEditImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftEditImageRouteArgs>();
return DriftEditImagePage(
key: args.key,
asset: args.asset,
image: args.image,
isEdited: args.isEdited,
);
},
);
}
class DriftEditImageRouteArgs {
const DriftEditImageRouteArgs({
this.key,
required this.asset,
required this.image,
required this.isEdited,
});
final Key? key;
final BaseAsset asset;
final Image image;
final bool isEdited;
@override
String toString() {
return 'DriftEditImageRouteArgs{key: $key, asset: $asset, image: $image, isEdited: $isEdited}';
}
}
/// generated route for
/// [DriftFavoritePage]
class DriftFavoriteRoute extends PageRouteInfo<void> {
@@ -844,6 +950,54 @@ class DriftFavoriteRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftFilterImagePage]
class DriftFilterImageRoute extends PageRouteInfo<DriftFilterImageRouteArgs> {
DriftFilterImageRoute({
Key? key,
required Image image,
required BaseAsset asset,
List<PageRouteInfo>? children,
}) : super(
DriftFilterImageRoute.name,
args: DriftFilterImageRouteArgs(key: key, image: image, asset: asset),
initialChildren: children,
);
static const String name = 'DriftFilterImageRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftFilterImageRouteArgs>();
return DriftFilterImagePage(
key: args.key,
image: args.image,
asset: args.asset,
);
},
);
}
class DriftFilterImageRouteArgs {
const DriftFilterImageRouteArgs({
this.key,
required this.image,
required this.asset,
});
final Key? key;
final Image image;
final BaseAsset asset;
@override
String toString() {
return 'DriftFilterImageRouteArgs{key: $key, image: $image, asset: $asset}';
}
}
/// generated route for
/// [DriftLibraryPage]
class DriftLibraryRoute extends PageRouteInfo<void> {

View File

@@ -1,64 +0,0 @@
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
typedef AlbumSortFn = List<RemoteAlbum> Function(List<RemoteAlbum> albums, bool isReverse);
class _RemoteAlbumSortHandlers {
const _RemoteAlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated;
static List<RemoteAlbum> _sortByCreated(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn title = _sortByTitle;
static List<RemoteAlbum> _sortByTitle(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn lastModified = _sortByLastModified;
static List<RemoteAlbum> _sortByLastModified(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sortedBy((album) => album.updatedAt);
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn assetCount = _sortByAssetCount;
static List<RemoteAlbum> _sortByAssetCount(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<RemoteAlbum> _sortByMostRecent(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
// For most recent, we sort by updatedAt in descending order
return b.updatedAt.compareTo(a.updatedAt);
});
return (isReverse ? sorted.reversed : sorted).toList();
}
static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<RemoteAlbum> _sortByMostOldest(List<RemoteAlbum> albums, bool isReverse) {
final sorted = albums.sorted((a, b) {
// For oldest, we sort by createdAt in ascending order
return a.createdAt.compareTo(b.createdAt);
});
return (isReverse ? sorted.reversed : sorted).toList();
}
}
enum RemoteAlbumSortMode {
title("library_page_sort_title", _RemoteAlbumSortHandlers.title),
assetCount("library_page_sort_asset_count", _RemoteAlbumSortHandlers.assetCount),
lastModified("library_page_sort_last_modified", _RemoteAlbumSortHandlers.lastModified),
created("library_page_sort_created", _RemoteAlbumSortHandlers.created),
mostRecent("sort_recent", _RemoteAlbumSortHandlers.mostRecent),
mostOldest("sort_oldest", _RemoteAlbumSortHandlers.mostOldest);
final String key;
final AlbumSortFn sortFn;
const RemoteAlbumSortMode(this.key, this.sortFn);
}

View File

@@ -14,7 +14,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/remote_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
@@ -74,76 +73,75 @@ class _MesmerizingSliverAppBarState extends ConsumerState<RemoteAlbumSliverAppBa
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
return isMultiSelectEnabled
? SliverToBoxAdapter(
child: switch (_scrollProgress) {
< 0.8 => const SizedBox(height: 120),
_ => const SizedBox(height: 452),
},
)
: SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: IconButton(
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: actionIconColor,
shadows: actionIconShadows,
),
onPressed: () {
ref.read(remoteAlbumProvider.notifier).refresh();
context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()]));
},
if (isMultiSelectEnabled) {
return SliverToBoxAdapter(
child: switch (_scrollProgress) {
< 0.8 => const SizedBox(height: 120),
_ => const SizedBox(height: 452),
},
);
} else {
return SliverAppBar(
expandedHeight: 400.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: IconButton(
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: actionIconColor,
shadows: actionIconShadows,
),
onPressed: () => context.navigateTo(const TabShellRoute(children: [DriftAlbumsRoute()])),
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
actions: [
if (widget.onToggleAlbumOrder != null)
IconButton(
icon: Icon(Icons.swap_vert_rounded, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onToggleAlbumOrder,
),
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
],
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
if (widget.onShowOptions != null)
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
],
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
});
return FlexibleSpaceBar(
centerTitle: true,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
),
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,
onEditTitle: widget.onEditTitle,
),
);
},
),
);
return FlexibleSpaceBar(
centerTitle: true,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
currentAlbum.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
),
background: _ExpandedBackground(
scrollProgress: scrollProgress,
icon: widget.icon,
onEditTitle: widget.onEditTitle,
),
);
},
),
);
}
}
}

View File

@@ -86,6 +86,7 @@ class _ImageWrapperState extends State<ImageWrapper> {
Size? _imageSize;
Object? _lastException;
StackTrace? _lastStack;
bool _didLoadSynchronously = false;
@override
void dispose() {
@@ -130,9 +131,11 @@ class _ImageWrapperState extends State<ImageWrapper> {
_loadingProgress = null;
_lastException = null;
_lastStack = null;
_didLoadSynchronously = synchronousCall;
}
synchronousCall ? setupCB() : setState(setupCB);
synchronousCall && !_didLoadSynchronously ? setupCB() : setState(setupCB);
}
void handleError(dynamic error, StackTrace? stackTrace) {

View File

@@ -25,6 +25,7 @@ migration:
dart run drift_dev make-migrations
translation:
npm --prefix ../web run format:i18n
dart run easy_localization:generate -S ../i18n
dart run bin/generate_keys.dart
dart format lib/generated/codegen_loader.g.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.137.3
- API version: 1.138.0
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
@@ -108,6 +108,7 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**updateAssets**](doc//AssetsApi.md#updateassets) | **PUT** /assets |
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets |
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail |
*AuthAdminApi* | [**unlinkAllOAuthAccountsAdmin**](doc//AuthAdminApi.md#unlinkalloauthaccountsadmin) | **POST** /admin/auth/unlink-all |
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password |
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code |
*AuthenticationApi* | [**getAuthStatus**](doc//AuthenticationApi.md#getauthstatus) | **GET** /auth/status |

View File

@@ -34,6 +34,7 @@ part 'api/api_keys_api.dart';
part 'api/activities_api.dart';
part 'api/albums_api.dart';
part 'api/assets_api.dart';
part 'api/auth_admin_api.dart';
part 'api/authentication_api.dart';
part 'api/deprecated_api.dart';
part 'api/download_api.dart';

View File

@@ -0,0 +1,54 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class AuthAdminApi {
AuthAdminApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission.
///
/// Note: This method returns the HTTP [Response].
Future<Response> unlinkAllOAuthAccountsAdminWithHttpInfo() async {
// ignore: prefer_const_declarations
final apiPath = r'/admin/auth/unlink-all';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission.
Future<void> unlinkAllOAuthAccountsAdmin() async {
final response = await unlinkAllOAuthAccountsAdminWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@@ -150,6 +150,7 @@ class Permission {
static const adminUserPeriodRead = Permission._(r'adminUser.read');
static const adminUserPeriodUpdate = Permission._(r'adminUser.update');
static const adminUserPeriodDelete = Permission._(r'adminUser.delete');
static const adminAuthPeriodUnlinkAll = Permission._(r'adminAuth.unlinkAll');
/// List of all possible values in this [enum][Permission].
static const values = <Permission>[
@@ -280,6 +281,7 @@ class Permission {
adminUserPeriodRead,
adminUserPeriodUpdate,
adminUserPeriodDelete,
adminAuthPeriodUnlinkAll,
];
static Permission? fromJson(dynamic value) => PermissionTypeTransformer().decode(value);
@@ -445,6 +447,7 @@ class PermissionTypeTransformer {
case r'adminUser.read': return Permission.adminUserPeriodRead;
case r'adminUser.update': return Permission.adminUserPeriodUpdate;
case r'adminUser.delete': return Permission.adminUserPeriodDelete;
case r'adminAuth.unlinkAll': return Permission.adminAuthPeriodUnlinkAll;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 1.137.3+3002
version: 1.138.0+3003
environment:
sdk: '>=3.8.0 <4.0.0'

View File

@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/services/remote_album.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
import '../../infrastructure/repository.mock.dart';
void main() {
late RemoteAlbumService sut;
late DriftRemoteAlbumRepository mockRemoteAlbumRepo;
late DriftAlbumApiRepository mockAlbumApiRepo;
setUp(() {
mockRemoteAlbumRepo = MockRemoteAlbumRepository();
mockAlbumApiRepo = MockDriftAlbumApiRepository();
sut = RemoteAlbumService(mockRemoteAlbumRepo, mockAlbumApiRepo);
when(() => mockRemoteAlbumRepo.getNewestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the newest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2023, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2023, 2, 1));
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
when(() => mockRemoteAlbumRepo.getOldestAssetTimestamp(any())).thenAnswer((invocation) {
// Simulate a timestamp for the oldest asset in the album
final albumID = invocation.positionalArguments[0] as String;
if (albumID == '1') {
return Future.value(DateTime(2019, 1, 1));
} else if (albumID == '2') {
return Future.value(DateTime(2019, 2, 1));
}
return Future.value(DateTime.fromMillisecondsSinceEpoch(0));
});
});
final albumA = RemoteAlbum(
id: '1',
name: 'Album A',
description: "",
isActivityEnabled: false,
order: AlbumAssetOrder.asc,
assetCount: 1,
createdAt: DateTime(2023, 1, 1),
updatedAt: DateTime(2023, 1, 2),
ownerId: 'owner1',
ownerName: "Test User",
);
final albumB = RemoteAlbum(
id: '2',
name: 'Album B',
description: "",
isActivityEnabled: false,
order: AlbumAssetOrder.desc,
assetCount: 2,
createdAt: DateTime(2023, 2, 1),
updatedAt: DateTime(2023, 2, 2),
ownerId: 'owner2',
ownerName: "Test User",
);
group('sortAlbums', () {
test('should sort correctly based on name', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.title);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on createdAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.created);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on updatedAt', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.lastModified);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on assetCount', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.assetCount);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on newestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostRecent);
expect(result, [albumA, albumB]);
});
test('should sort correctly based on oldestAssetTimestamp', () async {
final albums = [albumB, albumA];
final result = await sut.sortAlbums(albums, RemoteAlbumSortMode.mostOldest);
expect(result, [albumB, albumA]);
});
});
}

View File

@@ -2,12 +2,14 @@ import 'package:immich_mobile/infrastructure/repositories/device_asset.repositor
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/log.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:mocktail/mocktail.dart';
class MockStoreRepository extends Mock implements IsarStoreRepository {}
@@ -22,6 +24,8 @@ class MockSyncStreamRepository extends Mock implements SyncStreamRepository {}
class MockLocalAlbumRepository extends Mock implements DriftLocalAlbumRepository {}
class MockRemoteAlbumRepository extends Mock implements DriftRemoteAlbumRepository {}
class MockLocalAssetRepository extends Mock implements DriftLocalAssetRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
@@ -30,3 +34,5 @@ class MockStorageRepository extends Mock implements StorageRepository {}
class MockUserApiRepository extends Mock implements UserApiRepository {}
class MockSyncApiRepository extends Mock implements SyncApiRepository {}
class MockDriftAlbumApiRepository extends Mock implements DriftAlbumApiRepository {}

View File

@@ -214,6 +214,34 @@
"description": "This endpoint requires the `activity.delete` permission."
}
},
"/admin/auth/unlink-all": {
"post": {
"operationId": "unlinkAllOAuthAccountsAdmin",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Auth (admin)"
],
"x-immich-admin-only": true,
"x-immich-permission": "adminAuth.unlinkAll",
"description": "This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission."
}
},
"/admin/notifications": {
"post": {
"operationId": "createNotification",
@@ -961,7 +989,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -1252,7 +1280,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -1305,7 +1333,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -2540,7 +2568,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -2575,7 +2603,7 @@
"required": true
},
"responses": {
"201": {
"204": {
"description": ""
}
},
@@ -2610,7 +2638,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -2637,7 +2665,7 @@
"operationId": "lockAuthSession",
"parameters": [],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -2672,7 +2700,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -2894,7 +2922,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -2966,7 +2994,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -3095,7 +3123,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -3217,7 +3245,7 @@
"required": true
},
"responses": {
"201": {
"204": {
"description": ""
}
},
@@ -4224,7 +4252,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -4328,7 +4356,7 @@
"required": true
},
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -4365,7 +4393,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -4558,7 +4586,7 @@
"required": true
},
"responses": {
"201": {
"200": {
"content": {
"application/json": {
"schema": {
@@ -4692,7 +4720,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -5170,7 +5198,7 @@
"required": true
},
"responses": {
"201": {
"200": {
"content": {
"application/json": {
"schema": {
@@ -6222,7 +6250,7 @@
"operationId": "deleteServerLicense",
"parameters": [],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -6935,7 +6963,7 @@
}
],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -8956,7 +8984,7 @@
"operationId": "deleteUserLicense",
"parameters": [],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -9057,7 +9085,7 @@
"operationId": "deleteUserOnboarding",
"parameters": [],
"responses": {
"200": {
"204": {
"description": ""
}
},
@@ -9471,7 +9499,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.137.3",
"version": "1.138.0",
"contact": {}
},
"tags": [],
@@ -12687,7 +12715,8 @@
"adminUser.create",
"adminUser.read",
"adminUser.update",
"adminUser.delete"
"adminUser.delete",
"adminAuth.unlinkAll"
],
"type": "string"
},

View File

@@ -1,12 +1,12 @@
{
"name": "@immich/sdk",
"version": "1.137.3",
"version": "1.138.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@immich/sdk",
"version": "1.137.3",
"version": "1.138.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"

View File

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

View File

@@ -1,6 +1,6 @@
/**
* Immich
* 1.137.3
* 1.138.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -1646,6 +1646,15 @@ export function deleteActivity({ id }: {
method: "DELETE"
}));
}
/**
* This endpoint is an admin-only route, and requires the `adminAuth.unlinkAll` permission.
*/
export function unlinkAllOAuthAccountsAdmin(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/admin/auth/unlink-all", {
...opts,
method: "POST"
}));
}
export function createNotification({ notificationCreateDto }: {
notificationCreateDto: NotificationCreateDto;
}, opts?: Oazapfts.RequestOpts) {
@@ -2969,7 +2978,7 @@ export function linkOAuthAccount({ oAuthCallbackDto }: {
oAuthCallbackDto: OAuthCallbackDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
status: 200;
data: UserAdminResponseDto;
}>("/oauth/link", oazapfts.json({
...opts,
@@ -3160,7 +3169,7 @@ export function mergePerson({ id, mergePersonDto }: {
mergePersonDto: MergePersonDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
status: 200;
data: BulkIdResponseDto[];
}>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({
...opts,
@@ -4669,7 +4678,8 @@ export enum Permission {
AdminUserCreate = "adminUser.create",
AdminUserRead = "adminUser.read",
AdminUserUpdate = "adminUser.update",
AdminUserDelete = "adminUser.delete"
AdminUserDelete = "adminUser.delete",
AdminAuthUnlinkAll = "adminAuth.unlinkAll"
}
export enum AssetMediaStatus {
Created = "created",

View File

@@ -18,7 +18,7 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
RUN rm -rf /usr/src/app
RUN apt-get update && \
RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-11-jre-headless \
vim nano \
-y --no-install-recommends --fix-missing
@@ -69,8 +69,6 @@ RUN sudo apt-get update \
&& sudo apt-get update \
&& sudo apt-get install dcm -y
COPY --chmod=777 ../.devcontainer/mobile/container-mobile-post-create.sh /immich-devcontainer/container-mobile-post-create.sh
RUN dart --disable-analytics
FROM dev AS prod

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.137.3",
"version": "1.138.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.137.3",
"version": "1.138.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@nestjs/bullmq": "^11.0.1",

View File

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

View File

@@ -46,8 +46,8 @@ export class ActivityController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.ActivityDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteActivity(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
AddUsersDto,
@@ -62,6 +62,7 @@ export class AlbumController {
@Delete(':id')
@Authenticated({ permission: Permission.AlbumDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAlbum(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.delete(auth, id);
}
@@ -98,6 +99,7 @@ export class AlbumController {
@Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@@ -109,11 +111,12 @@ export class AlbumController {
@Delete(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeUserFromAlbum(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Param('userId', new ParseMeUUIDPipe({ version: '4' })) userId: string,
) {
): Promise<void> {
return this.service.removeUser(auth, id, userId);
}
}

View File

@@ -41,8 +41,8 @@ export class APIKeyController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.ApiKeyDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteApiKey(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -171,12 +171,12 @@ export class AssetMediaController {
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('exist')
@HttpCode(HttpStatus.OK)
@Authenticated()
@ApiOperation({
summary: 'checkExistingAssets',
description: 'Checks if multiple assets exist on the server and returns all existing - used by background backup',
})
@Authenticated()
@HttpCode(HttpStatus.OK)
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
@@ -188,12 +188,12 @@ export class AssetMediaController {
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@HttpCode(HttpStatus.OK)
@Authenticated()
@ApiOperation({
summary: 'checkBulkUpload',
description: 'Checks if assets exist by checksums',
})
@Authenticated()
@HttpCode(HttpStatus.OK)
checkBulkUpload(
@Auth() auth: AuthDto,
@Body() dto: AssetBulkUploadCheckDto,

View File

@@ -57,15 +57,15 @@ export class AssetController {
}
@Put()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.AssetUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkUpdateDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteAssets(@Auth() auth: AuthDto, @Body() dto: AssetBulkDeleteDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}

View File

@@ -0,0 +1,18 @@
import { Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AuthAdminService } from 'src/services/auth-admin.service';
@ApiTags('Auth (admin)')
@Controller('admin/auth')
export class AuthAdminController {
constructor(private service: AuthAdminService) {}
@Post('unlink-all')
@Authenticated({ permission: Permission.AdminAuthUnlinkAll, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
unlinkAllOAuthAccountsAdmin(@Auth() auth: AuthDto): Promise<void> {
return this.service.unlinkAll(auth);
}
}

View File

@@ -49,22 +49,22 @@ export class AuthController {
}
@Post('validateToken')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
validateAccessToken(): ValidateAccessTokenResponseDto {
return { authStatus: true };
}
@Post('change-password')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AuthChangePassword })
@HttpCode(HttpStatus.OK)
changePassword(@Auth() auth: AuthDto, @Body() dto: ChangePasswordDto): Promise<UserAdminResponseDto> {
return this.service.changePassword(auth, dto);
}
@Post('logout')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
async logout(
@Req() request: Request,
@Res({ passthrough: true }) res: Response,
@@ -88,32 +88,35 @@ export class AuthController {
@Post('pin-code')
@Authenticated({ permission: Permission.PinCodeCreate })
@HttpCode(HttpStatus.NO_CONTENT)
setupPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeSetupDto): Promise<void> {
return this.service.setupPinCode(auth, dto);
}
@Put('pin-code')
@Authenticated({ permission: Permission.PinCodeUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
async changePinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeChangeDto): Promise<void> {
return this.service.changePinCode(auth, dto);
}
@Delete('pin-code')
@Authenticated({ permission: Permission.PinCodeDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async resetPinCode(@Auth() auth: AuthDto, @Body() dto: PinCodeResetDto): Promise<void> {
return this.service.resetPinCode(auth, dto);
}
@Post('session/unlock')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
async unlockAuthSession(@Auth() auth: AuthDto, @Body() dto: SessionUnlockDto): Promise<void> {
return this.service.unlockSession(auth, dto);
}
@Post('session/lock')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.NO_CONTENT)
async lockAuthSession(@Auth() auth: AuthDto): Promise<void> {
return this.service.lockSession(auth);
}

View File

@@ -20,9 +20,9 @@ export class DownloadController {
}
@Post('archive')
@HttpCode(HttpStatus.OK)
@FileResponse()
@Authenticated({ permission: Permission.AssetDownload, sharedLink: true })
@FileResponse()
@HttpCode(HttpStatus.OK)
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -21,12 +21,14 @@ export class DuplicateController {
@Delete()
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteDuplicates(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@Delete(':id')
@Authenticated({ permission: Permission.DuplicateDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteDuplicate(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -42,7 +42,8 @@ export class FaceController {
@Delete(':id')
@Authenticated({ permission: Permission.FaceDelete })
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto) {
@HttpCode(HttpStatus.NO_CONTENT)
deleteFace(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: AssetFaceDeleteDto): Promise<void> {
return this.service.deleteFace(auth, id, dto);
}
}

View File

@@ -4,6 +4,7 @@ import { APIKeyController } from 'src/controllers/api-key.controller';
import { AppController } from 'src/controllers/app.controller';
import { AssetMediaController } from 'src/controllers/asset-media.controller';
import { AssetController } from 'src/controllers/asset.controller';
import { AuthAdminController } from 'src/controllers/auth-admin.controller';
import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller';
@@ -40,6 +41,7 @@ export const controllers = [
AssetController,
AssetMediaController,
AuthController,
AuthAdminController,
DownloadController,
DuplicateController,
FaceController,

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Permission } from 'src/enum';
@@ -18,6 +18,7 @@ export class JobController {
@Post()
@Authenticated({ permission: Permission.JobCreate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}

View File

@@ -43,15 +43,15 @@ export class LibraryController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.LibraryDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(id);
}
@Post(':id/validate')
@HttpCode(200)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
// TODO: change endpoint to validate current settings instead
validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise<ValidateLibraryResponseDto> {
return this.service.validate(id, dto);
@@ -64,9 +64,9 @@ export class LibraryController {
}
@Post(':id/scan')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.LibraryUpdate, admin: true })
scanLibrary(@Param() { id }: UUIDParamDto) {
@HttpCode(HttpStatus.NO_CONTENT)
scanLibrary(@Param() { id }: UUIDParamDto): Promise<void> {
return this.service.queueScan(id);
}
}

View File

@@ -54,8 +54,8 @@ export class MemoryController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.MemoryDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteMemory(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}
@@ -71,8 +71,8 @@ export class MemoryController {
}
@Delete(':id/assets')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.MemoryAssetDelete })
@HttpCode(HttpStatus.OK)
removeMemoryAssets(
@Auth() auth: AuthDto,
@Body() dto: BulkIdsDto,

View File

@@ -25,15 +25,15 @@ export class NotificationAdminController {
}
@Post('test-email')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
sendTestEmailAdmin(@Auth() auth: AuthDto, @Body() dto: SystemConfigSmtpDto): Promise<TestEmailResponseDto> {
return this.service.sendTestEmail(auth.user.id, dto);
}
@Post('templates/:name')
@HttpCode(HttpStatus.OK)
@Authenticated({ admin: true })
@HttpCode(HttpStatus.OK)
getNotificationTemplateAdmin(
@Auth() auth: AuthDto,
@Param('name') name: EmailTemplate,

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import {
@@ -26,12 +26,14 @@ export class NotificationController {
@Put()
@Authenticated({ permission: Permission.NotificationUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
updateNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationUpdateAllDto): Promise<void> {
return this.service.updateAll(auth, dto);
}
@Delete()
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteNotifications(@Auth() auth: AuthDto, @Body() dto: NotificationDeleteAllDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -54,6 +56,7 @@ export class NotificationController {
@Delete(':id')
@Authenticated({ permission: Permission.NotificationDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteNotification(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -70,6 +70,7 @@ export class OAuthController {
@Post('link')
@Authenticated()
@HttpCode(HttpStatus.OK)
linkOAuthAccount(
@Req() request: Request,
@Auth() auth: AuthDto,
@@ -79,8 +80,8 @@ export class OAuthController {
}
@Post('unlink')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
unlinkOAuthAccount(@Auth() auth: AuthDto): Promise<UserAdminResponseDto> {
return this.service.unlink(auth);
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerResponseDto, PartnerSearchDto, UpdatePartnerDto } from 'src/dtos/partner.dto';
@@ -36,6 +36,7 @@ export class PartnerController {
@Delete(':id')
@Authenticated({ permission: Permission.PartnerDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removePartner(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -63,8 +63,8 @@ export class PersonController {
}
@Delete()
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deletePeople(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
return this.service.deleteAll(auth, dto);
}
@@ -86,8 +86,8 @@ export class PersonController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.PersonDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deletePerson(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}
@@ -122,6 +122,7 @@ export class PersonController {
@Post(':id/merge')
@Authenticated({ permission: Permission.PersonMerge })
@HttpCode(HttpStatus.OK)
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,

View File

@@ -27,36 +27,36 @@ export class SearchController {
constructor(private service: SearchService) {}
@Post('metadata')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
searchAssets(@Auth() auth: AuthDto, @Body() dto: MetadataSearchDto): Promise<SearchResponseDto> {
return this.service.searchMetadata(auth, dto);
}
@Post('statistics')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetStatistics })
@HttpCode(HttpStatus.OK)
searchAssetStatistics(@Auth() auth: AuthDto, @Body() dto: StatisticsSearchDto): Promise<SearchStatisticsResponseDto> {
return this.service.searchStatistics(auth, dto);
}
@Post('random')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchRandom(auth, dto);
}
@Post('large-assets')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
searchLargeAssets(@Auth() auth: AuthDto, @Query() dto: LargeAssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.searchLargeAssets(auth, dto);
}
@Post('smart')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetRead })
@HttpCode(HttpStatus.OK)
searchSmart(@Auth() auth: AuthDto, @Body() dto: SmartSearchDto): Promise<SearchResponseDto> {
return this.service.searchSmart(auth, dto);
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Put } from '@nestjs/common';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Put } from '@nestjs/common';
import { ApiNotFoundResponse, ApiTags } from '@nestjs/swagger';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
@@ -104,6 +104,7 @@ export class ServerController {
@Delete('license')
@Authenticated({ permission: Permission.ServerLicenseDelete, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}

View File

@@ -1,4 +1,18 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, Req, Res } from '@nestjs/common';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
Query,
Req,
Res,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express';
import { AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
@@ -73,6 +87,7 @@ export class SharedLinkController {
@Delete(':id')
@Authenticated({ permission: Permission.SharedLinkDelete })
@HttpCode(HttpStatus.NO_CONTENT)
removeSharedLink(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -49,8 +49,8 @@ export class StackController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.StackDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteStack(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.delete(auth, id);
}

View File

@@ -26,23 +26,23 @@ export class SyncController {
) {}
@Post('full-sync')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
getFullSyncForUser(@Auth() auth: AuthDto, @Body() dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
return this.service.getFullSync(auth, dto);
}
@Post('delta-sync')
@HttpCode(HttpStatus.OK)
@Authenticated()
@HttpCode(HttpStatus.OK)
getDeltaSync(@Auth() auth: AuthDto, @Body() dto: AssetDeltaSyncDto): Promise<AssetDeltaSyncResponseDto> {
return this.service.getDeltaSync(auth, dto);
}
@Post('stream')
@Authenticated({ permission: Permission.SyncStream })
@Header('Content-Type', 'application/jsonlines+json')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.SyncStream })
async getSyncStream(@Auth() auth: AuthDto, @Res() res: Response, @Body() dto: SyncStreamDto) {
try {
await this.service.stream(auth, res, dto);
@@ -59,16 +59,16 @@ export class SyncController {
}
@Post('ack')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.SyncCheckpointUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
sendSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckSetDto) {
return this.service.setAcks(auth, dto);
}
@Delete('ack')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.SyncCheckpointDelete })
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto) {
@HttpCode(HttpStatus.NO_CONTENT)
deleteSyncAck(@Auth() auth: AuthDto, @Body() dto: SyncAckDeleteDto): Promise<void> {
return this.service.deleteAcks(auth, dto);
}
}

View File

@@ -21,8 +21,8 @@ export class SystemMetadataController {
}
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.SystemMetadataUpdate, admin: true })
@HttpCode(HttpStatus.NO_CONTENT)
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}

View File

@@ -57,8 +57,8 @@ export class TagController {
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.TagDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteTag(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise<void> {
return this.service.remove(auth, id);
}

View File

@@ -13,22 +13,22 @@ export class TrashController {
constructor(private service: TrashService) {}
@Post('empty')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.empty(auth);
}
@Post('restore')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.restore(auth);
}
@Post('restore/assets')
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.AssetDelete })
@HttpCode(HttpStatus.OK)
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
return this.service.restoreAssets(auth, dto);
}

View File

@@ -84,6 +84,7 @@ export class UserController {
@Delete('me/license')
@Authenticated({ permission: Permission.UserLicenseDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUserLicense(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteLicense(auth);
}
@@ -102,6 +103,7 @@ export class UserController {
@Delete('me/onboarding')
@Authenticated({ permission: Permission.UserOnboardingDelete })
@HttpCode(HttpStatus.NO_CONTENT)
async deleteUserOnboarding(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteOnboarding(auth);
}
@@ -112,11 +114,11 @@ export class UserController {
return this.service.get(id);
}
@Post('profile-image')
@Authenticated({ permission: Permission.UserProfileImageUpdate })
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Post('profile-image')
@Authenticated({ permission: Permission.UserProfileImageUpdate })
createProfileImage(
@Auth() auth: AuthDto,
@UploadedFile() fileInfo: Express.Multer.File,
@@ -125,8 +127,8 @@ export class UserController {
}
@Delete('profile-image')
@HttpCode(HttpStatus.NO_CONTENT)
@Authenticated({ permission: Permission.UserProfileImageDelete })
@HttpCode(HttpStatus.NO_CONTENT)
deleteProfileImage(@Auth() auth: AuthDto): Promise<void> {
return this.service.deleteProfileImage(auth);
}

View File

@@ -235,6 +235,8 @@ export enum Permission {
AdminUserRead = 'adminUser.read',
AdminUserUpdate = 'adminUser.update',
AdminUserDelete = 'adminUser.delete',
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export enum SharedLinkType {

View File

@@ -194,6 +194,10 @@ export class UserRepository {
.executeTakeFirstOrThrow();
}
async updateAll(dto: Updateable<UserTable>) {
await this.db.updateTable('user').set(dto).execute();
}
restore(id: string) {
return this.db
.updateTable('user')

View File

@@ -0,0 +1,11 @@
import { Injectable } from '@nestjs/common';
import { AuthDto } from 'src/dtos/auth.dto';
import { BaseService } from 'src/services/base.service';
@Injectable()
export class AuthAdminService extends BaseService {
async unlinkAll(_auth: AuthDto) {
// TODO replace '' with null
await this.userRepository.updateAll({ oauthId: '' });
}
}

View File

@@ -261,8 +261,12 @@ describe(DatabaseService.name, () => {
await expect(sut.onBootstrap()).rejects.toThrow('Failed to update extension');
expect(mocks.logger.warn.mock.calls[0][0]).toContain(
`The ${extensionName} extension can be updated to ${updateInRange}.`,
expect(mocks.logger.warn.mock.calls).toEqual(
expect.arrayContaining([
expect.arrayContaining([
expect.stringContaining(`The ${extensionName} extension can be updated to ${updateInRange}.`),
]),
]),
);
expect(mocks.logger.fatal).not.toHaveBeenCalled();
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
@@ -281,8 +285,10 @@ describe(DatabaseService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain(extensionName);
expect(mocks.logger.warn.mock.calls).toEqual(
expect.arrayContaining([expect.arrayContaining([expect.stringContaining(extensionName)])]),
);
expect(mocks.database.updateVectorExtension).toHaveBeenCalledWith(extension, updateInRange);
expect(mocks.database.runMigrations).toHaveBeenCalledTimes(1);
expect(mocks.logger.fatal).not.toHaveBeenCalled();
@@ -415,5 +421,21 @@ describe(DatabaseService.name, () => {
expect(mocks.database.dropExtension).not.toHaveBeenCalled();
});
it(`should warn if using pgvecto.rs`, async () => {
mocks.database.getExtensionVersions.mockResolvedValue([
{
name: DatabaseExtension.Vectors,
installedVersion: minVersionInRange,
availableVersion: minVersionInRange,
},
]);
mocks.database.getVectorExtension.mockResolvedValue(DatabaseExtension.Vectors);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.logger.warn).toHaveBeenCalledTimes(1);
expect(mocks.logger.warn.mock.calls[0][0]).toContain('DEPRECATION WARNING');
});
});
});

View File

@@ -53,6 +53,9 @@ const messages = {
`The database currently has ${name} ${installedVersion} activated, but the Postgres instance only has ${availableVersion} available.
This most likely means the extension was downgraded.
If ${name} ${installedVersion} is compatible with Immich, please ensure the Postgres instance has this available.`,
deprecatedExtension: (name: string) =>
`DEPRECATION WARNING: The ${name} extension is deprecated and support for it will be removed very soon.
See https://immich.app/docs/install/upgrading#migrating-to-vectorchord in order to switch to the VectorChord extension instead.`,
};
@Injectable()
@@ -71,6 +74,9 @@ export class DatabaseService extends BaseService {
await this.databaseRepository.withLock(DatabaseLock.Migrations, async () => {
const extension = await this.databaseRepository.getVectorExtension();
const name = EXTENSION_NAMES[extension];
if (extension === DatabaseExtension.Vectors) {
this.logger.warn(messages.deprecatedExtension(name));
}
const extensionRange = this.databaseRepository.getExtensionVersionRange(extension);
const extensionVersions = await this.databaseRepository.getExtensionVersions(VECTOR_EXTENSIONS);

View File

@@ -5,6 +5,7 @@ import { ApiService } from 'src/services/api.service';
import { AssetMediaService } from 'src/services/asset-media.service';
import { AssetService } from 'src/services/asset.service';
import { AuditService } from 'src/services/audit.service';
import { AuthAdminService } from 'src/services/auth-admin.service';
import { AuthService } from 'src/services/auth.service';
import { BackupService } from 'src/services/backup.service';
import { CliService } from 'src/services/cli.service';
@@ -49,6 +50,7 @@ export const services = [
AssetService,
AuditService,
AuthService,
AuthAdminService,
BackupService,
CliService,
DatabaseService,

View File

@@ -62,6 +62,23 @@ describe('compareColumns', () => {
]);
});
it('should detect a change in default', () => {
const source: DatabaseColumn = { ...testColumn, nullable: true };
const target: DatabaseColumn = { ...testColumn, nullable: true, default: "''" };
const reason = `default is different (null vs '')`;
expect(compareColumns.onCompare(source, target)).toEqual([
{
columnName: 'test',
tableName: 'table1',
type: 'ColumnAlter',
changes: {
default: 'NULL',
},
reason,
},
]);
});
it('should detect a comment change', () => {
const source: DatabaseColumn = { ...testColumn, comment: 'new comment' };
const target: DatabaseColumn = { ...testColumn, comment: 'old comment' };

View File

@@ -72,9 +72,9 @@ export const compareColumns = {
tableName: source.tableName,
columnName: source.name,
changes: {
default: String(source.default),
default: String(source.default ?? 'NULL'),
},
reason: `default is different (${source.default} vs ${target.default})`,
reason: `default is different (${source.default ?? 'null'} vs ${target.default})`,
});
}

View File

@@ -175,7 +175,7 @@ export const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) =
if (
withTypeCast(source.default, getColumnType(source)) === target.default ||
source.default === withTypeCast(target.default, getColumnType(target))
withTypeCast(target.default, getColumnType(target)) === source.default
) {
return true;
}

View File

@@ -116,6 +116,20 @@ describe(transformColumns.name, () => {
}),
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]);
});
it('should update the default value to NULL', () => {
expect(
transformColumns(ctx, {
type: 'ColumnAlter',
tableName: 'table1',
columnName: 'column1',
changes: {
default: 'NULL',
},
reason: 'unknown',
}),
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT NULL;`]);
});
});
describe('ColumnDrop', () => {

View File

@@ -0,0 +1,66 @@
import { Kysely } from 'kysely';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { DB } from 'src/schema';
import { AuthAdminService } from 'src/services/auth-admin.service';
import { newMediumService } from 'test/medium.factory';
import { factory } from 'test/small.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(AuthAdminService, {
database: db || defaultDatabase,
real: [UserRepository],
mock: [LoggingRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AuthAdminService.name, () => {
describe('unlinkAll', () => {
it('should reset user.oauthId', async () => {
const { sut, ctx } = setup();
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser({ oauthId: 'test-oauth-id' });
const auth = factory.auth();
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
});
it('should reset a deleted user', async () => {
const { sut, ctx } = setup();
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser({ oauthId: 'test-oauth-id', deletedAt: new Date() });
const auth = factory.auth();
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
});
it('should reset multiple users', async () => {
const { sut, ctx } = setup();
const userRepo = ctx.get(UserRepository);
const { user: user1 } = await ctx.newUser({ oauthId: '1' });
const { user: user2 } = await ctx.newUser({ oauthId: '2', deletedAt: new Date() });
const auth = factory.auth();
await expect(sut.unlinkAll(auth)).resolves.toBeUndefined();
await expect(userRepo.get(user1.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
await expect(userRepo.get(user2.id, { withDeleted: true })).resolves.toEqual(
expect.objectContaining({ oauthId: '' }),
);
});
});
});

41
web/package-lock.json generated
View File

@@ -1,17 +1,17 @@
{
"name": "immich-web",
"version": "1.137.3",
"version": "1.138.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "immich-web",
"version": "1.137.3",
"version": "1.138.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.23.6",
"@immich/ui": "^0.24.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",
@@ -94,7 +94,7 @@
},
"../open-api/typescript-sdk": {
"name": "@immich/sdk",
"version": "1.137.3",
"version": "1.138.0",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@oazapfts/runtime": "^1.0.2"
@@ -1370,15 +1370,15 @@
"link": true
},
"node_modules/@immich/ui": {
"version": "0.23.6",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.23.6.tgz",
"integrity": "sha512-HYIguDx/nCXcvqLKhY1R/+Aks6mn8B9jIiNVQH6WODDPbvGFrvQT5uINhXHrjsdyuzKBVS6dps+lx9+9Z6z4rA==",
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/@immich/ui/-/ui-0.24.0.tgz",
"integrity": "sha512-qqgKYz6g5HPJFtKO12CPzFA8E+dJXZZFoM8WLUUsGjILXHeIX6DeywjZuRC2Ojd3mo9I5KlEDUvHPfv9eh+NXw==",
"license": "GNU Affero General Public License version 3",
"dependencies": {
"@mdi/js": "^7.4.47",
"bits-ui": "^2.0.0",
"tailwind-merge": "^3.0.0",
"tailwind-variants": "^1.0.0"
"tailwind-variants": "^2.0.0"
},
"peerDependencies": {
"svelte": "^5.0.0"
@@ -9085,29 +9085,22 @@
}
},
"node_modules/tailwind-variants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-1.0.0.tgz",
"integrity": "sha512-2WSbv4ulEEyuBKomOunut65D8UZwxrHoRfYnxGcQNnHqlSCp2+B7Yz2W+yrNDrxRodOXtGD/1oCcKGNBnUqMqA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-2.1.0.tgz",
"integrity": "sha512-82m0eRex0z6A3GpvfoTCpHr+wWJmbecfVZfP3mqLoDxeya5tN4mYJQZwa5Aw1hRZTedwpu1D2JizYenoEdyD8w==",
"license": "MIT",
"dependencies": {
"tailwind-merge": "3.0.2"
},
"engines": {
"node": ">=16.x",
"pnpm": ">=7.x"
},
"peerDependencies": {
"tailwind-merge": ">=3.0.0",
"tailwindcss": "*"
}
},
"node_modules/tailwind-variants/node_modules/tailwind-merge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.0.2.tgz",
"integrity": "sha512-l7z+OYZ7mu3DTqrL88RiKrKIqO3NcpEO8V/Od04bNpvk0kiIFndGEoqfuzvj4yuhRkHKjRkII2z+KS2HfPcSxw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
},
"peerDependenciesMeta": {
"tailwind-merge": {
"optional": true
}
}
},
"node_modules/tailwindcss": {

View File

@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "1.137.3",
"version": "1.138.0",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -28,7 +28,7 @@
"dependencies": {
"@formatjs/icu-messageformat-parser": "^2.9.8",
"@immich/sdk": "file:../open-api/typescript-sdk",
"@immich/ui": "^0.23.6",
"@immich/ui": "^0.24.0",
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
"@mdi/js": "^7.4.47",
"@photo-sphere-viewer/core": "^5.11.5",

View File

@@ -1,5 +1,9 @@
<script lang="ts">
import FormatMessage from '$lib/components/i18n/format-message.svelte';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import SettingAccordion from '$lib/components/shared-components/settings/setting-accordion.svelte';
import SettingButtonsRow from '$lib/components/shared-components/settings/setting-buttons-row.svelte';
import SettingInputField from '$lib/components/shared-components/settings/setting-input-field.svelte';
@@ -7,8 +11,10 @@
import SettingSwitch from '$lib/components/shared-components/settings/setting-switch.svelte';
import { SettingInputFieldType } from '$lib/constants';
import AuthDisableLoginConfirmModal from '$lib/modals/AuthDisableLoginConfirmModal.svelte';
import { OAuthTokenEndpointAuthMethod, type SystemConfigDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { handleError } from '$lib/utils/handle-error';
import { OAuthTokenEndpointAuthMethod, unlinkAllOAuthAccountsAdmin, type SystemConfigDto } from '@immich/sdk';
import { Button, modalManager, Text } from '@immich/ui';
import { mdiRestart } from '@mdi/js';
import { isEqual } from 'lodash-es';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -44,6 +50,26 @@
onSave({ passwordLogin: config.passwordLogin, oauth: config.oauth });
};
const handleUnlinkAllOAuthAccounts = async () => {
const confirmed = await modalManager.showDialog({
icon: mdiRestart,
title: $t('admin.unlink_all_oauth_accounts'),
prompt: $t('admin.unlink_all_oauth_accounts_prompt'),
confirmColor: 'danger',
});
if (!confirmed) {
return;
}
try {
await unlinkAllOAuthAccountsAdmin({});
notificationController.show({ message: $t('success'), type: NotificationType.Info });
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
};
</script>
<div>
@@ -56,7 +82,7 @@
subtitle={$t('admin.oauth_settings_description')}
>
<div class="ms-4 mt-4 flex flex-col gap-4">
<p class="text-sm dark:text-immich-dark-fg">
<Text size="small">
<FormatMessage key="admin.oauth_settings_more_details">
{#snippet children({ message })}
<a
@@ -69,7 +95,7 @@
</a>
{/snippet}
</FormatMessage>
</p>
</Text>
<SettingSwitch
{disabled}
@@ -79,6 +105,14 @@
{#if config.oauth.enabled}
<hr />
<div class="flex items-center gap-2 justify-between">
<Text size="small">{$t('admin.unlink_all_oauth_accounts_description')}</Text>
<Button size="small" onclick={handleUnlinkAllOAuthAccounts}
>{$t('admin.unlink_all_oauth_accounts')}</Button
>
</div>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER_URL"

View File

@@ -14,7 +14,7 @@
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import type { AlbumResponseDto, SharedLinkResponseDto, UserResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui';
import { mdiFileImagePlusOutline, mdiFolderDownloadOutline } from '@mdi/js';
import { mdiDownload, mdiFileImagePlusOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import DownloadAction from '../photos-page/actions/download-action.svelte';
@@ -125,7 +125,7 @@
variant="ghost"
aria-label={$t('download')}
onclick={() => downloadAlbum(album)}
icon={mdiFolderDownloadOutline}
icon={mdiDownload}
/>
{/if}
{#if sharedLink.showMetadata && $featureFlags.loaded && $featureFlags.map}

Some files were not shown because too many files have changed in this diff Show More