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:
parent
937db00226
commit
45e195a84e
5 changed files with 112 additions and 78 deletions
|
@ -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:
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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',
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue