0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-18 01:24:26 -05:00

Merge branch 'main' into feat/mobile-folder-view

This commit is contained in:
Arno 2025-02-07 16:15:07 +01:00 committed by GitHub
commit aeadf6f5ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
106 changed files with 3184 additions and 1277 deletions

View file

@ -1,2 +1 @@
blank_issues_enabled: false
blank_pull_request_template_enabled: false

View file

@ -1,22 +0,0 @@
## Description
<!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes # (issue)
## How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] Test A
- [ ] Test B
## Screenshots (if appropriate):
## Checklist:
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable

36
.github/pull_request_template.md vendored Normal file
View file

@ -0,0 +1,36 @@
## Description
<!--- Describe your changes in detail -->
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->
Fixes # (issue)
## How Has This Been Tested?
<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration -->
- [ ] Test A
- [ ] Test B
<details><summary><h2>Screenshots (if appropriate)</h2></summary>
<!-- Images go below this line. -->
</details>
<!-- API endpoint changes (if relevant)
## API Changes
The `/api/something` endpoint is now `/api/something-else`
-->
## Checklist:
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable
- [ ] I have no unrelated changes in the PR.
- [ ] I have confirmed that any new dependencies are strictly necessary.
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
- [ ] All code in `src/services` uses repositories implementations for database calls, filesystem operations, etc.
- [ ] All code in `src/repositories/` is pretty basic/simple and does not have any immich specific logic (that belongs in `src/services`)

View file

@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.50.0"
constraints = "4.50.0"
version = "4.52.0"
constraints = "4.52.0"
hashes = [
"h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=",
"h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=",
"h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=",
"h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=",
"h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=",
"h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=",
"h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=",
"h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=",
"h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=",
"h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=",
"h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=",
"h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=",
"h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=",
"h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=",
"zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237",
"zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140",
"zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e",
"zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231",
"zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8",
"zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920",
"zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641",
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160",
"zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96",
"zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e",
"zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06",
"zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67",
"zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509",
"zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac",
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
]
}

View file

@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.50.0"
version = "4.52.0"
}
}
}

View file

@ -2,37 +2,37 @@
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.50.0"
constraints = "4.50.0"
version = "4.52.0"
constraints = "4.52.0"
hashes = [
"h1:0qvD5ZKn2tMZ8cOjQrUSITIC9tKCZbrSaSswV9lOyiU=",
"h1:4N0gplrZ0zOsJv3Kx1VfIx2FwrZHbYU0Un2yfiLZIGQ=",
"h1:81AMQq4kNKU/35U8ElQegUxG4E6xB0erIjG5xVmjIyo=",
"h1:EEQNADUmV3IL6x00yzy04i7OCSLeOMgM9XQkV3w71gA=",
"h1:HD0KI7td6oiSSAnJNn8UPSGf+hKiTo4JVQYfAiU1SqM=",
"h1:Hl+o5LtcvZg2f3l1hh9vaG/DFK6k+dTIZSeM0lXyfpo=",
"h1:ZUO2oIJ6jtZdvl816h0cEIiIeZ/fFCF64+abGEVxZZM=",
"h1:Zio80fnEeUKdlSOhTVskMEFSLUQ6TMsMKnXc+Dy2P2A=",
"h1:aLLvg36evTyqjtXGV2MjAV8imktXFmry7p/xCu9GQC4=",
"h1:azL05eWyy2V8SWkbZZImPWvv8ynG4eqmrbZhjXBDFug=",
"h1:ckMysHY4fJmr7o58XMi+DdgOTB/U/Mf1u1JA9ly3g/I=",
"h1:jxOwjDNjt5WCb4YjjiMsman91O8Y+MAPz6UwJ4a6F+0=",
"h1:u4OfnjSLa4Wk1IUFAzrvMnGgr8MvRHEWVDHEScPK2E8=",
"h1:wQkR1oeSkzlHn3rnVuLJRJLBHlg4EHt7Y64DeTjfkjQ=",
"zh:0ef99ed39472a94e6a0d6fa733cf0a46bce3bf66eba2873efae8846efdddc237",
"zh:2929cbbffcead171d45c88e4a7a59e9c013ea775dafa68b10da8db7cd04b6140",
"zh:462601c87118088e1a718842e367af7d8e7620598d426980a6d6b33de759865e",
"zh:56766eb62a74a9d88d9efb8486dd3a0c5c9db873d0a980ae9ef1e8af27d74231",
"zh:6b4e8810d99498a5a20a5872982a0f1354e79cfc4a7dfe7cc656f1c7eaae47d8",
"zh:6d65bdb4ec94b6eecc8abe26d94e2ca09262dc1e7a9934db829f418be0119920",
"zh:71adeaf31e41a358ec6095004062e43f56ee7d4b2504e5613ab351d511695641",
"h1:2BEJyXJtYC4B4nda/WCYUmuJYDaYk88F8t1pwPzr0iQ=",
"h1:4IASk5SESeWKQ7JU0+M7KApuF5mZyklvwMXPBabim3c=",
"h1:5ImZxxALSnWfH/4EXw/wFirSmk5Tr0ACmcysy51AafE=",
"h1:6TJ3dxLSin4ZKBJLsZDn95H2ZYnGm8S7GGHvvXuuMQU=",
"h1:IzTUjg9kQ4N3qizP9CjYLeHwjsuGgtxwXvfUQWyOLcA=",
"h1:NTaOQfYINA0YTG/V1/9+SYtgX1it63+cBugj4WK4FWc=",
"h1:PXH48LuJn329sCfMXprdMDk51EZaWFyajVvS03qhQLs=",
"h1:Pi5M+GeoMSN2eJ6QnIeXjBf19O+rby/74CfB2ocpv20=",
"h1:ShXZ2ZjBvm3thfoPPzPT8+OhyismnydQVkUAfI8X12w=",
"h1:WQ9hu0Wge2msBbODfottCSKgu8oKUrw4Opz+fDPVVHk=",
"h1:Z5yXML2DE0uH9UU+M0ut9JMQAORcwVZz1CxBHzeBmao=",
"h1:jqI2qKknpleS3JDSplyGYHMu0u9K/tor1ZOjFwDgEMk=",
"h1:kgfutDh14Q5nw4eg6qGFamFxIiY8Ae0FPKRBLDOzpcI=",
"h1:zCAO7GZmfYhWb+i6TfqlqhMeDyPZWGio2IzEzAh3YTs=",
"zh:19be1a91c982b902c42aba47766860dfa5dc151eed1e95fd39ca642229381ef0",
"zh:1de451c4d1ecf7efbe67b6dace3426ba810711afdd644b0f1b870364c8ae91f8",
"zh:352b4a2120173298622e669258744554339d959ac3a95607b117a48ee4a83238",
"zh:3c6f1346d9154afbd2d558fabb4b0150fc8d559aa961254144fe1bc17fe6032f",
"zh:4c4c92d53fb535b1e0eff26f222bbd627b97d3b4c891ec9c321268676d06152f",
"zh:53276f68006c9ceb7cdb10a6ccf91a5c1eadd1407a28edb5741e84e88d7e29e8",
"zh:7925a97773948171a63d4f65bb81ee92fd6d07a447e36012977313293a5435c9",
"zh:7dfb0a4496cfe032437386d0a2cd9229a1956e9c30bd920923c141b0f0440060",
"zh:890df766e9b839623b1f0437355032a3c006226a6c200cd911e15ee1a9014e9f",
"zh:89761c15908ccc2cf9c50bb5cb3be45d3ad0c45fc7c608c6b95f48c0288b7160",
"zh:8cc5d7c5939da89cfd01f3e51c84f3576564783acea9db86bd9e32049805ed96",
"zh:987cff8225b1dd436cdcb4fc6228689ae7e4281de6896412a2a9a3325c49f05e",
"zh:991e83ebb89867d71e01a1c215ed159efb425683b0a44707be8579eb0a337f06",
"zh:ab8177ae2d8f5cfa90043a6f867435012cae115f6061b832a7e2462e0ae87a67",
"zh:d1ca34df1398f201274a6a18102975148c10ca15aa43cfc56cc9897620929509",
"zh:d34946f70201baf6dda03e3b294c6bbe40d95d0278e97b9f636ded94822b24ac",
"zh:8d4aa79f0a414bb4163d771063c70cd991c8fac6c766e685bac2ee12903c5bd6",
"zh:a67540c13565616a7e7e51ee9366e88b0dc60046e1d75c72680e150bd02725bb",
"zh:a936383a4767f5393f38f622e92bf2d0c03fe04b69c284951f27345766c7b31b",
"zh:d4887d73c466ff036eecf50ad6404ba38fd82ea4855296b1846d244b0f13c380",
"zh:e9093c8bd5b6cd99c81666e315197791781b8f93afa14fc2e0f732d1bb2a44b7",
"zh:efd3b3f1ec59a37f635aa1d4efcf178734c2fcf8ddb0d56ea690bec342da8672",
]
}

View file

@ -5,7 +5,7 @@ terraform {
required_providers {
cloudflare = {
source = "cloudflare/cloudflare"
version = "4.50.0"
version = "4.52.0"
}
}
}

View file

@ -103,7 +103,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:11.4.0-ubuntu@sha256:afccec22ba0e4815cca1d2bf3836e414322390dc78d77f1851976ffa8d61051c
image: grafana/grafana:11.5.0-ubuntu@sha256:3c9e2b202eb933a22da5f2b5a22c98a665493f603b452263d9d6f242a87f60d7
volumes:
- grafana-data:/var/lib/grafana

View file

