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) => {
|
||||
dragStartTarget = e.target;
|
||||
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