1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-07-19 12:59:36 +02:00

feat: update Immich integration to use dynamic image URLs and enhance image retrieval logic

This commit is contained in:
Sean Morley 2025-06-02 21:25:07 -04:00
parent 937db00226
commit 45e195a84e
5 changed files with 112 additions and 78 deletions

View file

@ -809,15 +809,6 @@ class AdventureImage(models.Model):
if not has_image and not has_immich_id: if not has_image and not has_immich_id:
raise ValidationError("Must provide either an image file or an Immich ID.") raise ValidationError("Must provide either an image file or an Immich ID.")
# Enforce: immich_id may only be used by a single user
if has_immich_id:
# Check if this immich_id is already used by a *different* user
from adventures.models import AdventureImage
conflict = AdventureImage.objects.filter(immich_id=self.immich_id).exclude(user_id=self.user_id)
if conflict.exists():
raise ValidationError("This Immich ID is already used by another user.")
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
# Clean empty strings to None for proper database storage # Clean empty strings to None for proper database storage
if not self.image: if not self.image:

View file

@ -6,6 +6,7 @@ from main.utils import CustomModelSerializer
from users.serializers import CustomUserDetailsSerializer from users.serializers import CustomUserDetailsSerializer
from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer from worldtravel.serializers import CountrySerializer, RegionSerializer, CitySerializer
from geopy.distance import geodesic from geopy.distance import geodesic
from integrations.models import ImmichIntegration
class AdventureImageSerializer(CustomModelSerializer): class AdventureImageSerializer(CustomModelSerializer):
@ -21,7 +22,12 @@ class AdventureImageSerializer(CustomModelSerializer):
if instance.image: if instance.image:
representation['image'] = f"{public_url}/media/{instance.image.name}" representation['image'] = f"{public_url}/media/{instance.image.name}"
if instance.immich_id: if instance.immich_id:
representation['image'] = f"{public_url}/api/integrations/immich/get/{instance.immich_id}" integration = ImmichIntegration.objects.filter(user=instance.user_id).first()
if integration:
representation['image'] = f"{public_url}/api/integrations/immich/{integration.id}/get/{instance.immich_id}"
else:
representation['image'] = None
return representation return representation
class AttachmentSerializer(CustomModelSerializer): class AttachmentSerializer(CustomModelSerializer):

View file

