forked from Cutlery/immich
		
	Merge branch 'main' into feat/offline-files-job
This commit is contained in:
		
						commit
						482645e22d
					
				@ -9,8 +9,8 @@ The database is saved to your Immich upload folder in the `database-backup` subd
 | 
			
		||||
### Prerequisites
 | 
			
		||||
 | 
			
		||||
- Borg needs to be installed on your server as well as the remote machine. You can find instructions to install Borg [here](https://borgbackup.readthedocs.io/en/latest/installation.html).
 | 
			
		||||
- To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
 | 
			
		||||
- To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server.
 | 
			
		||||
- (Optional) To run this sript as a non-root user, you should [add your username to the docker group](https://docs.docker.com/engine/install/linux-postinstall/).
 | 
			
		||||
- To run this script non-interactively, set up [passwordless ssh](https://www.redhat.com/sysadmin/passwordless-ssh) to your remote machine from your server. If you skipped the previous step, make sure this step is done from your root account.
 | 
			
		||||
 | 
			
		||||
To initialize the borg repository, run the following commands once.
 | 
			
		||||
 | 
			
		||||
@ -19,16 +19,13 @@ UPLOAD_LOCATION="/path/to/immich/directory"       # Immich database location, as
 | 
			
		||||
BACKUP_PATH="/path/to/local/backup/directory"
 | 
			
		||||
 | 
			
		||||
mkdir "$UPLOAD_LOCATION/database-backup"
 | 
			
		||||
mkdir "$BACKUP_PATH/immich-borg"
 | 
			
		||||
 | 
			
		||||
borg init --encryption=none "$BACKUP_PATH/immich-borg"
 | 
			
		||||
 | 
			
		||||
## Remote set up
 | 
			
		||||
REMOTE_HOST="remote_host@IP"
 | 
			
		||||
REMOTE_BACKUP_PATH="/path/to/remote/backup/directory"
 | 
			
		||||
 | 
			
		||||
ssh "$REMOTE_HOST" "mkdir \"$REMOTE_BACKUP_PATH\"/immich-borg"
 | 
			
		||||
ssh "$REMOTE_HOST" "borg init --encryption=none \"$REMOTE_BACKUP_PATH\"/immich-borg"
 | 
			
		||||
borg init --encryption=none "$REMOTE_HOST:$REMOTE_BACKUP_PATH/immich-borg"
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
Edit the following script as necessary and add it to your crontab. Note that this script assumes there are no `:`, `@`, or `"` characters in your paths. If these characters exist, you will need to escape and/or rename the paths.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										56
									
								
								mobile/assets/immich-logo-inline-dark.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								mobile/assets/immich-logo-inline-dark.svg
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,56 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
 | 
			
		||||
<svg version="1.1" id="Router_Medium_x5F_Black_00000159464448132936669960000002337362428709113490_"
 | 
			
		||||
	 xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 266.25"
 | 
			
		||||
	 style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
 | 
			
		||||
 | 
			
		||||
<g>
 | 
			
		||||
	<path class="st0" style="fill:#ACCBFA;" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
 | 
			
		||||
		C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
 | 
			
		||||
		c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
 | 
			
		||||
		c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
 | 
			
		||||
	<path class="st0" style="fill:#ACCBFA;" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
 | 
			
		||||
		c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
 | 
			
		||||
		c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
 | 
			
		||||
		v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
 | 
			
		||||
		v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
 | 
			
		||||
		c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
 | 
			
		||||
		c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
 | 
			
		||||
	<path class="st0" style="fill:#ACCBFA;" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
 | 
			
		||||
		c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
 | 
			
		||||
		c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
 | 
			
		||||
		v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
 | 
			
		||||
		v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
 | 
			
		||||
		c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
 | 
			
		||||
		c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
 | 
			
		||||
	<path class="st0" style="fill:#ACCBFA;" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
 | 
			
		||||
		C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
 | 
			
		||||
		s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
 | 
			
		||||
		s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
 | 
			
		||||
	<path class="st0" style="fill:#ACCBFA;" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
 | 
			
		||||
		c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
 | 
			
		||||
		c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
 | 
			
		||||
		c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
 | 
			
		||||
	<path class="st0" style="fill:#ACCBFA;" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
 | 
			
		||||
		c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
 | 
			
		||||
		c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
 | 
			
		||||
		c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
 | 
			
		||||
		c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
 | 
			
		||||
</g>
 | 
			
		||||
<g>
 | 
			
		||||
	<path class="st1" style="fill:#FA2921;" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
 | 
			
		||||
		c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
 | 
			
		||||
	<path class="st2" style="fill:#ED79B5;" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
 | 
			
		||||
		c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
 | 
			
		||||
		C48.99,153.93,49.35,154.05,49.8,154.19z"/>
 | 
			
		||||
	<path class="st3" style="fill:#FFB400;" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
 | 
			
		||||
		c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
 | 
			
		||||
		c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
 | 
			
		||||
	<path class="st4" style="fill:#1E83F7;" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
 | 
			
		||||
		c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
 | 
			
		||||
		C111.05,199.44,106.04,185.79,102.8,171.18z"/>
 | 
			
		||||
	<path class="st5" style="fill:#18C249;" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
 | 
			
		||||
		c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
 | 
			
		||||
		c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
 | 
			
		||||
</g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 5.6 KiB  | 
							
								
								
									
										54
									
								
								mobile/assets/immich-logo-inline-light.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								mobile/assets/immich-logo-inline-light.svg
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,54 @@
 | 
			
		||||
<?xml version="1.0" encoding="utf-8"?>
 | 
			
		||||
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
 | 
			
		||||
<svg version="1.1" id="Router_Medium_x5F_White" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
 | 
			
		||||
	 x="0px" y="0px" viewBox="0 0 792 266.25" style="enable-background:new 0 0 792 266.25;" xml:space="preserve">
 | 
			
		||||
<g>
 | 
			
		||||
	<path class="st0" style="fill:#4251B0;" d="M268.73,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
 | 
			
		||||
		C257.04,68.36,262.39,63.18,268.73,63.18z M258.88,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
 | 
			
		||||
		c5.84,0,10.52,4.67,10.52,10.68c0,2.84-0.83,7.68-0.83,10.68v38.73c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.52,10.68
 | 
			
		||||
		c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
 | 
			
		||||
	<path class="st0" style="fill:#4251B0;" d="M394.28,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
 | 
			
		||||
		c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
 | 
			
		||||
		c0-2.84,0.83-7.68,0.83-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
 | 
			
		||||
		v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
 | 
			
		||||
		v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
 | 
			
		||||
		c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
 | 
			
		||||
		c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C399.12,182.55,394.28,177.88,394.28,171.87z"/>
 | 
			
		||||
	<path class="st0" style="fill:#4251B0;" d="M528.5,171.87c0-2.84,0.83-7.68,0.83-10.68V132.3c0-10.18-5.34-16.86-14.52-16.86c-6.01,0-11.35,3-14.86,8.85
 | 
			
		||||
		c0.33,1.84,0.5,3.67,0.5,5.68v31.22c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.68,10.68c-5.51,0-10.35-4.67-10.35-10.68
 | 
			
		||||
		c0-2.84,0.84-7.68,0.84-10.68V131.8c0-3.17-0.5-6.01-1.67-8.51c-2.17-4.84-6.51-7.85-12.52-7.85c-6.18,0-11.19,3.17-14.86,8.85
 | 
			
		||||
		v36.9c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68c-5.84,0-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68
 | 
			
		||||
		v-38.57c0-3.01-1.5-8.35-1.5-10.85c0-6.01,4.34-10.68,10.18-10.68c5.51,0,8.68,3.67,9.68,8.51c5.01-6.68,12.02-10.85,21.2-10.85
 | 
			
		||||
		c10.85,0,18.7,5.18,23.54,13.52c5.51-8.68,13.52-13.52,23.54-13.52c16.86,0,29.72,12.19,29.72,31.72v30.72
 | 
			
		||||
		c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.51,10.68-10.52,10.68C533.35,182.55,528.5,177.88,528.5,171.87z"/>
 | 
			
		||||
	<path class="st0" style="fill:#4251B0;" d="M576.92,63.18c6.34,0,11.52,5.18,11.52,11.35c0,6.34-5.18,11.35-11.52,11.35s-11.69-5.01-11.69-11.35
 | 
			
		||||
		C565.23,68.36,570.57,63.18,576.92,63.18z M567.07,122.45c0-3.01-0.67-7.85-0.67-10.68c0-6.01,4.67-10.68,10.52-10.68
 | 
			
		||||
		s10.52,4.67,10.52,10.68c0,2.84-0.84,7.68-0.84,10.68v38.73c0,3.01,0.84,7.85,0.84,10.68c0,6.01-4.67,10.68-10.52,10.68
 | 
			
		||||
		s-10.52-4.67-10.52-10.68c0-2.84,0.67-7.68,0.67-10.68V122.45z"/>
 | 
			
		||||
	<path class="st0" style="fill:#4251B0;" d="M601.79,141.31c0-23.54,14.69-42.57,39.07-42.57c12.86,0,24.71,5.84,30.05,14.53c2,3.17,2.34,5.01,2.34,6.51
 | 
			
		||||
		c0,5.18-4.01,9.52-9.85,9.52c-3.84,0-7.34-2.17-8.85-6.01c-2.34-5.18-6.85-8.18-13.69-8.18c-12.86,0-20.03,11.52-20.03,26.04
 | 
			
		||||
		c0,14.69,7.51,26.04,20.53,26.04c7.01,0,12.02-2.5,14.36-7.68c1.67-3.51,4.84-6.51,9.18-6.51c6.01,0,9.68,4.17,9.68,9.35
 | 
			
		||||
		c0,2.5-1,5.51-3.17,8.35c-5.51,7.35-15.86,13.19-30.05,13.19C616.32,183.89,601.79,165.19,601.79,141.31z"/>
 | 
			
		||||
	<path class="st0" style="fill:#4251B0;" d="M737.69,171.87c0-2.84,0.67-7.68,0.67-10.68v-28.55c0-10.18-5.68-17.2-15.36-17.2
 | 
			
		||||
		c-6.68,0-12.35,3.17-16.03,8.35v37.4c0,3.01,0.67,7.85,0.67,10.68c0,6.01-4.67,10.68-10.52,10.68s-10.52-4.67-10.52-10.68
 | 
			
		||||
		c0-2.84,0.84-7.68,0.84-10.68v-80.8c0-3.01-0.84-7.85-0.84-10.68c0-6.01,4.84-10.68,10.52-10.68c5.84,0,10.52,4.67,10.52,10.68
 | 
			
		||||
		c0,2.84-0.67,7.68-0.67,10.68v27.21c5.01-5.51,12.19-8.85,21.37-8.85c17.2,0,29.55,12.86,29.55,31.22v31.22
 | 
			
		||||
		c0,3.01,0.83,7.85,0.83,10.68c0,6.01-4.84,10.68-10.52,10.68C742.36,182.55,737.69,177.88,737.69,171.87z"/>
 | 
			
		||||
</g>
 | 
			
		||||
<g>
 | 
			
		||||
  <path class="st1" style="fill:#FA2921;" d="M114.82,96.21c11.92,10.55,21.52,21.86,27.7,32.52c10.62-18.99,17.71-41.55,17.8-55.92c0-0.1,0-0.19,0-0.28
 | 
			
		||||
  c0-21.26-21.21-29.54-39.48-29.54s-39.48,8.28-39.48,29.54c0,0.29,0,0.68,0,1.15C91.54,78.2,103.61,86.29,114.82,96.21z"/>
 | 
			
		||||
<path class="st2" style="fill:#ED79B5;" d="M49.8,154.19c7.45-8.29,18.88-17.27,31.77-24.86c13.72-8.07,27.44-13.71,39.49-16.3
 | 
			
		||||
  c-14.78-15.96-34.04-29.68-47.68-34.21c-0.1-0.03-0.18-0.06-0.27-0.09c-20.22-6.57-34.65,11.05-40.3,28.42s-4.33,40.11,15.89,46.68
 | 
			
		||||
  C48.99,153.93,49.35,154.05,49.8,154.19z"/>
 | 
			
		||||
<path class="st3" style="fill:#FFB400;" d="M209.07,106.86c-5.65-17.38-20.07-34.99-40.3-28.42c-0.28,0.09-0.65,0.21-1.09,0.35
 | 
			
		||||
  c-1.16,11.08-5.12,25.07-11.09,38.79c-6.35,14.6-14.14,27.23-22.36,36.39c21.34,4.23,44.99,4,58.68-0.35
 | 
			
		||||
  c0.1-0.03,0.19-0.06,0.27-0.09C213.4,146.97,214.71,124.24,209.07,106.86z"/>
 | 
			
		||||
<path class="st4" style="fill:#1E83F7;" d="M102.8,171.18c-3.44-15.54-4.56-30.34-3.3-42.59c-19.75,9.12-38.75,23.2-47.27,34.78
 | 
			
		||||
  c-0.06,0.08-0.11,0.16-0.16,0.23c-12.5,17.2-0.2,36.37,14.58,47.11s36.81,16.51,49.31-0.69c0.17-0.24,0.4-0.55,0.68-0.93
 | 
			
		||||
  C111.05,199.44,106.04,185.79,102.8,171.18z"/>
 | 
			
		||||
<path class="st5" style="fill:#18C249;" d="M189.48,162.49c-10.9,2.33-25.42,2.88-40.32,1.44c-15.84-1.53-30.26-5.03-41.52-10.02
 | 
			
		||||
  c2.57,21.6,10.09,44.02,18.47,55.7c0.06,0.08,0.11,0.16,0.16,0.23c12.5,17.2,34.52,11.43,49.31,0.69
 | 
			
		||||
  c14.78-10.74,27.08-29.9,14.58-47.11C189.99,163.18,189.76,162.86,189.48,162.49z"/>
 | 
			
		||||
</g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 5.5 KiB  | 
@ -180,4 +180,4 @@ SPEC CHECKSUMS:
 | 
			
		||||
 | 
			
		||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
 | 
			
		||||
 | 
			
		||||
COCOAPODS: 1.11.3
 | 
			
		||||
COCOAPODS: 1.12.1
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:easy_localization/easy_localization.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_svg/svg.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';
 | 
			
		||||
@ -169,11 +170,11 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
 | 
			
		||||
                  }
 | 
			
		||||
                  return Padding(
 | 
			
		||||
                    padding: const EdgeInsets.only(top: 3.0),
 | 
			
		||||
                    child: Image.asset(
 | 
			
		||||
                      height: 30,
 | 
			
		||||
                    child: SvgPicture.asset(
 | 
			
		||||
                      context.isDarkTheme
 | 
			
		||||
                          ? 'assets/immich-logo-inline-dark.png'
 | 
			
		||||
                          : 'assets/immich-logo-inline-light.png',
 | 
			
		||||
                          ? 'assets/immich-logo-inline-dark.svg'
 | 
			
		||||
                          : 'assets/immich-logo-inline-light.svg',
 | 
			
		||||
                      height: 40,
 | 
			
		||||
                    ),
 | 
			
		||||
                  );
 | 
			
		||||
                },
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ class ImmichLogo extends StatelessWidget {
 | 
			
		||||
        image: const AssetImage('assets/immich-logo.png'),
 | 
			
		||||
        width: size,
 | 
			
		||||
        filterQuality: FilterQuality.high,
 | 
			
		||||
        isAntiAlias: true,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							@ -161,6 +161,7 @@ Class | Method | HTTP request | Description
 | 
			
		||||
*PersonApi* | [**reassignFaces**](doc//PersonApi.md#reassignfaces) | **PUT** /person/{id}/reassign | 
 | 
			
		||||
*PersonApi* | [**updatePeople**](doc//PersonApi.md#updatepeople) | **PUT** /person | 
 | 
			
		||||
*PersonApi* | [**updatePerson**](doc//PersonApi.md#updateperson) | **PUT** /person/{id} | 
 | 
			
		||||
*SearchApi* | [**getAssetsByCity**](doc//SearchApi.md#getassetsbycity) | **GET** /search/cities | 
 | 
			
		||||
*SearchApi* | [**getExploreData**](doc//SearchApi.md#getexploredata) | **GET** /search/explore | 
 | 
			
		||||
*SearchApi* | [**getSearchSuggestions**](doc//SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | 
 | 
			
		||||
*SearchApi* | [**search**](doc//SearchApi.md#search) | **GET** /search | 
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										52
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										52
									
								
								mobile/openapi/doc/SearchApi.md
									
									
									
										generated
									
									
									
								
							@ -9,6 +9,7 @@ All URIs are relative to */api*
 | 
			
		||||
 | 
			
		||||
Method | HTTP request | Description
 | 
			
		||||
------------- | ------------- | -------------
 | 
			
		||||
[**getAssetsByCity**](SearchApi.md#getassetsbycity) | **GET** /search/cities | 
 | 
			
		||||
[**getExploreData**](SearchApi.md#getexploredata) | **GET** /search/explore | 
 | 
			
		||||
[**getSearchSuggestions**](SearchApi.md#getsearchsuggestions) | **GET** /search/suggestions | 
 | 
			
		||||
[**search**](SearchApi.md#search) | **GET** /search | 
 | 
			
		||||
@ -18,6 +19,57 @@ Method | HTTP request | Description
 | 
			
		||||
[**searchSmart**](SearchApi.md#searchsmart) | **POST** /search/smart | 
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# **getAssetsByCity**
 | 
			
		||||
> List<AssetResponseDto> getAssetsByCity()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Example
 | 
			
		||||
```dart
 | 
			
		||||
import 'package:openapi/api.dart';
 | 
			
		||||
// TODO Configure API key authorization: cookie
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
 | 
			
		||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
 | 
			
		||||
// TODO Configure API key authorization: api_key
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
 | 
			
		||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
 | 
			
		||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
 | 
			
		||||
// TODO Configure HTTP Bearer authorization: bearer
 | 
			
		||||
// Case 1. Use String Token
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
 | 
			
		||||
// Case 2. Use Function which generate token.
 | 
			
		||||
// String yourTokenGeneratorFunction() { ... }
 | 
			
		||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 | 
			
		||||
 | 
			
		||||
final api_instance = SearchApi();
 | 
			
		||||
 | 
			
		||||
try {
 | 
			
		||||
    final result = api_instance.getAssetsByCity();
 | 
			
		||||
    print(result);
 | 
			
		||||
} catch (e) {
 | 
			
		||||
    print('Exception when calling SearchApi->getAssetsByCity: $e\n');
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### Parameters
 | 
			
		||||
This endpoint does not need any parameter.
 | 
			
		||||
 | 
			
		||||
### Return type
 | 
			
		||||
 | 
			
		||||
[**List<AssetResponseDto>**](AssetResponseDto.md)
 | 
			
		||||
 | 
			
		||||
### Authorization
 | 
			
		||||
 | 
			
		||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
 | 
			
		||||
 | 
			
		||||
### HTTP request headers
 | 
			
		||||
 | 
			
		||||
 - **Content-Type**: Not defined
 | 
			
		||||
 - **Accept**: application/json
 | 
			
		||||
 | 
			
		||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 | 
			
		||||
 | 
			
		||||
# **getExploreData**
 | 
			
		||||
> List<SearchExploreResponseDto> getExploreData()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										44
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										44
									
								
								mobile/openapi/lib/api/search_api.dart
									
									
									
										generated
									
									
									
								
							@ -16,6 +16,50 @@ class SearchApi {
 | 
			
		||||
 | 
			
		||||
  final ApiClient apiClient;
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'GET /search/cities' operation and returns the [Response].
 | 
			
		||||
  Future<Response> getAssetsByCityWithHttpInfo() async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
    final path = r'/search/cities';
 | 
			
		||||
 | 
			
		||||
    // ignore: prefer_final_locals
 | 
			
		||||
    Object? postBody;
 | 
			
		||||
 | 
			
		||||
    final queryParams = <QueryParam>[];
 | 
			
		||||
    final headerParams = <String, String>{};
 | 
			
		||||
    final formParams = <String, String>{};
 | 
			
		||||
 | 
			
		||||
    const contentTypes = <String>[];
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    return apiClient.invokeAPI(
 | 
			
		||||
      path,
 | 
			
		||||
      'GET',
 | 
			
		||||
      queryParams,
 | 
			
		||||
      postBody,
 | 
			
		||||
      headerParams,
 | 
			
		||||
      formParams,
 | 
			
		||||
      contentTypes.isEmpty ? null : contentTypes.first,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<List<AssetResponseDto>?> getAssetsByCity() async {
 | 
			
		||||
    final response = await getAssetsByCityWithHttpInfo();
 | 
			
		||||
    if (response.statusCode >= HttpStatus.badRequest) {
 | 
			
		||||
      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
 | 
			
		||||
    }
 | 
			
		||||
    // When a remote server returns no body with a status of 204, we shall not decode it.
 | 
			
		||||
    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
 | 
			
		||||
    // FormatException when trying to decode an empty string.
 | 
			
		||||
    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
 | 
			
		||||
      final responseBody = await _decodeBodyBytes(response);
 | 
			
		||||
      return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
 | 
			
		||||
        .cast<AssetResponseDto>()
 | 
			
		||||
        .toList(growable: false);
 | 
			
		||||
 | 
			
		||||
    }
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Performs an HTTP 'GET /search/explore' operation and returns the [Response].
 | 
			
		||||
  Future<Response> getExploreDataWithHttpInfo() async {
 | 
			
		||||
    // ignore: prefer_const_declarations
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										5
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/search_api_test.dart
									
									
									
										generated
									
									
									
								
							@ -17,6 +17,11 @@ void main() {
 | 
			
		||||
  // final instance = SearchApi();
 | 
			
		||||
 | 
			
		||||
  group('tests for SearchApi', () {
 | 
			
		||||
    //Future<List<AssetResponseDto>> getAssetsByCity() async
 | 
			
		||||
    test('test getAssetsByCity', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    //Future<List<SearchExploreResponseDto>> getExploreData() async
 | 
			
		||||
    test('test getExploreData', () async {
 | 
			
		||||
      // TODO
 | 
			
		||||
 | 
			
		||||
@ -560,6 +560,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.4.9"
 | 
			
		||||
  flutter_svg:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: flutter_svg
 | 
			
		||||
      sha256: d39e7f95621fc84376bc0f7d504f05c3a41488c562f4a8ad410569127507402c
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.9"
 | 
			
		||||
  flutter_test:
 | 
			
		||||
    dependency: "direct dev"
 | 
			
		||||
    description: flutter
 | 
			
		||||
@ -1006,6 +1014,14 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.8.3"
 | 
			
		||||
  path_parsing:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: path_parsing
 | 
			
		||||
      sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.0.1"
 | 
			
		||||
  path_provider:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
@ -1587,6 +1603,30 @@ packages:
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "3.0.7"
 | 
			
		||||
  vector_graphics:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: vector_graphics
 | 
			
		||||
      sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.1.10+1"
 | 
			
		||||
  vector_graphics_codec:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: vector_graphics_codec
 | 
			
		||||
      sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.1.10+1"
 | 
			
		||||
  vector_graphics_compiler:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
      name: vector_graphics_compiler
 | 
			
		||||
      sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a"
 | 
			
		||||
      url: "https://pub.dev"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "1.1.10+1"
 | 
			
		||||
  vector_math:
 | 
			
		||||
    dependency: transitive
 | 
			
		||||
    description:
 | 
			
		||||
 | 
			
		||||
@ -34,6 +34,7 @@ dependencies:
 | 
			
		||||
      ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
 | 
			
		||||
  geolocator: ^11.0.0 # used to move to current location in map view
 | 
			
		||||
  flutter_udid: ^3.0.0
 | 
			
		||||
  flutter_svg: ^2.0.9
 | 
			
		||||
  package_info_plus: ^5.0.1
 | 
			
		||||
  url_launcher: ^6.2.4
 | 
			
		||||
  http: 0.13.5
 | 
			
		||||
 | 
			
		||||
@ -4597,6 +4597,41 @@
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/search/cities": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getAssetsByCity",
 | 
			
		||||
        "parameters": [],
 | 
			
		||||
        "responses": {
 | 
			
		||||
          "200": {
 | 
			
		||||
            "content": {
 | 
			
		||||
              "application/json": {
 | 
			
		||||
                "schema": {
 | 
			
		||||
                  "items": {
 | 
			
		||||
                    "$ref": "#/components/schemas/AssetResponseDto"
 | 
			
		||||
                  },
 | 
			
		||||
                  "type": "array"
 | 
			
		||||
                }
 | 
			
		||||
              }
 | 
			
		||||
            },
 | 
			
		||||
            "description": ""
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        "security": [
 | 
			
		||||
          {
 | 
			
		||||
            "bearer": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "cookie": []
 | 
			
		||||
          },
 | 
			
		||||
          {
 | 
			
		||||
            "api_key": []
 | 
			
		||||
          }
 | 
			
		||||
        ],
 | 
			
		||||
        "tags": [
 | 
			
		||||
          "Search"
 | 
			
		||||
        ]
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "/search/explore": {
 | 
			
		||||
      "get": {
 | 
			
		||||
        "operationId": "getExploreData",
 | 
			
		||||
 | 
			
		||||
@ -2204,6 +2204,14 @@ export function search({ clip, motion, page, q, query, recent, size, smart, $typ
 | 
			
		||||
        ...opts
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
export function getAssetsByCity(opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
			
		||||
        status: 200;
 | 
			
		||||
        data: AssetResponseDto[];
 | 
			
		||||
    }>("/search/cities", {
 | 
			
		||||
        ...opts
 | 
			
		||||
    }));
 | 
			
		||||
}
 | 
			
		||||
export function getExploreData(opts?: Oazapfts.RequestOpts) {
 | 
			
		||||
    return oazapfts.ok(oazapfts.fetchJson<{
 | 
			
		||||
        status: 200;
 | 
			
		||||
 | 
			
		||||
@ -67,11 +67,11 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
        expect.arrayContaining([
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            isOffline: false,
 | 
			
		||||
            originalFileName: 'silver_fir',
 | 
			
		||||
            originalFileName: 'silver_fir.jpg',
 | 
			
		||||
          }),
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            isOffline: false,
 | 
			
		||||
            originalFileName: 'tanners_ridge',
 | 
			
		||||
            originalFileName: 'tanners_ridge.jpg',
 | 
			
		||||
          }),
 | 
			
		||||
        ]),
 | 
			
		||||
      );
 | 
			
		||||
@ -103,10 +103,10 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
      expect(assets).toEqual(
 | 
			
		||||
        expect.arrayContaining([
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            originalFileName: 'el_torcal_rocks',
 | 
			
		||||
            originalFileName: 'el_torcal_rocks.jpg',
 | 
			
		||||
          }),
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            originalFileName: 'silver_fir',
 | 
			
		||||
            originalFileName: 'silver_fir.jpg',
 | 
			
		||||
          }),
 | 
			
		||||
        ]),
 | 
			
		||||
      );
 | 
			
		||||
@ -143,7 +143,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
        expect(assets[0]).toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            originalFileName: 'el_torcal_rocks',
 | 
			
		||||
            originalFileName: 'el_torcal_rocks.jpg',
 | 
			
		||||
            exifInfo: expect.objectContaining({
 | 
			
		||||
              dateTimeOriginal: '2023-09-25T08:33:30.880Z',
 | 
			
		||||
              exifImageHeight: 534,
 | 
			
		||||
@ -190,7 +190,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
        expect(assets[0]).toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            originalFileName: 'el_torcal_rocks',
 | 
			
		||||
            originalFileName: 'el_torcal_rocks.jpg',
 | 
			
		||||
            exifInfo: expect.objectContaining({
 | 
			
		||||
              dateTimeOriginal: '2012-08-05T11:39:59.000Z',
 | 
			
		||||
            }),
 | 
			
		||||
@ -230,7 +230,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
 | 
			
		||||
 | 
			
		||||
        expect(assets[0]).toEqual(
 | 
			
		||||
          expect.objectContaining({
 | 
			
		||||
            originalFileName: 'el_torcal_rocks',
 | 
			
		||||
            originalFileName: 'el_torcal_rocks.jpg',
 | 
			
		||||
            exifInfo: expect.objectContaining({
 | 
			
		||||
              exifImageHeight: 534,
 | 
			
		||||
              exifImageWidth: 800,
 | 
			
		||||
 | 
			
		||||
@ -548,19 +548,19 @@ describe(AssetService.name, () => {
 | 
			
		||||
      await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
 | 
			
		||||
        BadRequestException,
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should update the asset', async () => {
 | 
			
		||||
      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      assetMock.getById.mockResolvedValue(assetStub.image);
 | 
			
		||||
      await sut.update(authStub.admin, 'asset-1', { isFavorite: true });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-1', isFavorite: true });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should update the exif description', async () => {
 | 
			
		||||
      accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      assetMock.getById.mockResolvedValue(assetStub.image);
 | 
			
		||||
      await sut.update(authStub.admin, 'asset-1', { description: 'Test description' });
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalledWith({ assetId: 'asset-1', description: 'Test description' });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -324,7 +324,19 @@ export class AssetService {
 | 
			
		||||
    const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
 | 
			
		||||
    await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
 | 
			
		||||
 | 
			
		||||
    const asset = await this.assetRepository.save({ id, ...rest });
 | 
			
		||||
    await this.assetRepository.update({ id, ...rest });
 | 
			
		||||
    const asset = await this.assetRepository.getById(id, {
 | 
			
		||||
      exifInfo: true,
 | 
			
		||||
      owner: true,
 | 
			
		||||
      smartInfo: true,
 | 
			
		||||
      tags: true,
 | 
			
		||||
      faces: {
 | 
			
		||||
        person: true,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    if (!asset) {
 | 
			
		||||
      throw new BadRequestException('Asset not found');
 | 
			
		||||
    }
 | 
			
		||||
    return mapAsset(asset, { auth });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -93,27 +93,27 @@ export class AuditService {
 | 
			
		||||
 | 
			
		||||
      switch (pathType) {
 | 
			
		||||
        case AssetPathType.ENCODED_VIDEO: {
 | 
			
		||||
          await this.assetRepository.save({ id, encodedVideoPath: pathValue });
 | 
			
		||||
          await this.assetRepository.update({ id, encodedVideoPath: pathValue });
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        case AssetPathType.JPEG_THUMBNAIL: {
 | 
			
		||||
          await this.assetRepository.save({ id, resizePath: pathValue });
 | 
			
		||||
          await this.assetRepository.update({ id, resizePath: pathValue });
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        case AssetPathType.WEBP_THUMBNAIL: {
 | 
			
		||||
          await this.assetRepository.save({ id, webpPath: pathValue });
 | 
			
		||||
          await this.assetRepository.update({ id, webpPath: pathValue });
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        case AssetPathType.ORIGINAL: {
 | 
			
		||||
          await this.assetRepository.save({ id, originalPath: pathValue });
 | 
			
		||||
          await this.assetRepository.update({ id, originalPath: pathValue });
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        case AssetPathType.SIDECAR: {
 | 
			
		||||
          await this.assetRepository.save({ id, sidecarPath: pathValue });
 | 
			
		||||
          await this.assetRepository.update({ id, sidecarPath: pathValue });
 | 
			
		||||
          break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -384,7 +384,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
            fileModifiedAt: expect.any(Date),
 | 
			
		||||
            localDateTime: expect.any(Date),
 | 
			
		||||
            type: AssetType.IMAGE,
 | 
			
		||||
            originalFileName: 'photo',
 | 
			
		||||
            originalFileName: 'photo.jpg',
 | 
			
		||||
            sidecarPath: null,
 | 
			
		||||
            isReadOnly: true,
 | 
			
		||||
            isExternal: true,
 | 
			
		||||
@ -432,7 +432,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
            fileModifiedAt: expect.any(Date),
 | 
			
		||||
            localDateTime: expect.any(Date),
 | 
			
		||||
            type: AssetType.IMAGE,
 | 
			
		||||
            originalFileName: 'photo',
 | 
			
		||||
            originalFileName: 'photo.jpg',
 | 
			
		||||
            sidecarPath: '/data/user1/photo.jpg.xmp',
 | 
			
		||||
            isReadOnly: true,
 | 
			
		||||
            isExternal: true,
 | 
			
		||||
@ -479,7 +479,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
            fileModifiedAt: expect.any(Date),
 | 
			
		||||
            localDateTime: expect.any(Date),
 | 
			
		||||
            type: AssetType.VIDEO,
 | 
			
		||||
            originalFileName: 'video',
 | 
			
		||||
            originalFileName: 'video.mp4',
 | 
			
		||||
            sidecarPath: null,
 | 
			
		||||
            isReadOnly: true,
 | 
			
		||||
            isExternal: true,
 | 
			
		||||
@ -591,7 +591,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true });
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true });
 | 
			
		||||
      expect(jobMock.queue).not.toHaveBeenCalled();
 | 
			
		||||
      expect(jobMock.queueAll).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
@ -609,7 +609,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false });
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false });
 | 
			
		||||
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledWith({
 | 
			
		||||
        name: JobName.METADATA_EXTRACTION,
 | 
			
		||||
@ -638,7 +638,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
      assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
 | 
			
		||||
      assetMock.create.mockResolvedValue(assetStub.image);
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
 | 
			
		||||
    });
 | 
			
		||||
@ -1264,7 +1264,7 @@ describe(LibraryService.name, () => {
 | 
			
		||||
 | 
			
		||||
        await sut.watchAll();
 | 
			
		||||
 | 
			
		||||
        expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
 | 
			
		||||
        expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.external.id, isOffline: true });
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      it('should handle an error event', async () => {
 | 
			
		||||
 | 
			
		||||
@ -172,7 +172,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
            this.logger.debug(`Detected deleted file at ${path} in library ${library.id}`);
 | 
			
		||||
            const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
 | 
			
		||||
            if (asset && matcher(path)) {
 | 
			
		||||
              await this.assetRepository.save({ id: asset.id, isOffline: true });
 | 
			
		||||
              await this.assetRepository.update({ id: asset.id, isOffline: true });
 | 
			
		||||
            }
 | 
			
		||||
            this.emit(StorageEventType.UNLINK, path);
 | 
			
		||||
          };
 | 
			
		||||
@ -421,7 +421,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
        // Mark asset as offline
 | 
			
		||||
        this.logger.debug(`Marking asset as offline: ${assetPath}`);
 | 
			
		||||
 | 
			
		||||
        await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true });
 | 
			
		||||
        await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: true });
 | 
			
		||||
        return JobStatus.SUCCESS;
 | 
			
		||||
      } else {
 | 
			
		||||
        // File can't be accessed and does not already exist in db
 | 
			
		||||
@ -454,7 +454,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
    if (stats && existingAssetEntity?.isOffline) {
 | 
			
		||||
      // File was previously offline but is now online
 | 
			
		||||
      this.logger.debug(`Marking previously-offline asset as online: ${assetPath}`);
 | 
			
		||||
      await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: false });
 | 
			
		||||
      await this.assetRepository.update({ id: existingAssetEntity.id, isOffline: false });
 | 
			
		||||
      doRefresh = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -503,7 +503,7 @@ export class LibraryService extends EventEmitter {
 | 
			
		||||
        fileModifiedAt: stats.mtime,
 | 
			
		||||
        localDateTime: stats.mtime,
 | 
			
		||||
        type: assetType,
 | 
			
		||||
        originalFileName: parse(assetPath).name,
 | 
			
		||||
        originalFileName: parse(assetPath).base,
 | 
			
		||||
        sidecarPath,
 | 
			
		||||
        isReadOnly: true,
 | 
			
		||||
        isExternal: true,
 | 
			
		||||
 | 
			
		||||
@ -205,7 +205,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([]);
 | 
			
		||||
      await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
 | 
			
		||||
      expect(mediaMock.resize).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalledWith();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalledWith();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should skip video thumbnail generation if no video stream', async () => {
 | 
			
		||||
@ -213,7 +213,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([assetStub.video]);
 | 
			
		||||
      await sut.handleGenerateJpegThumbnail({ id: assetStub.image.id });
 | 
			
		||||
      expect(mediaMock.resize).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalledWith();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalledWith();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should generate a thumbnail for an image', async () => {
 | 
			
		||||
@ -227,7 +227,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
        quality: 80,
 | 
			
		||||
        colorspace: Colorspace.SRGB,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: 'asset-id',
 | 
			
		||||
        resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
 | 
			
		||||
      });
 | 
			
		||||
@ -246,7 +246,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
        quality: 80,
 | 
			
		||||
        colorspace: Colorspace.P3,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: 'asset-id',
 | 
			
		||||
        resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
 | 
			
		||||
      });
 | 
			
		||||
@ -271,7 +271,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
          twoPass: false,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: 'asset-id',
 | 
			
		||||
        resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
 | 
			
		||||
      });
 | 
			
		||||
@ -296,7 +296,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
          twoPass: false,
 | 
			
		||||
        },
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: 'asset-id',
 | 
			
		||||
        resizePath: 'upload/thumbs/user-id/as/se/asset-id.jpeg',
 | 
			
		||||
      });
 | 
			
		||||
@ -337,7 +337,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([]);
 | 
			
		||||
      await sut.handleGenerateWebpThumbnail({ id: assetStub.image.id });
 | 
			
		||||
      expect(mediaMock.resize).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalledWith();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalledWith();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should generate a thumbnail', async () => {
 | 
			
		||||
@ -350,7 +350,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
        quality: 80,
 | 
			
		||||
        colorspace: Colorspace.SRGB,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: 'asset-id',
 | 
			
		||||
        webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp',
 | 
			
		||||
      });
 | 
			
		||||
@ -370,7 +370,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
      quality: 80,
 | 
			
		||||
      colorspace: Colorspace.P3,
 | 
			
		||||
    });
 | 
			
		||||
    expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
    expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
      id: 'asset-id',
 | 
			
		||||
      webpPath: 'upload/thumbs/user-id/as/se/asset-id.webp',
 | 
			
		||||
    });
 | 
			
		||||
@ -397,7 +397,7 @@ describe(MediaService.name, () => {
 | 
			
		||||
      await sut.handleGenerateThumbhashThumbnail({ id: assetStub.image.id });
 | 
			
		||||
 | 
			
		||||
      expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -172,7 +172,7 @@ export class MediaService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const resizePath = await this.generateThumbnail(asset, 'jpeg');
 | 
			
		||||
    await this.assetRepository.save({ id: asset.id, resizePath });
 | 
			
		||||
    await this.assetRepository.update({ id: asset.id, resizePath });
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -222,7 +222,7 @@ export class MediaService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const webpPath = await this.generateThumbnail(asset, 'webp');
 | 
			
		||||
    await this.assetRepository.save({ id: asset.id, webpPath });
 | 
			
		||||
    await this.assetRepository.update({ id: asset.id, webpPath });
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -233,7 +233,7 @@ export class MediaService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath);
 | 
			
		||||
    await this.assetRepository.save({ id: asset.id, thumbhash });
 | 
			
		||||
    await this.assetRepository.update({ id: asset.id, thumbhash });
 | 
			
		||||
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
@ -286,7 +286,7 @@ export class MediaService {
 | 
			
		||||
      if (asset.encodedVideoPath) {
 | 
			
		||||
        this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [asset.encodedVideoPath] } });
 | 
			
		||||
        await this.assetRepository.save({ id: asset.id, encodedVideoPath: null });
 | 
			
		||||
        await this.assetRepository.update({ id: asset.id, encodedVideoPath: null });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return JobStatus.SKIPPED;
 | 
			
		||||
@ -321,7 +321,7 @@ export class MediaService {
 | 
			
		||||
 | 
			
		||||
    this.logger.log(`Successfully encoded ${asset.id}`);
 | 
			
		||||
 | 
			
		||||
    await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
 | 
			
		||||
    await this.assetRepository.update({ id: asset.id, encodedVideoPath: output });
 | 
			
		||||
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -117,7 +117,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
			
		||||
      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -127,7 +127,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
			
		||||
      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -137,7 +137,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
 | 
			
		||||
      expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -159,7 +159,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
        otherAssetId: assetStub.livePhotoMotionAsset.id,
 | 
			
		||||
        type: AssetType.IMAGE,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      expect(albumMock.removeAsset).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -182,11 +182,11 @@ describe(MetadataService.name, () => {
 | 
			
		||||
        otherAssetId: assetStub.livePhotoStillAsset.id,
 | 
			
		||||
        type: AssetType.VIDEO,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.livePhotoStillAsset.id,
 | 
			
		||||
        livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
 | 
			
		||||
      expect(albumMock.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -248,7 +248,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should handle a date in a sidecar file', async () => {
 | 
			
		||||
@ -267,7 +267,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      await sut.handleMetadataExtraction({ id: assetStub.image.id });
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.sidecar.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }));
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        duration: null,
 | 
			
		||||
        fileCreatedAt: sidecarDate,
 | 
			
		||||
@ -282,7 +282,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      await sut.handleMetadataExtraction({ id: assetStub.image.id });
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ iso: 160 }));
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        duration: null,
 | 
			
		||||
        fileCreatedAt: assetStub.image.createdAt,
 | 
			
		||||
@ -304,7 +304,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }),
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.withLocation.id,
 | 
			
		||||
        duration: null,
 | 
			
		||||
        fileCreatedAt: assetStub.withLocation.createdAt,
 | 
			
		||||
@ -333,7 +333,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(storageMock.writeFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(jobMock.queue).not.toHaveBeenCalled();
 | 
			
		||||
      expect(jobMock.queueAll).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalledWith(
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
@ -376,7 +376,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
			
		||||
      expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
 | 
			
		||||
      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenNthCalledWith(1, {
 | 
			
		||||
      expect(assetMock.update).toHaveBeenNthCalledWith(1, {
 | 
			
		||||
        id: assetStub.livePhotoStillAsset.id,
 | 
			
		||||
        livePhotoVideoId: fileStub.livePhotoMotion.uuid,
 | 
			
		||||
      });
 | 
			
		||||
@ -404,7 +404,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
 | 
			
		||||
      expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
 | 
			
		||||
      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenNthCalledWith(1, {
 | 
			
		||||
      expect(assetMock.update).toHaveBeenNthCalledWith(1, {
 | 
			
		||||
        id: assetStub.livePhotoStillAsset.id,
 | 
			
		||||
        livePhotoVideoId: fileStub.livePhotoMotion.uuid,
 | 
			
		||||
      });
 | 
			
		||||
@ -430,7 +430,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
 | 
			
		||||
      expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
 | 
			
		||||
      expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenNthCalledWith(1, {
 | 
			
		||||
      expect(assetMock.update).toHaveBeenNthCalledWith(1, {
 | 
			
		||||
        id: assetStub.livePhotoStillAsset.id,
 | 
			
		||||
        livePhotoVideoId: fileStub.livePhotoMotion.uuid,
 | 
			
		||||
      });
 | 
			
		||||
@ -470,7 +470,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      expect(assetMock.create).toHaveBeenCalledTimes(0);
 | 
			
		||||
      expect(storageMock.writeFile).toHaveBeenCalledTimes(0);
 | 
			
		||||
      // The still asset gets saved by handleMetadataExtraction, but not the video
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(jobMock.queue).toHaveBeenCalledTimes(0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -529,7 +529,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
        projectionType: 'EQUIRECTANGULAR',
 | 
			
		||||
        timeZone: tags.tz,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        duration: null,
 | 
			
		||||
        fileCreatedAt: new Date('1970-01-01'),
 | 
			
		||||
@ -545,7 +545,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith(
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: assetStub.image.id,
 | 
			
		||||
          duration: '00:00:06.210',
 | 
			
		||||
@ -561,7 +561,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith(
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: assetStub.image.id,
 | 
			
		||||
          duration: '00:00:08.410',
 | 
			
		||||
@ -577,7 +577,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith(
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: assetStub.image.id,
 | 
			
		||||
          duration: '00:00:06.200',
 | 
			
		||||
@ -593,7 +593,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
 | 
			
		||||
      expect(assetMock.upsertExif).toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith(
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith(
 | 
			
		||||
        expect.objectContaining({
 | 
			
		||||
          id: assetStub.image.id,
 | 
			
		||||
          duration: '00:00:06.207',
 | 
			
		||||
@ -638,13 +638,13 @@ describe(MetadataService.name, () => {
 | 
			
		||||
    it('should do nothing if asset could not be found', async () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([]);
 | 
			
		||||
      await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should do nothing if asset has no sidecar path', async () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 | 
			
		||||
      await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should set sidecar path if exists (sidecar named photo.ext.xmp)', async () => {
 | 
			
		||||
@ -653,7 +653,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.sidecar.id,
 | 
			
		||||
        sidecarPath: assetStub.sidecar.sidecarPath,
 | 
			
		||||
      });
 | 
			
		||||
@ -670,7 +670,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
        assetStub.sidecarWithoutExt.sidecarPath,
 | 
			
		||||
        constants.R_OK,
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.sidecarWithoutExt.id,
 | 
			
		||||
        sidecarPath: assetStub.sidecarWithoutExt.sidecarPath,
 | 
			
		||||
      });
 | 
			
		||||
@ -688,7 +688,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
        assetStub.sidecarWithoutExt.sidecarPath,
 | 
			
		||||
        constants.R_OK,
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.sidecar.id,
 | 
			
		||||
        sidecarPath: assetStub.sidecar.sidecarPath,
 | 
			
		||||
      });
 | 
			
		||||
@ -700,7 +700,7 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
      await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.sidecar.id,
 | 
			
		||||
        sidecarPath: null,
 | 
			
		||||
      });
 | 
			
		||||
@ -724,16 +724,15 @@ describe(MetadataService.name, () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(false);
 | 
			
		||||
      await sut.handleSidecarDiscovery({ id: assetStub.image.id });
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should update a image asset when a sidecar is found', async () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([assetStub.image]);
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
      await sut.handleSidecarDiscovery({ id: assetStub.image.id });
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.jpg.xmp', constants.R_OK);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        sidecarPath: '/original/path.jpg.xmp',
 | 
			
		||||
      });
 | 
			
		||||
@ -741,11 +740,10 @@ describe(MetadataService.name, () => {
 | 
			
		||||
 | 
			
		||||
    it('should update a video asset when a sidecar is found', async () => {
 | 
			
		||||
      assetMock.getByIds.mockResolvedValue([assetStub.video]);
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.video);
 | 
			
		||||
      storageMock.checkFileExists.mockResolvedValue(true);
 | 
			
		||||
      await sut.handleSidecarDiscovery({ id: assetStub.video.id });
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledWith('/original/path.ext.xmp', constants.R_OK);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        sidecarPath: '/original/path.ext.xmp',
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
@ -177,8 +177,8 @@ export class MetadataService {
 | 
			
		||||
 | 
			
		||||
    const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
 | 
			
		||||
 | 
			
		||||
    await this.assetRepository.save({ id: photoAsset.id, livePhotoVideoId: motionAsset.id });
 | 
			
		||||
    await this.assetRepository.save({ id: motionAsset.id, isVisible: false });
 | 
			
		||||
    await this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id });
 | 
			
		||||
    await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
 | 
			
		||||
    await this.albumRepository.removeAsset(motionAsset.id);
 | 
			
		||||
 | 
			
		||||
    // Notify clients to hide the linked live photo asset
 | 
			
		||||
@ -249,7 +249,7 @@ export class MetadataService {
 | 
			
		||||
    if (dateTimeOriginal && timeZoneOffset) {
 | 
			
		||||
      localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
 | 
			
		||||
    }
 | 
			
		||||
    await this.assetRepository.save({
 | 
			
		||||
    await this.assetRepository.update({
 | 
			
		||||
      id: asset.id,
 | 
			
		||||
      duration: tags.Duration ? this.getDuration(tags.Duration) : null,
 | 
			
		||||
      localDateTime,
 | 
			
		||||
@ -317,7 +317,7 @@ export class MetadataService {
 | 
			
		||||
    await this.repository.writeTags(sidecarPath, exif);
 | 
			
		||||
 | 
			
		||||
    if (!asset.sidecarPath) {
 | 
			
		||||
      await this.assetRepository.save({ id, sidecarPath });
 | 
			
		||||
      await this.assetRepository.update({ id, sidecarPath });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
@ -435,7 +435,7 @@ export class MetadataService {
 | 
			
		||||
        this.storageCore.ensureFolders(motionPath);
 | 
			
		||||
        await this.storageRepository.writeFile(motionAsset.originalPath, video);
 | 
			
		||||
        await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
 | 
			
		||||
        await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
 | 
			
		||||
        await this.assetRepository.update({ id: asset.id, livePhotoVideoId: motionAsset.id });
 | 
			
		||||
 | 
			
		||||
        // If the asset already had an associated livePhotoVideo, delete it, because
 | 
			
		||||
        // its checksum doesn't match the checksum of the motionAsset we just extracted
 | 
			
		||||
@ -587,7 +587,7 @@ export class MetadataService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (sidecarPath) {
 | 
			
		||||
      await this.assetRepository.save({ id: asset.id, sidecarPath });
 | 
			
		||||
      await this.assetRepository.update({ id: asset.id, sidecarPath });
 | 
			
		||||
      return JobStatus.SUCCESS;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -598,7 +598,7 @@ export class MetadataService {
 | 
			
		||||
    this.logger.debug(
 | 
			
		||||
      `Sidecar file was not found. Checked paths '${sidecarPathWithExt}' and '${sidecarPathWithoutExt}'. Removing sidecarPath for asset ${asset.id}`,
 | 
			
		||||
    );
 | 
			
		||||
    await this.assetRepository.save({ id: asset.id, sidecarPath: null });
 | 
			
		||||
    await this.assetRepository.update({ id: asset.id, sidecarPath: null });
 | 
			
		||||
 | 
			
		||||
    return JobStatus.SUCCESS;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -91,6 +91,25 @@ export type AssetCreate = Pick<
 | 
			
		||||
> &
 | 
			
		||||
  Partial<AssetEntity>;
 | 
			
		||||
 | 
			
		||||
export type AssetWithoutRelations = Omit<
 | 
			
		||||
  AssetEntity,
 | 
			
		||||
  | 'livePhotoVideo'
 | 
			
		||||
  | 'stack'
 | 
			
		||||
  | 'albums'
 | 
			
		||||
  | 'faces'
 | 
			
		||||
  | 'owner'
 | 
			
		||||
  | 'library'
 | 
			
		||||
  | 'exifInfo'
 | 
			
		||||
  | 'sharedLinks'
 | 
			
		||||
  | 'smartInfo'
 | 
			
		||||
  | 'smartSearch'
 | 
			
		||||
  | 'tags'
 | 
			
		||||
>;
 | 
			
		||||
 | 
			
		||||
export type AssetUpdateOptions = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
 | 
			
		||||
 | 
			
		||||
export type AssetUpdateAllOptions = Omit<Partial<AssetWithoutRelations>, 'id'>;
 | 
			
		||||
 | 
			
		||||
export interface MonthDay {
 | 
			
		||||
  day: number;
 | 
			
		||||
  month: number;
 | 
			
		||||
@ -139,8 +158,8 @@ export interface IAssetRepository {
 | 
			
		||||
  deleteAll(ownerId: string): Promise<void>;
 | 
			
		||||
  getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
 | 
			
		||||
  getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
 | 
			
		||||
  updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
 | 
			
		||||
  save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
 | 
			
		||||
  updateAll(ids: string[], options: Partial<AssetUpdateAllOptions>): Promise<void>;
 | 
			
		||||
  update(asset: AssetUpdateOptions): Promise<void>;
 | 
			
		||||
  remove(asset: AssetEntity): Promise<void>;
 | 
			
		||||
  softDeleteAll(ids: string[]): Promise<void>;
 | 
			
		||||
  restoreAll(ids: string[]): Promise<void>;
 | 
			
		||||
 | 
			
		||||
@ -187,5 +187,6 @@ export interface ISearchRepository {
 | 
			
		||||
  searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
 | 
			
		||||
  upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void>;
 | 
			
		||||
  searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
 | 
			
		||||
  getAssetsByCity(userIds: string[]): Promise<AssetEntity[]>;
 | 
			
		||||
  deleteAllSearchEmbeddings(): Promise<void>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -115,6 +115,32 @@ export class SearchService {
 | 
			
		||||
    return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getAssetsByCity(auth: AuthDto): Promise<AssetResponseDto[]> {
 | 
			
		||||
    const userIds = await this.getUserIdsToSearch(auth);
 | 
			
		||||
    const assets = await this.searchRepository.getAssetsByCity(userIds);
 | 
			
		||||
    return assets.map((asset) => mapAsset(asset));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
 | 
			
		||||
    switch (dto.type) {
 | 
			
		||||
      case SearchSuggestionType.COUNTRY: {
 | 
			
		||||
        return this.metadataRepository.getCountries(auth.user.id);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.STATE: {
 | 
			
		||||
        return this.metadataRepository.getStates(auth.user.id, dto.country);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.CITY: {
 | 
			
		||||
        return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.CAMERA_MAKE: {
 | 
			
		||||
        return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.CAMERA_MODEL: {
 | 
			
		||||
        return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // TODO: remove after implementing new search filters
 | 
			
		||||
  /** @deprecated */
 | 
			
		||||
  async search(auth: AuthDto, dto: SearchDto): Promise<SearchResponseDto> {
 | 
			
		||||
@ -191,24 +217,4 @@ export class SearchService {
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getSearchSuggestions(auth: AuthDto, dto: SearchSuggestionRequestDto): Promise<string[]> {
 | 
			
		||||
    switch (dto.type) {
 | 
			
		||||
      case SearchSuggestionType.COUNTRY: {
 | 
			
		||||
        return this.metadataRepository.getCountries(auth.user.id);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.STATE: {
 | 
			
		||||
        return this.metadataRepository.getStates(auth.user.id, dto.country);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.CITY: {
 | 
			
		||||
        return this.metadataRepository.getCities(auth.user.id, dto.country, dto.state);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.CAMERA_MAKE: {
 | 
			
		||||
        return this.metadataRepository.getCameraMakes(auth.user.id, dto.model);
 | 
			
		||||
      }
 | 
			
		||||
      case SearchSuggestionType.CAMERA_MODEL: {
 | 
			
		||||
        return this.metadataRepository.getCameraModels(auth.user.id, dto.make);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -111,7 +111,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(storageMock.checkFileExists).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.rename).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.copyFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      expect(moveMock.create).not.toHaveBeenCalled();
 | 
			
		||||
      expect(moveMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.stat).not.toHaveBeenCalled();
 | 
			
		||||
@ -122,14 +122,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      const newMotionPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.mp4`;
 | 
			
		||||
      const newStillPicturePath = `upload/library/${userStub.user1.id}/2022/2022-06-19/${assetStub.livePhotoStillAsset.id}.jpeg`;
 | 
			
		||||
 | 
			
		||||
      when(assetMock.save)
 | 
			
		||||
        .calledWith({ id: assetStub.livePhotoStillAsset.id, originalPath: newStillPicturePath })
 | 
			
		||||
        .mockResolvedValue(assetStub.livePhotoStillAsset);
 | 
			
		||||
 | 
			
		||||
      when(assetMock.save)
 | 
			
		||||
        .calledWith({ id: assetStub.livePhotoMotionAsset.id, originalPath: newMotionPicturePath })
 | 
			
		||||
        .mockResolvedValue(assetStub.livePhotoMotionAsset);
 | 
			
		||||
 | 
			
		||||
      when(assetMock.getByIds)
 | 
			
		||||
        .calledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true })
 | 
			
		||||
        .mockResolvedValue([assetStub.livePhotoStillAsset]);
 | 
			
		||||
@ -175,11 +167,11 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true });
 | 
			
		||||
      expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true });
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.livePhotoStillAsset.id,
 | 
			
		||||
        originalPath: newStillPicturePath,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.livePhotoMotionAsset.id,
 | 
			
		||||
        originalPath: newMotionPicturePath,
 | 
			
		||||
      });
 | 
			
		||||
@ -200,10 +192,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        newPath: previousFailedNewPath,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      when(assetMock.save)
 | 
			
		||||
        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
			
		||||
        .mockResolvedValue(assetStub.image);
 | 
			
		||||
 | 
			
		||||
      when(assetMock.getByIds)
 | 
			
		||||
        .calledWith([assetStub.image.id], { exifInfo: true })
 | 
			
		||||
        .mockResolvedValue([assetStub.image]);
 | 
			
		||||
@ -232,7 +220,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        oldPath: assetStub.image.originalPath,
 | 
			
		||||
        newPath,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        originalPath: newPath,
 | 
			
		||||
      });
 | 
			
		||||
@ -257,10 +245,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        newPath: previousFailedNewPath,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      when(assetMock.save)
 | 
			
		||||
        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
			
		||||
        .mockResolvedValue(assetStub.image);
 | 
			
		||||
 | 
			
		||||
      when(assetMock.getByIds)
 | 
			
		||||
        .calledWith([assetStub.image.id], { exifInfo: true })
 | 
			
		||||
        .mockResolvedValue([assetStub.image]);
 | 
			
		||||
@ -291,7 +275,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        oldPath: previousFailedNewPath,
 | 
			
		||||
        newPath,
 | 
			
		||||
      });
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        originalPath: newPath,
 | 
			
		||||
      });
 | 
			
		||||
@ -307,10 +291,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        .mockResolvedValue({ size: 5000 } as Stats);
 | 
			
		||||
      when(cryptoMock.hashFile).calledWith(newPath).mockResolvedValue(Buffer.from('different-hash', 'utf8'));
 | 
			
		||||
 | 
			
		||||
      when(assetMock.save)
 | 
			
		||||
        .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
			
		||||
        .mockResolvedValue(assetStub.image);
 | 
			
		||||
 | 
			
		||||
      when(assetMock.getByIds)
 | 
			
		||||
        .calledWith([assetStub.image.id], { exifInfo: true })
 | 
			
		||||
        .mockResolvedValue([assetStub.image]);
 | 
			
		||||
@ -345,7 +325,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(storageMock.copyFile).toHaveBeenCalledWith(assetStub.image.originalPath, newPath);
 | 
			
		||||
      expect(storageMock.unlink).toHaveBeenCalledWith(newPath);
 | 
			
		||||
      expect(storageMock.unlink).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it.each`
 | 
			
		||||
@ -374,10 +354,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
          newPath: previousFailedNewPath,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        when(assetMock.save)
 | 
			
		||||
          .calledWith({ id: assetStub.image.id, originalPath: newPath })
 | 
			
		||||
          .mockResolvedValue(assetStub.image);
 | 
			
		||||
 | 
			
		||||
        when(assetMock.getByIds)
 | 
			
		||||
          .calledWith([assetStub.image.id], { exifInfo: true })
 | 
			
		||||
          .mockResolvedValue([assetStub.image]);
 | 
			
		||||
@ -404,7 +380,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        expect(storageMock.rename).not.toHaveBeenCalled();
 | 
			
		||||
        expect(storageMock.copyFile).not.toHaveBeenCalled();
 | 
			
		||||
        expect(moveMock.update).not.toHaveBeenCalled();
 | 
			
		||||
        expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
        expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
      },
 | 
			
		||||
    );
 | 
			
		||||
  });
 | 
			
		||||
@ -427,7 +403,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        items: [assetStub.image],
 | 
			
		||||
        hasNextPage: false,
 | 
			
		||||
      });
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
			
		||||
      moveMock.create.mockResolvedValue({
 | 
			
		||||
        id: '123',
 | 
			
		||||
@ -449,7 +424,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.jpg',
 | 
			
		||||
      });
 | 
			
		||||
@ -474,7 +449,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(storageMock.rename).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.copyFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should skip when an asset is probably a duplicate', async () => {
 | 
			
		||||
@ -495,7 +470,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(storageMock.rename).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.copyFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.checkFileExists).not.toHaveBeenCalledTimes(2);
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should move an asset', async () => {
 | 
			
		||||
@ -503,7 +478,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        items: [assetStub.image],
 | 
			
		||||
        hasNextPage: false,
 | 
			
		||||
      });
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
			
		||||
      moveMock.create.mockResolvedValue({
 | 
			
		||||
        id: '123',
 | 
			
		||||
@ -520,7 +494,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        '/original/path.jpg',
 | 
			
		||||
        'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
			
		||||
      });
 | 
			
		||||
@ -531,7 +505,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        items: [assetStub.image],
 | 
			
		||||
        hasNextPage: false,
 | 
			
		||||
      });
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      userMock.getList.mockResolvedValue([userStub.storageLabel]);
 | 
			
		||||
      moveMock.create.mockResolvedValue({
 | 
			
		||||
        id: '123',
 | 
			
		||||
@ -548,7 +521,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        '/original/path.jpg',
 | 
			
		||||
        'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        originalPath: 'upload/library/label-1/2023/2023-02-23/asset-id.jpg',
 | 
			
		||||
      });
 | 
			
		||||
@ -592,7 +565,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(storageMock.utimes).toHaveBeenCalledWith(newPath, expect.any(Date), expect.any(Date));
 | 
			
		||||
      expect(storageMock.unlink).toHaveBeenCalledWith(assetStub.image.originalPath);
 | 
			
		||||
      expect(storageMock.unlink).toHaveBeenCalledTimes(1);
 | 
			
		||||
      expect(assetMock.save).toHaveBeenCalledWith({
 | 
			
		||||
      expect(assetMock.update).toHaveBeenCalledWith({
 | 
			
		||||
        id: assetStub.image.id,
 | 
			
		||||
        originalPath: newPath,
 | 
			
		||||
      });
 | 
			
		||||
@ -630,7 +603,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
			
		||||
      );
 | 
			
		||||
      expect(storageMock.stat).toHaveBeenCalledWith('upload/library/user-id/2023/2023-02-23/asset-id.jpg');
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not update the database if the move fails', async () => {
 | 
			
		||||
@ -656,7 +629,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        '/original/path.jpg',
 | 
			
		||||
        'upload/library/user-id/2023/2023-02-23/asset-id.jpg',
 | 
			
		||||
      );
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should not move read-only asset', async () => {
 | 
			
		||||
@ -670,7 +643,6 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
        ],
 | 
			
		||||
        hasNextPage: false,
 | 
			
		||||
      });
 | 
			
		||||
      assetMock.save.mockResolvedValue(assetStub.image);
 | 
			
		||||
      userMock.getList.mockResolvedValue([userStub.user1]);
 | 
			
		||||
 | 
			
		||||
      await sut.handleMigration();
 | 
			
		||||
@ -678,7 +650,7 @@ describe(StorageTemplateService.name, () => {
 | 
			
		||||
      expect(assetMock.getAll).toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.rename).not.toHaveBeenCalled();
 | 
			
		||||
      expect(storageMock.copyFile).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.save).not.toHaveBeenCalled();
 | 
			
		||||
      expect(assetMock.update).not.toHaveBeenCalled();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -286,19 +286,19 @@ export class StorageCore {
 | 
			
		||||
  private savePath(pathType: PathType, id: string, newPath: string) {
 | 
			
		||||
    switch (pathType) {
 | 
			
		||||
      case AssetPathType.ORIGINAL: {
 | 
			
		||||
        return this.assetRepository.save({ id, originalPath: newPath });
 | 
			
		||||
        return this.assetRepository.update({ id, originalPath: newPath });
 | 
			
		||||
      }
 | 
			
		||||
      case AssetPathType.JPEG_THUMBNAIL: {
 | 
			
		||||
        return this.assetRepository.save({ id, resizePath: newPath });
 | 
			
		||||
        return this.assetRepository.update({ id, resizePath: newPath });
 | 
			
		||||
      }
 | 
			
		||||
      case AssetPathType.WEBP_THUMBNAIL: {
 | 
			
		||||
        return this.assetRepository.save({ id, webpPath: newPath });
 | 
			
		||||
        return this.assetRepository.update({ id, webpPath: newPath });
 | 
			
		||||
      }
 | 
			
		||||
      case AssetPathType.ENCODED_VIDEO: {
 | 
			
		||||
        return this.assetRepository.save({ id, encodedVideoPath: newPath });
 | 
			
		||||
        return this.assetRepository.update({ id, encodedVideoPath: newPath });
 | 
			
		||||
      }
 | 
			
		||||
      case AssetPathType.SIDECAR: {
 | 
			
		||||
        return this.assetRepository.save({ id, sidecarPath: newPath });
 | 
			
		||||
        return this.assetRepository.update({ id, sidecarPath: newPath });
 | 
			
		||||
      }
 | 
			
		||||
      case PersonPathType.FACE: {
 | 
			
		||||
        return this.personRepository.update({ id, thumbnailPath: newPath });
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,5 @@
 | 
			
		||||
import {
 | 
			
		||||
  AssetResponseDto,
 | 
			
		||||
  AuthDto,
 | 
			
		||||
  MetadataSearchDto,
 | 
			
		||||
  PersonResponseDto,
 | 
			
		||||
@ -55,6 +56,11 @@ export class SearchController {
 | 
			
		||||
    return this.service.searchPlaces(dto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('cities')
 | 
			
		||||
  getAssetsByCity(@Auth() auth: AuthDto): Promise<AssetResponseDto[]> {
 | 
			
		||||
    return this.service.getAssetsByCity(auth);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('suggestions')
 | 
			
		||||
  getSearchSuggestions(@Auth() auth: AuthDto, @Query() dto: SearchSuggestionRequestDto): Promise<string[]> {
 | 
			
		||||
    return this.service.getSearchSuggestions(auth, dto);
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,8 @@ import {
 | 
			
		||||
  AssetSearchOptions,
 | 
			
		||||
  AssetStats,
 | 
			
		||||
  AssetStatsOptions,
 | 
			
		||||
  AssetUpdateAllOptions,
 | 
			
		||||
  AssetUpdateOptions,
 | 
			
		||||
  IAssetRepository,
 | 
			
		||||
  LivePhotoSearchOptions,
 | 
			
		||||
  MapMarker,
 | 
			
		||||
@ -275,7 +277,7 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
 | 
			
		||||
  @GenerateSql({ params: [[DummyValue.UUID], { deviceId: DummyValue.STRING }] })
 | 
			
		||||
  @Chunked()
 | 
			
		||||
  async updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void> {
 | 
			
		||||
  async updateAll(ids: string[], options: AssetUpdateAllOptions): Promise<void> {
 | 
			
		||||
    await this.repository.update({ id: In(ids) }, options);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -289,21 +291,8 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
    await this.repository.restore({ id: In(ids) });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async save(asset: Partial<AssetEntity>): Promise<AssetEntity> {
 | 
			
		||||
    const { id } = await this.repository.save(asset);
 | 
			
		||||
    return this.repository.findOneOrFail({
 | 
			
		||||
      where: { id },
 | 
			
		||||
      relations: {
 | 
			
		||||
        exifInfo: true,
 | 
			
		||||
        owner: true,
 | 
			
		||||
        smartInfo: true,
 | 
			
		||||
        tags: true,
 | 
			
		||||
        faces: {
 | 
			
		||||
          person: true,
 | 
			
		||||
        },
 | 
			
		||||
      },
 | 
			
		||||
      withDeleted: true,
 | 
			
		||||
    });
 | 
			
		||||
  async update(asset: AssetUpdateOptions): Promise<void> {
 | 
			
		||||
    await this.repository.update(asset.id, asset);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async remove(asset: AssetEntity): Promise<void> {
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ import { getCLIPModelInfo } from '@app/domain/smart-info/smart-info.constant';
 | 
			
		||||
import {
 | 
			
		||||
  AssetEntity,
 | 
			
		||||
  AssetFaceEntity,
 | 
			
		||||
  AssetType,
 | 
			
		||||
  GeodataPlacesEntity,
 | 
			
		||||
  SmartInfoEntity,
 | 
			
		||||
  SmartSearchEntity,
 | 
			
		||||
@ -33,6 +34,7 @@ import { Instrumentation } from '../instrumentation';
 | 
			
		||||
export class SearchRepository implements ISearchRepository {
 | 
			
		||||
  private logger = new ImmichLogger(SearchRepository.name);
 | 
			
		||||
  private faceColumns: string[];
 | 
			
		||||
  private assetsByCityQuery: string;
 | 
			
		||||
 | 
			
		||||
  constructor(
 | 
			
		||||
    @InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
 | 
			
		||||
@ -45,6 +47,14 @@ export class SearchRepository implements ISearchRepository {
 | 
			
		||||
      .getMetadata(AssetFaceEntity)
 | 
			
		||||
      .ownColumns.map((column) => column.propertyName)
 | 
			
		||||
      .filter((propertyName) => propertyName !== 'embedding');
 | 
			
		||||
    this.assetsByCityQuery =
 | 
			
		||||
      assetsByCityCte +
 | 
			
		||||
      this.assetRepository
 | 
			
		||||
        .createQueryBuilder('asset')
 | 
			
		||||
        .innerJoinAndSelect('asset.exifInfo', 'exif')
 | 
			
		||||
        .withDeleted()
 | 
			
		||||
        .getQuery() +
 | 
			
		||||
      ' INNER JOIN cte ON asset.id = cte."assetId"';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async init(modelName: string): Promise<void> {
 | 
			
		||||
@ -220,6 +230,27 @@ export class SearchRepository implements ISearchRepository {
 | 
			
		||||
      .getMany();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @GenerateSql({ params: [[DummyValue.UUID]] })
 | 
			
		||||
  async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
 | 
			
		||||
    const parameters = [userIds.join(', '), true, false, AssetType.IMAGE];
 | 
			
		||||
    const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
 | 
			
		||||
 | 
			
		||||
    const items: AssetEntity[] = [];
 | 
			
		||||
    for (const res of rawRes) {
 | 
			
		||||
      const item = { exifInfo: {} as Record<string, any> } as Record<string, any>;
 | 
			
		||||
      for (const [key, value] of Object.entries(res)) {
 | 
			
		||||
        if (key.startsWith('exif_')) {
 | 
			
		||||
          item.exifInfo[key.replace('exif_', '')] = value;
 | 
			
		||||
        } else {
 | 
			
		||||
          item[key.replace('asset_', '')] = value;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      items.push(item as AssetEntity);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return items;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async upsert(smartInfo: Partial<SmartInfoEntity>, embedding?: Embedding): Promise<void> {
 | 
			
		||||
    await this.repository.upsert(smartInfo, { conflictPaths: ['assetId'] });
 | 
			
		||||
    if (!smartInfo.assetId || !embedding) {
 | 
			
		||||
@ -290,3 +321,30 @@ export class SearchRepository implements ISearchRepository {
 | 
			
		||||
    return runtimeConfig;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
 | 
			
		||||
const assetsByCityCte = `
 | 
			
		||||
WITH RECURSIVE cte AS (
 | 
			
		||||
  (
 | 
			
		||||
    SELECT city, "assetId"
 | 
			
		||||
    FROM exif
 | 
			
		||||
    INNER JOIN assets ON exif."assetId" = assets.id
 | 
			
		||||
    WHERE "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
 | 
			
		||||
    ORDER BY city
 | 
			
		||||
    LIMIT 1
 | 
			
		||||
  )
 | 
			
		||||
 | 
			
		||||
  UNION ALL
 | 
			
		||||
  
 | 
			
		||||
  SELECT l.city, l."assetId"
 | 
			
		||||
  FROM cte c
 | 
			
		||||
    , LATERAL (
 | 
			
		||||
    SELECT city, "assetId"
 | 
			
		||||
    FROM exif
 | 
			
		||||
    INNER JOIN assets ON exif."assetId" = assets.id
 | 
			
		||||
    WHERE city > c.city AND "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
 | 
			
		||||
    ORDER BY city
 | 
			
		||||
    LIMIT 1
 | 
			
		||||
    ) l
 | 
			
		||||
)
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
@ -266,3 +266,111 @@ ORDER BY
 | 
			
		||||
  ) ASC
 | 
			
		||||