@ -76,7 +76,7 @@ To see local changes to `@immich/ui` in Immich, do the following:
### Mobile app
The mobile app `(/mobile)` will required Flutter toolchain 3.13.x to be installed on your system.
The mobile app `(/mobile)` will required Flutter toolchain 3.13.x and FVM to be installed on your system.
Please refer to the [Flutter's official documentation](https://flutter.dev/docs/get-started/install) for more information on setting up the toolchain on your machine.

View file

@ -11,7 +11,7 @@ You do not need to redo any machine learning jobs after enabling hardware accele
- ARM NN (Mali)
- CUDA (NVIDIA GPUs with [compute capability](https://developer.nvidia.com/cuda-gpus) 5.2 or higher)
- OpenVINO (Intel discrete GPUs such as Iris Xe and Arc)
- OpenVINO (Intel GPUs such as Iris Xe and Arc)
## Limitations
@ -43,8 +43,9 @@ You do not need to redo any machine learning jobs after enabling hardware accele
#### OpenVINO
- The server must have a discrete GPU, i.e. Iris Xe or Arc. Expect issues when attempting to use integrated graphics.
- Integrated GPUs are more likely to experience issues than discrete GPUs, especially for older processors or servers with low RAM.
- Ensure the server's kernel version is new enough to use the device for hardware accceleration.
- Expect higher RAM usage when using OpenVINO compared to CPU processing.
## Setup

View file

@ -27,6 +27,10 @@ SELECT * FROM "assets" WHERE "originalPath" = 'upload/library/admin/2023/2023-09
SELECT * FROM "assets" WHERE "originalPath" LIKE 'upload/library/admin/2023/%';
```
```sql title="Find by ID"
SELECT * FROM "assets" WHERE "id" = '9f94e60f-65b6-47b7-ae44-a4df7b57f0e9';
```
:::note
You can calculate the checksum for a particular file by using the command `sha1sum <filename>`.
:::

View file

@ -41,7 +41,7 @@ className="border rounded-xl"
:::info Permissions
The **pgData** dataset must be owned by the user `netdata` (UID 999) for postgres to start. The other datasets must be owned by the user `root` (UID 0) or a group that includes the user `root` (UID 0) for immich to have the necessary permissions.
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
If the **library** dataset uses ACL it must have [ACL mode](https://www.truenas.com/docs/core/coretutorials/storage/pools/permissions/#access-control-lists) set to `Passthrough` if you plan on using a [storage template](/docs/administration/storage-template.mdx) and the dataset is configured for network sharing (its ACL type is set to `SMB/NFSv4`). When the template is applied and files need to be moved from **upload** to **library**, Immich performs `chmod` internally and needs to be allowed to execute the command. [More info.](https://github.com/immich-app/immich/pull/13017)
:::
## Installing the Immich Application
@ -160,6 +160,10 @@ The image above has example values.
### Additional Storage [(External Libraries)](/docs/features/libraries)
:::danger Advanced Users Only
This feature should only be used by advanced users. If this is your first time installing Immich, then DO NOT mount an external library until you have a working setup. Also, your mount path MUST be something unique and should NOT be your library or upload location or a Linux directory like `/lib`. The picture below shows a valid example.
:::
<img
src={require('./img/truenas10.webp').default}
width="40%"
@ -168,7 +172,7 @@ className="border rounded-xl"
/>
You may configure [External Libraries](/docs/features/libraries) by mounting them using **Additional Storage**.
The **Mount Path** is the loaction you will need to copy and paste into the External Library settings within Immich.
The **Mount Path** is the location you will need to copy and paste into the External Library settings within Immich.
The **Host Path** is the location on the TrueNAS SCALE server where your external library is located.
<!-- A section for Labels would go here but I don't know what they do. -->

View file

@ -72,7 +72,7 @@ alt="Select Plugins > Compose.Manager > Add New Stack > Label it Immich"
</ul>
</details>
5. Click "**Save Changes**", you will be promoted to edit stack UI labels, just leave this blank and click "**Ok**"
5. Click "**Save Changes**", you will be prompted to edit stack UI labels, just leave this blank and click "**Ok**"
6. Select the cog ⚙️ next to Immich, click "**Edit Stack**", then click "**Env File**"
7. Paste the entire contents of the [Immich example.env](https://github.com/immich-app/immich/releases/latest/download/example.env) file into the Unraid editor, then **before saving** edit the following:

View file

@ -110,9 +110,9 @@ const config = {
label: 'API',
},
{
to: '/blog',
href: 'https://immich.store',
position: 'right',
label: 'Blog',
label: 'Merch',
},
{
href: 'https://github.com/immich-app/immich',

View file

@ -44,12 +44,12 @@ export default function VersionSwitcher(): JSX.Element {
return (
versions.length > 0 && (
<DropdownNavbarItem
className="navbar__item"
className="version-switcher-34ab39"
label={label}
mobile={windowSize === 'mobile'}
items={versions.map(({ label, url }) => ({
label,
to: url,
to: url + location.pathname,
target: '_self',
}))}
/>

View file

@ -75,6 +75,11 @@ div[class^='announcementBar_'] {
font-weight: 500;
}
/* workaround for version switcher PR 15894 */
div[class*='navbar__items'] > li:has(a[class*='version-switcher-34ab39']) {
display: none;
}
code {
font-weight: 600;
}

View file

@ -50,6 +50,13 @@ function HomepageHeader() {
>
Demo
</Link>
<Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary/10 dark:bg-gray-300 rounded-xl hover:no-underline text-immich-primary dark:text-immich-dark-bg font-bold uppercase"
to="https://demo.immich.app/"
>
Buy Merch
</Link>
</div>
<div className="my-12 flex gap-1 font-medium place-items-center place-content-center text-immich-primary dark:text-immich-dark-primary">

View file

@ -0,0 +1,86 @@
import { JobCommand, JobName, LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { errorDto } from 'src/responses';
import { app, testAssetDir, utils } from 'src/utils';
import request from 'supertest';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';
describe('/jobs', () => {
let admin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
});
describe('PUT /jobs', () => {
afterEach(async () => {
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Resume,
force: false,
});
});
it('should require authentication', async () => {
const { status, body } = await request(app).put('/jobs/metadataExtraction');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should queue metadata extraction for missing assets', async () => {
const path1 = `${testAssetDir}/formats/raw/Nikon/D700/philadelphia.nef`;
const path2 = `${testAssetDir}/formats/raw/Nikon/D80/glarus.nef`;
await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path1), filename: basename(path1) },
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Pause,
force: false,
});
const { id } = await utils.createAsset(admin.accessToken, {
assetData: { bytes: await readFile(path2), filename: basename(path2) },
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
{
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo?.make).toBeNull();
}
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Empty,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Resume,
force: false,
});
await utils.jobCommand(admin.accessToken, JobName.MetadataExtraction, {
command: JobCommand.Start,
force: false,
});
await utils.waitForQueueFinish(admin.accessToken, 'metadataExtraction');
{
const asset = await utils.getAssetInfo(admin.accessToken, id);
expect(asset.exifInfo).toBeDefined();
expect(asset.exifInfo?.make).toBe('NIKON CORPORATION');
}
});
});
});

View file

@ -1,7 +1,7 @@
import { LoginResponseDto, PersonResponseDto } from '@immich/sdk';
import { getPerson, LoginResponseDto, PersonResponseDto } from '@immich/sdk';
import { uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
@ -195,6 +195,7 @@ describe('/people', () => {
.send({
name: 'New Person',
birthDate: '1990-01-01',
color: '#333',
});
expect(status).toBe(201);
expect(body).toMatchObject({
@ -203,6 +204,22 @@ describe('/people', () => {
birthDate: '1990-01-01T00:00:00.000Z',
});
});
it('should create a favorite person', async () => {
const { status, body } = await request(app)
.post(`/people`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
name: 'New Favorite Person',
isFavorite: true,
});
expect(status).toBe(201);
expect(body).toMatchObject({
id: expect.any(String),
name: 'New Favorite Person',
isFavorite: true,
});
});
});
describe('PUT /people/:id', () => {
@ -216,6 +233,7 @@ describe('/people', () => {
{ key: 'name', type: 'string' },
{ key: 'featureFaceAssetId', type: 'string' },
{ key: 'isHidden', type: 'boolean value' },
{ key: 'isFavorite', type: 'boolean value' },
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(app)
@ -255,6 +273,42 @@ describe('/people', () => {
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: null });
});
it('should set a color', async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ color: '#555' });
expect(status).toBe(200);
expect(body).toMatchObject({ color: '#555' });
});
it('should clear a color', async () => {
const { status, body } = await request(app)
.put(`/people/${visiblePerson.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ color: null });
expect(status).toBe(200);
expect(body.color).toBeUndefined();
});
it('should mark a person as favorite', async () => {
const person = await utils.createPerson(admin.accessToken, {
name: 'visible_person',
});
expect(person.isFavorite).toBe(false);
const { status, body } = await request(app)
.put(`/people/${person.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ isFavorite: true });
expect(status).toBe(200);
expect(body).toMatchObject({ isFavorite: true });
const person2 = await getPerson({ id: person.id }, { headers: asBearerAuth(admin.accessToken) });
expect(person2).toMatchObject({ id: person.id, isFavorite: true });
});
});
describe('POST /people/:id/merge', () => {

View file

@ -89,7 +89,7 @@ describe('/shared-links', () => {
await deleteUserAdmin({ id: user2.userId, userAdminDeleteDto: {} }, { headers: asBearerAuth(admin.accessToken) });
});
describe('GET /share/${key}', () => {
describe('GET /share/:key', () => {
it('should have correct asset count in meta tag for non-empty album', async () => {
const resp = await request(shareUrl).get(`/${linkWithMetadata.key}`);
expect(resp.status).toBe(200);
@ -139,7 +139,10 @@ describe('/shared-links', () => {
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: linkWithAlbum.id }),
expect.objectContaining({ id: linkWithAssets.id }),
expect.objectContaining({
id: linkWithAssets.id,
assets: expect.arrayContaining([expect.objectContaining({ id: asset1.id })]),
}),
expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }),

View file

@ -6,6 +6,8 @@ import {
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
JobCommandDto,
JobName,
MetadataSearchDto,
Permission,
PersonCreateDto,
@ -29,6 +31,7 @@ import {
getConfigDefaults,
login,
searchAssets,
sendJobCommand,
setBaseUrl,
signUpAdmin,
tagAssets,
@ -475,6 +478,9 @@ export const utils = {
tagAssets: (accessToken: string, tagId: string, assetIds: string[]) =>
tagAssets({ id: tagId, bulkIdsDto: { ids: assetIds } }, { headers: asBearerAuth(accessToken) }),
jobCommand: async (accessToken: string, jobName: JobName, jobCommandDto: JobCommandDto) =>
sendJobCommand({ id: jobName, jobCommandDto }, { headers: asBearerAuth(accessToken) }),
setAuthCookies: async (context: BrowserContext, accessToken: string, domain = '127.0.0.1') =>
await context.addCookies([
{

View file

@ -1,4 +1,6 @@
{
"search_by_description_example": "Hiking day in Sapa",
"search_by_description": "Search by description",
"about": "About",
"account": "Account",
"account_settings": "Account Settings",
@ -766,8 +768,10 @@
"go_to_search": "Go to search",
"go_to_folder": "Go to folder",
"group_albums_by": "Group albums by...",
"group_country": "Group by country",
"group_no": "No grouping",
"group_owner": "Group by owner",
"group_places_by": "Group places by...",
"group_year": "Group by year",
"has_quota": "Has quota",
"hi_user": "Hi {name} ({email})",
@ -985,6 +989,7 @@
"pick_a_location": "Pick a location",
"place": "Place",
"places": "Places",
"places_count": "{count, plural, one {{count, number} Place} other {{count, number} Places}}",
"play": "Play",
"play_memories": "Play memories",
"play_motion_photo": "Play Motion Photo",
@ -1276,6 +1281,7 @@
"unfavorite": "Unfavorite",
"unhide_person": "Unhide person",
"unknown": "Unknown",
"unknown_country": "Unknown Country",
"unknown_year": "Unknown Year",
"unlimited": "Unlimited",
"unlink_motion_video": "Unlink motion video",
@ -1350,4 +1356,4 @@
"yes": "Yes",
"you_dont_have_any_shared_links": "You don't have any shared links",
"zoom_image": "Zoom Image"
}
}

View file

@ -51,6 +51,10 @@ start_docker_compose() {
show_friendly_message() {
local ip_address
ip_address=$(hostname -I | awk '{print $1}')
# If length of ip_address is 0, then we are on a Mac
if [ ${#ip_address} -eq 0 ]; then
ip_address=$(ipconfig getifaddr en0)
fi
cat <<EOF
Successfully deployed Immich!
You can access the website or the mobile app at http://$ip_address:2283

View file

@ -106,6 +106,22 @@ COPY --from=builder /opt/venv /opt/venv
COPY ann/ann.py /usr/src/ann/ann.py
COPY start.sh log_conf.json gunicorn_conf.py ./
COPY app .
ARG BUILD_ID
ARG BUILD_IMAGE
ARG BUILD_SOURCE_REF
ARG BUILD_SOURCE_COMMIT
ENV IMMICH_BUILD=${BUILD_ID}
ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID}
ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE}
ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-machine-learning
ENV IMMICH_REPOSITORY=immich-app/immich
ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich
ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF}
ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
ENTRYPOINT ["tini", "--"]
CMD ["./start.sh"]

View file

@ -1,5 +1,7 @@
#!/usr/bin/env sh
echo "Initializing Immich ML $IMMICH_SOURCE_REF"
lib_path="/usr/lib/$(arch)-linux-gnu/libmimalloc.so.2"
# mimalloc seems to increase memory usage dramatically with openvino, need to investigate
if ! [ "$DEVICE" = "openvino" ]; then

View file

@ -1,5 +1,9 @@
{
"search_filter_contextual": "Search by context",
"search_filter_filename": "Search by file name",
"search_filter_description": "Search by description",
"search_no_result": "No results found, try a different search term or combination",
"description_search": "Hiking day in Sapa",
"search_no_more_result": "No more results",
"action_common_back": "Back",
"action_common_cancel": "Cancel",
@ -289,9 +293,9 @@
"header_settings_field_validator_msg": "Value cannot be empty",
"header_settings_header_name_input": "Header name",
"header_settings_header_value_input": "Header value",
"header_settings_page_title": "Proxy Headers (EXPERIMENTAL)",
"header_settings_page_title": "Proxy Headers",
"headers_settings_tile_subtitle": "Define proxy headers the app should send with each network request",
"headers_settings_tile_title": "Custom proxy headers",
"headers_settings_tile_title": "Custom proxy headers (EXPERIMENTAL)",
"home_page_add_to_album_conflicts": "Added {added} assets to album {album}. {failed} assets are already in the album.",
"home_page_add_to_album_err_local": "Can not add local assets to albums yet, skipping",
"home_page_add_to_album_success": "Added {added} assets to album {album}.",

View file

@ -2,3 +2,9 @@ enum SortOrder {
asc,
desc,
}
enum TextSearchType {
context,
filename,
description,
}

View file

@ -545,19 +545,13 @@ enum AssetType {
}
extension AssetTypeEnumHelper on AssetTypeEnum {
AssetType toAssetType() {
switch (this) {
case AssetTypeEnum.IMAGE:
return AssetType.image;
case AssetTypeEnum.VIDEO:
return AssetType.video;
case AssetTypeEnum.AUDIO:
return AssetType.audio;
case AssetTypeEnum.OTHER:
return AssetType.other;
}
throw Exception();
}
AssetType toAssetType() => switch (this) {
AssetTypeEnum.IMAGE => AssetType.image,
AssetTypeEnum.VIDEO => AssetType.video,
AssetTypeEnum.AUDIO => AssetType.audio,
AssetTypeEnum.OTHER => AssetType.other,
_ => throw Exception(),
};
}
/// Describes where the information of this asset came from:

View file

@ -96,25 +96,16 @@ class StoreValue {
int? intValue;
String? strValue;
T? _extract<T>(StoreKey<T> key) {
switch (key.type) {
case const (int):
return intValue as T?;
case const (bool):
return intValue == null ? null : (intValue! == 1) as T;
case const (DateTime):
return intValue == null
T? _extract<T>(StoreKey<T> key) => switch (key.type) {
const (int) => intValue as T?,
const (bool) => intValue == null ? null : (intValue! == 1) as T,
const (DateTime) => intValue == null
? null
: DateTime.fromMicrosecondsSinceEpoch(intValue!) as T;
case const (String):
return strValue as T?;
default:
if (key.fromDb != null) {
return key.fromDb!.call(Store._db, intValue!);
}
}
throw TypeError();
}
: DateTime.fromMicrosecondsSinceEpoch(intValue!) as T,
const (String) => strValue as T?,
_ when key.fromDb != null => key.fromDb!.call(Store._db, intValue!),
_ => throw TypeError(),
};
static Future<StoreValue> _of<T>(T? value, StoreKey<T> key) async {
int? i;

View file

@ -149,56 +149,33 @@ enum AvatarColorEnum {
}
extension AvatarColorEnumHelper on UserAvatarColor {
AvatarColorEnum toAvatarColor() {
switch (this) {
case UserAvatarColor.primary:
return AvatarColorEnum.primary;
case UserAvatarColor.pink:
return AvatarColorEnum.pink;
case UserAvatarColor.red:
return AvatarColorEnum.red;
case UserAvatarColor.yellow:
return AvatarColorEnum.yellow;
case UserAvatarColor.blue:
return AvatarColorEnum.blue;
case UserAvatarColor.green:
return AvatarColorEnum.green;
case UserAvatarColor.purple:
return AvatarColorEnum.purple;
case UserAvatarColor.orange:
return AvatarColorEnum.orange;
case UserAvatarColor.gray:
return AvatarColorEnum.gray;
case UserAvatarColor.amber:
return AvatarColorEnum.amber;
}
return AvatarColorEnum.primary;
}
AvatarColorEnum toAvatarColor() => switch (this) {
UserAvatarColor.primary => AvatarColorEnum.primary,
UserAvatarColor.pink => AvatarColorEnum.pink,
UserAvatarColor.red => AvatarColorEnum.red,
UserAvatarColor.yellow => AvatarColorEnum.yellow,
UserAvatarColor.blue => AvatarColorEnum.blue,
UserAvatarColor.green => AvatarColorEnum.green,
UserAvatarColor.purple => AvatarColorEnum.purple,
UserAvatarColor.orange => AvatarColorEnum.orange,
UserAvatarColor.gray => AvatarColorEnum.gray,
UserAvatarColor.amber => AvatarColorEnum.amber,
_ => AvatarColorEnum.primary,
};
}
extension AvatarColorToColorHelper on AvatarColorEnum {
Color toColor([bool isDarkTheme = false]) {
switch (this) {
case AvatarColorEnum.primary:
return isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF);
case AvatarColorEnum.pink:
return const Color.fromARGB(255, 244, 114, 182);
case AvatarColorEnum.red:
return const Color.fromARGB(255, 239, 68, 68);
case AvatarColorEnum.yellow:
return const Color.fromARGB(255, 234, 179, 8);
case AvatarColorEnum.blue:
return const Color.fromARGB(255, 59, 130, 246);
case AvatarColorEnum.green:
return const Color.fromARGB(255, 22, 163, 74);
case AvatarColorEnum.purple:
return const Color.fromARGB(255, 147, 51, 234);
case AvatarColorEnum.orange:
return const Color.fromARGB(255, 234, 88, 12);
case AvatarColorEnum.gray:
return const Color.fromARGB(255, 75, 85, 99);
case AvatarColorEnum.amber:
return const Color.fromARGB(255, 217, 119, 6);
}
}
Color toColor([bool isDarkTheme = false]) => switch (this) {
AvatarColorEnum.primary =>
isDarkTheme ? const Color(0xFFABCBFA) : const Color(0xFF4250AF),
AvatarColorEnum.pink => const Color.fromARGB(255, 244, 114, 182),
AvatarColorEnum.red => const Color.fromARGB(255, 239, 68, 68),
AvatarColorEnum.yellow => const Color.fromARGB(255, 234, 179, 8),
AvatarColorEnum.blue => const Color.fromARGB(255, 59, 130, 246),
AvatarColorEnum.green => const Color.fromARGB(255, 22, 163, 74),
AvatarColorEnum.purple => const Color.fromARGB(255, 147, 51, 234),
AvatarColorEnum.orange => const Color.fromARGB(255, 234, 88, 12),
AvatarColorEnum.gray => const Color.fromARGB(255, 75, 85, 99),
AvatarColorEnum.amber => const Color.fromARGB(255, 217, 119, 6),
};
}

View file

@ -235,6 +235,7 @@ class SearchDisplayFilters {
class SearchFilter {
String? context;
String? filename;
String? description;
Set<Person> people;
SearchLocationFilter location;
SearchCameraFilter camera;
@ -247,6 +248,7 @@ class SearchFilter {
SearchFilter({
this.context,
this.filename,
this.description,
required this.people,
required this.location,
required this.camera,
@ -258,6 +260,7 @@ class SearchFilter {
bool get isEmpty {
return (context == null || (context != null && context!.isEmpty)) &&
(filename == null || (filename!.isEmpty)) &&
(description == null || (description!.isEmpty)) &&
people.isEmpty &&
location.country == null &&
location.state == null &&
@ -275,6 +278,7 @@ class SearchFilter {
SearchFilter copyWith({
String? context,
String? filename,
String? description,
Set<Person>? people,
SearchLocationFilter? location,
SearchCameraFilter? camera,
@ -285,6 +289,7 @@ class SearchFilter {
return SearchFilter(
context: context ?? this.context,
filename: filename ?? this.filename,
description: description ?? this.description,
people: people ?? this.people,
location: location ?? this.location,
camera: camera ?? this.camera,
@ -296,7 +301,7 @@ class SearchFilter {
@override
String toString() {
return 'SearchFilter(context: $context, filename: $filename, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
return 'SearchFilter(context: $context, filename: $filename, description: $description, people: $people, location: $location, camera: $camera, date: $date, display: $display, mediaType: $mediaType)';
}
@override
@ -305,6 +310,7 @@ class SearchFilter {
return other.context == context &&
other.filename == filename &&
other.description == description &&
other.people == people &&
other.location == location &&
other.camera == camera &&
@ -317,6 +323,7 @@ class SearchFilter {
int get hashCode {
return context.hashCode ^
filename.hashCode ^
description.hashCode ^
people.hashCode ^
location.hashCode ^
camera.hashCode ^

View file

@ -36,32 +36,19 @@ class AppLogPage extends HookConsumerWidget {
);
}
Widget buildLeadingIcon(LogLevel level) {
switch (level) {
case LogLevel.INFO:
return colorStatusIndicator(context.primaryColor);
case LogLevel.SEVERE:
return colorStatusIndicator(Colors.redAccent);
Widget buildLeadingIcon(LogLevel level) => switch (level) {
LogLevel.INFO => colorStatusIndicator(context.primaryColor),
LogLevel.SEVERE => colorStatusIndicator(Colors.redAccent),
LogLevel.WARNING => colorStatusIndicator(Colors.orangeAccent),
_ => colorStatusIndicator(Colors.grey),
};
case LogLevel.WARNING:
return colorStatusIndicator(Colors.orangeAccent);
default:
return colorStatusIndicator(Colors.grey);
}
}
getTileColor(LogLevel level) {
switch (level) {
case LogLevel.INFO:
return Colors.transparent;
case LogLevel.SEVERE:
return Colors.redAccent.withOpacity(0.25);
case LogLevel.WARNING:
return Colors.orangeAccent.withOpacity(0.25);
default:
return context.primaryColor.withOpacity(0.1);
}
}
Color getTileColor(LogLevel level) => switch (level) {
LogLevel.INFO => Colors.transparent,
LogLevel.SEVERE => Colors.redAccent.withOpacity(0.25),
LogLevel.WARNING => Colors.orangeAccent.withOpacity(0.25),
_ => context.primaryColor.withOpacity(0.1),
};
return Scaffold(
appBar: AppBar(

View file

@ -74,26 +74,16 @@ class DownloadTaskTile extends StatelessWidget {
Widget build(BuildContext context) {
final progressPercent = (progress * 100).round();
getStatusText() {
switch (status) {
case TaskStatus.running:
return 'downloading'.tr();
case TaskStatus.complete:
return 'download_complete'.tr();
case TaskStatus.failed:
return 'download_failed'.tr();
case TaskStatus.canceled:
return 'download_canceled'.tr();
case TaskStatus.paused:
return 'download_paused'.tr();
case TaskStatus.enqueued:
return 'download_enqueue'.tr();
case TaskStatus.notFound:
return 'download_notfound'.tr();
case TaskStatus.waitingToRetry:
return 'download_waiting_to_retry'.tr();
}
}
String getStatusText() => switch (status) {
TaskStatus.running => 'downloading'.tr(),
TaskStatus.complete => 'download_complete'.tr(),
TaskStatus.failed => 'download_failed'.tr(),
TaskStatus.canceled => 'download_canceled'.tr(),
TaskStatus.paused => 'download_paused'.tr(),
TaskStatus.enqueued => 'download_enqueue'.tr(),
TaskStatus.notFound => 'download_notfound'.tr(),
TaskStatus.waitingToRetry => 'download_waiting_to_retry'.tr(),
};
return SizedBox(
key: const ValueKey('download_progress'),

View file

@ -26,6 +26,7 @@ import 'package:wakelock_plus/wakelock_plus.dart';
class NativeVideoViewerPage extends HookConsumerWidget {
final Asset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
const NativeVideoViewerPage({
@ -33,6 +34,7 @@ class NativeVideoViewerPage extends HookConsumerWidget {
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
});
@override
@ -317,12 +319,16 @@ class NativeVideoViewerPage extends HookConsumerWidget {
}
// Delay the video playback to avoid a stutter in the swipe animation
// Note, in some circumstances a longer delay is needed (eg: memories),
// the playbackDelayFactor can be used for this
// This delay seems like a hacky way to resolve underlying bugs in video
// playback, but other resolutions failed thus far
Timer(
Platform.isIOS
? const Duration(milliseconds: 300)
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? const Duration(milliseconds: 200)
: const Duration(milliseconds: 400), () {
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor), () {
if (!context.mounted) {
return;
}

View file

@ -20,6 +20,8 @@ class TabControllerPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isRefreshingAssets = ref.watch(assetProvider);
final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider);
final isScreenLandscape =
MediaQuery.orientationOf(context) == Orientation.landscape;
Widget buildIcon({required Widget icon, required bool isProcessing}) {
if (!isProcessing) return icon;
@ -45,7 +47,7 @@ class TabControllerPage extends HookConsumerWidget {
);
}
onNavigationSelected(TabsRouter router, int index) {
void onNavigationSelected(TabsRouter router, int index) {
// On Photos page menu tapped
if (router.activeIndex == 0 && index == 0) {
scrollToTopNotifierProvider.scrollToTop();
@ -61,62 +63,82 @@ class TabControllerPage extends HookConsumerWidget {
ref.read(tabProvider.notifier).state = TabEnum.values[index];
}
bottomNavigationBar(TabsRouter tabsRouter) {
final navigationDestinations = [
NavigationDestination(
label: 'tab_controller_nav_photos'.tr(),
icon: const Icon(
Icons.photo_library_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(
Icons.photo_library,
color: context.primaryColor,
),
),
),
NavigationDestination(
label: 'tab_controller_nav_search'.tr(),
icon: const Icon(
Icons.search_rounded,
),
selectedIcon: Icon(
Icons.search,
color: context.primaryColor,
),
),
NavigationDestination(
label: 'albums'.tr(),
icon: const Icon(
Icons.photo_album_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingRemoteAlbums,
icon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
),
),
NavigationDestination(
label: 'library'.tr(),
icon: const Icon(
Icons.space_dashboard_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
),
),
];
Widget bottomNavigationBar(TabsRouter tabsRouter) {
return NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) =>
onNavigationSelected(tabsRouter, index),
destinations: [
NavigationDestination(
label: 'tab_controller_nav_photos'.tr(),
icon: const Icon(
Icons.photo_library_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(
Icons.photo_library,
color: context.primaryColor,
destinations: navigationDestinations,
);
}
Widget navigationRail(TabsRouter tabsRouter) {
return NavigationRail(
destinations: navigationDestinations
.map(
(e) => NavigationRailDestination(
icon: e.icon,
label: Text(e.label),
),
),
),
NavigationDestination(
label: 'tab_controller_nav_search'.tr(),
icon: const Icon(
Icons.search_rounded,
),
selectedIcon: Icon(
Icons.search,
color: context.primaryColor,
),
),
NavigationDestination(
label: 'albums'.tr(),
icon: const Icon(
Icons.photo_album_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingRemoteAlbums,
icon: Icon(
Icons.photo_album_rounded,
color: context.primaryColor,
),
),
),
NavigationDestination(
label: 'library'.tr(),
icon: const Icon(
Icons.space_dashboard_outlined,
),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(
Icons.space_dashboard_rounded,
color: context.primaryColor,
),
),
),
],
)
.toList(),
onDestinationSelected: (index) =>
onNavigationSelected(tabsRouter, index),
selectedIndex: tabsRouter.activeIndex,
labelType: NavigationRailLabelType.all,
groupAlignment: 0.0,
);
}
@ -135,17 +157,27 @@ class TabControllerPage extends HookConsumerWidget {
),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
final heroedChild = HeroControllerScope(
controller: HeroController(),
child: child,
);
return PopScope(
canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) =>
!didPop ? tabsRouter.setActiveIndex(0) : null,
child: Scaffold(
body: HeroControllerScope(
controller: HeroController(),
child: child,
),
bottomNavigationBar:
multiselectEnabled ? null : bottomNavigationBar(tabsRouter),
body: isScreenLandscape
? Row(
children: [
navigationRail(tabsRouter),
const VerticalDivider(),
Expanded(child: heroedChild),
],
)
: heroedChild,
bottomNavigationBar: multiselectEnabled || isScreenLandscape
? null
: bottomNavigationBar(tabsRouter),
),
);
},

View file

@ -174,33 +174,19 @@ class _AspectRatioButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
IconData iconData;
switch (label) {
case 'Free':
iconData = Icons.crop_free_rounded;
break;
case '1:1':
iconData = Icons.crop_square_rounded;
break;
case '16:9':
iconData = Icons.crop_16_9_rounded;
break;
case '3:2':
iconData = Icons.crop_3_2_rounded;
break;
case '7:5':
iconData = Icons.crop_7_5_rounded;
break;
default:
iconData = Icons.crop_free_rounded;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(
iconData,
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,

View file

@ -136,23 +136,16 @@ class PermissionOnboardingPage extends HookConsumerWidget {
);
}
final Widget child;
switch (permission) {
case PermissionStatus.limited:
child = buildPermissionLimited();
break;
case PermissionStatus.denied:
child = buildRequestPermission();
break;
case PermissionStatus.granted:
case PermissionStatus.provisional:
child = buildPermissionGranted();
break;
case PermissionStatus.restricted:
case PermissionStatus.permanentlyDenied:
child = buildPermissionDenied();
break;
}
final Widget child = switch (permission) {
PermissionStatus.limited => buildPermissionLimited(),
PermissionStatus.denied => buildRequestPermission(),
PermissionStatus.granted ||
PermissionStatus.provisional =>
buildPermissionGranted(),
PermissionStatus.restricted ||
PermissionStatus.permanentlyDenied =>
buildPermissionDenied()
};
return Scaffold(
body: SafeArea(

View file

@ -5,6 +5,8 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
@ -13,6 +15,8 @@ import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@RoutePage()
/// Expects [currentAssetProvider] to be set before navigating to this page
class MemoryPage extends HookConsumerWidget {
final List<Memory> memories;
final int memoryIndex;
@ -32,6 +36,7 @@ class MemoryPage extends HookConsumerWidget {
"${currentAssetPage.value + 1}|${currentMemory.value.assets.length}",
);
const bgColor = Colors.black;
final currentAsset = useState<Asset?>(null);
/// The list of all of the asset page controllers
final memoryAssetPageControllers =
@ -135,6 +140,14 @@ class MemoryPage extends HookConsumerWidget {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
currentAssetPage.value = otherIndex;
updateProgressText();
final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset;
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
@ -274,6 +287,16 @@ class MemoryPage extends HookConsumerWidget {
),
),
),
if (currentAsset.value != null &&
currentAsset.value!.isVideo)
Positioned(
bottom: 24,
right: 32,
child: Icon(
Icons.videocam_outlined,
color: Colors.grey[200],
),
),
],
),
),

View file

@ -5,6 +5,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
@ -31,7 +32,8 @@ class SearchPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final isContextualSearch = useState(true);
final textSearchType = useState<TextSearchType>(TextSearchType.context);
final searchHintText = useState<String>('contextual_search'.tr());
final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
@ -478,37 +480,148 @@ class SearchPage extends HookConsumerWidget {
}
handleTextSubmitted(String value) {
if (isContextualSearch.value) {
filter.value = filter.value.copyWith(
filename: '',
context: value,
);
} else {
filter.value = filter.value.copyWith(
filename: value,
context: '',
);
switch (textSearchType.value) {
case TextSearchType.context:
filter.value = filter.value.copyWith(
filename: '',
context: value,
description: '',
);
break;
case TextSearchType.filename:
filter.value = filter.value.copyWith(
filename: value,
context: '',
description: '',
);
break;
case TextSearchType.description:
filter.value = filter.value.copyWith(
filename: '',
context: '',
description: value,
);
break;
}
search();
}
IconData getSearchPrefixIcon() {
switch (textSearchType.value) {
case TextSearchType.context:
return Icons.image_search_rounded;
case TextSearchType.filename:
return Icons.abc_rounded;
case TextSearchType.description:
return Icons.text_snippet_outlined;
default:
return Icons.search_rounded;
}
}
return Scaffold(
resizeToAvoidBottomInset: true,
appBar: AppBar(
automaticallyImplyLeading: true,
actions: [
Padding(
padding: const EdgeInsets.only(right: 14.0),
child: IconButton(
key: const Key('contextual_search_button'),
icon: isContextualSearch.value
? const Icon(Icons.abc_rounded)
: const Icon(Icons.image_search_rounded),
onPressed: () {
isContextualSearch.value = !isContextualSearch.value;
textSearchController.clear();
padding: const EdgeInsets.only(right: 16.0),
child: MenuAnchor(
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.all(4),
),
),
builder: (
BuildContext context,
MenuController controller,
Widget? child,
) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert_rounded),
tooltip: 'Show text search menu',
);
},
menuChildren: [
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.image_search_rounded),
title: Text(
'search_filter_contextual'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context,
),
onPressed: () {
textSearchType.value = TextSearchType.context;
searchHintText.value = 'contextual_search'.tr();
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.abc_rounded),
title: Text(
'search_filter_filename'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename,
),
onPressed: () {
textSearchType.value = TextSearchType.filename;
searchHintText.value = 'filename_search'.tr();
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(
'search_filter_description'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color:
textSearchType.value == TextSearchType.description
? context.colorScheme.primary
: null,
),
),
selectedColor: context.colorScheme.primary,
selected:
textSearchType.value == TextSearchType.description,
),
onPressed: () {
textSearchType.value = TextSearchType.description;
searchHintText.value = 'description_search'.tr();
},
),
],
),
),
],
@ -539,12 +652,10 @@ class SearchPage extends HookConsumerWidget {
prefixIcon: prefilter != null
? null
: Icon(
Icons.search_rounded,
getSearchPrefixIcon(),
color: context.colorScheme.primary,
),
hintText: isContextualSearch.value
? 'contextual_search'.tr()
: 'filename_search'.tr(),
hintText: searchHintText.value,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.themeData.colorScheme.onSurfaceSecondary,
),

View file

@ -18,15 +18,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
@override
Future<int> count({bool? local}) {
final baseQuery = db.albums.where();
final QueryBuilder<Album, Album, QAfterWhereClause> query;
switch (local) {
case null:
query = baseQuery.noOp();
case true:
query = baseQuery.localIdIsNotNull();
case false:
query = baseQuery.remoteIdIsNotNull();
}
final QueryBuilder<Album, Album, QAfterWhereClause> query = switch (local) {
null => baseQuery.noOp(),
true => baseQuery.localIdIsNotNull(),
false => baseQuery.remoteIdIsNotNull(),
};
return query.count();
}
@ -91,15 +87,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
if (ownerId != null) {
filterQuery = filterQuery.owner((q) => q.isarIdEqualTo(ownerId));
}
final QueryBuilder<Album, Album, QAfterSortBy> query;
switch (sortBy) {
case null:
query = filterQuery.noOp();
case AlbumSort.remoteId:
query = filterQuery.sortByRemoteId();
case AlbumSort.localId:
query = filterQuery.sortByLocalId();
}
final QueryBuilder<Album, Album, QAfterSortBy> query = switch (sortBy) {
null => filterQuery.noOp(),
AlbumSort.remoteId => filterQuery.sortByRemoteId(),
AlbumSort.localId => filterQuery.sortByLocalId(),
};
return query.findAll();
}
@ -150,14 +142,11 @@ class AlbumRepository extends DatabaseRepository implements IAlbumRepository {
query = query.owner(
(q) => q.not().isarIdEqualTo(Store.get(StoreKey.currentUser).isarId),
);
break;
case QuickFilterMode.myAlbums:
query = query.owner(
(q) => q.isarIdEqualTo(Store.get(StoreKey.currentUser).isarId),
);
break;
case QuickFilterMode.all:
default:
break;
}

View file

@ -38,27 +38,20 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
query = query.ownerIdEqualTo(ownerId);
}
switch (state) {
case null:
break;
case AssetState.local:
query = query.remoteIdIsNull();
case AssetState.remote:
query = query.localIdIsNull();
case AssetState.merged:
query = query.localIdIsNotNull().remoteIdIsNotNull();
if (state != null) {
query = switch (state) {
AssetState.local => query.remoteIdIsNull(),
AssetState.remote => query.localIdIsNull(),
AssetState.merged => query.localIdIsNotNull().remoteIdIsNotNull(),
};
}
final QueryBuilder<Asset, Asset, QAfterSortBy> sortedQuery;
switch (sortBy) {
case null:
sortedQuery = query.noOp();
case AssetSort.checksum:
sortedQuery = query.sortByChecksum();
case AssetSort.ownerIdChecksum:
sortedQuery = query.sortByOwnerId().thenByChecksum();
}
final QueryBuilder<Asset, Asset, QAfterSortBy> sortedQuery =
switch (sortBy) {
null => query.noOp(),
AssetSort.checksum => query.sortByChecksum(),
AssetSort.ownerIdChecksum => query.sortByOwnerId().thenByChecksum(),
};
return sortedQuery.findAll();
}
@ -84,16 +77,12 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
AssetState? state,
) {
final query = db.assets.remote(ids).filter();
switch (state) {
case null:
return query.noOp();
case AssetState.local:
return query.remoteIdIsNull();
case AssetState.remote:
return query.localIdIsNull();
case AssetState.merged:
return query.localIdIsNotEmpty().remoteIdIsNotNull();
}
return switch (state) {
null => query.noOp(),
AssetState.local => query.remoteIdIsNull(),
AssetState.remote => query.localIdIsNull(),
AssetState.merged => query.localIdIsNotEmpty().remoteIdIsNotNull(),
};
}
@override
@ -104,39 +93,32 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
int? limit,
}) {
final baseQuery = db.assets.where();
final QueryBuilder<Asset, Asset, QAfterFilterCondition> filteredQuery;
switch (state) {
case null:
filteredQuery = baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp();
case AssetState.local:
filteredQuery = baseQuery
.remoteIdIsNull()
.filter()
.localIdIsNotNull()
.ownerIdEqualTo(ownerId);
case AssetState.remote:
filteredQuery = baseQuery
.localIdIsNull()
.filter()
.remoteIdIsNotNull()
.ownerIdEqualTo(ownerId);
case AssetState.merged:
filteredQuery = baseQuery
.ownerIdEqualToAnyChecksum(ownerId)
.filter()
.remoteIdIsNotNull()
.localIdIsNotNull();
}
final QueryBuilder<Asset, Asset, QAfterFilterCondition> filteredQuery =
switch (state) {
null => baseQuery.ownerIdEqualToAnyChecksum(ownerId).noOp(),
AssetState.local => baseQuery
.remoteIdIsNull()
.filter()
.localIdIsNotNull()
.ownerIdEqualTo(ownerId),
AssetState.remote => baseQuery
.localIdIsNull()
.filter()
.remoteIdIsNotNull()
.ownerIdEqualTo(ownerId),
AssetState.merged => baseQuery
.ownerIdEqualToAnyChecksum(ownerId)
.filter()
.remoteIdIsNotNull()
.localIdIsNotNull(),
};
final QueryBuilder<Asset, Asset, QAfterSortBy> query;
switch (sortBy) {
case null:
query = filteredQuery.noOp();
case AssetSort.checksum:
query = filteredQuery.sortByChecksum();
case AssetSort.ownerIdChecksum:
query = filteredQuery.sortByOwnerId().thenByChecksum();
}
final QueryBuilder<Asset, Asset, QAfterSortBy> query = switch (sortBy) {
null => filteredQuery.noOp(),
AssetSort.checksum => filteredQuery.sortByChecksum(),
AssetSort.ownerIdChecksum =>
filteredQuery.sortByOwnerId().thenByChecksum(),
};
return limit == null ? query.findAll() : query.limit(limit).findAll();
}
@ -155,17 +137,16 @@ class AssetRepository extends DatabaseRepository implements IAssetRepository {
int limit = 100,
}) {
final baseQuery = db.assets.where();
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query;
switch (state) {
case null:
query = baseQuery.noOp();
case AssetState.local:
query = baseQuery.remoteIdIsNull().filter().localIdIsNotNull();
case AssetState.remote:
query = baseQuery.localIdIsNull().filter().remoteIdIsNotNull();
case AssetState.merged:
query = baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull();
}
final QueryBuilder<Asset, Asset, QAfterFilterCondition> query =
switch (state) {
null => baseQuery.noOp(),
AssetState.local =>
baseQuery.remoteIdIsNull().filter().localIdIsNotNull(),
AssetState.remote =>
baseQuery.localIdIsNull().filter().remoteIdIsNotNull(),
AssetState.merged =>
baseQuery.localIdIsNotNull().filter().remoteIdIsNotNull(),
};
return _getMatchesImpl(query, ownerId, assets, limit);
}

View file

@ -14,13 +14,11 @@ class BackupRepository extends DatabaseRepository implements IBackupRepository {
@override
Future<List<BackupAlbum>> getAll({BackupAlbumSort? sort}) {
final baseQuery = db.backupAlbums.where();
final QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> query;
switch (sort) {
case null:
query = baseQuery.noOp();
case BackupAlbumSort.id:
query = baseQuery.sortById();
}
final QueryBuilder<BackupAlbum, BackupAlbum, QAfterSortBy> query =
switch (sort) {
null => baseQuery.noOp(),
BackupAlbumSort.id => baseQuery.sortById(),
};
return query.findAll();
}

View file

@ -25,13 +25,10 @@ class UserRepository extends DatabaseRepository implements IUserRepository {
final int userId = Store.get(StoreKey.currentUser).isarId;
final QueryBuilder<User, User, QAfterWhereClause> afterWhere =
self ? baseQuery.noOp() : baseQuery.isarIdNotEqualTo(userId);
final QueryBuilder<User, User, QAfterSortBy> query;
switch (sortBy) {
case null:
query = afterWhere.noOp();
case UserSort.id:
query = afterWhere.sortById();
}
final QueryBuilder<User, User, QAfterSortBy> query = switch (sortBy) {
null => afterWhere.noOp(),
UserSort.id => afterWhere.sortById(),
};
return query.findAll();
}

View file

@ -519,18 +519,12 @@ class BackupService {
return responseBody.containsKey('id') ? responseBody['id'] : null;
}
String _getAssetType(AssetType assetType) {
switch (assetType) {
case AssetType.audio:
return "AUDIO";
case AssetType.image:
return "IMAGE";
case AssetType.video:
return "VIDEO";
case AssetType.other:
return "OTHER";
}
}
String _getAssetType(AssetType assetType) => switch (assetType) {
AssetType.audio => "AUDIO",
AssetType.image => "IMAGE",
AssetType.video => "VIDEO",
AssetType.other => "OTHER",
};
}
class MultipartRequest extends http.MultipartRequest {

View file

@ -84,6 +84,10 @@ class SearchService {
? filter.filename
: null,
country: filter.location.country,
description:
filter.description != null && filter.description!.isNotEmpty
? filter.description
: null,
state: filter.location.state,
city: filter.location.city,
make: filter.camera.make,

View file

@ -2,13 +2,8 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
/// Returns the suitable [IconData] to represent an [Asset]s storage location
IconData storageIcon(Asset asset) {
switch (asset.storage) {
case AssetState.local:
return Icons.cloud_off_outlined;
case AssetState.remote:
return Icons.cloud_outlined;
case AssetState.merged:
return Icons.cloud_done_outlined;
}
}
IconData storageIcon(Asset asset) => switch (asset.storage) {
AssetState.local => Icons.cloud_off_outlined,
AssetState.remote => Icons.cloud_outlined,
AssetState.merged => Icons.cloud_done_outlined,
};

View file

@ -204,6 +204,13 @@ class ThumbnailImage extends ConsumerWidget {
storageIcon(asset),
color: Colors.white.withOpacity(.8),
size: 16,
shadows: [
Shadow(
blurRadius: 5.0,
color: Colors.black.withOpacity(0.6),
offset: const Offset(0.0, 0.0),
),
],
),
),
if (asset.isFavorite)

View file

@ -15,36 +15,26 @@ class ImmichToast {
final fToast = FToast();
fToast.init(context);
Color getColor(ToastType type, BuildContext context) {
switch (type) {
case ToastType.info:
return context.primaryColor;
case ToastType.success:
return const Color.fromARGB(255, 78, 140, 124);
case ToastType.error:
return const Color.fromARGB(255, 220, 48, 85);
}
}
Color getColor(ToastType type, BuildContext context) => switch (type) {
ToastType.info => context.primaryColor,
ToastType.success => const Color.fromARGB(255, 78, 140, 124),
ToastType.error => const Color.fromARGB(255, 220, 48, 85),
};
Icon getIcon(ToastType type) {
switch (type) {
case ToastType.info:
return Icon(
Icons.info_outline_rounded,
color: context.primaryColor,
);
case ToastType.success:
return const Icon(
Icons.check_circle_rounded,
color: Color.fromARGB(255, 78, 140, 124),
);
case ToastType.error:
return const Icon(
Icons.error_outline_rounded,
color: Color.fromARGB(255, 240, 162, 156),
);
}
}
Icon getIcon(ToastType type) => switch (type) {
ToastType.info => Icon(
Icons.info_outline_rounded,
color: context.primaryColor,
),
ToastType.success => const Icon(
Icons.check_circle_rounded,
color: Color.fromARGB(255, 78, 140, 124),
),
ToastType.error => const Icon(
Icons.error_outline_rounded,
color: Color.fromARGB(255, 240, 162, 156),
),
};
fToast.showToast(
child: Container(

View file

@ -168,7 +168,7 @@ class LoginForm extends HookConsumerWidget {
populateTestLoginInfo1() {
emailController.text = 'testuser@email.com';
passwordController.text = 'password';
serverEndpointController.text = 'http://10.1.15.216:3000/api';
serverEndpointController.text = 'http://10.1.15.216:2283/api';
}
login() async {

View file

@ -75,11 +75,12 @@ class MemoryCard extends StatelessWidget {
key: ValueKey(asset.id),
asset: asset,
showControls: false,
playbackDelayFactor: 2,
image: ImmichImage(
asset,
width: context.width,
height: context.height,
fit: fit,
fit: BoxFit.contain,
),
),
),

View file

@ -4,6 +4,8 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
@ -33,6 +35,13 @@ class MemoryLane extends HookConsumerWidget {
),
onTap: (memoryIndex) {
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
if (memories[memoryIndex].assets.isNotEmpty) {
final asset = memories[memoryIndex].assets[0];
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
}
}
context.pushRoute(
MemoryRoute(
memories: memories,

View file

@ -590,21 +590,15 @@ class _PhotoViewState extends State<PhotoView>
}
/// The default [ScaleStateCycle]
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
switch (actual) {
case PhotoViewScaleState.initial:
return PhotoViewScaleState.covering;
case PhotoViewScaleState.covering:
return PhotoViewScaleState.originalSize;
case PhotoViewScaleState.originalSize:
return PhotoViewScaleState.initial;
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return PhotoViewScaleState.initial;
default:
return PhotoViewScaleState.initial;
}
}
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) =>
switch (actual) {
PhotoViewScaleState.initial => PhotoViewScaleState.covering,
PhotoViewScaleState.covering => PhotoViewScaleState.originalSize,
PhotoViewScaleState.originalSize => PhotoViewScaleState.initial,
PhotoViewScaleState.zoomedIn ||
PhotoViewScaleState.zoomedOut =>
PhotoViewScaleState.initial,
};
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
/// It is used internally to walk in the "doubletap gesture cycle".

View file

@ -9,25 +9,20 @@ double getScaleForScaleState(
PhotoViewScaleState scaleState,
ScaleBoundaries scaleBoundaries,
) {
switch (scaleState) {
case PhotoViewScaleState.initial:
case PhotoViewScaleState.zoomedIn:
case PhotoViewScaleState.zoomedOut:
return _clampSize(scaleBoundaries.initialScale, scaleBoundaries);
case PhotoViewScaleState.covering:
return _clampSize(
return switch (scaleState) {
PhotoViewScaleState.initial ||
PhotoViewScaleState.zoomedIn ||
PhotoViewScaleState.zoomedOut =>
_clampSize(scaleBoundaries.initialScale, scaleBoundaries),
PhotoViewScaleState.covering => _clampSize(
_scaleForCovering(
scaleBoundaries.outerSize,
scaleBoundaries.childSize,
),
scaleBoundaries,
);
case PhotoViewScaleState.originalSize:
return _clampSize(1.0, scaleBoundaries);
// Will never be reached
default:
return 0;
}
),
PhotoViewScaleState.originalSize => _clampSize(1.0, scaleBoundaries),
};
}
/// Internal class to wraps custom scale boundaries (min, max and initial)

View file

@ -220,23 +220,20 @@ class NetworkStatusIcon extends StatelessWidget {
);
}
Widget _buildIcon(BuildContext context) {
switch (status) {
case AuxCheckStatus.loading:
return Padding(
padding: const EdgeInsets.only(left: 4.0),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: context.primaryColor,
strokeWidth: 2,
key: const ValueKey('loading'),
Widget _buildIcon(BuildContext context) => switch (status) {
AuxCheckStatus.loading => Padding(
padding: const EdgeInsets.only(left: 4.0),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: context.primaryColor,
strokeWidth: 2,
key: const ValueKey('loading'),
),
),
),
);
case AuxCheckStatus.valid:
return enabled
AuxCheckStatus.valid => enabled
? const Icon(
Icons.check_circle_rounded,
color: Colors.green,
@ -246,9 +243,8 @@ class NetworkStatusIcon extends StatelessWidget {
Icons.check_circle_rounded,
color: context.colorScheme.onSurface.withAlpha(100),
key: const ValueKey('success'),
);
case AuxCheckStatus.error:
return enabled
),
AuxCheckStatus.error => enabled
? const Icon(
Icons.error_rounded,
color: Colors.red,
@ -258,9 +254,7 @@ class NetworkStatusIcon extends StatelessWidget {
Icons.error_rounded,
color: Colors.grey,
key: ValueKey('error'),
);
default:
return const Icon(Icons.circle_outlined, key: ValueKey('unknown'));
}
}
),
_ => const Icon(Icons.circle_outlined, key: ValueKey('unknown')),
};
}

View file

@ -18,6 +18,7 @@ class MetadataSearchDto {
this.country,
this.createdAfter,
this.createdBefore,
this.description,
this.deviceAssetId,
this.deviceId,
this.encodedVideoPath,
@ -85,6 +86,14 @@ class MetadataSearchDto {
///
DateTime? createdBefore;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? description;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
@ -343,6 +352,7 @@ class MetadataSearchDto {
other.country == country &&
other.createdAfter == createdAfter &&
other.createdBefore == createdBefore &&
other.description == description &&
other.deviceAssetId == deviceAssetId &&
other.deviceId == deviceId &&
other.encodedVideoPath == encodedVideoPath &&
@ -389,6 +399,7 @@ class MetadataSearchDto {
(country == null ? 0 : country!.hashCode) +
(createdAfter == null ? 0 : createdAfter!.hashCode) +
(createdBefore == null ? 0 : createdBefore!.hashCode) +
(description == null ? 0 : description!.hashCode) +
(deviceAssetId == null ? 0 : deviceAssetId!.hashCode) +
(deviceId == null ? 0 : deviceId!.hashCode) +
(encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) +
@ -428,7 +439,7 @@ class MetadataSearchDto {
(withStacked == null ? 0 : withStacked!.hashCode);
@override
String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -457,6 +468,11 @@ class MetadataSearchDto {
} else {
// json[r'createdBefore'] = null;
}
if (this.description != null) {
json[r'description'] = this.description;
} else {
// json[r'description'] = null;
}
if (this.deviceAssetId != null) {
json[r'deviceAssetId'] = this.deviceAssetId;
} else {
@ -643,6 +659,7 @@ class MetadataSearchDto {
country: mapValueOfType<String>(json, r'country'),
createdAfter: mapDateTime(json, r'createdAfter', r''),
createdBefore: mapDateTime(json, r'createdBefore', r''),
description: mapValueOfType<String>(json, r'description'),
deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId'),
deviceId: mapValueOfType<String>(json, r'deviceId'),
encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath'),

View file

@ -14,8 +14,10 @@ class PeopleUpdateItem {
/// Returns a new [PeopleUpdateItem] instance.
PeopleUpdateItem({
this.birthDate,
this.color,
this.featureFaceAssetId,
required this.id,
this.isFavorite,
this.isHidden,
this.name,
});
@ -23,6 +25,8 @@ class PeopleUpdateItem {
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
DateTime? birthDate;
String? color;
/// Asset is used to get the feature face thumbnail.
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -35,6 +39,14 @@ class PeopleUpdateItem {
/// Person id.
String id;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -56,8 +68,10 @@ class PeopleUpdateItem {
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleUpdateItem &&
other.birthDate == birthDate &&
other.color == color &&
other.featureFaceAssetId == featureFaceAssetId &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name;
@ -65,13 +79,15 @@ class PeopleUpdateItem {
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, id=$id, isHidden=$isHidden, name=$name]';
String toString() => 'PeopleUpdateItem[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -80,12 +96,22 @@ class PeopleUpdateItem {
} else {
// json[r'birthDate'] = null;
}
if (this.color != null) {
json[r'color'] = this.color;
} else {
// json[r'color'] = null;
}
if (this.featureFaceAssetId != null) {
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
} else {
// json[r'featureFaceAssetId'] = null;
}
json[r'id'] = this.id;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
@ -109,8 +135,10 @@ class PeopleUpdateItem {
return PeopleUpdateItem(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
);

View file

@ -14,6 +14,8 @@ class PersonCreateDto {
/// Returns a new [PersonCreateDto] instance.
PersonCreateDto({
this.birthDate,
this.color,
this.isFavorite,
this.isHidden,
this.name,
});
@ -21,6 +23,16 @@ class PersonCreateDto {
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
DateTime? birthDate;
String? color;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -42,6 +54,8 @@ class PersonCreateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PersonCreateDto &&
other.birthDate == birthDate &&
other.color == color &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name;
@ -49,11 +63,13 @@ class PersonCreateDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'PersonCreateDto[birthDate=$birthDate, isHidden=$isHidden, name=$name]';
String toString() => 'PersonCreateDto[birthDate=$birthDate, color=$color, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -62,6 +78,16 @@ class PersonCreateDto {
} else {
// json[r'birthDate'] = null;
}
if (this.color != null) {
json[r'color'] = this.color;
} else {
// json[r'color'] = null;
}
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
@ -85,6 +111,8 @@ class PersonCreateDto {
return PersonCreateDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
);

View file

@ -14,7 +14,9 @@ class PersonResponseDto {
/// Returns a new [PersonResponseDto] instance.
PersonResponseDto({
required this.birthDate,
this.color,
required this.id,
this.isFavorite,
required this.isHidden,
required this.name,
required this.thumbnailPath,
@ -23,8 +25,26 @@ class PersonResponseDto {
DateTime? birthDate;
/// This property was added in v1.126.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? color;
String id;
/// This property was added in v1.126.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
bool isHidden;
String name;
@ -43,7 +63,9 @@ class PersonResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate &&
other.color == color &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
@ -53,14 +75,16 @@ class PersonResponseDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode);
@override
String toString() => 'PersonResponseDto[birthDate=$birthDate, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -68,8 +92,18 @@ class PersonResponseDto {
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
} else {
// json[r'birthDate'] = null;
}
if (this.color != null) {
json[r'color'] = this.color;
} else {
// json[r'color'] = null;
}
json[r'id'] = this.id;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
json[r'isHidden'] = this.isHidden;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
@ -91,7 +125,9 @@ class PersonResponseDto {
return PersonResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,

View file

@ -14,7 +14,9 @@ class PersonUpdateDto {
/// Returns a new [PersonUpdateDto] instance.
PersonUpdateDto({
this.birthDate,
this.color,
this.featureFaceAssetId,
this.isFavorite,
this.isHidden,
this.name,
});
@ -22,6 +24,8 @@ class PersonUpdateDto {
/// Person date of birth. Note: the mobile app cannot currently set the birth date to null.
DateTime? birthDate;
String? color;
/// Asset is used to get the feature face thumbnail.
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -31,6 +35,14 @@ class PersonUpdateDto {
///
String? featureFaceAssetId;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
/// Person visibility
///
/// Please note: This property should have been non-nullable! Since the specification file
@ -52,7 +64,9 @@ class PersonUpdateDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.birthDate == birthDate &&
other.color == color &&
other.featureFaceAssetId == featureFaceAssetId &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name;
@ -60,12 +74,14 @@ class PersonUpdateDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode) +
(name == null ? 0 : name!.hashCode);
@override
String toString() => 'PersonUpdateDto[birthDate=$birthDate, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden, name=$name]';
String toString() => 'PersonUpdateDto[birthDate=$birthDate, color=$color, featureFaceAssetId=$featureFaceAssetId, isFavorite=$isFavorite, isHidden=$isHidden, name=$name]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -74,11 +90,21 @@ class PersonUpdateDto {
} else {
// json[r'birthDate'] = null;
}
if (this.color != null) {
json[r'color'] = this.color;
} else {
// json[r'color'] = null;
}
if (this.featureFaceAssetId != null) {
json[r'featureFaceAssetId'] = this.featureFaceAssetId;
} else {
// json[r'featureFaceAssetId'] = null;
}
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
@ -102,7 +128,9 @@ class PersonUpdateDto {
return PersonUpdateDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
name: mapValueOfType<String>(json, r'name'),
);

View file

@ -14,8 +14,10 @@ class PersonWithFacesResponseDto {
/// Returns a new [PersonWithFacesResponseDto] instance.
PersonWithFacesResponseDto({
required this.birthDate,
this.color,
this.faces = const [],
required this.id,
this.isFavorite,
required this.isHidden,
required this.name,
required this.thumbnailPath,
@ -24,10 +26,28 @@ class PersonWithFacesResponseDto {
DateTime? birthDate;
/// This property was added in v1.126.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
String? color;
List<AssetFaceWithoutPersonResponseDto> faces;
String id;
/// This property was added in v1.126.0
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
bool? isFavorite;
bool isHidden;
String name;
@ -46,8 +66,10 @@ class PersonWithFacesResponseDto {
@override
bool operator ==(Object other) => identical(this, other) || other is PersonWithFacesResponseDto &&
other.birthDate == birthDate &&
other.color == color &&
_deepEquality.equals(other.faces, faces) &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
other.name == name &&
other.thumbnailPath == thumbnailPath &&
@ -57,15 +79,17 @@ class PersonWithFacesResponseDto {
int get hashCode =>
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(faces.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode) +
(updatedAt == null ? 0 : updatedAt!.hashCode);
@override
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, faces=$faces, id=$id, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
String toString() => 'PersonWithFacesResponseDto[birthDate=$birthDate, color=$color, faces=$faces, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -73,9 +97,19 @@ class PersonWithFacesResponseDto {
json[r'birthDate'] = _dateFormatter.format(this.birthDate!.toUtc());
} else {
// json[r'birthDate'] = null;
}
if (this.color != null) {
json[r'color'] = this.color;
} else {
// json[r'color'] = null;
}
json[r'faces'] = this.faces;
json[r'id'] = this.id;
if (this.isFavorite != null) {
json[r'isFavorite'] = this.isFavorite;
} else {
// json[r'isFavorite'] = null;
}
json[r'isHidden'] = this.isHidden;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
@ -97,8 +131,10 @@ class PersonWithFacesResponseDto {
return PersonWithFacesResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: mapValueOfType<String>(json, r'color'),
faces: AssetFaceWithoutPersonResponseDto.listFromJson(json[r'faces']),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: mapValueOfType<bool>(json, r'isFavorite'),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,

View file

@ -9949,6 +9949,9 @@
"format": "date-time",
"type": "string"
},
"description": {
"type": "string"
},
"deviceAssetId": {
"type": "string"
},
@ -10283,6 +10286,10 @@
"nullable": true,
"type": "string"
},
"color": {
"nullable": true,
"type": "string"
},
"featureFaceAssetId": {
"description": "Asset is used to get the feature face thumbnail.",
"type": "string"
@ -10291,6 +10298,9 @@
"description": "Person id.",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10396,6 +10406,13 @@
"nullable": true,
"type": "string"
},
"color": {
"nullable": true,
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10414,9 +10431,17 @@
"nullable": true,
"type": "string"
},
"color": {
"description": "This property was added in v1.126.0",
"type": "string"
},
"id": {
"type": "string"
},
"isFavorite": {
"description": "This property was added in v1.126.0",
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
@ -10460,10 +10485,17 @@
"nullable": true,
"type": "string"
},
"color": {
"nullable": true,
"type": "string"
},
"featureFaceAssetId": {
"description": "Asset is used to get the feature face thumbnail.",
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"description": "Person visibility",
"type": "boolean"
@ -10482,6 +10514,10 @@
"nullable": true,
"type": "string"
},
"color": {
"description": "This property was added in v1.126.0",
"type": "string"
},
"faces": {
"items": {
"$ref": "#/components/schemas/AssetFaceWithoutPersonResponseDto"
@ -10491,6 +10527,10 @@
"id": {
"type": "string"
},
"isFavorite": {
"description": "This property was added in v1.126.0",
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
@ -12591,7 +12631,6 @@
"properties": {
"color": {
"nullable": true,
"pattern": "^#?([0-9A-F]{3}|[0-9A-F]{4}|[0-9A-F]{6}|[0-9A-F]{8})$",
"type": "string"
}
},

View file

@ -213,8 +213,12 @@ export type AssetFaceWithoutPersonResponseDto = {
};
export type PersonWithFacesResponseDto = {
birthDate: string | null;
/** This property was added in v1.126.0 */
color?: string;
faces: AssetFaceWithoutPersonResponseDto[];
id: string;
/** This property was added in v1.126.0 */
isFavorite?: boolean;
isHidden: boolean;
name: string;
thumbnailPath: string;
@ -491,7 +495,11 @@ export type DuplicateResponseDto = {
};
export type PersonResponseDto = {
birthDate: string | null;
/** This property was added in v1.126.0 */
color?: string;
id: string;
/** This property was added in v1.126.0 */
isFavorite?: boolean;
isHidden: boolean;
name: string;
thumbnailPath: string;
@ -689,6 +697,8 @@ export type PersonCreateDto = {
/** Person date of birth.
Note: the mobile app cannot currently set the birth date to null. */
birthDate?: string | null;
color?: string | null;
isFavorite?: boolean;
/** Person visibility */
isHidden?: boolean;
/** Person name. */
@ -698,10 +708,12 @@ export type PeopleUpdateItem = {
/** Person date of birth.
Note: the mobile app cannot currently set the birth date to null. */
birthDate?: string | null;
color?: string | null;
/** Asset is used to get the feature face thumbnail. */
featureFaceAssetId?: string;
/** Person id. */
id: string;
isFavorite?: boolean;
/** Person visibility */
isHidden?: boolean;
/** Person name. */
@ -714,8 +726,10 @@ export type PersonUpdateDto = {
/** Person date of birth.
Note: the mobile app cannot currently set the birth date to null. */
birthDate?: string | null;
color?: string | null;
/** Asset is used to get the feature face thumbnail. */
featureFaceAssetId?: string;
isFavorite?: boolean;
/** Person visibility */
isHidden?: boolean;
/** Person name. */
@ -769,6 +783,7 @@ export type MetadataSearchDto = {
country?: string | null;
createdAfter?: string;
createdBefore?: string;
description?: string;
deviceAssetId?: string;
deviceId?: string;
encodedVideoPath?: string;

View file

@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20250123@sha256:04eba5cd87d61bc3d20a3915b2302f04d08fbc329c55ee0cde103c502f59f412 AS dev
FROM ghcr.io/immich-app/base-server-dev:20250204@sha256:8b203f19f4d5cf4619b60ee5f50d6d4b5ea3745747f5e5170d1b7404ebeb0792 AS dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@ -42,7 +42,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20250123@sha256:591739983913f82672d8191258f3a1a24c123db0d619ff91fca8fef431ee1338
FROM ghcr.io/immich-app/base-server-prod:20250204@sha256:2af3da713d5ab3ccca23b216b747557ea6016117e72deac101e35069ccaf9b5e
WORKDIR /usr/src/app
ENV NODE_ENV=production \

1872
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -35,16 +35,16 @@
"email:dev": "email dev -p 3050 --dir src/emails"
},
"dependencies": {
"@nestjs/bullmq": "^11.0.0",
"@nestjs/common": "^10.2.2",
"@nestjs/core": "^10.2.2",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
"@nestjs/event-emitter": "^3.0.0",
"@nestjs/platform-express": "^10.2.2",
"@nestjs/platform-socket.io": "^10.2.2",
"@nestjs/platform-express": "^11.0.4",
"@nestjs/platform-socket.io": "^11.0.4",
"@nestjs/schedule": "^5.0.0",
"@nestjs/swagger": "^8.0.0",
"@nestjs/typeorm": "^10.0.0",
"@nestjs/websockets": "^10.2.2",
"@nestjs/swagger": "^11.0.2",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.0.4",
"@opentelemetry/auto-instrumentations-node": "^0.55.0",
"@opentelemetry/context-async-hooks": "^1.24.0",
"@opentelemetry/exporter-prometheus": "^0.57.0",
@ -72,9 +72,9 @@
"kysely-postgres-js": "^2.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",
"nest-commander": "^3.11.1",
"nestjs-cls": "^4.3.0",
"nestjs-kysely": "^1.0.0",
"nest-commander": "^3.16.0",
"nestjs-cls": "^5.0.0",
"nestjs-kysely": "^1.1.0",
"nestjs-otel": "^6.0.0",
"nodemailer": "^6.9.13",
"openid-client": "^5.4.3",
@ -97,9 +97,9 @@
"devDependencies": {
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@nestjs/cli": "^10.1.16",
"@nestjs/schematics": "^10.0.2",
"@nestjs/testing": "^10.2.2",
"@nestjs/cli": "^11.0.2",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.4",
"@swc/core": "^1.4.14",
"@testcontainers/postgresql": "^10.2.1",
"@types/archiver": "^6.0.0",

18
server/src/db.d.ts vendored
View file

@ -276,9 +276,11 @@ export interface Partners {
export interface Person {
birthDate: Timestamp | null;
color: string | null;
createdAt: Generated<Timestamp>;
faceAssetId: string | null;
id: Generated<string>;
isFavorite: Generated<boolean>;
isHidden: Generated<boolean>;
name: Generated<string>;
ownerId: string;
@ -327,11 +329,6 @@ export interface SocketIoAttachments {
payload: Buffer | null;
}
export interface SystemConfig {
key: string;
value: string | null;
}
export interface SystemMetadata {
key: string;
value: Json;
@ -357,6 +354,15 @@ export interface TagsClosure {
id_descendant: string;
}
export interface TypeormMetadata {
database: string | null;
name: string | null;
schema: string | null;
table: string | null;
type: string;
value: string | null;
}
export interface UserMetadata {
key: string;
userId: string;
@ -431,11 +437,11 @@ export interface DB {
shared_links: SharedLinks;
smart_search: SmartSearch;
socket_io_attachments: SocketIoAttachments;
system_config: SystemConfig;
system_metadata: SystemMetadata;
tag_asset: TagAsset;
tags: Tags;
tags_closure: TagsClosure;
typeorm_metadata: TypeormMetadata;
user_metadata: UserMetadata;
users: Users;
"vectors.pg_vector_index_stat": VectorsPgVectorIndexStat;

View file

@ -118,7 +118,7 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As
id: entity.id,
type: entity.type,
originalMimeType: mimeTypes.lookup(entity.originalFileName),
thumbhash: entity.thumbhash?.toString('base64') ?? null,
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
localDateTime: entity.localDateTime,
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,

View file

@ -7,7 +7,14 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { IsDateStringFormat, MaxDateString, Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
import {
IsDateStringFormat,
MaxDateString,
Optional,
ValidateBoolean,
ValidateHexColor,
ValidateUUID,
} from 'src/validation';
export class PersonCreateDto {
/**
@ -32,6 +39,13 @@ export class PersonCreateDto {
*/
@ValidateBoolean({ optional: true })
isHidden?: boolean;
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
@Optional({ emptyToNull: true, nullable: true })
@ValidateHexColor()
color?: string | null;
}
export class PersonUpdateDto extends PersonCreateDto {
@ -97,6 +111,10 @@ export class PersonResponseDto {
isHidden!: boolean;
@PropertyLifecycle({ addedAt: 'v1.107.0' })
updatedAt?: Date;
@PropertyLifecycle({ addedAt: 'v1.126.0' })
isFavorite?: boolean;
@PropertyLifecycle({ addedAt: 'v1.126.0' })
color?: string;
}
export class PersonWithFacesResponseDto extends PersonResponseDto {
@ -170,6 +188,8 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
birthDate: person.birthDate,
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: person.updatedAt,
};
}

View file

@ -133,6 +133,11 @@ export class MetadataSearchDto extends RandomSearchDto {
@Optional()
deviceAssetId?: string;
@IsString()
@IsNotEmpty()
@Optional()
description?: string;
@IsString()
@IsNotEmpty()
@Optional()

View file

@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator';
import { TagEntity } from 'src/entities/tag.entity';
import { Optional, ValidateUUID } from 'src/validation';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
export class TagCreateDto {
@IsString()
@ -18,9 +17,8 @@ export class TagCreateDto {
}
export class TagUpdateDto {
@Optional({ nullable: true, emptyToNull: true })
@IsHexColor()
@Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value))
@Optional({ emptyToNull: true, nullable: true })
@ValidateHexColor()
color?: string | null;
}

View file

@ -396,6 +396,11 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
sql`'%' || f_unaccent(${options.originalFileName}) || '%'`,
),
)
.$if(!!options.description, (qb) =>
qb
.innerJoin('exif', 'assets.id', 'exif.assetId')
.where(sql`f_unaccent(exif.description)`, 'ilike', sql`'%' || f_unaccent(${options.description}) || '%'`),
)
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!))

View file

@ -49,4 +49,10 @@ export class PersonEntity {
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
isFavorite!: boolean;
@Column({ type: 'varchar', nullable: true, default: null })
color?: string | null;
}

View file

@ -101,6 +101,7 @@ export interface SearchExifOptions {
make?: string | null;
model?: string | null;
state?: string | null;
description?: string | null;
}
export interface SearchEmbeddingOptions {

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddIsFavoritePerson1734879118272 implements MigrationInterface {
name = 'AddIsFavoritePerson1734879118272'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "isFavorite" boolean NOT NULL DEFAULT false`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "isFavorite"`);
}
}

View file

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddPersonColor1738889177573 implements MigrationInterface {
name = 'AddPersonColor1738889177573'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" ADD "color" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "person" DROP COLUMN "color"`);
}
}