@ -10,6 +10,7 @@ from rest_framework.pagination import PageNumberPagination
from django.conf import settings from django.conf import settings
from adventures.models import AdventureImage from adventures.models import AdventureImage
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404
class IntegrationView(viewsets.ViewSet): class IntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
@ -36,11 +37,12 @@ class StandardResultsSetPagination(PageNumberPagination):
class ImmichIntegrationView(viewsets.ViewSet): class ImmichIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination pagination_class = StandardResultsSetPagination
def check_integration(self, request): def check_integration(self, request):
""" """
Checks if the user has an active Immich integration. Checks if the user has an active Immich integration.
Returns: Returns:
- None if the integration exists. - The integration object if it exists.
- A Response with an error message if the integration is missing. - A Response with an error message if the integration is missing.
""" """
if not request.user.is_authenticated: if not request.user.is_authenticated:
@ -52,6 +54,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
}, },
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
user_integrations = ImmichIntegration.objects.filter(user=request.user) user_integrations = ImmichIntegration.objects.filter(user=request.user)
if not user_integrations.exists(): if not user_integrations.exists():
return Response( return Response(
@ -62,7 +65,8 @@ class ImmichIntegrationView(viewsets.ViewSet):
}, },
status=status.HTTP_403_FORBIDDEN status=status.HTTP_403_FORBIDDEN
) )
return ImmichIntegration.objects.first()
return user_integrations.first()
@action(detail=False, methods=['get'], url_path='search') @action(detail=False, methods=['get'], url_path='search')
def search(self, request): def search(self, request):
@ -118,7 +122,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "") public_url = public_url.replace("'", "")
for item in res['assets']['items']: for item in res['assets']['items']:
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}' item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
result_page = paginator.paginate_queryset(res['assets']['items'], request) result_page = paginator.paginate_queryset(res['assets']['items'], request)
return paginator.get_paginated_response(result_page) return paginator.get_paginated_response(result_page)
else: else:
@ -170,6 +174,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
""" """
# Check for integration before proceeding # Check for integration before proceeding
integration = self.check_integration(request) integration = self.check_integration(request)
print(integration.user)
if isinstance(integration, Response): if isinstance(integration, Response):
return integration return integration
@ -205,7 +210,7 @@ class ImmichIntegrationView(viewsets.ViewSet):
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/') public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "") public_url = public_url.replace("'", "")
for item in res['assets']: for item in res['assets']:
item['image_url'] = f'{public_url}/api/integrations/immich/get/{item["id"]}' item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
result_page = paginator.paginate_queryset(res['assets'], request) result_page = paginator.paginate_queryset(res['assets'], request)
return paginator.get_paginated_response(result_page) return paginator.get_paginated_response(result_page)
else: else:
@ -218,77 +223,93 @@ class ImmichIntegrationView(viewsets.ViewSet):
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
@action(detail=False, methods=['get'], url_path='get/(?P<imageid>[^/.]+)', permission_classes=[]) @action(
def get(self, request, imageid=None): detail=False,
methods=['get'],
url_path='(?P<integration_id>[^/.]+)/get/(?P<imageid>[^/.]+)',
permission_classes=[]
)
def get_by_integration(self, request, integration_id=None, imageid=None):
""" """
RESTful GET method for retrieving a specific Immich image by ID. GET an Immich image using the integration and asset ID.
Allows access to images for public adventures even if the user doesn't have Immich integration. - Public adventures: accessible by anyone
- Private adventures: accessible only to the owner
- No AdventureImage: owner can still view via integration
""" """
if not imageid: if not imageid or not integration_id:
return Response( return Response({
{ 'message': 'Image ID and Integration ID are required.',
'message': 'Image ID is required.',
'error': True, 'error': True,
'code': 'immich.imageid_required' 'code': 'immich.missing_params'
}, }, status=status.HTTP_400_BAD_REQUEST)
status=status.HTTP_400_BAD_REQUEST
# Lookup integration and user
integration = get_object_or_404(ImmichIntegration, id=integration_id)
owner_id = integration.user_id
# Try to find the image entry
image_entry = (
AdventureImage.objects
.filter(immich_id=imageid, user_id=owner_id)
.select_related('adventure')
.order_by('-adventure__is_public') # True (1) first, False (0) last
.first()
) )
# Check if the image ID is associated with a public adventure # Access control
if image_entry:
public_image = AdventureImage.objects.filter( if image_entry.adventure.is_public:
immich_id=imageid, is_authorized = True
adventure__is_public=True elif request.user.is_authenticated and request.user.id == owner_id:
).first() is_authorized = True
# If it's a public adventure image, use any available integration
if public_image:
integration = ImmichIntegration.objects.filter(
user_id=public_image.adventure.user_id
).first()
if not integration:
return Response(
{
'message': 'No Immich integration available for public access.',
'error': True,
'code': 'immich.no_integration'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
else: else:
# Not a public image, check user's integration return Response({
integration = self.check_integration(request) 'message': 'This image belongs to a private adventure and you are not authorized.',
if isinstance(integration, Response): 'error': True,
return integration 'code': 'immich.permission_denied'
}, status=status.HTTP_403_FORBIDDEN)
else:
# No AdventureImage exists; allow only the integration owner
if not request.user.is_authenticated or request.user.id != owner_id:
return Response({
'message': 'Image is not linked to any adventure and you are not the owner.',
'error': True,
'code': 'immich.not_found'
}, status=status.HTTP_404_NOT_FOUND)
is_authorized = True # Integration owner fallback
# Proceed with fetching the image # Fetch from Immich
try: try:
immich_fetch = requests.get( immich_response = requests.get(
f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview', f'{integration.server_url}/assets/{imageid}/thumbnail?size=preview',
headers={'x-api-key': integration.api_key}, headers={'x-api-key': integration.api_key},
timeout=5 # Add timeout to prevent hanging timeout=5
) )
response = HttpResponse(immich_fetch.content, content_type='image/jpeg', status=status.HTTP_200_OK) content_type = immich_response.headers.get('Content-Type', 'image/jpeg')
if not content_type.startswith('image/'):
return Response({
'message': 'Invalid content type returned from Immich.',
'error': True,
'code': 'immich.invalid_content'
}, status=status.HTTP_502_BAD_GATEWAY)
response = HttpResponse(immich_response.content, content_type=content_type, status=200)
response['Cache-Control'] = 'public, max-age=86400, stale-while-revalidate=3600' response['Cache-Control'] = 'public, max-age=86400, stale-while-revalidate=3600'
return response return response
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
return Response( return Response({
{ 'message': 'The Immich server is unreachable.',
'message': 'The Immich server is currently down or unreachable.',
'error': True, 'error': True,
'code': 'immich.server_down' 'code': 'immich.server_down'
}, }, status=status.HTTP_503_SERVICE_UNAVAILABLE)
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
return Response( return Response({
{
'message': 'The Immich server request timed out.', 'message': 'The Immich server request timed out.',
'error': True, 'error': True,
'code': 'immich.server_timeout' 'code': 'immich.timeout'
}, }, status=status.HTTP_504_GATEWAY_TIMEOUT)
status=status.HTTP_504_GATEWAY_TIMEOUT
)
class ImmichIntegrationViewSet(viewsets.ModelViewSet): class ImmichIntegrationViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]

View file

@ -209,7 +209,7 @@
<div class="flex flex-col items-center gap-2" class:blur-sm={loading}> <div class="flex flex-col items-center gap-2" class:blur-sm={loading}>
<!-- svelte-ignore a11y-img-redundant-alt --> <!-- svelte-ignore a11y-img-redundant-alt -->
<img <img
src={`/immich/${image.id}`} src={`${image.image_url}`}
alt="Image from Immich" alt="Image from Immich"
class="h-24 w-24 object-cover rounded-md" class="h-24 w-24 object-cover rounded-md"
/> />

View file

@ -16,8 +16,24 @@ export const GET: RequestHandler = async (event) => {
}); });
} }
// Proxy the request to the backend let integrationFetch = await fetch(`${endpoint}/api/integrations/immich`, {
const res = await fetch(`${endpoint}/api/integrations/immich/get/${key}`, { method: 'GET',
headers: {
'Content-Type': 'application/json',
Cookie: `sessionid=${sessionid}`
}
});
if (!integrationFetch.ok) {
return new Response(JSON.stringify({ error: 'Failed to fetch integration data' }), {
status: integrationFetch.status,
headers: { 'Content-Type': 'application/json' }
});
}
const integrationData = await integrationFetch.json();
const integrationId = integrationData.id;
// Proxy the request to the backend{
const res = await fetch(`${endpoint}/api/integrations/immich/${integrationId}/get/${key}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',