LIMIT
 | 
			
		||||
  20
 | 
			
		||||
 | 
			
		||||
-- SearchRepository.getAssetsByCity
 | 
			
		||||
WITH RECURSIVE
 | 
			
		||||
  cte AS (
 | 
			
		||||
    (
 | 
			
		||||
      SELECT
 | 
			
		||||
        city,
 | 
			
		||||
        "assetId"
 | 
			
		||||
      FROM
 | 
			
		||||
        exif
 | 
			
		||||
        INNER JOIN assets ON exif."assetId" = assets.id
 | 
			
		||||
      WHERE
 | 
			
		||||
        "ownerId" IN ($1)
 | 
			
		||||
        AND "isVisible" = $2
 | 
			
		||||
        AND "isArchived" = $3
 | 
			
		||||
        AND type = $4
 | 
			
		||||
      ORDER BY
 | 
			
		||||
        city
 | 
			
		||||
      LIMIT
 | 
			
		||||
        1
 | 
			
		||||
    )
 | 
			
		||||
    UNION ALL
 | 
			
		||||
    SELECT
 | 
			
		||||
      l.city,
 | 
			
		||||
      l."assetId"
 | 
			
		||||
    FROM
 | 
			
		||||
      cte c,
 | 
			
		||||
      LATERAL (
 | 
			
		||||
        SELECT
 | 
			
		||||
          city,
 | 
			
		||||
          "assetId"
 | 
			
		||||
        FROM
 | 
			
		||||
          exif
 | 
			
		||||
          INNER JOIN assets ON exif."assetId" = assets.id
 | 
			
		||||
        WHERE
 | 
			
		||||
          city > c.city
 | 
			
		||||
          AND "ownerId" IN ($1)
 | 
			
		||||
          AND "isVisible" = $2
 | 
			
		||||
          AND "isArchived" = $3
 | 
			
		||||
          AND type = $4
 | 
			
		||||
        ORDER BY
 | 
			
		||||
          city
 | 
			
		||||
        LIMIT
 | 
			
		||||
          1
 | 
			
		||||
      ) l
 | 
			
		||||
  )
 | 
			
		||||
