From 720bd4d142a5ffa102ea9b0b7be4761da5a8cde3 Mon Sep 17 00:00:00 2001 From: Thies Date: Sun, 2 Feb 2025 16:48:37 +0100 Subject: [PATCH] immich: Allow users to upload the URL's to their images, instead of the entire file This saves duplicate storage, and stores all of your images in one place, meaning edits will appear in both places! --- ...22_adventureimage_external_url_and_more.py | 31 ++++++++++++++++ backend/server/adventures/models.py | 23 ++++++++++-- backend/server/adventures/serializers.py | 7 +++- docker-compose.yml | 1 + .../docs/configuration/immich_integration.md | 20 +++++++++++ frontend/.env.example | 1 + .../src/lib/components/AdventureModal.svelte | 35 ++++++++++++++++++- 7 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 backend/server/adventures/migrations/0022_adventureimage_external_url_and_more.py diff --git a/backend/server/adventures/migrations/0022_adventureimage_external_url_and_more.py b/backend/server/adventures/migrations/0022_adventureimage_external_url_and_more.py new file mode 100644 index 0000000..520301d --- /dev/null +++ b/backend/server/adventures/migrations/0022_adventureimage_external_url_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.11 on 2025-02-02 14:59 + +import adventures.models +import django_resized.forms +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('adventures', '0021_alter_attachment_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='adventureimage', + name='external_url', + field=models.URLField(null=True), + ), + migrations.AlterField( + model_name='adventureimage', + name='image', + field=django_resized.forms.ResizedImageField(blank=True, crop=None, force_format='WEBP', keep_meta=True, quality=75, scale=None, size=[1920, 1080], upload_to=adventures.models.PathAndRename('images/')), + ), + migrations.AddConstraint( + model_name='adventureimage', + constraint=models.CheckConstraint(check=models.Q(('image__exact', ''), ('external_url__isnull', True), _connector='XOR'), name='image_xor_external_url'), + ), + ] diff --git a/backend/server/adventures/models.py b/backend/server/adventures/models.py index 96d439b..d291914 100644 --- a/backend/server/adventures/models.py +++ b/backend/server/adventures/models.py @@ -277,16 +277,35 @@ class AdventureImage(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) user_id = models.ForeignKey( User, on_delete=models.CASCADE, default=default_user_id) + image = ResizedImageField( force_format="WEBP", quality=75, - upload_to=PathAndRename('images/') # Use the callable class here + upload_to=PathAndRename('images/'), # Use the callable class here + blank=True ) + + external_url = models.URLField(null=True) + + class Meta: + # Require image, or external_url, but not both -> XOR(^) + # Image is a string(Path to a file), so we can check if it is empty + constraints = [ + models.CheckConstraint( + check=models.Q(image__exact='') ^ models.Q(external_url__isnull=True), + name="image_xor_external_url" + ) + ] + + adventure = models.ForeignKey(Adventure, related_name='images', on_delete=models.CASCADE) is_primary = models.BooleanField(default=False) def __str__(self): - return self.image.url + if self.external_url is not None: + return self.external_url + else: + return self.image.url class Attachment(models.Model): id = models.UUIDField(default=uuid.uuid4, editable=False, unique=True, primary_key=True) diff --git a/backend/server/adventures/serializers.py b/backend/server/adventures/serializers.py index 6452922..d0b6bfa 100644 --- a/backend/server/adventures/serializers.py +++ b/backend/server/adventures/serializers.py @@ -9,7 +9,7 @@ from users.serializers import CustomUserDetailsSerializer class AdventureImageSerializer(CustomModelSerializer): class Meta: model = AdventureImage - fields = ['id', 'image', 'adventure', 'is_primary', 'user_id'] + fields = ['id', 'image', 'adventure', 'is_primary', 'user_id', 'external_url'] read_only_fields = ['id', 'user_id'] def to_representation(self, instance): @@ -20,6 +20,11 @@ class AdventureImageSerializer(CustomModelSerializer): # remove any ' from the url public_url = public_url.replace("'", "") representation['image'] = f"{public_url}/media/{instance.image.name}" + representation['external_url'] = representation['image'] + elif instance.external_url: + representation['image'] = instance.external_url + representation['external_url'] = instance.external_url + return representation class AttachmentSerializer(CustomModelSerializer): diff --git a/docker-compose.yml b/docker-compose.yml index 562bd27..fd2b58b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - PUBLIC_SERVER_URL=http://server:8000 # Should be the service name of the backend with port 8000, even if you change the port in the backend service - ORIGIN=http://localhost:8015 - BODY_SIZE_LIMIT=Infinity + - VITE_IMMICH_UPLOAD_URLS_ONLY=false ports: - "8015:3000" depends_on: diff --git a/documentation/docs/configuration/immich_integration.md b/documentation/docs/configuration/immich_integration.md index a8b2ae1..3921ce1 100644 --- a/documentation/docs/configuration/immich_integration.md +++ b/documentation/docs/configuration/immich_integration.md @@ -26,3 +26,23 @@ To integrate Immich with AdventureLog, you need to have an Immich server running 3. Now, when you are adding images to an adventure, you will see an option to search for images in Immich or upload from an album. Enjoy the privacy and control of managing your travel media with Immich and AdventureLog! 🎉 + + +### How to use the pictures from Immich, but not save them in AdventureLog? + +This is possible with the environment variable `VITE_IMMICH_UPLOAD_URLS_ONLY` on the frontend/web container. When set to `true`, AdventureLog will only use the pictures from Immich and not save them in AdventureLog. This can be useful if you want to save storage space on your AdventureLog server but still want to use the pictures from Immich in your adventures. + +1. Go to the AdventureLog server and open the `docker-compose` file. +2. Add the following environment variable to the `web` service: + ```yaml + environment: + - VITE_IMMICH_UPLOAD_URLS_ONLY=true + ``` + +3. Save the file and restart the AdventureLog server with `docker-compose up -d`. + +This saves the URL's in the format of: `https:///immich/b8a8b977-37b6-48fe-b4a0-1739ed7997dc`. Changing the URL where immich is hosted will not break this, but changing the frontend URL will break the links. To fix this, run the following migration inside your postgres database: + +```sql +UPDATE adventures_adventureimage SET external_url = REPLACE(external_url, 'http://127.0.0.1:5173/', 'https://https://adventurelog.app/'); +``` \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index 88f6c69..ae6c501 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,6 +1,7 @@ PUBLIC_SERVER_URL=http://127.0.0.1:8000 BODY_SIZE_LIMIT=Infinity +VITE_IMMICH_UPLOAD_URLS_ONLY=false # OPTIONAL VARIABLES FOR UMAMI ANALYTICS PUBLIC_UMAMI_SRC= diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte index a0aa8f8..e9cd0dd 100644 --- a/frontend/src/lib/components/AdventureModal.svelte +++ b/frontend/src/lib/components/AdventureModal.svelte @@ -20,6 +20,8 @@ import { DefaultMarker, MapEvents, MapLibre } from 'svelte-maplibre'; + const immichUploadURLSOnly = import.meta.env.VITE_IMMICH_UPLOAD_URLS_ONLY === 'true'; + let query: string = ''; let places: OpenStreetMapPlace[] = []; let images: { id: string; image: string; is_primary: boolean }[] = []; @@ -331,6 +333,27 @@ } } + async function uploadURLToImage(url: string) { + let formData = new FormData(); + formData.append('external_url', url); + formData.append('adventure', adventure.id); + + let res = await fetch(`/adventures?/image`, { + method: 'POST', + body: formData + }); + if (res.ok) { + let newData = deserialize(await res.text()) as { data: { id: string; image: string } }; + let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false }; + images = [...images, newImage]; + + adventure.images = images; + addToast('success', $t('adventures.image_upload_success')); + } else { + addToast('error', $t('adventures.image_upload_error')); + } + } + async function uploadImage(file: File) { let formData = new FormData(); formData.append('image', file); @@ -1256,11 +1279,21 @@ it would also work to just use on:click on the MapLibre component itself. --> {#if immichIntegration} + {#if immichUploadURLSOnly} + + We are only uploading the URL of your immich picture, not the picture itself. This is to save space on our servers. If you want to upload the picture, please disable this feature in the frontend environment. + +
+ {/if} { url = e.detail; - fetchImage(); + if (immichUploadURLSOnly) { + uploadURLToImage(url); + } else { + fetchImage(); + } }} /> {/if}