View file

@ -47,6 +47,8 @@ with
and "asset_files"."type" = $6
)
and "assets"."deletedAt" is null
order by
(assets."localDateTime" at time zone 'UTC')::date desc
limit
$7
) as "a" on true

View file

@ -0,0 +1,22 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MapRepository.getMapMarkers
select
"id",
"exif"."latitude" as "lat",
"exif"."longitude" as "lon",
"exif"."city",
"exif"."state",
"exif"."country"
from
"assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
and "exif"."latitude" is not null
and "exif"."longitude" is not null
left join "albums_assets_assets" on "assets"."id" = "albums_assets_assets"."assetsId"
where
"isVisible" = $1
and "deletedAt" is null
and "ownerId" in ($2)
order by
"fileCreatedAt" desc

View file

@ -100,13 +100,14 @@ order by
-- SharedLinkRepository.getAll
select distinct
on ("shared_links"."createdAt") "shared_links".*,
to_json("assets") as "assets",
to_json("album") as "album"
from
"shared_links"
left join "shared_link__asset" on "shared_link__asset"."sharedLinksId" = "shared_links"."id"
left join lateral (
select
"assets".*
json_agg("assets") as "assets"
from
"assets"
where

View file

@ -121,6 +121,7 @@ export class AssetRepository implements IAssetRepository {
),
)
.where('assets.deletedAt', 'is', null)
.orderBy(sql`(assets."localDateTime" at time zone 'UTC')::date`, 'desc')
.limit(20)
.as('a'),
(join) => join.onTrue(),
@ -466,8 +467,8 @@ export class AssetRepository implements IAssetRepository {
)
.$if(property === WithoutProperty.EXIF, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
.where('job_status.metadataExtractedAt', 'is', null)
.leftJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
.where((eb) => eb.or([eb('job_status.metadataExtractedAt', 'is', null), eb('assetId', 'is', null)]))
.where('assets.isVisible', '=', true),
)
.$if(property === WithoutProperty.FACES, (qb) =>

View file

@ -227,6 +227,7 @@ const getEnv = (): EnvData => {
}
const driverOptions = {
...parsedOptions,
onnotice: (notice: Notice) => {
if (notice['severity'] !== 'NOTICE') {
console.warn('Postgres notice:', notice);
@ -247,7 +248,9 @@ const getEnv = (): EnvData => {
serialize: (value: number) => value.toString(),
},
},
...parsedOptions,
connection: {
TimeZone: 'UTC',
},
};
return {

View file

@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises';
import readLine from 'node:readline';
import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { AssetEntity, withExif } from 'src/entities/asset.entity';
import { DummyValue, GenerateSql } from 'src/decorators';
import { NaturalEarthCountriesTempEntity } from 'src/entities/natural-earth-countries.entity';
import { LogLevel, SystemMetadataKey } from 'src/enum';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -76,17 +76,19 @@ export class MapRepository {
this.logger.log('Geodata import completed');
}
async getMapMarkers(
ownerIds: string[],
albumIds: string[],
options: MapMarkerSearchOptions = {},
): Promise<MapMarker[]> {
@GenerateSql({ params: [[DummyValue.UUID], []] })
getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
const assets = (await this.db
return this.db
.selectFrom('assets')
.$call(withExif)
.select('id')
.innerJoin('exif', (builder) =>
builder
.onRef('assets.id', '=', 'exif.assetId')
.on('exif.latitude', 'is not', null)
.on('exif.longitude', 'is not', null),
)
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
.leftJoin('albums_assets_assets', (join) => join.onRef('assets.id', '=', 'albums_assets_assets.assetsId'))
.where('isVisible', '=', true)
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
@ -94,32 +96,21 @@ export class MapRepository {
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))
.where('deletedAt', 'is', null)
.where('exif.latitude', 'is not', null)
.where('exif.longitude', 'is not', null)
.where((eb) => {
const ors: Expression<SqlBool>[] = [];
.where((builder) => {
const expression: Expression<SqlBool>[] = [];
if (ownerIds.length > 0) {
ors.push(eb('ownerId', 'in', ownerIds));
expression.push(builder('ownerId', 'in', ownerIds));
}
if (albumIds.length > 0) {
ors.push(eb('albums_assets_assets.albumsId', 'in', albumIds));
expression.push(builder('albums_assets_assets.albumsId', 'in', albumIds));
}
return eb.or(ors);
return builder.or(expression);
})
.orderBy('fileCreatedAt', 'desc')
.execute()) as any as AssetEntity[];
return assets.map((asset) => ({
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
}));
.execute() as Promise<MapMarker[]>;
}
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {

View file

@ -132,6 +132,7 @@ export class PersonRepository implements IPersonRepository {
)
.where('person.ownerId', '=', userId)
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
eb.or([
eb('person.name', '!=', ''),

View file

@ -103,12 +103,13 @@ export class SharedLinkRepository implements ISharedLinkRepository {
(eb) =>
eb
.selectFrom('assets')
.select((eb) => eb.fn.jsonAgg('assets').as('assets'))
.whereRef('assets.id', '=', 'shared_link__asset.assetsId')
.where('assets.deletedAt', 'is', null)
.selectAll('assets')
.as('assets'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('assets').as('assets'))
.leftJoinLateral(
(eb) =>
eb

View file

@ -30,6 +30,7 @@ const responseDto: PersonResponseDto = {
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
updatedAt: expect.any(Date),
isFavorite: false,
};
const statistics = { assets: 3 };
@ -116,6 +117,7 @@ describe(PersonService.name, () => {
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
isFavorite: false,
updatedAt: expect.any(Date),
},
],
@ -125,6 +127,35 @@ describe(PersonService.name, () => {
withHidden: true,
});
});
it('should get all visible people and favorites should be first in the array', async () => {
personMock.getAllForUser.mockResolvedValue({
items: [personStub.isFavorite, personStub.withName],
hasNextPage: false,
});
personMock.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
hasNextPage: false,
total: 2,
hidden: 1,
people: [
{
id: 'person-4',
name: personStub.isFavorite.name,
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
isFavorite: true,
updatedAt: expect.any(Date),
},
responseDto,
],
});
expect(personMock.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
minimumFaceCount: 3,
withHidden: false,
});
});
});
describe('getById', () => {
@ -227,6 +258,7 @@ describe(PersonService.name, () => {
birthDate: '1976-06-30',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
isFavorite: false,
updatedAt: expect.any(Date),
});
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: '1976-06-30' });
@ -245,6 +277,16 @@ describe(PersonService.name, () => {
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it('should update a person favorite status', async () => {
personMock.update.mockResolvedValue(personStub.withName);
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
});
it("should update a person's thumbnailPath", async () => {
personMock.update.mockResolvedValue(personStub.withName);
personMock.getFacesByIds.mockResolvedValue([faceStub.face1]);
@ -313,7 +355,7 @@ describe(PersonService.name, () => {
sut.reassignFaces(authStub.admin, personStub.noName.id, {
data: [{ personId: personStub.withName.id, assetId: assetStub.image.id }],
}),
).resolves.toEqual([personStub.noName]);
).resolves.toBeDefined();
expect(jobMock.queueAll).toHaveBeenCalledWith([
{
@ -375,6 +417,7 @@ describe(PersonService.name, () => {
).resolves.toEqual({
birthDate: personStub.noName.birthDate,
isHidden: personStub.noName.isHidden,
isFavorite: personStub.noName.isFavorite,
id: personStub.noName.id,
name: personStub.noName.name,
thumbnailPath: personStub.noName.thumbnailPath,
@ -405,7 +448,7 @@ describe(PersonService.name, () => {
it('should create a new person', async () => {
personMock.create.mockResolvedValue(personStub.primaryPerson);
await expect(sut.create(authStub.admin, {})).resolves.toBe(personStub.primaryPerson);
await expect(sut.create(authStub.admin, {})).resolves.toBeDefined();
expect(personMock.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
});

View file

@ -104,7 +104,7 @@ export class PersonService extends BaseService {
await this.personRepository.reassignFace(face.id, personId);
}
result.push(person);
result.push(mapPerson(person));
}
if (changeFeaturePhoto.length > 0) {
// Remove duplicates
@ -178,19 +178,23 @@ export class PersonService extends BaseService {
});
}
create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
return this.personRepository.create({
async create(auth: AuthDto, dto: PersonCreateDto): Promise<PersonResponseDto> {
const person = await this.personRepository.create({
ownerId: auth.user.id,
name: dto.name,
birthDate: dto.birthDate,
isHidden: dto.isHidden,
isFavorite: dto.isFavorite,
color: dto.color,
});
return mapPerson(person);
}
async update(auth: AuthDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
await this.requireAccess({ auth, permission: Permission.PERSON_UPDATE, ids: [id] });
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
const { name, birthDate, isHidden, featureFaceAssetId: assetId, isFavorite, color } = dto;
// TODO: set by faceId directly
let faceId: string | undefined = undefined;
if (assetId) {
@ -203,7 +207,15 @@ export class PersonService extends BaseService {
faceId = face.id;
}
const person = await this.personRepository.update({ id, faceAssetId: faceId, name, birthDate, isHidden });
const person = await this.personRepository.update({
id,
faceAssetId: faceId,
name,
birthDate,
isHidden,
isFavorite,
color,
});
if (assetId) {
await this.jobRepository.queue({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id } });
@ -221,6 +233,7 @@ export class PersonService extends BaseService {
name: person.name,
birthDate: person.birthDate,
featureFaceAssetId: person.featureFaceAssetId,
isFavorite: person.isFavorite,
});
results.push({ id: person.id, success: true });
} catch (error: Error | any) {

View file

@ -31,6 +31,8 @@ describe(SearchService.name, () => {
it('should pass options to search', async () => {
const { name } = personStub.withName;
personMock.getByName.mockResolvedValue([]);
await sut.searchPerson(authStub.user1, { name, withHidden: false });
expect(personMock.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });

View file

@ -1,8 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { PersonResponseDto } from 'src/dtos/person.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import {
mapPlaces,
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
@ -12,7 +13,6 @@ import {
SearchSuggestionRequestDto,
SearchSuggestionType,
SmartSearchDto,
mapPlaces,
} from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum';
@ -24,7 +24,8 @@ import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class SearchService extends BaseService {
async searchPerson(auth: AuthDto, dto: SearchPeopleDto): Promise<PersonResponseDto[]> {
return this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
const people = await this.personRepository.getByName(auth.user.id, dto.name, { withHidden: dto.withHidden });
return people.map((person) => mapPerson(person));
}
async searchPlaces(dto: SearchPlacesDto): Promise<PlacesResponseDto[]> {

View file

@ -12,6 +12,7 @@ import {
IsArray,
IsBoolean,
IsDate,
IsHexColor,
IsNotEmpty,
IsOptional,
IsString,
@ -97,6 +98,15 @@ export function Optional({ nullable, emptyToNull, ...validationOptions }: Option
return applyDecorators(...decorators);
}
export const ValidateHexColor = () => {
const decorators = [
IsHexColor(),
Transform(({ value }) => (typeof value === 'string' && value[0] !== '#' ? `#${value}` : value)),
];
return applyDecorators(...decorators);
};
type UUIDOptions = { optional?: boolean; each?: boolean; nullable?: boolean };
export const ValidateUUID = (options?: UUIDOptions) => {
const { optional, each, nullable } = { optional: false, each: false, nullable: false, ...options };

View file

@ -15,6 +15,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
hidden: Object.freeze<PersonEntity>({
id: 'person-1',
@ -29,6 +30,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: true,
isFavorite: false,
}),
withName: Object.freeze<PersonEntity>({
id: 'person-1',
@ -43,6 +45,7 @@ export const personStub = {
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
withBirthDate: Object.freeze<PersonEntity>({
id: 'person-1',
@ -57,6 +60,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
noThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@ -71,6 +75,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
@ -85,6 +90,7 @@ export const personStub = {
faceAssetId: 'asset-id',
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
primaryPerson: Object.freeze<PersonEntity>({
id: 'person-1',
@ -99,6 +105,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
mergePerson: Object.freeze<PersonEntity>({
id: 'person-2',
@ -113,6 +120,7 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
randomPerson: Object.freeze<PersonEntity>({
id: 'person-3',
@ -127,5 +135,21 @@ export const personStub = {
faceAssetId: null,
faceAsset: null,
isHidden: false,
isFavorite: false,
}),
isFavorite: Object.freeze<PersonEntity>({
id: 'person-4',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userStub.admin.id,
owner: userStub.admin,
name: 'Person 1',
birthDate: null,
thumbnailPath: '/path/to/thumbnail.jpg',
faces: [],
faceAssetId: 'assetFaceId',
faceAsset: null,
isHidden: false,
isFavorite: true,
}),
};

View file

@ -35,12 +35,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe
}
};
document.addEventListener('click', handleClick, true);
document.addEventListener('mousedown', handleClick, true);
node.addEventListener('keydown', handleKey, false);
return {
destroy() {
document.removeEventListener('click', handleClick, true);
document.removeEventListener('mousedown', handleClick, true);
node.removeEventListener('keydown', handleKey, false);
},
};

View file

@ -26,12 +26,14 @@
controlable?: boolean;
hideTextOnSmallScreen?: boolean;
title?: string | undefined;
position?: 'bottom-left' | 'bottom-right';
onSelect: (option: T) => void;
onClickOutside?: () => void;
render?: (item: T) => string | RenderedOption;
}
let {
position = 'bottom-left',
class: className = '',
options,
selectedOption = $bindable(options[0]),
@ -76,9 +78,24 @@
};
let renderedSelectedOption = $derived(renderOption(selectedOption));
const getAlignClass = (position: 'bottom-left' | 'bottom-right') => {
switch (position) {
case 'bottom-left': {
return 'left-0';
}
case 'bottom-right': {
return 'right-0';
}
default: {
return '';
}
}
};
</script>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }}>
<div use:clickOutside={{ onOutclick: handleClickOutside, onEscape: handleClickOutside }} class="relative">
<!-- BUTTON TITLE -->
<Button onclick={() => (showMenu = true)} fullWidth {title} variant="ghost" color="secondary" size="small">
{#if renderedSelectedOption?.icon}
@ -91,7 +108,9 @@
{#if showMenu}
<div
transition:fly={{ y: -30, duration: 250 }}
class="text-sm font-medium fixed z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className}"
class="text-sm font-medium absolute z-50 flex min-w-[250px] max-h-[70vh] overflow-y-auto immich-scrollbar flex-col rounded-2xl bg-gray-100 py-2 text-black shadow-lg dark:bg-gray-700 dark:text-white {className} {getAlignClass(
position,
)}"
>
{#each options as option (option)}
{@const renderedOption = renderOption(option)}

View file

@ -1,11 +1,12 @@
<script lang="ts">
import { onMount, tick } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { mdiArrowLeft, mdiMerge } from '@mdi/js';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import Button from '../elements/buttons/button.svelte';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { t } from 'svelte-i18n';
@ -36,6 +37,11 @@
[potentialMergePeople[index], personMerge2] = [personMerge2, potentialMergePeople[index]];
choosePersonToMerge = false;
};
onMount(async () => {
await tick();
document.querySelector<HTMLElement>('#merge-confirm-button')?.focus();
});
</script>
<FullScreenModal title="{$t('merge_people')} - {title}" {onClose}>
@ -113,7 +119,9 @@
</div>
{#snippet stickyBottom()}
<Button fullwidth color="gray" onclick={onReject}>{$t('no')}</Button>
<Button fullwidth onclick={() => onConfirm([personMerge1, personMerge2])}>{$t('yes')}</Button>
<Button fullWidth shape="round" color="secondary" onclick={onReject}>{$t('no')}</Button>
<Button id="merge-confirm-button" fullWidth shape="round" onclick={() => onConfirm([personMerge1, personMerge2])}>
{$t('yes')}
</Button>
{/snippet}
</FullScreenModal>

View file

@ -1,4 +1,7 @@
<script lang="ts">
import { focusOutside } from '$lib/actions/focus-outside';
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type PersonResponseDto } from '@immich/sdk';
@ -8,12 +11,13 @@
mdiCalendarEditOutline,
mdiDotsVertical,
mdiEyeOffOutline,
mdiHeart,
mdiHeartMinusOutline,
mdiHeartOutline,
} from '@mdi/js';
import { t } from 'svelte-i18n';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import MenuOption from '../shared-components/context-menu/menu-option.svelte';
import { t } from 'svelte-i18n';
import { focusOutside } from '$lib/actions/focus-outside';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
interface Props {
person: PersonResponseDto;
@ -22,9 +26,18 @@
onSetBirthDate: () => void;
onMergePeople: () => void;
onHidePerson: () => void;
onToggleFavorite: () => void;
}
let { person, preload = false, onChangeName, onSetBirthDate, onMergePeople, onHidePerson }: Props = $props();
let {
person,
preload = false,
onChangeName,
onSetBirthDate,
onMergePeople,
onHidePerson,
onToggleFavorite,
}: Props = $props();
let showVerticalDots = $state(false);
</script>
@ -51,6 +64,11 @@
title={person.name}
widthStyle="100%"
/>
{#if person.isFavorite}
<div class="absolute bottom-2 left-2 z-10">
<Icon path={mdiHeart} size="24" class="text-white" />
</div>
{/if}
</div>
{#if person.name}
<span
@ -76,6 +94,11 @@
<MenuOption onClick={onChangeName} icon={mdiAccountEditOutline} text={$t('change_name')} />
<MenuOption onClick={onSetBirthDate} icon={mdiCalendarEditOutline} text={$t('set_date_of_birth')} />
<MenuOption onClick={onMergePeople} icon={mdiAccountMultipleCheckOutline} text={$t('merge_people')} />
<MenuOption
onClick={onToggleFavorite}
icon={person.isFavorite ? mdiHeartMinusOutline : mdiHeartOutline}
text={person.isFavorite ? $t('unfavorite') : $t('to_favorite')}
/>
</ButtonContextMenu>
</div>
{/if}

View file

@ -54,7 +54,7 @@
<div class="w-full flex place-content-start">
<Button class="flex gap-2 place-content-center" onclick={() => onPrevious()}>
<Icon path={mdiArrowLeft} size="18" />
<p>{$t('theme')}</p>
<p>{$t('privacy')}</p>
</Button>
</div>
<div class="flex w-full place-content-end">

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { AppRoute } from '$lib/constants';
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
import { placesViewSettings } from '$lib/stores/preferences.store';
import { type PlacesGroup, isPlacesGroupCollapsed, togglePlacesGroupCollapsing } from '$lib/utils/places-utils';
import { mdiChevronRight } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { t } from 'svelte-i18n';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
interface Props {
places: AssetResponseDto[];
group?: PlacesGroup | undefined;
}
let { places, group = undefined }: Props = $props();
let isCollapsed = $derived(!!group && isPlacesGroupCollapsed($placesViewSettings, group.id));
let iconRotation = $derived(isCollapsed ? 'rotate-0' : 'rotate-90');
</script>
{#if group}
<div class="grid">
<button
type="button"
onclick={() => togglePlacesGroupCollapsing(group.id)}
class="w-fit mt-2 pt-2 pr-2 mb-2 dark:text-immich-dark-fg"
aria-expanded={!isCollapsed}
>
<Icon
path={mdiChevronRight}
size="24"
class="inline-block -mt-2.5 transition-all duration-[250ms] {iconRotation}"
/>
<span class="font-bold text-3xl text-black dark:text-white">{group.name}</span>
<span class="ml-1.5">({$t('places_count', { values: { count: places.length } })})</span>
</button>
<hr class="dark:border-immich-dark-gray" />
</div>
{/if}
<div class="mt-4">
{#if !isCollapsed}
<div class="flex flex-row flex-wrap gap-4">
{#each places as item}
{@const city = item.exifInfo?.city}
<a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city })}" draggable="false">
<div
class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-[156px] justify-center overflow-hidden rounded-xl brightness-75 filter"
>
<img
src={getAssetThumbnailUrl({ id: item.id, size: AssetMediaSize.Thumbnail })}
alt={city}
class="object-cover w-[156px] h-[156px]"
/>
</div>
<span
class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
>
{city}
</span>
</a>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,93 @@
<script lang="ts">
import { IconButton } from '@immich/ui';
import Dropdown from '$lib/components/elements/dropdown.svelte';
import SearchBar from '$lib/components/elements/search-bar.svelte';
import { PlacesGroupBy, placesViewSettings } from '$lib/stores/preferences.store';
import {
mdiFolderArrowUpOutline,
mdiFolderRemoveOutline,
mdiUnfoldLessHorizontal,
mdiUnfoldMoreHorizontal,
} from '@mdi/js';
import {
type PlacesGroupOptionMetadata,
findGroupOptionMetadata,
getSelectedPlacesGroupOption,
groupOptionsMetadata,
expandAllPlacesGroups,
collapseAllPlacesGroups,
} from '$lib/utils/places-utils';
import { fly } from 'svelte/transition';
import { t } from 'svelte-i18n';
interface Props {
placesGroups: string[];
searchQuery: string;
}
let { placesGroups, searchQuery = $bindable() }: Props = $props();
const handleChangeGroupBy = ({ id }: PlacesGroupOptionMetadata) => {
$placesViewSettings.groupBy = id;
};
let groupIcon = $derived.by(() => {
return selectedGroupOption.id === PlacesGroupBy.None ? mdiFolderRemoveOutline : mdiFolderArrowUpOutline; // OR mdiFolderArrowDownOutline
});
let selectedGroupOption = $derived(findGroupOptionMetadata($placesViewSettings.groupBy));
let placesGroupByNames: Record<PlacesGroupBy, string> = $derived({
[PlacesGroupBy.None]: $t('group_no'),
[PlacesGroupBy.Country]: $t('group_country'),
});
</script>
<!-- Search Places -->
<div class="hidden md:block h-10 xl:w-60 2xl:w-80">
<SearchBar placeholder={$t('search_places')} bind:name={searchQuery} showLoadingSpinner={false} />
</div>
<!-- Group Places -->
<Dropdown
position="bottom-right"
title={$t('group_places_by')}
options={Object.values(groupOptionsMetadata)}
selectedOption={selectedGroupOption}
onSelect={handleChangeGroupBy}
render={({ id, isDisabled }) => ({
title: placesGroupByNames[id],
icon: groupIcon,
disabled: isDisabled(),
})}
/>
{#if getSelectedPlacesGroupOption($placesViewSettings) !== PlacesGroupBy.None}
<span in:fly={{ x: -50, duration: 250 }}>
<!-- Expand Countries Groups -->
<div class="hidden xl:flex gap-0">
<div class="block">
<IconButton
title={$t('expand_all')}
onclick={() => expandAllPlacesGroups()}
variant="ghost"
color="secondary"
shape="round"
icon={mdiUnfoldMoreHorizontal}
/>
</div>
<!-- Collapse Countries Groups -->
<div class="block">
<IconButton
title={$t('collapse_all')}
onclick={() => collapseAllPlacesGroups(placesGroups)}
variant="ghost"
color="secondary"
shape="round"
icon={mdiUnfoldLessHorizontal}
/>
</div>
</div>
</span>
{/if}

View file

@ -0,0 +1,121 @@
<script lang="ts">
import PlacesCardGroup from './places-card-group.svelte';
import { groupBy } from 'lodash-es';
import { normalizeSearchString } from '$lib/utils/string-utils';
import { type AssetResponseDto } from '@immich/sdk';
import { mdiMapMarkerOff } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
import { PlacesGroupBy, type PlacesViewSettings } from '$lib/stores/preferences.store';
import { type PlacesGroup, getSelectedPlacesGroupOption } from '$lib/utils/places-utils';
import { t } from 'svelte-i18n';
import { run } from 'svelte/legacy';
interface Props {
places?: AssetResponseDto[];
searchQuery?: string;
searchResultCount: number;
userSettings: PlacesViewSettings;
placesGroupIds?: string[];
}
let {
places = $bindable([]),
searchQuery = '',
searchResultCount = $bindable(0),
userSettings,
placesGroupIds = $bindable([]),
}: Props = $props();
interface PlacesGroupOption {
[option: string]: (places: AssetResponseDto[]) => PlacesGroup[];
}
const groupOptions: PlacesGroupOption = {
/** No grouping */
[PlacesGroupBy.None]: (places): PlacesGroup[] => {
return [
{
id: $t('places'),
name: $t('places'),
places,
},
];
},
/** Group by year */
[PlacesGroupBy.Country]: (places): PlacesGroup[] => {
const unknownCountry = $t('unknown_country');
const groupedByCountry = groupBy(places, (place) => {
return place.exifInfo?.country ?? unknownCountry;
});
const sortedByCountryName = Object.entries(groupedByCountry).sort(([a], [b]) => {
// We make sure empty albums stay at the end of the list
if (a === unknownCountry) {
return 1;
} else if (b === unknownCountry) {
return -1;
} else {
return a.localeCompare(b);
}
});
return sortedByCountryName.map(([country, places]) => ({
id: country,
name: country,
places,
}));
},
};
let filteredPlaces: AssetResponseDto[] = $state([]);
let groupedPlaces: PlacesGroup[] = $state([]);
let placesGroupOption: string = $state(PlacesGroupBy.None);
let hasPlaces = $derived(places.length > 0);
// Step 1: Filter using the given search query.
run(() => {
if (searchQuery) {
const searchQueryNormalized = normalizeSearchString(searchQuery);
filteredPlaces = places.filter((place) => {
return normalizeSearchString(place.exifInfo?.city ?? '').includes(searchQueryNormalized);
});
} else {
filteredPlaces = places;
}
searchResultCount = filteredPlaces.length;
});
// Step 2: Group places.
run(() => {
placesGroupOption = getSelectedPlacesGroupOption(userSettings);
const groupFunc = groupOptions[placesGroupOption] ?? groupOptions[PlacesGroupBy.None];
groupedPlaces = groupFunc(filteredPlaces);
placesGroupIds = groupedPlaces.map(({ id }) => id);
});
</script>
{#if hasPlaces}
<!-- Album Cards -->
{#if placesGroupOption === PlacesGroupBy.None}
<PlacesCardGroup places={groupedPlaces[0].places} />
{:else}
{#each groupedPlaces as placeGroup (placeGroup.id)}
<PlacesCardGroup places={placeGroup.places} group={placeGroup} />
{/each}
{/if}
{:else}
<div class="flex min-h-[calc(66vh_-_11rem)] w-full place-content-center items-center dark:text-white">
<div class="flex flex-col content-center items-center text-center">
<Icon path={mdiMapMarkerOff} size="3.5em" />
<p class="mt-5 text-3xl font-medium">{$t('no_places')}</p>
</div>
</div>
{/if}

View file

@ -6,7 +6,7 @@
export type SearchFilter = {
query: string;
queryType: 'smart' | 'metadata';
queryType: 'smart' | 'metadata' | 'description';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string>;
location: SearchLocationFilter;
@ -110,6 +110,7 @@
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,
country: filter.location.country,
state: filter.location.state,
city: filter.location.city,

View file

@ -4,7 +4,7 @@
interface Props {
query: string | undefined;
queryType?: 'smart' | 'metadata';
queryType?: 'smart' | 'metadata' | 'description';
}
let { query = $bindable(), queryType = $bindable('smart') }: Props = $props();
@ -21,6 +21,13 @@
bind:group={queryType}
value="metadata"
/>
<RadioButton
name="query-type"
id="description-radio"
label={$t('description')}
bind:group={queryType}
value="description"
/>
</div>
</fieldset>
@ -34,7 +41,7 @@
placeholder={$t('sunrise_on_the_beach')}
bind:value={query}
/>
{:else}
{:else if queryType === 'metadata'}
<label for="file-name-input" class="immich-form-label">{$t('search_by_filename')}</label>
<input
class="immich-form-input hover:cursor-text w-full !mt-1"
@ -45,4 +52,15 @@
bind:value={query}
aria-labelledby="file-name-label"
/>
{:else if queryType === 'description'}
<label for="description-input" class="immich-form-label">{$t('search_by_description')}</label>
<input
class="immich-form-input hover:cursor-text w-full !mt-1"
type="text"
id="description-input"
name="description"
placeholder={$t('search_by_description_example')}
bind:value={query}
aria-labelledby="description-label"
/>
{/if}

View file

@ -101,6 +101,14 @@ export interface AlbumViewSettings {
};
}
export interface PlacesViewSettings {
groupBy: string;
collapsedGroups: {
// Grouping Option => Array<Group ID>
[group: string]: string[];
};
}
export interface SidebarSettings {
people: boolean;
sharing: boolean;
@ -147,6 +155,16 @@ export const albumViewSettings = persisted<AlbumViewSettings>('album-view-settin
collapsedGroups: {},
});
export enum PlacesGroupBy {
None = 'None',
Country = 'Country',
}
export const placesViewSettings = persisted<PlacesViewSettings>('places-view-settings', {
groupBy: PlacesGroupBy.None,
collapsedGroups: {},
});
export const showDeleteModal = persisted<boolean>('delete-confirm-dialog', true, {});
export const alwaysLoadOriginalFile = persisted<boolean>('always-load-original-file', false, {});

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