SELECT
 | 
			
		||||
  "asset"."id" AS "asset_id",
 | 
			
		||||
  "asset"."deviceAssetId" AS "asset_deviceAssetId",
 | 
			
		||||
  "asset"."ownerId" AS "asset_ownerId",
 | 
			
		||||
  "asset"."libraryId" AS "asset_libraryId",
 | 
			
		||||
  "asset"."deviceId" AS "asset_deviceId",
 | 
			
		||||
  "asset"."type" AS "asset_type",
 | 
			
		||||
  "asset"."originalPath" AS "asset_originalPath",
 | 
			
		||||
  "asset"."resizePath" AS "asset_resizePath",
 | 
			
		||||
  "asset"."webpPath" AS "asset_webpPath",
 | 
			
		||||
  "asset"."thumbhash" AS "asset_thumbhash",
 | 
			
		||||
  "asset"."encodedVideoPath" AS "asset_encodedVideoPath",
 | 
			
		||||
  "asset"."createdAt" AS "asset_createdAt",
 | 
			
		||||
  "asset"."updatedAt" AS "asset_updatedAt",
 | 
			
		||||
  "asset"."deletedAt" AS "asset_deletedAt",
 | 
			
		||||
  "asset"."fileCreatedAt" AS "asset_fileCreatedAt",
 | 
			
		||||
  "asset"."localDateTime" AS "asset_localDateTime",
 | 
			
		||||
  "asset"."fileModifiedAt" AS "asset_fileModifiedAt",
 | 
			
		||||
  "asset"."isFavorite" AS "asset_isFavorite",
 | 
			
		||||
  "asset"."isArchived" AS "asset_isArchived",
 | 
			
		||||
  "asset"."isExternal" AS "asset_isExternal",
 | 
			
		||||
  "asset"."isReadOnly" AS "asset_isReadOnly",
 | 
			
		||||
  "asset"."isOffline" AS "asset_isOffline",
 | 
			
		||||
  "asset"."checksum" AS "asset_checksum",
 | 
			
		||||
  "asset"."duration" AS "asset_duration",
 | 
			
		||||
  "asset"."isVisible" AS "asset_isVisible",
 | 
			
		||||
  "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
 | 
			
		||||
  "asset"."originalFileName" AS "asset_originalFileName",
 | 
			
		||||
  "asset"."sidecarPath" AS "asset_sidecarPath",
 | 
			
		||||
  "asset"."stackId" AS "asset_stackId",
 | 
			
		||||
  "exif"."assetId" AS "exif_assetId",
 | 
			
		||||
  "exif"."description" AS "exif_description",
 | 
			
		||||
  "exif"."exifImageWidth" AS "exif_exifImageWidth",
 | 
			
		||||
  "exif"."exifImageHeight" AS "exif_exifImageHeight",
 | 
			
		||||
  "exif"."fileSizeInByte" AS "exif_fileSizeInByte",
 | 
			
		||||
  "exif"."orientation" AS "exif_orientation",
 | 
			
		||||
  "exif"."dateTimeOriginal" AS "exif_dateTimeOriginal",
 | 
			
		||||
  "exif"."modifyDate" AS "exif_modifyDate",
 | 
			
		||||
  "exif"."timeZone" AS "exif_timeZone",
 | 
			
		||||
  "exif"."latitude" AS "exif_latitude",
 | 
			
		||||
  "exif"."longitude" AS "exif_longitude",
 | 
			
		||||
  "exif"."projectionType" AS "exif_projectionType",
 | 
			
		||||
  "exif"."city" AS "exif_city",
 | 
			
		||||
  "exif"."livePhotoCID" AS "exif_livePhotoCID",
 | 
			
		||||
  "exif"."autoStackId" AS "exif_autoStackId",
 | 
			
		||||
  "exif"."state" AS "exif_state",
 | 
			
		||||
  "exif"."country" AS "exif_country",
 | 
			
		||||
  "exif"."make" AS "exif_make",
 | 
			
		||||
  "exif"."model" AS "exif_model",
 | 
			
		||||
  "exif"."lensModel" AS "exif_lensModel",
 | 
			
		||||
  "exif"."fNumber" AS "exif_fNumber",
 | 
			
		||||
  "exif"."focalLength" AS "exif_focalLength",
 | 
			
		||||
  "exif"."iso" AS "exif_iso",
 | 
			
		||||
  "exif"."exposureTime" AS "exif_exposureTime",
 | 
			
		||||
  "exif"."profileDescription" AS "exif_profileDescription",
 | 
			
		||||
  "exif"."colorspace" AS "exif_colorspace",
 | 
			
		||||
  "exif"."bitsPerSample" AS "exif_bitsPerSample",
 | 
			
		||||
  "exif"."fps" AS "exif_fps"
 | 
			
		||||
