diff --git a/docs/docs/administration/oauth.md b/docs/docs/administration/oauth.md index 66a752f0b..f97f8da7d 100644 --- a/docs/docs/administration/oauth.md +++ b/docs/docs/administration/oauth.md @@ -11,7 +11,7 @@ Unable to set `app.immich:/` as a valid redirect URI? See [Mobile Redirect URI]( Immich supports 3rd party authentication via [OpenID Connect][oidc] (OIDC), an identity layer built on top of OAuth2. OIDC is supported by most identity providers, including: - [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) -- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/) +- [Authelia](https://www.authelia.com/configuration/identity-providers/openid-connect/clients/) - [Okta](https://www.okta.com/openid-connect/) - [Google](https://developers.google.com/identity/openid-connect/openid-connect) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 74f4b51b6..1b6d8ad19 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1158,9 +1158,9 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.11.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.25.tgz", - "integrity": "sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==", + "version": "20.11.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.27.tgz", + "integrity": "sha512-qyUZfMnCg1KEz57r7pzFtSGt49f6RPkPBis3Vo4PbS7roQEDn22hiHzl/Lo1q4i4hDEgBJmBF/NTNg2XR0HbFg==", "dev": true, "dependencies": { "undici-types": "~5.26.4" diff --git a/e2e/src/api/specs/library.e2e-spec.ts b/e2e/src/api/specs/library.e2e-spec.ts index d9ac1eddb..3e4f971cf 100644 --- a/e2e/src/api/specs/library.e2e-spec.ts +++ b/e2e/src/api/specs/library.e2e-spec.ts @@ -27,7 +27,7 @@ describe('/library', () => { await utils.resetDatabase(); admin = await utils.adminSetup(); user = await utils.userSetup(admin.accessToken, userDto.user1); - library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + library = await utils.createLibrary(admin.accessToken, { ownerId: admin.userId, type: LibraryType.External }); websocket = await utils.connectWebsocket(admin.accessToken); }); @@ -82,7 +82,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${user.accessToken}`) - .send({ type: LibraryType.External }); + .send({ ownerId: admin.userId, type: LibraryType.External }); expect(status).toBe(403); expect(body).toEqual(errorDto.forbidden); @@ -92,7 +92,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.External }); + .send({ ownerId: admin.userId, type: LibraryType.External }); expect(status).toBe(201); expect(body).toEqual( @@ -113,6 +113,7 @@ describe('/library', () => { .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ + ownerId: admin.userId, type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path/to/import'], @@ -133,6 +134,7 @@ describe('/library', () => { .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ + ownerId: admin.userId, type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path', '/path'], @@ -148,6 +150,7 @@ describe('/library', () => { .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) .send({ + ownerId: admin.userId, type: LibraryType.External, name: 'My Awesome Library', importPaths: ['/path/to/import'], @@ -162,7 +165,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload }); + .send({ ownerId: admin.userId, type: LibraryType.Upload }); expect(status).toBe(201); expect(body).toEqual( @@ -182,7 +185,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload, name: 'My Awesome Library' }); + .send({ ownerId: admin.userId, type: LibraryType.Upload, name: 'My Awesome Library' }); expect(status).toBe(201); expect(body).toEqual( @@ -196,7 +199,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload, importPaths: ['/path/to/import'] }); + .send({ ownerId: admin.userId, type: LibraryType.Upload, importPaths: ['/path/to/import'] }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have import paths')); @@ -206,7 +209,7 @@ describe('/library', () => { const { status, body } = await request(app) .post('/library') .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); + .send({ ownerId: admin.userId, type: LibraryType.Upload, exclusionPatterns: ['**/Raw/**'] }); expect(status).toBe(400); expect(body).toEqual(errorDto.badRequest('Upload libraries cannot have exclusion patterns')); @@ -330,7 +333,10 @@ describe('/library', () => { }); it('should get library by id', async () => { - const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + type: LibraryType.External, + }); const { status, body } = await request(app) .get(`/library/${library.id}`) @@ -386,7 +392,10 @@ describe('/library', () => { }); it('should delete an external library', async () => { - const library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); + const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, + type: LibraryType.External, + }); const { status, body } = await request(app) .delete(`/library/${library.id}`) @@ -407,6 +416,7 @@ describe('/library', () => { it('should delete an external library with assets', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); @@ -455,6 +465,7 @@ describe('/library', () => { it('should not scan an upload library', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.Upload, }); @@ -468,6 +479,7 @@ describe('/library', () => { it('should scan external library', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp/directoryA`], }); @@ -483,6 +495,7 @@ describe('/library', () => { it('should scan external library with exclusion pattern', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], exclusionPatterns: ['**/directoryA'], @@ -499,6 +512,7 @@ describe('/library', () => { it('should scan multiple import paths', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`], }); @@ -515,6 +529,7 @@ describe('/library', () => { it('should pick up new files', async () => { const library = await utils.createLibrary(admin.accessToken, { + ownerId: admin.userId, type: LibraryType.External, importPaths: [`${testAssetDirInternal}/temp`], }); diff --git a/install.sh b/install.sh index fc894ce21..232ee1597 100755 --- a/install.sh +++ b/install.sh @@ -6,7 +6,7 @@ ip_address=$(hostname -I | awk '{print $1}') create_immich_directory() { echo "Creating Immich directory..." - mkdir -p ./immich-app/immich-data + mkdir -p ./immich-app cd ./immich-app || exit } @@ -20,21 +20,6 @@ download_dot_env_file() { curl -L https://github.com/immich-app/immich/releases/latest/download/example.env -o ./.env >/dev/null 2>&1 } -replace_env_value() { - KERNEL="$(uname -s | tr '[:upper:]' '[:lower:]')" - if [ "$KERNEL" = "darwin" ]; then - sed -i '' "s|$1=.*|$1=$2|" ./.env - else - sed -i "s|$1=.*|$1=$2|" ./.env - fi -} - -populate_upload_location() { - echo "Populating default UPLOAD_LOCATION value..." - upload_location=$(pwd)/immich-data - replace_env_value "UPLOAD_LOCATION" "$upload_location" -} - start_docker_compose() { echo "Starting Immich's docker containers" @@ -59,7 +44,6 @@ start_docker_compose() { show_friendly_message() { echo "Successfully deployed Immich!" echo "You can access the website at http://$ip_address:2283 and the server URL for the mobile app is http://$ip_address:2283/api" - echo "The library location is $upload_location" echo "---------------------------------------------------" echo "If you want to configure custom information of the server, including the database, Redis information, or the backup (or upload) location, etc. @@ -75,5 +59,4 @@ show_friendly_message() { create_immich_directory download_docker_compose_file download_dot_env_file -populate_upload_location start_docker_compose diff --git a/mobile/assets/immich-logo.json b/mobile/assets/immich-logo.json new file mode 100644 index 000000000..a83bd4660 --- /dev/null +++ b/mobile/assets/immich-logo.json @@ -0,0 +1 @@ +{"content": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAFACAYAAAAszc0KAAAAxnpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjabVBbEoMwCPzPKXoEXonkOLHqTG/Q4xeEzKjTdbKLICuh7N/PUV4OQilSF229NTBIl07DAoXAOBlBTj5hJcrsLV9khmTKppwNLRRnPhum4rCoXoz0nYX1XugSSvowinmAfSKPtzTqacQUBUyDEdeC1nW5XmHd4Q6NU5zWw+bxHWXz810W295W7T9MtDMyGDO3GID9SOFhARsjd/vQn5GMrDmJLeTfnibKD6lPWpnDBF3gAAABhGlDQ1BJQ0MgcHJvZmlsZQAAeJx9kT1Iw0AcxV9bpSJVQYuIOGSoTnZRUcdahSJUCLVCqw4ml35Bk4YkxcVRcC04+LFYdXBx1tXBVRAEP0CcHZwUXaTE/yWFFjEeHPfj3b3H3TvAXy8z1eyIAapmGalEXMhkV4XgKwLoxyBm0CsxU58TxSQ8x9c9fHy9i/Is73N/jh4lZzLAJxDHmG5YxBvE05uWznmfOMyKkkJ8Tjxu0AWJH7kuu/zGueCwn2eGjXRqnjhMLBTaWG5jVjRU4iniiKJqlO/PuKxw3uKslquseU/+wlBOW1nmOs0RJLCIJYgQIKOKEsqwEKVVI8VEivbjHv5hxy+SSyZXCYwcC6hAheT4wf/gd7dmfnLCTQrFgc4X2/4YBYK7QKNm29/Htt04AQLPwJXW8lfqwOwn6bWWFjkC+raBi+uWJu8BlzvA0JMuGZIjBWj683ng/Yy+KQsM3ALda25vzX2cPgBp6ip5AxwcAmMFyl73eHdXe2//nmn29wO1pXLBEGyzRgAADXppVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6MDY0YmE0N2YtMDU3MC00OTBmLWJkOGUtY2UzNDFhYjk0ZGY1IgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjdmYzk2NDZlLTE5NWQtNGE1Mi1iYTE3LWM0NDM4NmQyMWU1ZiIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjM0MGQxNWRmLTgzZDMtNDQ1Yi1hMzkwLTBjNTQ1NGNiMWI3ZCIKICAgR0lNUDpBUEk9IjIuMCIKICAgR0lNUDpQbGF0Zm9ybT0iTWFjIE9TIgogICBHSU1QOlRpbWVTdGFtcD0iMTcxMDgwMjgyMTQ5NjYwMyIKICAgR0lNUDpWZXJzaW9uPSIyLjEwLjM2IgogICBkYzpGb3JtYXQ9ImltYWdlL3BuZyIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQ6MDM6MTlUMDA6MDA6MTgrMDE6MDAiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI0OjAzOjE5VDAwOjAwOjE4KzAxOjAwIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiCiAgICAgIHN0RXZ0OmNoYW5nZWQ9Ii8iCiAgICAgIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MTEwZjhhOTQtNTQ4MS00MTkxLTkxYjktZjQ3NzA3ZDUyYTVlIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJHaW1wIDIuMTAgKE1hYyBPUykiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDMtMTlUMDA6MDA6MjErMDE6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgIAo8P3hwYWNrZXQgZW5kPSJ3Ij8+fd4ZrQAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAAOwwAADsMBx2+oZAAAAAd0SU1FB+gDEhcAFf/Hh5UAACAASURBVHja7d15nBT1nTfwT3fPTPfcMMoMwqgzcnvwgERAnnXETTYZnyCR4OYBEhLdx30E8yhiEkUTA2zceCUq+CyQXfcRY4Q8CTgq7oKRuGj2EUhUDrkPRTkEBplh7p6rnj9qqqeq+ldXd3V3dffn/XrNS+mjuqq6uuvT3/odPkmSJBARERFR1vBzFxARERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASERERMQASEREREQMgERERETEAEhEREREDIBERERGlixzuAnKTz+dzdXmSJHGnEhERMQBSuoe6RL02wyIREREDIKVJyEvGNjAcEhERMQAy7HGbGQqJiIgBkBj4uF8YComIiAGQGPgcSWR4StY2qV+HYZCIiDI2K0g8yzHwpTjcpct+4EeFiIgYACnjwk6mHgrcV0RERAyADDIMMNyXRETEAMjdkNlBhW8x9zUREREDYBaEEb6t3P9EREQMgBkeOvg28r0hIiJiAMyCcMG3ju8XERERA2AWBAm+XQyDREREDIBZEBr4FjHQExERMQAy+Al1bXkXrQ8vRu/xEwnbBv+llchfeA+Cf/tNvqEMgkRExABIqQh9ao3XTkFvfX3itykYxMAje/jmeuz9JyIisotzAafJiT8Z1T27gt9i9c/p+2r3eFAexyBIREQJzSmsAKY2/Nnd/U6re2XHDzte7/anl6P9medcXy4l7ocBERERA2AGBj8vVf6c8l9aidy/moKu/3zPcv2zrW1hIo4VIiIiBsAMOZk3TrkpKjzpq3DJavuX8P2YhW0LGQSJiIgBMINP4HZ3c/j3r6D9mefcq/j5fIDdt9jngyRJ8CXjtSyWkY29je0cS/y4EhERA2AGBT/AXhs8K2XHD8ddEcxfeA/Cv/mta1XF3Kk1KHp+BXzBoOZ2q/XM1t7GDIJERMQAmEHBz7XqnhuVtiS9pg+A1bOkvsfphebORsHP/4HHGYMgERG5zM9d4O5JWZIk4Um5/enlaL3/QVvhL3dqDQYe2YOy44cjf/5Bg9QvYnud1cvKX3hP7Buve01luVbLtLOmor1advxw1oY/s+Mo1qBIRESkOX+wAuhO8FNO2mqu9eBNRcXPzjoJgqGdil/Ur5BLKxH6u++hbek/Gm5zts9AwsvCRETEAJgmJ15RD1435Ez8EqSuTvTs2O3qcvMX3oOOFf8MKRx2ZXkD936AjudXo+NfX4TU1BR1f+7U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QGJIJCIiIo/ycxcQERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgEREREQMgERERETEAEhEREREDIBERERExABIRERFRuvn/XcNQ24xp80UAAAAASUVORK5CYII="} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index ffab2548e..6bf81eb64 100644 --- a/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/mobile/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1 +1,354 @@ -{"images":[{"size":"60x60","expected-size":"180","filename":"180.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"40x40","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"60x60","expected-size":"120","filename":"120.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"57x57","expected-size":"57","filename":"57.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"1x"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"57x57","expected-size":"114","filename":"114.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"2x"},{"size":"20x20","expected-size":"60","filename":"60.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"iphone","scale":"3x"},{"size":"1024x1024","filename":"1024.png","expected-size":"1024","idiom":"ios-marketing","folder":"Assets.xcassets/AppIcon.appiconset/","scale":"1x"},{"size":"40x40","expected-size":"80","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"72x72","expected-size":"72","filename":"72.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"76x76","expected-size":"152","filename":"152.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"50x50","expected-size":"100","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"76x76","expected-size":"76","filename":"76.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"29x29","expected-size":"29","filename":"29.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"50x50","expected-size":"50","filename":"50.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"72x72","expected-size":"144","filename":"144.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"40x40","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"83.5x83.5","expected-size":"167","filename":"167.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"size":"20x20","expected-size":"20","filename":"20.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"1x"},{"size":"20x20","expected-size":"40","filename":"40.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"ipad","scale":"2x"},{"idiom":"watch","filename":"172.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"86x86","expected-size":"172","role":"quickLook"},{"idiom":"watch","filename":"80.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"40x40","expected-size":"80","role":"appLauncher"},{"idiom":"watch","filename":"88.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"40mm","scale":"2x","size":"44x44","expected-size":"88","role":"appLauncher"},{"idiom":"watch","filename":"102.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"45x45","expected-size":"102","role":"appLauncher"},{"idiom":"watch","filename":"92.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"41mm","scale":"2x","size":"46x46","expected-size":"92","role":"appLauncher"},{"idiom":"watch","filename":"100.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"50x50","expected-size":"100","role":"appLauncher"},{"idiom":"watch","filename":"196.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"98x98","expected-size":"196","role":"quickLook"},{"idiom":"watch","filename":"216.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"44mm","scale":"2x","size":"108x108","expected-size":"216","role":"quickLook"},{"idiom":"watch","filename":"48.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"38mm","scale":"2x","size":"24x24","expected-size":"48","role":"notificationCenter"},{"idiom":"watch","filename":"55.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"42mm","scale":"2x","size":"27.5x27.5","expected-size":"55","role":"notificationCenter"},{"idiom":"watch","filename":"66.png","folder":"Assets.xcassets/AppIcon.appiconset/","subtype":"45mm","scale":"2x","size":"33x33","expected-size":"66","role":"notificationCenter"},{"size":"29x29","expected-size":"87","filename":"87.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"3x"},{"size":"29x29","expected-size":"58","filename":"58.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch","role":"companionSettings","scale":"2x"},{"size":"1024x1024","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"watch-marketing","scale":"1x"},{"size":"128x128","expected-size":"128","filename":"128.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"256x256","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"128x128","expected-size":"256","filename":"256.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"256x256","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"512x512","expected-size":"512","filename":"512.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"16","filename":"16.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"1x"},{"size":"16x16","expected-size":"32","filename":"32.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"32x32","expected-size":"64","filename":"64.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"},{"size":"512x512","expected-size":"1024","filename":"1024.png","folder":"Assets.xcassets/AppIcon.appiconset/","idiom":"mac","scale":"2x"}]} \ No newline at end of file +{ + "images" : [ + { + "filename" : "40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "57.png", + "idiom" : "iphone", + "scale" : "1x", + "size" : "57x57" + }, + { + "filename" : "114.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "57x57" + }, + { + "filename" : "120.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "58.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "40.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "80.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "50.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "50x50" + }, + { + "filename" : "100.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "50x50" + }, + { + "filename" : "72.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "72x72" + }, + { + "filename" : "144.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "72x72" + }, + { + "filename" : "76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "filename" : "16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + }, + { + "filename" : "48.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "24x24", + "subtype" : "38mm" + }, + { + "filename" : "55.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "27.5x27.5", + "subtype" : "42mm" + }, + { + "filename" : "58.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "87.png", + "idiom" : "watch", + "role" : "companionSettings", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "66.png", + "idiom" : "watch", + "role" : "notificationCenter", + "scale" : "2x", + "size" : "33x33", + "subtype" : "45mm" + }, + { + "filename" : "80.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "40x40", + "subtype" : "38mm" + }, + { + "filename" : "88.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "44x44", + "subtype" : "40mm" + }, + { + "filename" : "92.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "46x46", + "subtype" : "41mm" + }, + { + "filename" : "100.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "50x50", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "51x51", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "54x54", + "subtype" : "49mm" + }, + { + "filename" : "172.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "86x86", + "subtype" : "38mm" + }, + { + "filename" : "196.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "98x98", + "subtype" : "42mm" + }, + { + "filename" : "216.png", + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "108x108", + "subtype" : "44mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "117x117", + "subtype" : "45mm" + }, + { + "idiom" : "watch", + "role" : "quickLook", + "scale" : "2x", + "size" : "129x129", + "subtype" : "49mm" + }, + { + "filename" : "1024.png", + "idiom" : "watch-marketing", + "scale" : "1x", + "size" : "1024x1024" + }, + { + "filename" : "102.png", + "idiom" : "watch", + "role" : "appLauncher", + "scale" : "2x", + "size" : "45x45", + "subtype" : "41mm" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart index a94a1239f..620f4f3cd 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_sheet/exif_people.dart @@ -22,8 +22,11 @@ class ExifPeople extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final peopleProvider = ref.watch(assetPeopleNotifierProvider(asset).notifier); - final people = ref.watch(assetPeopleNotifierProvider(asset)); - final double imageSize = math.min(context.width / 3, 150); + final people = ref + .watch(assetPeopleNotifierProvider(asset)) + .value + ?.where((p) => !p.isHidden); + final double imageSize = math.min(context.width / 3, 120); showPersonNameEditModel( String personId, @@ -40,15 +43,14 @@ class ExifPeople extends ConsumerWidget { }); } - if (people.value?.isEmpty ?? true) { + if (people?.isEmpty ?? true) { // Empty list or loading return Container(); } - final curatedPeople = people.value - ?.map((p) => CuratedContent(id: p.id, label: p.name)) - .toList() ?? - []; + final curatedPeople = + people?.map((p) => CuratedContent(id: p.id, label: p.name)).toList() ?? + []; return Column( children: [ diff --git a/mobile/lib/modules/backup/models/available_album.model.dart b/mobile/lib/modules/backup/models/available_album.model.dart index 7469581bf..0b428eea0 100644 --- a/mobile/lib/modules/backup/models/available_album.model.dart +++ b/mobile/lib/modules/backup/models/available_album.model.dart @@ -5,11 +5,9 @@ import 'package:photo_manager/photo_manager.dart'; class AvailableAlbum { final AssetPathEntity albumEntity; final DateTime? lastBackup; - final Uint8List? thumbnailData; AvailableAlbum({ required this.albumEntity, this.lastBackup, - this.thumbnailData, }); AvailableAlbum copyWith({ @@ -20,7 +18,6 @@ class AvailableAlbum { return AvailableAlbum( albumEntity: albumEntity ?? this.albumEntity, lastBackup: lastBackup ?? this.lastBackup, - thumbnailData: thumbnailData ?? this.thumbnailData, ); } @@ -34,7 +31,7 @@ class AvailableAlbum { @override String toString() => - 'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup, thumbnailData: $thumbnailData)'; + 'AvailableAlbum(albumEntity: $albumEntity, lastBackup: $lastBackup)'; @override bool operator ==(Object other) { diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart index a02ddf4e3..a2de92d6d 100644 --- a/mobile/lib/modules/backup/providers/backup.provider.dart +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -234,33 +234,9 @@ class BackupNotifier extends StateNotifier { for (AssetPathEntity album in albums) { AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); - final assetCountInAlbum = await album.assetCountAsync; - if (assetCountInAlbum > 0) { - final assetList = await album.getAssetListPaged(page: 0, size: 1); + availableAlbums.add(availableAlbum); - // Even though we check assetCountInAlbum to make sure that there are assets in album - // The `getAssetListPaged` method still return empty list and cause not assets get rendered - if (assetList.isEmpty) { - continue; - } - final thumbnailAsset = assetList.first; - try { - final thumbnailData = await thumbnailAsset - .thumbnailDataWithSize(const ThumbnailSize(512, 512)); - availableAlbum = - availableAlbum.copyWith(thumbnailData: thumbnailData); - } catch (e, stack) { - log.severe( - "Failed to get thumbnail for album ${album.name}", - e, - stack, - ); - } - - availableAlbums.add(availableAlbum); - - albumMap[album.id] = album; - } + albumMap[album.id] = album; } state = state.copyWith(availableAlbums: availableAlbums); diff --git a/mobile/lib/modules/backup/ui/album_info_card.dart b/mobile/lib/modules/backup/ui/album_info_card.dart index 0008c0a9e..5380360ff 100644 --- a/mobile/lib/modules/backup/ui/album_info_card.dart +++ b/mobile/lib/modules/backup/ui/album_info_card.dart @@ -11,17 +11,16 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; class AlbumInfoCard extends HookConsumerWidget { - final Uint8List? imageData; - final AvailableAlbum albumInfo; + final AvailableAlbum album; - const AlbumInfoCard({super.key, this.imageData, required this.albumInfo}); + const AlbumInfoCard({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { final bool isSelected = - ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); + ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = - ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); + ref.watch(backupProvider).excludedBackupAlbums.contains(album); final isDarkTheme = context.isDarkTheme; ColorFilter selectedFilter = ColorFilter.mode( @@ -82,9 +81,9 @@ class AlbumInfoCard extends HookConsumerWidget { HapticFeedback.selectionClick(); if (isSelected) { - ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { - ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).addAlbumForBackup(album); } }, onDoubleTap: () { @@ -92,13 +91,11 @@ class AlbumInfoCard extends HookConsumerWidget { if (isExcluded) { // Remove from exclude album list - ref - .read(backupProvider.notifier) - .removeExcludedAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album); } else { // Add to exclude album list - if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') { + if (album.id == 'isAll' || album.name == 'Recents') { ImmichToast.show( context: context, msg: 'Cannot exclude album contains all assets', @@ -108,9 +105,7 @@ class AlbumInfoCard extends HookConsumerWidget { return; } - ref - .read(backupProvider.notifier) - .addExcludedAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album); } }, child: Card( @@ -136,14 +131,12 @@ class AlbumInfoCard extends HookConsumerWidget { children: [ ColorFiltered( colorFilter: buildImageFilter(), - child: Image( + child: const Image( width: double.infinity, height: double.infinity, - image: imageData != null - ? MemoryImage(imageData!) - : const AssetImage( - 'assets/immich-logo.png', - ) as ImageProvider, + image: AssetImage( + 'assets/immich-logo.png', + ), fit: BoxFit.cover, ), ), @@ -168,7 +161,7 @@ class AlbumInfoCard extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - albumInfo.name, + album.name, style: TextStyle( fontSize: 14, color: context.primaryColor, @@ -182,7 +175,7 @@ class AlbumInfoCard extends HookConsumerWidget { if (snapshot.hasData) { return Text( snapshot.data.toString() + - (albumInfo.isAll + (album.isAll ? " (${'backup_all'.tr()})" : ""), style: TextStyle( @@ -193,7 +186,7 @@ class AlbumInfoCard extends HookConsumerWidget { } return const Text("0"); }), - future: albumInfo.assetCount, + future: album.assetCount, ), ), ], @@ -202,7 +195,7 @@ class AlbumInfoCard extends HookConsumerWidget { IconButton( onPressed: () { context.pushRoute( - AlbumPreviewRoute(album: albumInfo.albumEntity), + AlbumPreviewRoute(album: album.albumEntity), ); }, icon: Icon( diff --git a/mobile/lib/modules/backup/ui/album_info_list_tile.dart b/mobile/lib/modules/backup/ui/album_info_list_tile.dart index c87bec09a..dcf0923a1 100644 --- a/mobile/lib/modules/backup/ui/album_info_list_tile.dart +++ b/mobile/lib/modules/backup/ui/album_info_list_tile.dart @@ -11,47 +11,26 @@ import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart'; class AlbumInfoListTile extends HookConsumerWidget { - final Uint8List? imageData; - final AvailableAlbum albumInfo; + final AvailableAlbum album; - const AlbumInfoListTile({super.key, this.imageData, required this.albumInfo}); + const AlbumInfoListTile({super.key, required this.album}); @override Widget build(BuildContext context, WidgetRef ref) { final bool isSelected = - ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); + ref.watch(backupProvider).selectedBackupAlbums.contains(album); final bool isExcluded = - ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); - - ColorFilter selectedFilter = ColorFilter.mode( - context.primaryColor.withAlpha(100), - BlendMode.darken, - ); - ColorFilter excludedFilter = - ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken); - ColorFilter unselectedFilter = - const ColorFilter.mode(Colors.black, BlendMode.color); - + ref.watch(backupProvider).excludedBackupAlbums.contains(album); var assetCount = useState(0); useEffect( () { - albumInfo.assetCount.then((value) => assetCount.value = value); + album.assetCount.then((value) => assetCount.value = value); return null; }, - [albumInfo], + [album], ); - buildImageFilter() { - if (isSelected) { - return selectedFilter; - } else if (isExcluded) { - return excludedFilter; - } else { - return unselectedFilter; - } - } - buildTileColor() { if (isSelected) { return context.isDarkTheme @@ -66,19 +45,38 @@ class AlbumInfoListTile extends HookConsumerWidget { } } + buildIcon() { + if (isSelected) { + return const Icon( + Icons.check_circle_rounded, + color: Colors.green, + ); + } + + if (isExcluded) { + return const Icon( + Icons.remove_circle_rounded, + color: Colors.red, + ); + } + + return Icon( + Icons.circle, + color: context.isDarkTheme ? Colors.grey[400] : Colors.black45, + ); + } + return GestureDetector( onDoubleTap: () { HapticFeedback.selectionClick(); if (isExcluded) { // Remove from exclude album list - ref - .read(backupProvider.notifier) - .removeExcludedAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album); } else { // Add to exclude album list - if (albumInfo.id == 'isAll' || albumInfo.name == 'Recents') { + if (album.id == 'isAll' || album.name == 'Recents') { ImmichToast.show( context: context, msg: 'Cannot exclude album contains all assets', @@ -88,9 +86,7 @@ class AlbumInfoListTile extends HookConsumerWidget { return; } - ref - .read(backupProvider.notifier) - .addExcludedAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album); } }, child: ListTile( @@ -99,33 +95,14 @@ class AlbumInfoListTile extends HookConsumerWidget { onTap: () { HapticFeedback.selectionClick(); if (isSelected) { - ref.read(backupProvider.notifier).removeAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).removeAlbumForBackup(album); } else { - ref.read(backupProvider.notifier).addAlbumForBackup(albumInfo); + ref.read(backupProvider.notifier).addAlbumForBackup(album); } }, - leading: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: SizedBox( - height: 80, - width: 80, - child: ColorFiltered( - colorFilter: buildImageFilter(), - child: Image( - width: double.infinity, - height: double.infinity, - image: imageData != null - ? MemoryImage(imageData!) - : const AssetImage( - 'assets/immich-logo.png', - ) as ImageProvider, - fit: BoxFit.cover, - ), - ), - ), - ), + leading: buildIcon(), title: Text( - albumInfo.name, + album.name, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -135,7 +112,7 @@ class AlbumInfoListTile extends HookConsumerWidget { trailing: IconButton( onPressed: () { context.pushRoute( - AlbumPreviewRoute(album: albumInfo.albumEntity), + AlbumPreviewRoute(album: album.albumEntity), ); }, icon: Icon( diff --git a/mobile/lib/modules/backup/views/backup_album_selection_page.dart b/mobile/lib/modules/backup/views/backup_album_selection_page.dart index e892e57c1..c2bcbb15c 100644 --- a/mobile/lib/modules/backup/views/backup_album_selection_page.dart +++ b/mobile/lib/modules/backup/views/backup_album_selection_page.dart @@ -43,10 +43,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { sliver: SliverList( delegate: SliverChildBuilderDelegate( ((context, index) { - var thumbnailData = albums[index].thumbnailData; return AlbumInfoListTile( - imageData: thumbnailData, - albumInfo: albums[index], + album: albums[index], ); }), childCount: albums.length, @@ -74,10 +72,8 @@ class BackupAlbumSelectionPage extends HookConsumerWidget { ), itemCount: albums.length, itemBuilder: ((context, index) { - var thumbnailData = albums[index].thumbnailData; return AlbumInfoCard( - imageData: thumbnailData, - albumInfo: albums[index], + album: albums[index], ); }), ), diff --git a/mobile/lib/modules/backup/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart index 3b219e3f6..0e22adeb9 100644 --- a/mobile/lib/modules/backup/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -26,7 +26,7 @@ class BackupControllerPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { BackUpState backupState = ref.watch(backupProvider); final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty; - + final didGetBackupInfo = useState(false); bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground; bool shouldBackup = backupState.allUniqueAssets.length - @@ -38,11 +38,6 @@ class BackupControllerPage extends HookConsumerWidget { useEffect( () { - if (backupState.backupProgress != BackUpProgressEnum.inProgress && - backupState.backupProgress != BackUpProgressEnum.manualInProgress) { - ref.watch(backupProvider.notifier).getBackupInfo(); - } - // Update the background settings information just to make sure we // have the latest, since the platform channel will not update // automatically @@ -58,6 +53,18 @@ class BackupControllerPage extends HookConsumerWidget { [], ); + useEffect( + () { + if (backupState.backupProgress == BackUpProgressEnum.idle && + !didGetBackupInfo.value) { + ref.watch(backupProvider.notifier).getBackupInfo(); + didGetBackupInfo.value = true; + } + return null; + }, + [backupState.backupProgress], + ); + Widget buildSelectedAlbumName() { var text = "backup_controller_page_backup_selected".tr(); var albums = ref.watch(backupProvider).selectedBackupAlbums; @@ -235,6 +242,15 @@ class BackupControllerPage extends HookConsumerWidget { ); } + buildLoadingIndicator() { + return const Padding( + padding: EdgeInsets.only(top: 42.0), + child: Center( + child: CircularProgressIndicator(), + ), + ); + } + return Scaffold( appBar: AppBar( elevation: 0, @@ -297,7 +313,10 @@ class BackupControllerPage extends HookConsumerWidget { if (!hasExclusiveAccess) buildBackgroundBackupInfo(), buildBackupButton(), ] - : [buildFolderSelectionTile()], + : [ + buildFolderSelectionTile(), + if (!didGetBackupInfo.value) buildLoadingIndicator(), + ], ), ), ); diff --git a/mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart b/mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart new file mode 100644 index 000000000..cf1de0383 --- /dev/null +++ b/mobile/lib/modules/home/ui/asset_grid/asset_drag_region.dart @@ -0,0 +1,223 @@ +// ignore_for_file: library_private_types_in_public_api +// Based on https://stackoverflow.com/a/52625182 + +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class AssetDragRegion extends StatefulWidget { + final Widget child; + + final void Function(AssetIndex valueKey)? onStart; + final void Function(AssetIndex valueKey)? onAssetEnter; + final void Function()? onEnd; + final void Function()? onScrollStart; + final void Function(ScrollDirection direction)? onScroll; + + const AssetDragRegion({ + super.key, + required this.child, + this.onStart, + this.onAssetEnter, + this.onEnd, + this.onScrollStart, + this.onScroll, + }); + @override + State createState() => _AssetDragRegionState(); +} + +class _AssetDragRegionState extends State { + late AssetIndex? assetUnderPointer; + late AssetIndex? anchorAsset; + + // Scroll related state + static const double scrollOffset = 0.10; + double? topScrollOffset; + double? bottomScrollOffset; + Timer? scrollTimer; + late bool scrollNotified; + + @override + void initState() { + super.initState(); + assetUnderPointer = null; + anchorAsset = null; + scrollNotified = false; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + topScrollOffset = null; + bottomScrollOffset = null; + } + + @override + void dispose() { + scrollTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return RawGestureDetector( + gestures: { + _CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers< + _CustomLongPressGestureRecognizer>( + () => _CustomLongPressGestureRecognizer(), + _registerCallbacks, + ), + }, + child: widget.child, + ); + } + + void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) { + recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details); + recognizer.onLongPressStart = (details) => _onLongPressStart(details); + recognizer.onLongPressUp = _onLongPressEnd; + recognizer.onLongPressCancel = _onLongPressEnd; + } + + AssetIndex? _getValueKeyAtPositon(Offset position) { + final box = context.findAncestorRenderObjectOfType(); + if (box == null) return null; + + final hitTestResult = BoxHitTestResult(); + final local = box.globalToLocal(position); + if (!box.hitTest(hitTestResult, position: local)) return null; + + return (hitTestResult.path + .firstWhereOrNull((hit) => hit.target is _AssetIndexProxy) + ?.target as _AssetIndexProxy?) + ?.index; + } + + void _onLongPressStart(LongPressStartDetails event) { + /// Calculate widget height and scroll offset when long press starting instead of in [initState] + /// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size + final height = context.size?.height; + if (height != null && + (topScrollOffset == null || bottomScrollOffset == null)) { + topScrollOffset = height * scrollOffset; + bottomScrollOffset = height - topScrollOffset!; + } + + final initialHit = _getValueKeyAtPositon(event.globalPosition); + anchorAsset = initialHit; + if (initialHit == null) return; + + if (anchorAsset != null) { + widget.onStart?.call(anchorAsset!); + } + } + + void _onLongPressEnd() { + scrollNotified = false; + scrollTimer?.cancel(); + widget.onEnd?.call(); + } + + void _onLongPressMove(LongPressMoveUpdateDetails event) { + if (anchorAsset == null) return; + if (topScrollOffset == null || bottomScrollOffset == null) return; + + final currentDy = event.localPosition.dy; + + if (currentDy > bottomScrollOffset!) { + scrollTimer ??= Timer.periodic( + const Duration(milliseconds: 50), + (_) => widget.onScroll?.call(ScrollDirection.forward), + ); + } else if (currentDy < topScrollOffset!) { + scrollTimer ??= Timer.periodic( + const Duration(milliseconds: 50), + (_) => widget.onScroll?.call(ScrollDirection.reverse), + ); + } else { + scrollTimer?.cancel(); + scrollTimer = null; + } + + final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition); + if (currentlyTouchingAsset == null) return; + + if (assetUnderPointer != currentlyTouchingAsset) { + if (!scrollNotified) { + scrollNotified = true; + widget.onScrollStart?.call(); + } + + widget.onAssetEnter?.call(currentlyTouchingAsset); + assetUnderPointer = currentlyTouchingAsset; + } + } +} + +class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer { + @override + void rejectGesture(int pointer) { + acceptGesture(pointer); + } +} + +// ignore: prefer-single-widget-per-file +class AssetIndexWrapper extends SingleChildRenderObjectWidget { + final int rowIndex; + final int sectionIndex; + + const AssetIndexWrapper({ + required Widget super.child, + required this.rowIndex, + required this.sectionIndex, + super.key, + }); + + @override + _AssetIndexProxy createRenderObject(BuildContext context) { + return _AssetIndexProxy( + index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex), + ); + } + + @override + void updateRenderObject( + BuildContext context, + _AssetIndexProxy renderObject, + ) { + renderObject.index = + AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex); + } +} + +class _AssetIndexProxy extends RenderProxyBox { + AssetIndex index; + + _AssetIndexProxy({ + required this.index, + }); +} + +class AssetIndex { + final int rowIndex; + final int sectionIndex; + + const AssetIndex({ + required this.rowIndex, + required this.sectionIndex, + }); + + @override + bool operator ==(covariant AssetIndex other) { + if (identical(this, other)) return true; + + return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex; + } + + @override + int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode; +} diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart index 8a6310816..4c520fe6f 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart @@ -5,12 +5,15 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/collection_extensions.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/scroll_notifier.provider.dart'; +import 'package:immich_mobile/modules/home/ui/asset_grid/asset_drag_region.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_placeholder.dart'; +import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart'; import 'package:immich_mobile/shared/models/asset.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -73,6 +76,8 @@ class ImmichAssetGridView extends StatefulWidget { class ImmichAssetGridViewState extends State { final ItemScrollController _itemScrollController = ItemScrollController(); + final ScrollOffsetController _scrollOffsetController = + ScrollOffsetController(); final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create(); @@ -83,6 +88,12 @@ class ImmichAssetGridViewState extends State { final Set _selectedAssets = LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + bool _dragging = false; + int? _dragAnchorAssetIndex; + int? _dragAnchorSectionIndex; + final Set _draggedAssets = + HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id); + Set _getSelectedAssets() { return Set.from(_selectedAssets); } @@ -93,20 +104,26 @@ class ImmichAssetGridViewState extends State { void _selectAssets(List assets) { setState(() { + if (_dragging) { + _draggedAssets.addAll(assets); + } _selectedAssets.addAll(assets); _callSelectionListener(true); }); } void _deselectAssets(List assets) { + final assetsToDeselect = assets.where( + (a) => + widget.canDeselect || + !(widget.preselectedAssets?.contains(a) ?? false), + ); + setState(() { - _selectedAssets.removeAll( - assets.where( - (a) => - widget.canDeselect || - !(widget.preselectedAssets?.contains(a) ?? false), - ), - ); + _selectedAssets.removeAll(assetsToDeselect); + if (_dragging) { + _draggedAssets.removeAll(assetsToDeselect); + } _callSelectionListener(_selectedAssets.isNotEmpty); }); } @@ -114,6 +131,10 @@ class ImmichAssetGridViewState extends State { void _deselectAll() { setState(() { _selectedAssets.clear(); + _dragAnchorAssetIndex = null; + _dragAnchorSectionIndex = null; + _draggedAssets.clear(); + _dragging = false; if (!widget.canDeselect && widget.preselectedAssets != null && widget.preselectedAssets!.isNotEmpty) { @@ -142,6 +163,7 @@ class ImmichAssetGridViewState extends State { showStorageIndicator: widget.showStorageIndicator, selectedAssets: _selectedAssets, selectionActive: widget.selectionActive, + sectionIndex: index, section: section, margin: widget.margin, renderList: widget.renderList, @@ -199,6 +221,7 @@ class ImmichAssetGridViewState extends State { itemBuilder: _itemBuilder, itemPositionsListener: _itemPositionsListener, itemScrollController: _itemScrollController, + scrollOffsetController: _scrollOffsetController, itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0), addRepaintBoundaries: true, @@ -253,6 +276,7 @@ class ImmichAssetGridViewState extends State { if (widget.visibleItemsListener != null) { _itemPositionsListener.itemPositions.removeListener(_positionListener); } + _itemPositionsListener.itemPositions.removeListener(_hapticsListener); super.dispose(); } @@ -308,6 +332,107 @@ class ImmichAssetGridViewState extends State { ); } + void _setDragStartIndex(AssetIndex index) { + setState(() { + _dragAnchorAssetIndex = index.rowIndex; + _dragAnchorSectionIndex = index.sectionIndex; + _dragging = true; + }); + } + + void _stopDrag() { + setState(() { + _dragging = false; + _draggedAssets.clear(); + }); + } + + void _dragDragScroll(ScrollDirection direction) { + _scrollOffsetController.animateScroll( + offset: direction == ScrollDirection.forward ? 175 : -175, + duration: const Duration(milliseconds: 125), + ); + } + + void _handleDragAssetEnter(AssetIndex index) { + if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) { + return; + } + + final dragAnchorSectionIndex = _dragAnchorSectionIndex!; + final dragAnchorAssetIndex = _dragAnchorAssetIndex!; + + late final int startSectionIndex; + late final int startSectionAssetIndex; + late final int endSectionIndex; + late final int endSectionAssetIndex; + + if (index.sectionIndex < dragAnchorSectionIndex) { + startSectionIndex = index.sectionIndex; + startSectionAssetIndex = index.rowIndex; + endSectionIndex = dragAnchorSectionIndex; + endSectionAssetIndex = dragAnchorAssetIndex; + } else if (index.sectionIndex > dragAnchorSectionIndex) { + startSectionIndex = dragAnchorSectionIndex; + startSectionAssetIndex = dragAnchorAssetIndex; + endSectionIndex = index.sectionIndex; + endSectionAssetIndex = index.rowIndex; + } else { + startSectionIndex = dragAnchorSectionIndex; + endSectionIndex = dragAnchorSectionIndex; + + // If same section, assign proper start / end asset Index + if (dragAnchorAssetIndex < index.rowIndex) { + startSectionAssetIndex = dragAnchorAssetIndex; + endSectionAssetIndex = index.rowIndex; + } else { + startSectionAssetIndex = index.rowIndex; + endSectionAssetIndex = dragAnchorAssetIndex; + } + } + + final selectedAssets = {}; + var currentSectionIndex = startSectionIndex; + while (currentSectionIndex < endSectionIndex) { + final section = + widget.renderList.elements.elementAtOrNull(currentSectionIndex); + if (section == null) continue; + + final sectionAssets = + widget.renderList.loadAssets(section.offset, section.count); + + if (currentSectionIndex == startSectionIndex) { + selectedAssets.addAll( + sectionAssets.slice(startSectionAssetIndex, sectionAssets.length), + ); + } else { + selectedAssets.addAll(sectionAssets); + } + + currentSectionIndex += 1; + } + + final section = widget.renderList.elements.elementAtOrNull(endSectionIndex); + if (section != null) { + final sectionAssets = + widget.renderList.loadAssets(section.offset, section.count); + if (startSectionIndex == endSectionIndex) { + selectedAssets.addAll( + sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1), + ); + } else { + selectedAssets.addAll( + sectionAssets.slice(0, endSectionAssetIndex + 1), + ); + } + } + + _deselectAssets(_draggedAssets.toList()); + _draggedAssets.clear(); + _draggedAssets.addAll(selectedAssets); + _selectAssets(_draggedAssets.toList()); + } + @override Widget build(BuildContext context) { return PopScope( @@ -315,7 +440,16 @@ class ImmichAssetGridViewState extends State { onPopInvoked: (didPop) => !didPop ? _deselectAll() : null, child: Stack( children: [ - _buildAssetGrid(), + AssetDragRegion( + onStart: _setDragStartIndex, + onAssetEnter: _handleDragAssetEnter, + onEnd: _stopDrag, + onScroll: _dragDragScroll, + onScrollStart: () => WidgetsBinding.instance.addPostFrameCallback( + (_) => controlBottomAppBarNotifier.minimize(), + ), + child: _buildAssetGrid(), + ), if (widget.showMultiSelectIndicator && widget.selectionActive) _buildMultiSelectIndicator(), ], @@ -361,6 +495,7 @@ class _PlaceholderRow extends StatelessWidget { /// A section for the render grid class _Section extends StatelessWidget { final RenderAssetGridElement section; + final int sectionIndex; final Set selectedAssets; final bool scrolling; final double margin; @@ -377,6 +512,7 @@ class _Section extends StatelessWidget { const _Section({ required this.section, + required this.sectionIndex, required this.scrolling, required this.margin, required this.assetsPerRow, @@ -435,6 +571,8 @@ class _Section extends StatelessWidget { ) : _AssetRow( key: ValueKey(i), + rowStartIndex: i * assetsPerRow, + sectionIndex: sectionIndex, assets: assetsToRender.nestedSlice( i * assetsPerRow, min((i + 1) * assetsPerRow, section.count), @@ -522,6 +660,8 @@ class _Title extends StatelessWidget { /// The row of assets class _AssetRow extends StatelessWidget { final List assets; + final int rowStartIndex; + final int sectionIndex; final Set selectedAssets; final int absoluteOffset; final double width; @@ -539,6 +679,8 @@ class _AssetRow extends StatelessWidget { const _AssetRow({ super.key, + required this.rowStartIndex, + required this.sectionIndex, required this.assets, required this.absoluteOffset, required this.width, @@ -594,18 +736,22 @@ class _AssetRow extends StatelessWidget { bottom: margin, right: last ? 0.0 : margin, ), - child: ThumbnailImage( - asset: asset, - index: absoluteOffset + index, - loadAsset: renderList.loadAsset, - totalAssets: renderList.totalAssets, - multiselectEnabled: selectionActive, - isSelected: isSelectionActive && selectedAssets.contains(asset), - onSelect: () => onSelect?.call(asset), - onDeselect: () => onDeselect?.call(asset), - showStorageIndicator: showStorageIndicator, - heroOffset: heroOffset, - showStack: showStack, + child: AssetIndexWrapper( + rowIndex: rowStartIndex + index, + sectionIndex: sectionIndex, + child: ThumbnailImage( + asset: asset, + index: absoluteOffset + index, + loadAsset: renderList.loadAsset, + totalAssets: renderList.totalAssets, + multiselectEnabled: selectionActive, + isSelected: isSelectionActive && selectedAssets.contains(asset), + onSelect: () => onSelect?.call(asset), + onDeselect: () => onDeselect?.call(asset), + showStorageIndicator: showStorageIndicator, + heroOffset: heroOffset, + showStack: showStack, + ), ), ); }).toList(), diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index 2a2843089..23bb2ed61 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -1,5 +1,6 @@ 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/extensions/build_context_extensions.dart'; import 'package:immich_mobile/modules/album/providers/album.provider.dart'; @@ -11,8 +12,17 @@ import 'package:immich_mobile/modules/home/ui/upload_dialog.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/ui/drag_sheet.dart'; import 'package:immich_mobile/shared/models/album.dart'; +import 'package:immich_mobile/utils/draggable_scroll_controller.dart'; -class ControlBottomAppBar extends ConsumerWidget { +final controlBottomAppBarNotifier = ControlBottomAppBarNotifier(); + +class ControlBottomAppBarNotifier with ChangeNotifier { + void minimize() { + notifyListeners(); + } +} + +class ControlBottomAppBar extends HookConsumerWidget { final void Function(bool shareLocal) onShare; final void Function()? onFavorite; final void Function()? onArchive; @@ -64,6 +74,25 @@ class ControlBottomAppBar extends ConsumerWidget { final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList(); final sharedAlbums = ref.watch(sharedAlbumProvider); const bottomPadding = 0.20; + final scrollController = useDraggableScrollController(); + + void minimize() { + scrollController.animateTo( + bottomPadding, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + + useEffect( + () { + controlBottomAppBarNotifier.addListener(minimize); + return () { + controlBottomAppBarNotifier.removeListener(minimize); + }; + }, + [], + ); void showForceDeleteDialog( Function(bool) deleteCb, { @@ -242,6 +271,7 @@ class ControlBottomAppBar extends ConsumerWidget { } return DraggableScrollableSheet( + controller: scrollController, initialChildSize: hasRemote ? 0.35 : bottomPadding, minChildSize: bottomPadding, maxChildSize: hasRemote ? 0.65 : bottomPadding, diff --git a/mobile/lib/modules/search/ui/curated_people_row.dart b/mobile/lib/modules/search/ui/curated_people_row.dart index 78dc1af4f..049c1ce46 100644 --- a/mobile/lib/modules/search/ui/curated_people_row.dart +++ b/mobile/lib/modules/search/ui/curated_people_row.dart @@ -23,7 +23,7 @@ class CuratedPeopleRow extends StatelessWidget { @override Widget build(BuildContext context) { - const imageSize = 85.0; + const imageSize = 80.0; // Guard empty [content] if (content.isEmpty) { diff --git a/mobile/lib/shared/providers/immich_logo_provider.dart b/mobile/lib/shared/providers/immich_logo_provider.dart new file mode 100644 index 000000000..c5c65fcfe --- /dev/null +++ b/mobile/lib/shared/providers/immich_logo_provider.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'immich_logo_provider.g.dart'; + +@riverpod +Future immichLogo(ImmichLogoRef ref) async { + final json = await rootBundle.loadString('assets/immich-logo.json'); + final j = jsonDecode(json); + return base64Decode(j['content']); +} diff --git a/mobile/lib/shared/providers/immich_logo_provider.g.dart b/mobile/lib/shared/providers/immich_logo_provider.g.dart new file mode 100644 index 000000000..1a95814e3 --- /dev/null +++ b/mobile/lib/shared/providers/immich_logo_provider.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'immich_logo_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$immichLogoHash() => r'040cc44fae3339e0f40a091fb3b2f2abe9f83acd'; + +/// See also [immichLogo]. +@ProviderFor(immichLogo) +final immichLogoProvider = AutoDisposeFutureProvider.internal( + immichLogo, + name: r'immichLogoProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$immichLogoHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ImmichLogoRef = AutoDisposeFutureProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index 84f070863..5b26432d8 100644 --- a/mobile/lib/shared/ui/immich_app_bar.dart +++ b/mobile/lib/shared/ui/immich_app_bar.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/shared/models/store.dart'; +import 'package:immich_mobile/shared/providers/immich_logo_provider.dart'; import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; @@ -26,6 +27,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { final bool isEnableAutoBackup = backupState.backgroundBackup || backupState.autoBackup; final ServerInfo serverInfoState = ref.watch(serverInfoProvider); + final immichLogo = ref.watch(immichLogoProvider); final user = Store.tryGet(StoreKey.currentUser); final isDarkTheme = context.isDarkTheme; const widgetSize = 30.0; @@ -152,14 +154,29 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { builder: (BuildContext context) { return Row( children: [ - Container( - padding: const EdgeInsets.only(top: 3), - height: 30, - child: Image.asset( - context.isDarkTheme - ? 'assets/immich-logo-inline-dark.png' - : 'assets/immich-logo-inline-light.png', - ), + Builder( + builder: (context) { + final today = DateTime.now(); + if (today.month == 4 && today.day == 1) { + if (immichLogo.value == null) { + return const SizedBox.shrink(); + } + return Image.memory( + immichLogo.value!, + fit: BoxFit.cover, + height: 80, + ); + } + return Padding( + padding: const EdgeInsets.only(top: 3.0), + child: Image.asset( + height: 30, + context.isDarkTheme + ? 'assets/immich-logo-inline-dark.png' + : 'assets/immich-logo-inline-light.png', + ), + ); + }, ), ], ); diff --git a/mobile/openapi/doc/CreateLibraryDto.md b/mobile/openapi/doc/CreateLibraryDto.md index e0caf1c8a..94e96493e 100644 --- a/mobile/openapi/doc/CreateLibraryDto.md +++ b/mobile/openapi/doc/CreateLibraryDto.md @@ -13,7 +13,7 @@ Name | Type | Description | Notes **isVisible** | **bool** | | [optional] **isWatched** | **bool** | | [optional] **name** | **String** | | [optional] -**ownerId** | **String** | | [optional] +**ownerId** | **String** | | **type** | [**LibraryType**](LibraryType.md) | | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md b/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md index 4601d8d2f..1ebcb04ef 100644 --- a/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md +++ b/mobile/openapi/doc/ValidateLibraryImportPathResponseDto.md @@ -9,7 +9,7 @@ import 'package:openapi/api.dart'; Name | Type | Description | Notes ------------ | ------------- | ------------- | ------------- **importPath** | **String** | | -**isValid** | **bool** | | [optional] [default to false] +**isValid** | **bool** | | [default to false] **message** | **String** | | [optional] [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) diff --git a/mobile/openapi/lib/model/create_library_dto.dart b/mobile/openapi/lib/model/create_library_dto.dart index ef656ea2a..24cc04530 100644 --- a/mobile/openapi/lib/model/create_library_dto.dart +++ b/mobile/openapi/lib/model/create_library_dto.dart @@ -18,7 +18,7 @@ class CreateLibraryDto { this.isVisible, this.isWatched, this.name, - this.ownerId, + required this.ownerId, required this.type, }); @@ -50,13 +50,7 @@ class CreateLibraryDto { /// String? name; - /// - /// 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? ownerId; + String ownerId; LibraryType type; @@ -78,7 +72,7 @@ class CreateLibraryDto { (isVisible == null ? 0 : isVisible!.hashCode) + (isWatched == null ? 0 : isWatched!.hashCode) + (name == null ? 0 : name!.hashCode) + - (ownerId == null ? 0 : ownerId!.hashCode) + + (ownerId.hashCode) + (type.hashCode); @override @@ -103,11 +97,7 @@ class CreateLibraryDto { } else { // json[r'name'] = null; } - if (this.ownerId != null) { json[r'ownerId'] = this.ownerId; - } else { - // json[r'ownerId'] = null; - } json[r'type'] = this.type; return json; } @@ -129,7 +119,7 @@ class CreateLibraryDto { isVisible: mapValueOfType(json, r'isVisible'), isWatched: mapValueOfType(json, r'isWatched'), name: mapValueOfType(json, r'name'), - ownerId: mapValueOfType(json, r'ownerId'), + ownerId: mapValueOfType(json, r'ownerId')!, type: LibraryType.fromJson(json[r'type'])!, ); } @@ -178,6 +168,7 @@ class CreateLibraryDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'ownerId', 'type', }; } diff --git a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart index 1297c824c..142055f2c 100644 --- a/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart +++ b/mobile/openapi/lib/model/validate_library_import_path_response_dto.dart @@ -67,7 +67,7 @@ class ValidateLibraryImportPathResponseDto { return ValidateLibraryImportPathResponseDto( importPath: mapValueOfType(json, r'importPath')!, - isValid: mapValueOfType(json, r'isValid') ?? false, + isValid: mapValueOfType(json, r'isValid')!, message: mapValueOfType(json, r'message'), ); } @@ -117,6 +117,7 @@ class ValidateLibraryImportPathResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'importPath', + 'isValid', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 15ada078c..82562100a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7646,6 +7646,7 @@ } }, "required": [ + "ownerId", "type" ], "type": "object" @@ -10689,7 +10690,8 @@ } }, "required": [ - "importPath" + "importPath", + "isValid" ], "type": "object" }, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6a660f4e1..6b5064252 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -466,7 +466,7 @@ export type CreateLibraryDto = { isVisible?: boolean; isWatched?: boolean; name?: string; - ownerId?: string; + ownerId: string; "type": LibraryType; }; export type UpdateLibraryDto = { @@ -491,7 +491,7 @@ export type ValidateLibraryDto = { }; export type ValidateLibraryImportPathResponseDto = { importPath: string; - isValid?: boolean; + isValid: boolean; message?: string; }; export type ValidateLibraryResponseDto = { diff --git a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts index 475787689..5f05d736b 100644 --- a/server/e2e/jobs/specs/library-watcher.e2e-spec.ts +++ b/server/e2e/jobs/specs/library-watcher.e2e-spec.ts @@ -46,6 +46,7 @@ describe(`Library watcher (e2e)`, () => { describe('Single import path', () => { beforeEach(async () => { await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -133,6 +134,7 @@ describe(`Library watcher (e2e)`, () => { await fs.mkdir(`${IMMICH_TEST_ASSET_TEMP_PATH}/dir3`, { recursive: true }); await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [ `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, @@ -190,6 +192,7 @@ describe(`Library watcher (e2e)`, () => { beforeEach(async () => { library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [ `${IMMICH_TEST_ASSET_TEMP_PATH}/dir1`, diff --git a/server/e2e/jobs/specs/library.e2e-spec.ts b/server/e2e/jobs/specs/library.e2e-spec.ts index 4ebb00c4d..a4ee4977a 100644 --- a/server/e2e/jobs/specs/library.e2e-spec.ts +++ b/server/e2e/jobs/specs/library.e2e-spec.ts @@ -1,6 +1,6 @@ -import { LibraryResponseDto, LoginResponseDto } from '@app/domain'; +import { LoginResponseDto } from '@app/domain'; import { LibraryController } from '@app/immich'; -import { AssetType, LibraryType } from '@app/infra/entities'; +import { LibraryType } from '@app/infra/entities'; import { errorStub, uuidStub } from '@test/fixtures'; import * as fs from 'node:fs'; import request from 'supertest'; @@ -41,6 +41,7 @@ describe(`${LibraryController.name} (e2e)`, () => { }); const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -72,6 +73,7 @@ describe(`${LibraryController.name} (e2e)`, () => { it('should scan new files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -107,6 +109,7 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('with refreshModifiedFiles=true', () => { it('should reimport modified files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -153,6 +156,7 @@ describe(`${LibraryController.name} (e2e)`, () => { it('should not reimport unmodified files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -192,6 +196,7 @@ describe(`${LibraryController.name} (e2e)`, () => { describe('with refreshAllFiles=true', () => { it('should reimport all files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -251,6 +256,7 @@ describe(`${LibraryController.name} (e2e)`, () => { }); const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`], }); @@ -277,6 +283,7 @@ describe(`${LibraryController.name} (e2e)`, () => { it('should not remove online files', async () => { const library = await api.libraryApi.create(server, admin.accessToken, { + ownerId: admin.userId, type: LibraryType.EXTERNAL, importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`], }); diff --git a/server/package.json b/server/package.json index c575571d0..d60f67a1f 100644 --- a/server/package.json +++ b/server/package.json @@ -153,7 +153,7 @@ "coverageDirectory": "./coverage", "coverageThreshold": { "./src/domain/": { - "branches": 79, + "branches": 75, "functions": 80, "lines": 90, "statements": 90 diff --git a/server/src/domain/access/access.core.ts b/server/src/domain/access/access.core.ts index 7063cb49a..40b01de1d 100644 --- a/server/src/domain/access/access.core.ts +++ b/server/src/domain/access/access.core.ts @@ -33,12 +33,6 @@ export enum Permission { TIMELINE_READ = 'timeline.read', TIMELINE_DOWNLOAD = 'timeline.download', - LIBRARY_CREATE = 'library.create', - LIBRARY_READ = 'library.read', - LIBRARY_UPDATE = 'library.update', - LIBRARY_DELETE = 'library.delete', - LIBRARY_DOWNLOAD = 'library.download', - PERSON_READ = 'person.read', PERSON_WRITE = 'person.write', PERSON_MERGE = 'person.merge', @@ -261,29 +255,6 @@ export class AccessCore { return ids.has(auth.user.id) ? new Set([auth.user.id]) : new Set(); } - case Permission.LIBRARY_READ: { - if (auth.user.isAdmin) { - return new Set(ids); - } - const isOwner = await this.repository.library.checkOwnerAccess(auth.user.id, ids); - const isPartner = await this.repository.library.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); - } - - case Permission.LIBRARY_UPDATE: { - if (auth.user.isAdmin) { - return new Set(ids); - } - return await this.repository.library.checkOwnerAccess(auth.user.id, ids); - } - - case Permission.LIBRARY_DELETE: { - if (auth.user.isAdmin) { - return new Set(ids); - } - return await this.repository.library.checkOwnerAccess(auth.user.id, ids); - } - case Permission.PERSON_READ: { return await this.repository.person.checkOwnerAccess(auth.user.id, ids); } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index 8ba5d93ba..361946f61 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -15,6 +15,8 @@ import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock, + partnerStub, + userStub, } from '@test'; import { when } from 'jest-when'; import { JobName } from '../job'; @@ -317,6 +319,7 @@ describe(AssetService.name, () => { }); it('should set the title correctly', async () => { + partnerMock.getAll.mockResolvedValue([]); assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ @@ -324,7 +327,17 @@ describe(AssetService.name, () => { { title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] }, ]); - expect(assetMock.getByDayOfYear.mock.calls).toEqual([[authStub.admin.user.id, { day: 15, month: 1 }]]); + expect(assetMock.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]); + }); + + it('should get memories with partners with inTimeline enabled', async () => { + partnerMock.getAll.mockResolvedValue([partnerStub.user1ToAdmin1]); + + await sut.getMemoryLane(authStub.admin, { day: 15, month: 1 }); + + expect(assetMock.getByDayOfYear.mock.calls).toEqual([ + [[authStub.admin.user.id, userStub.user1.id], { day: 15, month: 1 }], + ]); }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 857f1648f..e54eb8439 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -172,7 +172,16 @@ export class AssetService { async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { const currentYear = new Date().getFullYear(); - const assets = await this.assetRepository.getByDayOfYear(auth.user.id, dto); + + // get partners id + const userIds: string[] = [auth.user.id]; + const partners = await this.partnerRepository.getAll(auth.user.id); + const partnersIds = partners + .filter((partner) => partner.sharedBy && partner.inTimeline) + .map((partner) => partner.sharedById); + userIds.push(...partnersIds); + + const assets = await this.assetRepository.getByDayOfYear(userIds, dto); return _.chain(assets) .filter((asset) => asset.localDateTime.getFullYear() < currentYear) diff --git a/server/src/domain/library/library.dto.ts b/server/src/domain/library/library.dto.ts index 5e4bb4ec6..fcce02f87 100644 --- a/server/src/domain/library/library.dto.ts +++ b/server/src/domain/library/library.dto.ts @@ -8,8 +8,8 @@ export class CreateLibraryDto { @ApiProperty({ enumName: 'LibraryType', enum: LibraryType }) type!: LibraryType; - @ValidateUUID({ optional: true }) - ownerId?: string; + @ValidateUUID() + ownerId!: string; @IsString() @Optional() diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index 57bdf7373..3b5258b97 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -706,7 +706,7 @@ describe(LibraryService.name, () => { libraryMock.getUploadLibraryCount.mockResolvedValue(2); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); + await sut.delete(libraryStub.externalLibrary1.id); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, @@ -721,9 +721,7 @@ describe(LibraryService.name, () => { libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.delete(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(sut.delete(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled(); @@ -735,7 +733,7 @@ describe(LibraryService.name, () => { libraryMock.getUploadLibraryCount.mockResolvedValue(1); libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.delete(authStub.admin, libraryStub.externalLibrary1.id); + await sut.delete(libraryStub.externalLibrary1.id); expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.LIBRARY_DELETE, @@ -757,26 +755,16 @@ describe(LibraryService.name, () => { storageMock.watch.mockImplementation(makeMockWatcher({ close: mockClose })); await sut.init(); - await sut.delete(authStub.admin, libraryStub.externalLibraryWithImportPaths1.id); + await sut.delete(libraryStub.externalLibraryWithImportPaths1.id); expect(mockClose).toHaveBeenCalled(); }); }); - describe('getCount', () => { - it('should call the repository', async () => { - libraryMock.getCountForUser.mockResolvedValue(17); - - await expect(sut.getCount(authStub.admin)).resolves.toBe(17); - - expect(libraryMock.getCountForUser).toHaveBeenCalledWith(authStub.admin.user.id); - }); - }); - describe('get', () => { it('should return a library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual( + await expect(sut.get(libraryStub.uploadLibrary1.id)).resolves.toEqual( expect.objectContaining({ id: libraryStub.uploadLibrary1.id, name: libraryStub.uploadLibrary1.name, @@ -789,15 +777,16 @@ describe(LibraryService.name, () => { it('should throw an error when a library is not found', async () => { libraryMock.get.mockResolvedValue(null); - await expect(sut.get(authStub.admin, libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.get(libraryStub.uploadLibrary1.id)).rejects.toBeInstanceOf(BadRequestException); expect(libraryMock.get).toHaveBeenCalledWith(libraryStub.uploadLibrary1.id); }); }); describe('getStatistics', () => { it('should return library statistics', async () => { + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); - await expect(sut.getStatistics(authStub.admin, libraryStub.uploadLibrary1.id)).resolves.toEqual({ + await expect(sut.getStatistics(libraryStub.uploadLibrary1.id)).resolves.toEqual({ photos: 10, videos: 0, total: 10, @@ -812,11 +801,7 @@ describe(LibraryService.name, () => { describe('external library', () => { it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); - await expect( - sut.create(authStub.admin, { - type: LibraryType.EXTERNAL, - }), - ).resolves.toEqual( + await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL })).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, type: LibraryType.EXTERNAL, @@ -845,10 +830,7 @@ describe(LibraryService.name, () => { it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { - type: LibraryType.EXTERNAL, - name: 'My Awesome Library', - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, name: 'My Awesome Library' }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -878,10 +860,7 @@ describe(LibraryService.name, () => { it('should create invisible', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { - type: LibraryType.EXTERNAL, - isVisible: false, - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, isVisible: false }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.externalLibrary1.id, @@ -911,7 +890,8 @@ describe(LibraryService.name, () => { it('should create with import paths', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, importPaths: ['/data/images', '/data/videos'], }), @@ -948,7 +928,8 @@ describe(LibraryService.name, () => { libraryMock.getAll.mockResolvedValue([]); await sut.init(); - await sut.create(authStub.admin, { + await sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, importPaths: libraryStub.externalLibraryWithImportPaths1.importPaths, }); @@ -963,7 +944,8 @@ describe(LibraryService.name, () => { it('should create with exclusion patterns', async () => { libraryMock.create.mockResolvedValue(libraryStub.externalLibrary1); await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.EXTERNAL, exclusionPatterns: ['*.tmp', '*.bak'], }), @@ -997,11 +979,7 @@ describe(LibraryService.name, () => { describe('upload library', () => { it('should create with default settings', async () => { libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); - await expect( - sut.create(authStub.admin, { - type: LibraryType.UPLOAD, - }), - ).resolves.toEqual( + await expect(sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD })).resolves.toEqual( expect.objectContaining({ id: libraryStub.uploadLibrary1.id, type: LibraryType.UPLOAD, @@ -1030,10 +1008,7 @@ describe(LibraryService.name, () => { it('should create with name', async () => { libraryMock.create.mockResolvedValue(libraryStub.uploadLibrary1); await expect( - sut.create(authStub.admin, { - type: LibraryType.UPLOAD, - name: 'My Awesome Library', - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, name: 'My Awesome Library' }), ).resolves.toEqual( expect.objectContaining({ id: libraryStub.uploadLibrary1.id, @@ -1062,7 +1037,8 @@ describe(LibraryService.name, () => { it('should not create with import paths', async () => { await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, importPaths: ['/data/images', '/data/videos'], }), @@ -1073,7 +1049,8 @@ describe(LibraryService.name, () => { it('should not create with exclusion patterns', async () => { await expect( - sut.create(authStub.admin, { + sut.create({ + ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, exclusionPatterns: ['*.tmp', '*.bak'], }), @@ -1084,10 +1061,7 @@ describe(LibraryService.name, () => { it('should not create watched', async () => { await expect( - sut.create(authStub.admin, { - type: LibraryType.UPLOAD, - isWatched: true, - }), + sut.create({ ownerId: authStub.admin.user.id, type: LibraryType.UPLOAD, isWatched: true }), ).rejects.toBeInstanceOf(BadRequestException); expect(storageMock.watch).not.toHaveBeenCalled(); @@ -1117,14 +1091,9 @@ describe(LibraryService.name, () => { it('should update library', async () => { libraryMock.update.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.update(authStub.admin, authStub.admin.user.id, {})).resolves.toEqual( - mapLibrary(libraryStub.uploadLibrary1), - ); - expect(libraryMock.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: authStub.admin.user.id, - }), - ); + libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); + await expect(sut.update('library-id', {})).resolves.toEqual(mapLibrary(libraryStub.uploadLibrary1)); + expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); }); it('should re-watch library when updating import paths', async () => { @@ -1137,15 +1106,11 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - await expect( - sut.update(authStub.admin, authStub.admin.user.id, { importPaths: ['/data/user1/foo'] }), - ).resolves.toEqual(mapLibrary(libraryStub.externalLibraryWithImportPaths1)); - - expect(libraryMock.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: authStub.admin.user.id, - }), + await expect(sut.update('library-id', { importPaths: ['/data/user1/foo'] })).resolves.toEqual( + mapLibrary(libraryStub.externalLibraryWithImportPaths1), ); + + expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); expect(storageMock.watch).toHaveBeenCalledWith( libraryStub.externalLibraryWithImportPaths1.importPaths, expect.anything(), @@ -1158,15 +1123,11 @@ describe(LibraryService.name, () => { configMock.load.mockResolvedValue(systemConfigStub.libraryWatchEnabled); libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); - await expect(sut.update(authStub.admin, authStub.admin.user.id, { exclusionPatterns: ['bar'] })).resolves.toEqual( + await expect(sut.update('library-id', { exclusionPatterns: ['bar'] })).resolves.toEqual( mapLibrary(libraryStub.externalLibraryWithImportPaths1), ); - expect(libraryMock.update).toHaveBeenCalledWith( - expect.objectContaining({ - id: authStub.admin.user.id, - }), - ); + expect(libraryMock.update).toHaveBeenCalledWith(expect.objectContaining({ id: 'library-id' })); expect(storageMock.watch).toHaveBeenCalledWith( expect.arrayContaining([expect.any(String)]), expect.anything(), @@ -1411,7 +1372,7 @@ describe(LibraryService.name, () => { it('should queue a library scan of external library', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, {}); + await sut.queueScan(libraryStub.externalLibrary1.id, {}); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1430,9 +1391,7 @@ describe(LibraryService.name, () => { it('should not queue a library scan of upload library', async () => { libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); - await expect(sut.queueScan(authStub.admin, libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf( - BadRequestException, - ); + await expect(sut.queueScan(libraryStub.uploadLibrary1.id, {})).rejects.toBeInstanceOf(BadRequestException); expect(jobMock.queue).not.toBeCalled(); }); @@ -1440,7 +1399,7 @@ describe(LibraryService.name, () => { it('should queue a library scan of all modified assets', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); + await sut.queueScan(libraryStub.externalLibrary1.id, { refreshModifiedFiles: true }); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1459,7 +1418,7 @@ describe(LibraryService.name, () => { it('should queue a forced library scan', async () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); - await sut.queueScan(authStub.admin, libraryStub.externalLibrary1.id, { refreshAllFiles: true }); + await sut.queueScan(libraryStub.externalLibrary1.id, { refreshAllFiles: true }); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1478,7 +1437,7 @@ describe(LibraryService.name, () => { describe('queueEmptyTrash', () => { it('should queue the trash job', async () => { - await sut.queueRemoveOffline(authStub.admin, libraryStub.externalLibrary1.id); + await sut.queueRemoveOffline(libraryStub.externalLibrary1.id); expect(jobMock.queue.mock.calls).toEqual([ [ @@ -1566,17 +1525,15 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(true); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: true, + message: undefined, + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: true, - message: undefined, - }, - ]); }); it('should detect when path does not exist', async () => { @@ -1585,17 +1542,15 @@ describe(LibraryService.name, () => { throw error; }); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: false, + message: 'Path does not exist (ENOENT)', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: false, - message: 'Path does not exist (ENOENT)', - }, - ]); }); it('should detect when path is not a directory', async () => { @@ -1603,17 +1558,15 @@ describe(LibraryService.name, () => { isDirectory: () => false, } as Stats); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/file'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/file'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/file', + isValid: false, + message: 'Not a directory', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/file', - isValid: false, - message: 'Not a directory', - }, - ]); }); it('should return an unknown exception from stat', async () => { @@ -1621,17 +1574,15 @@ describe(LibraryService.name, () => { throw new Error('Unknown error'); }); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: false, + message: 'Error: Unknown error', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: false, - message: 'Error: Unknown error', - }, - ]); }); it('should detect when access rights are missing', async () => { @@ -1641,17 +1592,15 @@ describe(LibraryService.name, () => { storageMock.checkFileExists.mockResolvedValue(false); - const result = await sut.validate(authStub.external1, libraryStub.externalLibraryWithImportPaths1.id, { - importPaths: ['/data/user1/'], + await expect(sut.validate('library-id', { importPaths: ['/data/user1/'] })).resolves.toEqual({ + importPaths: [ + { + importPath: '/data/user1/', + isValid: false, + message: 'Lacking read permission for folder', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: '/data/user1/', - isValid: false, - message: 'Lacking read permission for folder', - }, - ]); }); it('should detect when import path is in immich media folder', async () => { @@ -1659,26 +1608,26 @@ describe(LibraryService.name, () => { const validImport = libraryStub.hasImmichPaths.importPaths[1]; when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true); - const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, { - importPaths: libraryStub.hasImmichPaths.importPaths, + await expect( + sut.validate('library-id', { importPaths: libraryStub.hasImmichPaths.importPaths }), + ).resolves.toEqual({ + importPaths: [ + { + importPath: libraryStub.hasImmichPaths.importPaths[0], + isValid: false, + message: 'Cannot use media upload folder for external libraries', + }, + { + importPath: validImport, + isValid: true, + }, + { + importPath: libraryStub.hasImmichPaths.importPaths[2], + isValid: false, + message: 'Cannot use media upload folder for external libraries', + }, + ], }); - - expect(result.importPaths).toEqual([ - { - importPath: libraryStub.hasImmichPaths.importPaths[0], - isValid: false, - message: 'Cannot use media upload folder for external libraries', - }, - { - importPath: validImport, - isValid: true, - }, - { - importPath: libraryStub.hasImmichPaths.importPaths[2], - isValid: false, - message: 'Cannot use media upload folder for external libraries', - }, - ]); }); }); }); diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index 12d135fa3..000acac29 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -8,8 +8,7 @@ import { EventEmitter } from 'node:events'; import { Stats } from 'node:fs'; import path, { basename, parse } from 'node:path'; import picomatch from 'picomatch'; -import { AccessCore, Permission } from '../access'; -import { AuthDto } from '../auth'; +import { AccessCore } from '../access'; import { mimeTypes } from '../domain.constant'; import { handlePromiseError, usePagination, validateCronExpression } from '../domain.util'; import { IBaseJob, IEntityJob, ILibraryFileJob, ILibraryRefreshJob, JOBS_ASSET_PAGINATION_SIZE, JobName } from '../job'; @@ -226,24 +225,17 @@ export class LibraryService extends EventEmitter { } } - async getStatistics(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); - + async getStatistics(id: string): Promise { + await this.findOrFail(id); return this.repository.getStatistics(id); } - async getCount(auth: AuthDto): Promise { - return this.repository.getCountForUser(auth.user.id); - } - - async get(auth: AuthDto, id: string): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_READ, id); - + async get(id: string): Promise { const library = await this.findOrFail(id); return mapLibrary(library); } - async getAll(auth: AuthDto, dto: SearchLibraryDto): Promise { + async getAll(dto: SearchLibraryDto): Promise { const libraries = await this.repository.getAll(false, dto.type); return libraries.map((library) => mapLibrary(library)); } @@ -257,7 +249,7 @@ export class LibraryService extends EventEmitter { return JobStatus.SUCCESS; } - async create(auth: AuthDto, dto: CreateLibraryDto): Promise { + async create(dto: CreateLibraryDto): Promise { switch (dto.type) { case LibraryType.EXTERNAL: { if (!dto.name) { @@ -282,14 +274,8 @@ export class LibraryService extends EventEmitter { } } - let ownerId = auth.user.id; - - if (dto.ownerId) { - ownerId = dto.ownerId; - } - const library = await this.repository.create({ - ownerId, + ownerId: dto.ownerId, name: dto.name, type: dto.type, importPaths: dto.importPaths ?? [], @@ -297,7 +283,7 @@ export class LibraryService extends EventEmitter { isVisible: dto.isVisible ?? true, }); - this.logger.log(`Creating ${dto.type} library for user ${auth.user.name}`); + this.logger.log(`Creating ${dto.type} library for ${dto.ownerId}}`); if (dto.type === LibraryType.EXTERNAL) { await this.watch(library.id); @@ -364,29 +350,19 @@ export class LibraryService extends EventEmitter { return validation; } - public async validate(auth: AuthDto, id: string, dto: ValidateLibraryDto): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - - const response = new ValidateLibraryResponseDto(); - - if (dto.importPaths) { - response.importPaths = await Promise.all( - dto.importPaths.map(async (importPath) => { - return await this.validateImportPath(importPath); - }), - ); - } - - return response; + async validate(id: string, dto: ValidateLibraryDto): Promise { + const importPaths = await Promise.all( + (dto.importPaths || []).map((importPath) => this.validateImportPath(importPath)), + ); + return { importPaths }; } - async update(auth: AuthDto, id: string, dto: UpdateLibraryDto): Promise { - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - + async update(id: string, dto: UpdateLibraryDto): Promise { + await this.findOrFail(id); const library = await this.repository.update({ id, ...dto }); if (dto.importPaths) { - const validation = await this.validate(auth, id, { importPaths: dto.importPaths }); + const validation = await this.validate(id, { importPaths: dto.importPaths }); if (validation.importPaths) { for (const path of validation.importPaths) { if (!path.isValid) { @@ -404,11 +380,9 @@ export class LibraryService extends EventEmitter { return mapLibrary(library); } - async delete(auth: AuthDto, id: string) { - await this.access.requirePermission(auth, Permission.LIBRARY_DELETE, id); - + async delete(id: string) { const library = await this.findOrFail(id); - const uploadCount = await this.repository.getUploadLibraryCount(auth.user.id); + const uploadCount = await this.repository.getUploadLibraryCount(library.ownerId); if (library.type === LibraryType.UPLOAD && uploadCount <= 1) { throw new BadRequestException('Cannot delete the last upload library'); } @@ -565,11 +539,9 @@ export class LibraryService extends EventEmitter { return JobStatus.SUCCESS; } - async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) { - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - - const library = await this.repository.get(id); - if (!library || library.type !== LibraryType.EXTERNAL) { + async queueScan(id: string, dto: ScanLibraryDto) { + const library = await this.findOrFail(id); + if (library.type !== LibraryType.EXTERNAL) { throw new BadRequestException('Can only refresh external libraries'); } @@ -583,16 +555,9 @@ export class LibraryService extends EventEmitter { }); } - async queueRemoveOffline(auth: AuthDto, id: string) { + async queueRemoveOffline(id: string) { this.logger.verbose(`Removing offline files from library: ${id}`); - await this.access.requirePermission(auth, Permission.LIBRARY_UPDATE, id); - - await this.jobRepository.queue({ - name: JobName.LIBRARY_REMOVE_OFFLINE, - data: { - id, - }, - }); + await this.jobRepository.queue({ name: JobName.LIBRARY_REMOVE_OFFLINE, data: { id } }); } async handleQueueAllScan(job: IBaseJob): Promise { diff --git a/server/src/domain/repositories/access.repository.ts b/server/src/domain/repositories/access.repository.ts index 6aa70a212..7924a29dd 100644 --- a/server/src/domain/repositories/access.repository.ts +++ b/server/src/domain/repositories/access.repository.ts @@ -26,7 +26,6 @@ export interface IAccessRepository { library: { checkOwnerAccess(userId: string, libraryIds: Set): Promise>; - checkPartnerAccess(userId: string, partnerIds: Set): Promise>; }; timeline: { diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 8b14ce597..c4ddb3107 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -123,7 +123,7 @@ export interface IAssetRepository { select?: FindOptionsSelect, ): Promise; getByIdsWithAllRelations(ids: string[]): Promise; - getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise; + getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise; getByChecksum(userId: string, checksum: Buffer): Promise; getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated; getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated; diff --git a/server/src/immich/controllers/library.controller.ts b/server/src/immich/controllers/library.controller.ts index 801dc173d..2b509645c 100644 --- a/server/src/immich/controllers/library.controller.ts +++ b/server/src/immich/controllers/library.controller.ts @@ -1,5 +1,4 @@ import { - AuthDto, CreateLibraryDto as CreateDto, LibraryService, LibraryStatsResponseDto, @@ -12,7 +11,7 @@ import { } from '@app/domain'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AdminRoute, Auth, Authenticated } from '../app.guard'; +import { AdminRoute, Authenticated } from '../app.guard'; import { UUIDParamDto } from './dto/uuid-param.dto'; @ApiTags('Library') @@ -23,55 +22,52 @@ export class LibraryController { constructor(private service: LibraryService) {} @Get() - getAllLibraries(@Auth() auth: AuthDto, @Query() dto: SearchLibraryDto): Promise { - return this.service.getAll(auth, dto); + getAllLibraries(@Query() dto: SearchLibraryDto): Promise { + return this.service.getAll(dto); } @Post() - createLibrary(@Auth() auth: AuthDto, @Body() dto: CreateDto): Promise { - return this.service.create(auth, dto); + createLibrary(@Body() dto: CreateDto): Promise { + return this.service.create(dto); } @Put(':id') - updateLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise { - return this.service.update(auth, id, dto); + updateLibrary(@Param() { id }: UUIDParamDto, @Body() dto: UpdateDto): Promise { + return this.service.update(id, dto); } @Get(':id') - getLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.get(auth, id); + getLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.get(id); } @Post(':id/validate') @HttpCode(200) - validate( - @Auth() auth: AuthDto, - @Param() { id }: UUIDParamDto, - @Body() dto: ValidateLibraryDto, - ): Promise { - return this.service.validate(auth, id, dto); + // TODO: change endpoint to validate current settings instead + validate(@Param() { id }: UUIDParamDto, @Body() dto: ValidateLibraryDto): Promise { + return this.service.validate(id, dto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - deleteLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.delete(auth, id); + deleteLibrary(@Param() { id }: UUIDParamDto): Promise { + return this.service.delete(id); } @Get(':id/statistics') - getLibraryStatistics(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.getStatistics(auth, id); + getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { + return this.service.getStatistics(id); } @Post(':id/scan') @HttpCode(HttpStatus.NO_CONTENT) - scanLibrary(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { - return this.service.queueScan(auth, id, dto); + scanLibrary(@Param() { id }: UUIDParamDto, @Body() dto: ScanLibraryDto) { + return this.service.queueScan(id, dto); } @Post(':id/removeOffline') @HttpCode(HttpStatus.NO_CONTENT) - removeOfflineFiles(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { - return this.service.queueRemoveOffline(auth, id); + removeOfflineFiles(@Param() { id }: UUIDParamDto) { + return this.service.queueRemoveOffline(id); } } diff --git a/server/src/infra/repositories/access.repository.ts b/server/src/infra/repositories/access.repository.ts index 25691846d..ad650bf0e 100644 --- a/server/src/infra/repositories/access.repository.ts +++ b/server/src/infra/repositories/access.repository.ts @@ -307,10 +307,7 @@ class AuthDeviceAccess implements IAuthDeviceAccess { } class LibraryAccess implements ILibraryAccess { - constructor( - private libraryRepository: Repository, - private partnerRepository: Repository, - ) {} + constructor(private libraryRepository: Repository) {} @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) @ChunkedSet({ paramIndex: 1 }) @@ -329,22 +326,6 @@ class LibraryAccess implements ILibraryAccess { }) .then((libraries) => new Set(libraries.map((library) => library.id))); } - - @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) - @ChunkedSet({ paramIndex: 1 }) - async checkPartnerAccess(userId: string, partnerIds: Set): Promise> { - if (partnerIds.size === 0) { - return new Set(); - } - - return this.partnerRepository - .createQueryBuilder('partner') - .select('partner.sharedById') - .where('partner.sharedById IN (:...partnerIds)', { partnerIds: [...partnerIds] }) - .andWhere('partner.sharedWithId = :userId', { userId }) - .getMany() - .then((partners) => new Set(partners.map((partner) => partner.sharedById))); - } } class TimelineAccess implements ITimelineAccess { @@ -457,7 +438,7 @@ export class AccessRepository implements IAccessRepository { this.album = new AlbumAccess(albumRepository, sharedLinkRepository); this.asset = new AssetAccess(albumRepository, assetRepository, partnerRepository, sharedLinkRepository); this.authDevice = new AuthDeviceAccess(tokenRepository); - this.library = new LibraryAccess(libraryRepository, partnerRepository); + this.library = new LibraryAccess(libraryRepository); this.person = new PersonAccess(assetFaceRepository, personRepository); this.partner = new PartnerAccess(partnerRepository); this.timeline = new TimelineAccess(partnerRepository); diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 871a44460..09cf2c779 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -109,18 +109,18 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerId: string, { day, month }: MonthDay): Promise { + getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { return this.repository .createQueryBuilder('entity') .where( - `entity.ownerId = :ownerId + `entity.ownerId IN (:...ownerIds) AND entity.isVisible = true AND entity.isArchived = false AND entity.resizePath IS NOT NULL AND EXTRACT(DAY FROM entity.localDateTime AT TIME ZONE 'UTC') = :day AND EXTRACT(MONTH FROM entity.localDateTime AT TIME ZONE 'UTC') = :month`, { - ownerId, + ownerIds, day, month, }, diff --git a/server/src/infra/sql/access.repository.sql b/server/src/infra/sql/access.repository.sql index 638be9f90..a0c4e1927 100644 --- a/server/src/infra/sql/access.repository.sql +++ b/server/src/infra/sql/access.repository.sql @@ -196,16 +196,6 @@ WHERE ) AND ("LibraryEntity"."deletedAt" IS NULL) --- AccessRepository.library.checkPartnerAccess -SELECT - "partner"."sharedById" AS "partner_sharedById", - "partner"."sharedWithId" AS "partner_sharedWithId" -FROM - "partners" "partner" -WHERE - "partner"."sharedById" IN ($1) - AND "partner"."sharedWithId" = $2 - -- AccessRepository.person.checkOwnerAccess SELECT "PersonEntity"."id" AS "PersonEntity_id" diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index 39f46e0d0..e230a7346 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -76,89 +76,6 @@ WHERE ORDER BY "AssetEntity"."fileCreatedAt" DESC --- AssetRepository.getByDayOfYear -SELECT - "entity"."id" AS "entity_id", - "entity"."deviceAssetId" AS "entity_deviceAssetId", - "entity"."ownerId" AS "entity_ownerId", - "entity"."libraryId" AS "entity_libraryId", - "entity"."deviceId" AS "entity_deviceId", - "entity"."type" AS "entity_type", - "entity"."originalPath" AS "entity_originalPath", - "entity"."resizePath" AS "entity_resizePath", - "entity"."webpPath" AS "entity_webpPath", - "entity"."thumbhash" AS "entity_thumbhash", - "entity"."encodedVideoPath" AS "entity_encodedVideoPath", - "entity"."createdAt" AS "entity_createdAt", - "entity"."updatedAt" AS "entity_updatedAt", - "entity"."deletedAt" AS "entity_deletedAt", - "entity"."fileCreatedAt" AS "entity_fileCreatedAt", - "entity"."localDateTime" AS "entity_localDateTime", - "entity"."fileModifiedAt" AS "entity_fileModifiedAt", - "entity"."isFavorite" AS "entity_isFavorite", - "entity"."isArchived" AS "entity_isArchived", - "entity"."isExternal" AS "entity_isExternal", - "entity"."isReadOnly" AS "entity_isReadOnly", - "entity"."isOffline" AS "entity_isOffline", - "entity"."checksum" AS "entity_checksum", - "entity"."duration" AS "entity_duration", - "entity"."isVisible" AS "entity_isVisible", - "entity"."livePhotoVideoId" AS "entity_livePhotoVideoId", - "entity"."originalFileName" AS "entity_originalFileName", - "entity"."sidecarPath" AS "entity_sidecarPath", - "entity"."stackId" AS "entity_stackId", - "exifInfo"."assetId" AS "exifInfo_assetId", - "exifInfo"."description" AS "exifInfo_description", - "exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth", - "exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight", - "exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte", - "exifInfo"."orientation" AS "exifInfo_orientation", - "exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal", - "exifInfo"."modifyDate" AS "exifInfo_modifyDate", - "exifInfo"."timeZone" AS "exifInfo_timeZone", - "exifInfo"."latitude" AS "exifInfo_latitude", - "exifInfo"."longitude" AS "exifInfo_longitude", - "exifInfo"."projectionType" AS "exifInfo_projectionType", - "exifInfo"."city" AS "exifInfo_city", - "exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID", - "exifInfo"."autoStackId" AS "exifInfo_autoStackId", - "exifInfo"."state" AS "exifInfo_state", - "exifInfo"."country" AS "exifInfo_country", - "exifInfo"."make" AS "exifInfo_make", - "exifInfo"."model" AS "exifInfo_model", - "exifInfo"."lensModel" AS "exifInfo_lensModel", - "exifInfo"."fNumber" AS "exifInfo_fNumber", - "exifInfo"."focalLength" AS "exifInfo_focalLength", - "exifInfo"."iso" AS "exifInfo_iso", - "exifInfo"."exposureTime" AS "exifInfo_exposureTime", - "exifInfo"."profileDescription" AS "exifInfo_profileDescription", - "exifInfo"."colorspace" AS "exifInfo_colorspace", - "exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample", - "exifInfo"."fps" AS "exifInfo_fps" -FROM - "assets" "entity" - LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "entity"."id" -WHERE - ( - "entity"."ownerId" = $1 - AND "entity"."isVisible" = true - AND "entity"."isArchived" = false - AND "entity"."resizePath" IS NOT NULL - AND EXTRACT( - DAY - FROM - "entity"."localDateTime" AT TIME ZONE 'UTC' - ) = $2 - AND EXTRACT( - MONTH - FROM - "entity"."localDateTime" AT TIME ZONE 'UTC' - ) = $3 - ) - AND ("entity"."deletedAt" IS NULL) -ORDER BY - "entity"."localDateTime" DESC - -- AssetRepository.getByIds SELECT "AssetEntity"."id" AS "AssetEntity_id", diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index a1f09aa75..e10dd7d9a 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -42,7 +42,6 @@ export const newAccessRepositoryMock = (reset = true): IAccessRepositoryMock => library: { checkOwnerAccess: jest.fn().mockResolvedValue(new Set()), - checkPartnerAccess: jest.fn().mockResolvedValue(new Set()), }, timeline: { diff --git a/web/src/lib/assets/immich-logo.json b/web/src/lib/assets/immich-logo.json new file mode 100644 index 000000000..a83bd4660 --- /dev/null +++ b/web/src/lib/assets/immich-logo.json @@ -0,0 +1 @@ +{"content": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAFACAYAAAAszc0KAAAAxnpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjabVBbEoMwCPzPKXoEXonkOLHqTG/Q4xeEzKjTdbKLICuh7N/PUV4OQilSF229NTBIl07DAoXAOBlBTj5hJcrsLV9khmTKppwNLRRnPhum4rCoXoz0nYX1XugSSvowinmAfSKPtzTqacQUBUyDEdeC1nW5XmHd4Q6NU5zWw+bxHWXz810W295W7T9MtDMyGDO3GID9SOFhARsjd/vQn5GMrDmJLeTfnibKD6lPWpnDBF3gAAABhGlDQ1BJQ0MgcHJvZmlsZQAAeJx9kT1Iw0AcxV9bpSJVQYuIOGSoTnZRUcdahSJUCLVCqw4ml35Bk4YkxcVRcC04+LFYdXBx1tXBVRAEP0CcHZwUXaTE/yWFFjEeHPfj3b3H3TvAXy8z1eyIAapmGalEXMhkV4XgKwLoxyBm0CsxU58TxSQ8x9c9fHy9i/Is73N/jh4lZzLAJxDHmG5YxBvE05uWznmfOMyKkkJ8Tjxu0AWJH7kuu/zGueCwn2eGjXRqnjhMLBTaWG5jVjRU4iniiKJqlO/PuKxw3uKslquseU/+wlBOW1nmOs0RJLCIJYgQIKOKEsqwEKVVI8VEivbjHv5hxy+SSyZXCYwcC6hAheT4wf/gd7dmfnLCTQrFgc4X2/4YBYK7QKNm29/Htt04AQLPwJXW8lfqwOwn6bWWFjkC+raBi+uWJu8BlzvA0JMuGZIjBWj683ng/Yy+KQsM3ALda25vzX2cPgBp6ip5AxwcAmMFyl73eHdXe2//nmn29wO1pXLBEGyzRgAADXppVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+Cjx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IlhNUCBDb3JlIDQuNC4wLUV4aXYyIj4KIDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+CiAgPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIKICAgIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIgogICAgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIKICAgIHhtbG5zOkdJTVA9Imh0dHA6Ly93d3cuZ2ltcC5vcmcveG1wLyIKICAgIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICAgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIgogICB4bXBNTTpEb2N1bWVudElEPSJnaW1wOmRvY2lkOmdpbXA6MDY0YmE0N2YtMDU3MC00OTBmLWJkOGUtY2UzNDFhYjk0ZGY1IgogICB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjdmYzk2NDZlLTE5NWQtNGE1Mi1iYTE3LWM0NDM4NmQyMWU1ZiIKICAgeG1wTU06T3JpZ2luYWxEb2N1bWVudElEPSJ4bXAuZGlkOjM0MGQxNWRmLTgzZDMtNDQ1Yi1hMzkwLTBjNTQ1NGNiMWI3ZCIKICAgR0lNUDpBUEk9IjIuMCIKICAgR0lNUDpQbGF0Zm9ybT0iTWFjIE9TIgogICBHSU1QOlRpbWVTdGFtcD0iMTcxMDgwMjgyMTQ5NjYwMyIKICAgR0lNUDpWZXJzaW9uPSIyLjEwLjM2IgogICBkYzpGb3JtYXQ9ImltYWdlL3BuZyIKICAgdGlmZjpPcmllbnRhdGlvbj0iMSIKICAgeG1wOkNyZWF0b3JUb29sPSJHSU1QIDIuMTAiCiAgIHhtcDpNZXRhZGF0YURhdGU9IjIwMjQ6MDM6MTlUMDA6MDA6MTgrMDE6MDAiCiAgIHhtcDpNb2RpZnlEYXRlPSIyMDI0OjAzOjE5VDAwOjAwOjE4KzAxOjAwIj4KICAgPHhtcE1NOkhpc3Rvcnk+CiAgICA8cmRmOlNlcT4KICAgICA8cmRmOmxpCiAgICAgIHN0RXZ0OmFjdGlvbj0ic2F2ZWQiCiAgICAgIHN0RXZ0OmNoYW5nZWQ9Ii8iCiAgICAgIHN0RXZ0Omluc3RhbmNlSUQ9InhtcC5paWQ6MTEwZjhhOTQtNTQ4MS00MTkxLTkxYjktZjQ3NzA3ZDUyYTVlIgogICAgICBzdEV2dDpzb2Z0d2FyZUFnZW50PSJHaW1wIDIuMTAgKE1hYyBPUykiCiAgICAgIHN0RXZ0OndoZW49IjIwMjQtMDMtMTlUMDA6MDA6MjErMDE6MDAiLz4KICAgIDwvcmRmOlNlcT4KICAgPC94bXBNTTpIaXN0b3J5PgogIDwvcmRmOkRlc2NyaXB0aW9uPgogPC9yZGY6UkRGPgo8L3g6eG1wbWV0YT4KICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgIAo8P3hwYWNrZXQgZW5kPSJ3Ij8+fd4ZrQAAAAZiS0dEAAAAAAAA+UO7fwAAAAlwSFlzAAAOwwAADsMBx2+oZAAAAAd0SU1FB+gDEhcAFf/Hh5UAACAASURBVHja7d15nBT1nTfwT3fPTPfcMMoMwqgzcnvwgERAnnXETTYZnyCR4OYBEhLdx30E8yhiEkUTA2zceCUq+CyQXfcRY4Q8CTgq7oKRuGj2EUhUDrkPRTkEBplh7p6rnj9qqqeq+ldXd3V3dffn/XrNS+mjuqq6uuvT3/odPkmSJBARERFR1vBzFxARERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASERERMQASEREREQMgERERETEAEhEREREDIBERERGlixzuAnKTz+dzdXmSJHGnEhERMQBSuoe6RL02wyIREREDIKVJyEvGNjAcEhERMQAy7HGbGQqJiIgBkBj4uF8YComIiAGQGPgcSWR4StY2qV+HYZCIiDI2K0g8yzHwpTjcpct+4EeFiIgYACnjwk6mHgrcV0RERAyADDIMMNyXRETEAMjdkNlBhW8x9zUREREDYBaEEb6t3P9EREQMgBkeOvg28r0hIiJiAMyCcMG3ju8XERERA2AWBAm+XQyDREREDIBZEBr4FjHQExERMQAy+Al1bXkXrQ8vRu/xEwnbBv+llchfeA+Cf/tNvqEMgkRExABIqQh9ao3XTkFvfX3itykYxMAje/jmeuz9JyIisotzAafJiT8Z1T27gt9i9c/p+2r3eFAexyBIREQJzSmsAKY2/Nnd/U6re2XHDzte7/anl6P9medcXy4l7ocBERERA2AGBj8vVf6c8l9aidy/moKu/3zPcv2zrW1hIo4VIiIiBsAMOZk3TrkpKjzpq3DJavuX8P2YhW0LGQSJiIgBMINP4HZ3c/j3r6D9mefcq/j5fIDdt9jngyRJ8CXjtSyWkY29je0cS/y4EhERA2AGBT/AXhs8K2XHD8ddEcxfeA/Cv/mta1XF3Kk1KHp+BXzBoOZ2q/XM1t7GDIJERMQAmEHBz7XqnhuVtiS9pg+A1bOkvsfphebORsHP/4HHGYMgERG5zM9d4O5JWZIk4Um5/enlaL3/QVvhL3dqDQYe2YOy44cjf/5Bg9QvYnud1cvKX3hP7Buve01luVbLtLOmor1advxw1oY/s+Mo1qBIRESkOX+wAuhO8FNO2mqu9eBNRcXPzjoJgqGdil/Ur5BLKxH6u++hbek/Gm5zts9AwsvCRETEAJgmJ15RD1435Ez8EqSuTvTs2O3qcvMX3oOOFf8MKRx2ZXkD936AjudXo+NfX4TU1BR1f+7U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QGJIJCIiIo/ycxcQERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgERERETEAEhEREREDIBERERExABIRERERAyARERERMQASEREREQMgEREREQMgERERETEAEhEREREDIBERERExABIRERFRuvn/XcNQ24xp80UAAAAASUVORK5CYII="} diff --git a/web/src/lib/components/asset-viewer/panorama-viewer.svelte b/web/src/lib/components/asset-viewer/panorama-viewer.svelte index fa8740d20..66d8f6309 100644 --- a/web/src/lib/components/asset-viewer/panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/panorama-viewer.svelte @@ -2,11 +2,11 @@ import { serveFile, type AssetResponseDto } from '@immich/sdk'; import { fade } from 'svelte/transition'; import LoadingSpinner from '../shared-components/loading-spinner.svelte'; - + import { getKey } from '$lib/utils'; export let asset: AssetResponseDto; const loadAssetData = async () => { - const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false }); + const data = await serveFile({ id: asset.id, isWeb: false, isThumb: false, key: getKey() }); return URL.createObjectURL(data); }; diff --git a/web/src/lib/components/elements/buttons/skip-link.svelte b/web/src/lib/components/elements/buttons/skip-link.svelte index 457661d65..8a304469d 100644 --- a/web/src/lib/components/elements/buttons/skip-link.svelte +++ b/web/src/lib/components/elements/buttons/skip-link.svelte @@ -14,7 +14,7 @@ }; -
+
- - +
diff --git a/web/src/lib/components/shared-components/combobox.svelte b/web/src/lib/components/shared-components/combobox.svelte index c69460640..19fd73d25 100644 --- a/web/src/lib/components/shared-components/combobox.svelte +++ b/web/src/lib/components/shared-components/combobox.svelte @@ -11,48 +11,93 @@ -
+ +
{ + if (e.relatedTarget instanceof Node && !e.currentTarget.contains(e.relatedTarget)) { + deactivate(); + } + }} +>
- {#if isOpen} + {#if isActive}
- +
{/if} (searchQuery = e.currentTarget.value)} - on:focus={handleClick} - on:blur={() => (inputFocused = false)} + class:cursor-pointer={!isActive} + class="immich-form-input text-sm text-left w-full !pr-12 transition-all" + id={inputId} + on:click={activate} + on:focus={activate} + on:input={onInput} + role="combobox" + type="text" + value={searchQuery} + use:shortcuts={[ + { + shortcut: { key: 'ArrowUp' }, + onShortcut: () => { + openDropdown(); + void incrementSelectedIndex(-1); + }, + }, + { + shortcut: { key: 'ArrowDown' }, + onShortcut: () => { + openDropdown(); + void incrementSelectedIndex(1); + }, + }, + { + shortcut: { key: 'ArrowDown', alt: true }, + onShortcut: () => { + openDropdown(); + }, + }, + { + shortcut: { key: 'Enter' }, + onShortcut: () => { + if (selectedIndex !== undefined && filteredOptions.length > 0) { + onSelect(filteredOptions[selectedIndex]); + } + closeDropdown(); + }, + }, + { + shortcut: { key: 'Escape' }, + onShortcut: () => { + closeDropdown(); + }, + }, + ]} />
{#if selectedOption} - + {:else if !isOpen} - + {/if}
- {#if isOpen} -
+
    + {#if isOpen} {#if filteredOptions.length === 0} -
    No results
    - {/if} - {#each filteredOptions as option (option.label)} - {@const selected = option.label === selectedOption?.label} - + {/each} -
- {/if} + {/if} +
diff --git a/web/src/lib/components/shared-components/immich-logo.svelte b/web/src/lib/components/shared-components/immich-logo.svelte index ad747150a..fbfc5f845 100644 --- a/web/src/lib/components/shared-components/immich-logo.svelte +++ b/web/src/lib/components/shared-components/immich-logo.svelte @@ -2,8 +2,10 @@ import logoDarkUrl from '$lib/assets/immich-logo-inline-dark.svg'; import logoLightUrl from '$lib/assets/immich-logo-inline-light.svg'; import logoNoText from '$lib/assets/immich-logo.svg'; + import { content as alternativeLogo } from '$lib/assets/immich-logo.json'; import { Theme } from '$lib/constants'; import { colorTheme } from '$lib/stores/preferences.store'; + import { DateTime } from 'luxon'; import type { HTMLImgAttributes } from 'svelte/elements'; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -14,11 +16,17 @@ export let noText = false; export let draggable = false; + + const today = DateTime.now().toLocal(); -Immich Logo +{#if today.month === 4 && today.day === 1} + Immich Logo +{:else} + Immich Logo +{/if} diff --git a/web/src/lib/components/shared-components/search-bar/search-bar.svelte b/web/src/lib/components/shared-components/search-bar/search-bar.svelte index 041181f71..8afa56df7 100644 --- a/web/src/lib/components/shared-components/search-bar/search-bar.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-bar.svelte @@ -11,6 +11,7 @@ import type { MetadataSearchDto, SmartSearchDto } from '@immich/sdk'; import { getMetadataSearchQuery } from '$lib/utils/metadata-search'; import { handlePromiseError } from '$lib/utils'; + import { shortcut } from '$lib/utils/shortcut'; export let value = ''; export let grayTheme: boolean; @@ -84,7 +85,16 @@ }; -
+ { + onFocusOut(); + }, + }} +/> + +
{ + onFocusOut(); + }, + }} />
diff --git a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte index 6f377faff..7acac54d8 100644 --- a/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-camera-section.svelte @@ -40,24 +40,24 @@
- (filters.make = detail?.value)} + options={toComboBoxOptions(makes)} placeholder="Search camera make..." + selectedOption={makeFilter ? { label: makeFilter, value: makeFilter } : undefined} />
- (filters.model = detail?.value)} + options={toComboBoxOptions(models)} placeholder="Search camera model..." + selectedOption={modelFilter ? { label: modelFilter, value: modelFilter } : undefined} />
diff --git a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte index ac412c513..fdaabe0f7 100644 --- a/web/src/lib/components/shared-components/search-bar/search-location-section.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-location-section.svelte @@ -62,35 +62,35 @@
- (filters.country = detail?.value)} + options={toComboBoxOptions(countries)} placeholder="Search country..." + selectedOption={filters.country ? { label: filters.country, value: filters.country } : undefined} />
- (filters.state = detail?.value)} + options={toComboBoxOptions(states)} placeholder="Search state..." + selectedOption={filters.state ? { label: filters.state, value: filters.state } : undefined} />
- (filters.city = detail?.value)} + options={toComboBoxOptions(cities)} placeholder="Search city..." + selectedOption={filters.city ? { label: filters.city, value: filters.city } : undefined} />
diff --git a/web/src/lib/components/shared-components/settings/setting-combobox.svelte b/web/src/lib/components/shared-components/settings/setting-combobox.svelte index 7f3dc1906..ee396935c 100644 --- a/web/src/lib/components/shared-components/settings/setting-combobox.svelte +++ b/web/src/lib/components/shared-components/settings/setting-combobox.svelte @@ -3,6 +3,7 @@ import { fly } from 'svelte/transition'; import Combobox, { type ComboBoxOption } from '$lib/components/shared-components/combobox.svelte'; + export let id: string; export let title: string; export let comboboxPlaceholder: string; export let subtitle = ''; @@ -32,6 +33,9 @@
{ + const handleCreate = async (ownerId: string) => { try { - let createLibraryDto: CreateLibraryDto = { type: LibraryType.External }; - if (ownerId) { - createLibraryDto = { ...createLibraryDto, ownerId }; - } - - const createdLibrary = await createLibrary({ createLibraryDto }); + const createdLibrary = await createLibrary({ createLibraryDto: { ownerId, type: LibraryType.External } }); notificationController.show({ message: `Created library: ${createdLibrary.name}`, diff --git a/web/src/routes/admin/repair/+page.svelte b/web/src/routes/admin/repair/+page.svelte index fa0f66ec3..abe12d8dc 100644 --- a/web/src/routes/admin/repair/+page.svelte +++ b/web/src/routes/admin/repair/+page.svelte @@ -286,7 +286,7 @@
-

UNTRACKS FILES {extras.length > 0 ? `(${extras.length})` : ''}

+

UNTRACKED FILES {extras.length > 0 ? `(${extras.length})` : ''}

These files are not tracked by the application. They can be the results of failed moves, interrupted uploads, or left behind due to a bug