FROM
 | 
			
		||||
  "assets" "asset"
 | 
			
		||||
  INNER JOIN "exif" "exif" ON "exif"."assetId" = "asset"."id"
 | 
			
		||||
  INNER JOIN cte ON asset.id = cte."assetId"
 | 
			
		||||
 | 
			
		||||
@ -24,7 +24,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
 | 
			
		||||
    getLibraryAssetPaths: jest.fn(),
 | 
			
		||||
    getByLibraryIdAndOriginalPath: jest.fn(),
 | 
			
		||||
    deleteAll: jest.fn(),
 | 
			
		||||
    save: jest.fn(),
 | 
			
		||||
    update: jest.fn(),
 | 
			
		||||
    remove: jest.fn(),
 | 
			
		||||
    findLivePhotoMatch: jest.fn(),
 | 
			
		||||
    getMapMarkers: jest.fn(),
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,7 @@ export const newSearchRepositoryMock = (): jest.Mocked<ISearchRepository> => {
 | 
			
		||||
    searchFaces: jest.fn(),
 | 
			
		||||
    upsert: jest.fn(),
 | 
			
		||||
    searchPlaces: jest.fn(),
 | 
			
		||||
    getAssetsByCity: jest.fn(),
 | 
			
		||||
    deleteAllSearchEmbeddings: jest.fn(),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										18
									
								
								web/src/lib/__mocks__/sdk.mock.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								web/src/lib/__mocks__/sdk.mock.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,18 @@
 | 
			
		||||
import sdk from '@immich/sdk';
 | 
			
		||||
import type { Mock, MockedObject } from 'vitest';
 | 
			
		||||
 | 
			
		||||
vi.mock('@immich/sdk', async (originalImport) => {
 | 
			
		||||
  const module = await originalImport<typeof import('@immich/sdk')>();
 | 
			
		||||
 | 
			
		||||
  const mocks: Record<string, Mock> = {};
 | 
			
		||||
  for (const [key, value] of Object.entries(module)) {
 | 
			
		||||
    if (typeof value === 'function') {
 | 
			
		||||
      mocks[key] = vi.fn();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const mock = { ...module, ...mocks };
 | 
			
		||||
  return { ...mock, default: mock };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const sdkMock = sdk as MockedObject<typeof sdk>;
 | 
			
		||||
@ -1,18 +1,11 @@
 | 
			
		||||
import { createObjectURLMock } from '$lib/__mocks__/jsdom-url.mock';
 | 
			
		||||
import sdk, { ThumbnailFormat } from '@immich/sdk';
 | 
			
		||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
 | 
			
		||||
import { ThumbnailFormat } from '@immich/sdk';
 | 
			
		||||
import { albumFactory } from '@test-data';
 | 
			
		||||
import '@testing-library/jest-dom';
 | 
			
		||||
import { fireEvent, render, waitFor, type RenderResult } from '@testing-library/svelte';
 | 
			
		||||
import type { MockedObject } from 'vitest';
 | 
			
		||||
import AlbumCard from '../album-card.svelte';
 | 
			
		||||
 | 
			
		||||
vi.mock('@immich/sdk', async (originalImport) => {
 | 
			
		||||
  const module = await originalImport<typeof import('@immich/sdk')>();
 | 
			
		||||
  const mock = { ...module, getAssetThumbnail: vi.fn() };
 | 
			
		||||
  return { ...mock, default: mock };
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const sdkMock: MockedObject<typeof sdk> = sdk as MockedObject<typeof sdk>;
 | 
			
		||||
const onShowContextMenu = vi.fn();
 | 
			
		||||
 | 
			
		||||
describe('AlbumCard component', () => {
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,9 @@
 | 
			
		||||
  let dragStartTarget: EventTarget | null = null;
 | 
			
		||||
 | 
			
		||||
  const handleDragEnter = (e: DragEvent) => {
 | 
			
		||||
    if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
 | 
			
		||||
      dragStartTarget = e.target;
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										357
									
								
								web/src/lib/stores/asset.store.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								web/src/lib/stores/asset.store.spec.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,357 @@
 | 
			
		||||
import { sdkMock } from '$lib/__mocks__/sdk.mock';
 | 
			
		||||
import { TimeBucketSize, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
import { assetFactory } from '@test-data/factories/asset-factory';
 | 
			
		||||
import { AssetStore, BucketPosition } from './assets.store';
 | 
			
		||||
 | 
			
		||||
describe('AssetStore', () => {
 | 
			
		||||
  beforeEach(() => {
 | 
			
		||||
    vi.resetAllMocks();
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('init', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
    const bucketAssets: Record<string, AssetResponseDto[]> = {
 | 
			
		||||
      '2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
 | 
			
		||||
      '2024-02-01T00:00:00.000Z': assetFactory.buildList(100),
 | 
			
		||||
      '2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([
 | 
			
		||||
        { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
 | 
			
		||||
        { count: 100, timeBucket: '2024-02-01T00:00:00.000Z' },
 | 
			
		||||
        { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
 | 
			
		||||
      ]);
 | 
			
		||||
      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
 | 
			
		||||
 | 
			
		||||
      await assetStore.init({ width: 1588, height: 1000 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('should load buckets in viewport', () => {
 | 
			
		||||
      expect(sdkMock.getTimeBuckets).toBeCalledTimes(1);
 | 
			
		||||
      expect(sdkMock.getTimeBuckets).toBeCalledWith({ size: TimeBucketSize.Month });
 | 
			
		||||
      expect(sdkMock.getTimeBucket).toHaveBeenCalledTimes(2);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('calculates bucket height', () => {
 | 
			
		||||
      expect(assetStore.buckets).toEqual(
 | 
			
		||||
        expect.arrayContaining([
 | 
			
		||||
          expect.objectContaining({ bucketDate: '2024-03-01T00:00:00.000Z', bucketHeight: 235 }),
 | 
			
		||||
          expect.objectContaining({ bucketDate: '2024-02-01T00:00:00.000Z', bucketHeight: 3760 }),
 | 
			
		||||
          expect.objectContaining({ bucketDate: '2024-01-01T00:00:00.000Z', bucketHeight: 235 }),
 | 
			
		||||
        ]),
 | 
			
		||||
      );
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('calculates timeline height', () => {
 | 
			
		||||
      expect(assetStore.timelineHeight).toBe(4230);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('loadBucket', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
    const bucketAssets: Record<string, AssetResponseDto[]> = {
 | 
			
		||||
      '2024-01-03T00:00:00.000Z': assetFactory.buildList(1),
 | 
			
		||||
      '2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([
 | 
			
		||||
        { count: 1, timeBucket: '2024-01-03T00:00:00.000Z' },
 | 
			
		||||
        { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
 | 
			
		||||
      ]);
 | 
			
		||||
      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
 | 
			
		||||
      await assetStore.init({ width: 0, height: 0 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('loads a bucket', async () => {
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
 | 
			
		||||
      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      expect(sdkMock.getTimeBucket).toBeCalledTimes(1);
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(3);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('ignores invalid buckets', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2023-01-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      expect(sdkMock.getTimeBucket).toBeCalledTimes(0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('only updates the position of loaded buckets', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Unknown);
 | 
			
		||||
 | 
			
		||||
      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.position).toEqual(BucketPosition.Visible);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('cancels bucket loading', async () => {
 | 
			
		||||
      const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
 | 
			
		||||
      const loadPromise = assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Unknown);
 | 
			
		||||
 | 
			
		||||
      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
 | 
			
		||||
      expect(bucket).not.toBeNull();
 | 
			
		||||
 | 
			
		||||
      assetStore.cancelBucket(bucket!);
 | 
			
		||||
      expect(abortSpy).toBeCalledTimes(1);
 | 
			
		||||
      await loadPromise;
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')?.assets.length).toEqual(0);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('addAssets', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([]);
 | 
			
		||||
      await assetStore.init({ width: 1588, height: 1000 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('is empty initially', () => {
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(0);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('adds assets to new bucket', () => {
 | 
			
		||||
      const asset = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([asset]);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.buckets[0].assets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
 | 
			
		||||
      expect(assetStore.assets[0].id).toEqual(asset.id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('adds assets to existing bucket', () => {
 | 
			
		||||
      const [assetOne, assetTwo] = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([assetOne]);
 | 
			
		||||
      assetStore.addAssets([assetTwo]);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(2);
 | 
			
		||||
      expect(assetStore.buckets[0].assets.length).toEqual(2);
 | 
			
		||||
      expect(assetStore.buckets[0].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('orders assets in buckets by descending date', () => {
 | 
			
		||||
      const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      const assetTwo = assetFactory.build({ fileCreatedAt: '2024-01-15T12:00:00.000Z' });
 | 
			
		||||
      const assetThree = assetFactory.build({ fileCreatedAt: '2024-01-16T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([assetOne, assetTwo, assetThree]);
 | 
			
		||||
 | 
			
		||||
      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
 | 
			
		||||
      expect(bucket).not.toBeNull();
 | 
			
		||||
      expect(bucket?.assets.length).toEqual(3);
 | 
			
		||||
      expect(bucket?.assets[0].id).toEqual(assetOne.id);
 | 
			
		||||
      expect(bucket?.assets[1].id).toEqual(assetThree.id);
 | 
			
		||||
      expect(bucket?.assets[2].id).toEqual(assetTwo.id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('orders buckets by descending date', () => {
 | 
			
		||||
      const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      const assetTwo = assetFactory.build({ fileCreatedAt: '2024-04-20T12:00:00.000Z' });
 | 
			
		||||
      const assetThree = assetFactory.build({ fileCreatedAt: '2023-01-20T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([assetOne, assetTwo, assetThree]);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(3);
 | 
			
		||||
      expect(assetStore.buckets[0].bucketDate).toEqual('2024-04-01T00:00:00.000Z');
 | 
			
		||||
      expect(assetStore.buckets[1].bucketDate).toEqual('2024-01-01T00:00:00.000Z');
 | 
			
		||||
      expect(assetStore.buckets[2].bucketDate).toEqual('2023-01-01T00:00:00.000Z');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('updates existing asset', () => {
 | 
			
		||||
      const updateAssetsSpy = vi.spyOn(assetStore, 'updateAssets');
 | 
			
		||||
      const asset = assetFactory.build();
 | 
			
		||||
      assetStore.addAssets([asset]);
 | 
			
		||||
 | 
			
		||||
      assetStore.addAssets([asset]);
 | 
			
		||||
      expect(updateAssetsSpy).toBeCalledWith([asset]);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(1);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('updateAssets', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([]);
 | 
			
		||||
      await assetStore.init({ width: 1588, height: 1000 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('ignores non-existing assets', () => {
 | 
			
		||||
      assetStore.updateAssets([assetFactory.build()]);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(0);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('updates an asset', () => {
 | 
			
		||||
      const asset = assetFactory.build({ isFavorite: false });
 | 
			
		||||
      const updatedAsset = { ...asset, isFavorite: true };
 | 
			
		||||
 | 
			
		||||
      assetStore.addAssets([asset]);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.assets[0].isFavorite).toEqual(false);
 | 
			
		||||
 | 
			
		||||
      assetStore.updateAssets([updatedAsset]);
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.assets[0].isFavorite).toEqual(true);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('replaces bucket date when asset date changes', () => {
 | 
			
		||||
      const asset = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      const updatedAsset = { ...asset, fileCreatedAt: '2024-03-20T12:00:00.000Z' };
 | 
			
		||||
 | 
			
		||||
      assetStore.addAssets([asset]);
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).not.toBeNull();
 | 
			
		||||
 | 
			
		||||
      assetStore.updateAssets([updatedAsset]);
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-01-01T00:00:00.000Z')).toBeNull();
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).not.toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('removeAssets', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([]);
 | 
			
		||||
      await assetStore.init({ width: 1588, height: 1000 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('ignores invalid IDs', () => {
 | 
			
		||||
      assetStore.addAssets(assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' }));
 | 
			
		||||
      assetStore.removeAssets(['', 'invalid', '4c7d9acc']);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(2);
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.buckets[0].assets.length).toEqual(2);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('removes asset from bucket', () => {
 | 
			
		||||
      const [assetOne, assetTwo] = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([assetOne, assetTwo]);
 | 
			
		||||
      assetStore.removeAssets([assetOne.id]);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(1);
 | 
			
		||||
      expect(assetStore.buckets[0].assets.length).toEqual(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('removes bucket when empty', () => {
 | 
			
		||||
      const assets = assetFactory.buildList(2, { fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets(assets);
 | 
			
		||||
      assetStore.removeAssets(assets.map((asset) => asset.id));
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.assets.length).toEqual(0);
 | 
			
		||||
      expect(assetStore.buckets.length).toEqual(0);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getPreviousAssetId', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
    const bucketAssets: Record<string, AssetResponseDto[]> = {
 | 
			
		||||
      '2024-03-01T00:00:00.000Z': assetFactory.buildList(1),
 | 
			
		||||
      '2024-02-01T00:00:00.000Z': assetFactory.buildList(6),
 | 
			
		||||
      '2024-01-01T00:00:00.000Z': assetFactory.buildList(3),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([
 | 
			
		||||
        { count: 1, timeBucket: '2024-03-01T00:00:00.000Z' },
 | 
			
		||||
        { count: 6, timeBucket: '2024-02-01T00:00:00.000Z' },
 | 
			
		||||
        { count: 3, timeBucket: '2024-01-01T00:00:00.000Z' },
 | 
			
		||||
      ]);
 | 
			
		||||
      sdkMock.getTimeBucket.mockImplementation(({ timeBucket }) => Promise.resolve(bucketAssets[timeBucket]));
 | 
			
		||||
 | 
			
		||||
      await assetStore.init({ width: 0, height: 0 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null for invalid assetId', async () => {
 | 
			
		||||
      expect(() => assetStore.getPreviousAssetId('invalid')).not.toThrow();
 | 
			
		||||
      expect(await assetStore.getPreviousAssetId('invalid')).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns previous assetId', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      const bucket = assetStore.getBucketByDate('2024-01-01T00:00:00.000Z');
 | 
			
		||||
 | 
			
		||||
      expect(await assetStore.getPreviousAssetId(bucket!.assets[1].id)).toEqual(bucket!.assets[0].id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns previous assetId spanning multiple buckets', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
 | 
			
		||||
      const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
 | 
			
		||||
      const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
 | 
			
		||||
      expect(await assetStore.getPreviousAssetId(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0].id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('loads previous bucket', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
 | 
			
		||||
      const loadBucketSpy = vi.spyOn(assetStore, 'loadBucket');
 | 
			
		||||
      const bucket = assetStore.getBucketByDate('2024-02-01T00:00:00.000Z');
 | 
			
		||||
      const previousBucket = assetStore.getBucketByDate('2024-03-01T00:00:00.000Z');
 | 
			
		||||
      expect(await assetStore.getPreviousAssetId(bucket!.assets[0].id)).toEqual(previousBucket!.assets[0].id);
 | 
			
		||||
      expect(loadBucketSpy).toBeCalledTimes(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('skips removed assets', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2024-01-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      await assetStore.loadBucket('2024-02-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
 | 
			
		||||
      const [assetOne, assetTwo, assetThree] = assetStore.assets;
 | 
			
		||||
      assetStore.removeAssets([assetTwo.id]);
 | 
			
		||||
      expect(await assetStore.getPreviousAssetId(assetThree.id)).toEqual(assetOne.id);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null when no more assets', async () => {
 | 
			
		||||
      await assetStore.loadBucket('2024-03-01T00:00:00.000Z', BucketPosition.Visible);
 | 
			
		||||
      expect(await assetStore.getPreviousAssetId(assetStore.assets[0].id)).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  describe('getBucketIndexByAssetId', () => {
 | 
			
		||||
    let assetStore: AssetStore;
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      assetStore = new AssetStore({});
 | 
			
		||||
      sdkMock.getTimeBuckets.mockResolvedValue([]);
 | 
			
		||||
      await assetStore.init({ width: 0, height: 0 });
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns null for invalid buckets', () => {
 | 
			
		||||
      expect(assetStore.getBucketByDate('invalid')).toBeNull();
 | 
			
		||||
      expect(assetStore.getBucketByDate('2024-03-01T00:00:00.000Z')).toBeNull();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('returns the bucket index', () => {
 | 
			
		||||
      const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      const assetTwo = assetFactory.build({ fileCreatedAt: '2024-02-15T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([assetOne, assetTwo]);
 | 
			
		||||
 | 
			
		||||
      expect(assetStore.getBucketIndexByAssetId(assetTwo.id)).toEqual(0);
 | 
			
		||||
      expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(1);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    it('ignores removed buckets', () => {
 | 
			
		||||
      const assetOne = assetFactory.build({ fileCreatedAt: '2024-01-20T12:00:00.000Z' });
 | 
			
		||||
      const assetTwo = assetFactory.build({ fileCreatedAt: '2024-02-15T12:00:00.000Z' });
 | 
			
		||||
      assetStore.addAssets([assetOne, assetTwo]);
 | 
			
		||||
 | 
			
		||||
      assetStore.removeAssets([assetTwo.id]);
 | 
			
		||||
      expect(assetStore.getBucketIndexByAssetId(assetOne.id)).toEqual(0);
 | 
			
		||||
    });
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@ -3,20 +3,20 @@
 | 
			
		||||
  import Icon from '$lib/components/elements/icon.svelte';
 | 
			
		||||
  import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
 | 
			
		||||
  import { AppRoute } from '$lib/constants';
 | 
			
		||||
  import type { SearchExploreResponseDto } from '@immich/sdk';
 | 
			
		||||
  import { mdiMapMarkerOff } from '@mdi/js';
 | 
			
		||||
  import type { PageData } from './$types';
 | 
			
		||||
  import { getMetadataSearchQuery } from '$lib/utils/metadata-search';
 | 
			
		||||
  import type { AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
 | 
			
		||||
  export let data: PageData;
 | 
			
		||||
 | 
			
		||||
  const CITY_FIELD = 'exifInfo.city';
 | 
			
		||||
  const getFieldItems = (items: SearchExploreResponseDto[]) => {
 | 
			
		||||
    const targetField = items.find((item) => item.fieldName === CITY_FIELD);
 | 
			
		||||
    return targetField?.items || [];
 | 
			
		||||
  type AssetWithCity = AssetResponseDto & {
 | 
			
		||||
    exifInfo: {
 | 
			
		||||
      city: string;
 | 
			
		||||
    };
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  $: places = getFieldItems(data.items);
 | 
			
		||||
  $: places = data.items.filter((item): item is AssetWithCity => !!item.exifInfo?.city);
 | 
			
		||||
  $: hasPlaces = places.length > 0;
 | 
			
		||||
 | 
			
		||||
  let innerHeight: number;
 | 
			
		||||
@ -27,17 +27,18 @@
 | 
			
		||||
<UserPageLayout title="Places">
 | 
			
		||||
  {#if hasPlaces}
 | 
			
		||||
    <div class="flex flex-row flex-wrap gap-4">
 | 
			
		||||
      {#each places as item (item.data.id)}
 | 
			
		||||
        <a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city: item.value })}" draggable="false">
 | 
			
		||||
      {#each places as item (item.id)}
 | 
			
		||||
        {@const city = item.exifInfo.city}
 | 
			
		||||
        <a class="relative" href="{AppRoute.SEARCH}?{getMetadataSearchQuery({ city })}" draggable="false">
 | 
			
		||||
          <div
 | 
			
		||||
            class="flex w-[calc((100vw-(72px+5rem))/2)] max-w-[156px] justify-center overflow-hidden rounded-xl brightness-75 filter"
 | 
			
		||||
          >
 | 
			
		||||
            <Thumbnail thumbnailSize={156} asset={item.data} readonly />
 | 
			
		||||
            <Thumbnail thumbnailSize={156} asset={item} readonly />
 | 
			
		||||
          </div>
 | 
			
		||||
          <span
 | 
			
		||||
            class="w-100 absolute bottom-2 w-full text-ellipsis px-1 text-center text-sm font-medium capitalize text-white backdrop-blur-[1px] hover:cursor-pointer"
 | 
			
		||||
          >
 | 
			
		||||
            {item.value}
 | 
			
		||||
            {city}
 | 
			
		||||
          </span>
 | 
			
		||||
        </a>
 | 
			
		||||
      {/each}
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
import { authenticate } from '$lib/utils/auth';
 | 
			
		||||
import { getExploreData } from '@immich/sdk';
 | 
			
		||||
import { getAssetsByCity } from '@immich/sdk';
 | 
			
		||||
import type { PageLoad } from './$types';
 | 
			
		||||
 | 
			
		||||
export const load = (async () => {
 | 
			
		||||
  await authenticate();
 | 
			
		||||
  const items = await getExploreData();
 | 
			
		||||
  const items = await getAssetsByCity();
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    items,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										30
									
								
								web/src/test-data/factories/asset-factory.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								web/src/test-data/factories/asset-factory.ts
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,30 @@
 | 
			
		||||
import { faker } from '@faker-js/faker';
 | 
			
		||||
import { AssetTypeEnum, type AssetResponseDto } from '@immich/sdk';
 | 
			
		||||
import { Sync } from 'factory.ts';
 | 
			
		||||
 | 
			
		||||
export const assetFactory = Sync.makeFactory<AssetResponseDto>({
 | 
			
		||||
  id: Sync.each(() => faker.string.uuid()),
 | 
			
		||||
  deviceAssetId: Sync.each(() => faker.string.uuid()),
 | 
			
		||||
  ownerId: Sync.each(() => faker.string.uuid()),
 | 
			
		||||
  deviceId: '',
 | 
			
		||||
  libraryId: Sync.each(() => faker.string.uuid()),
 | 
			
		||||
  type: Sync.each(() => faker.helpers.enumValue(AssetTypeEnum)),
 | 
			
		||||
  originalPath: Sync.each(() => faker.system.filePath()),
 | 
			
		||||
  originalFileName: Sync.each(() => faker.system.fileName()),
 | 
			
		||||
  resized: true,
 | 
			
		||||
  thumbhash: Sync.each(() => faker.string.alphanumeric(28)),
 | 
			
		||||
  fileCreatedAt: Sync.each(() => faker.date.past().toISOString()),
 | 
			
		||||
  fileModifiedAt: Sync.each(() => faker.date.past().toISOString()),
 | 
			
		||||
  localDateTime: Sync.each(() => faker.date.past().toISOString()),
 | 
			
		||||
  updatedAt: Sync.each(() => faker.date.past().toISOString()),
 | 
			
		||||
  isFavorite: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  isArchived: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  isTrashed: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  duration: '0:00:00.00000',
 | 
			
		||||
  checksum: Sync.each(() => faker.string.alphanumeric(28)),
 | 
			
		||||
  isExternal: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  isOffline: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  isReadOnly: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  hasMetadata: Sync.each(() => faker.datatype.boolean()),
 | 
			
		||||
  stackCount: null,
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user