Update Immich integration for Immich v3.0

This commit is contained in:
Sean Morley
2026-07-21 17:50:42 -04:00
parent c7ed8befd1
commit bef0403bbb
3 changed files with 190 additions and 66 deletions

View File

@@ -0,0 +1,95 @@
from unittest.mock import MagicMock, patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from rest_framework.test import APIRequestFactory, force_authenticate
from integrations.models import ImmichIntegration
from integrations.views.immich_view import ImmichIntegrationView
User = get_user_model()
class ImmichViewTests(TestCase):
def setUp(self):
self.factory = APIRequestFactory()
self.user = User.objects.create_user(username='immich-user', password='test-pass')
self.integration = ImmichIntegration.objects.create(
user=self.user,
server_url='http://immich.example.com/api',
api_key='test-api-key',
)
self.view = ImmichIntegrationView.as_view({'get': 'search'})
self.album_view = ImmichIntegrationView.as_view({'get': 'album'})
@patch('integrations.views.immich_view.requests.post')
def test_search_by_date_uses_iso8601_datetime_range(self, mock_post):
mock_response = MagicMock()
mock_response.ok = True
mock_response.json.return_value = {
'assets': {
'items': [{'id': 'asset-1'}],
}
}
mock_post.return_value = mock_response
request = self.factory.get('/api/integrations/immich/search/', {'date': '2024-06-15'})
force_authenticate(request, user=self.user)
response = self.view(request)
self.assertEqual(response.status_code, 200)
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
self.assertEqual(kwargs['json']['takenAfter'], '2024-06-15T00:00:00.000Z')
self.assertEqual(kwargs['json']['takenBefore'], '2024-06-16T00:00:00.000Z')
@patch('integrations.views.immich_view.requests.get')
@patch('integrations.views.immich_view.requests.post')
def test_album_falls_back_to_metadata_search_without_inline_assets(self, mock_post, mock_get):
mock_get_response = MagicMock()
mock_get_response.json.return_value = {
'id': 'album-1',
'albumName': 'Trip Photos',
}
mock_get.return_value = mock_get_response
mock_post_response = MagicMock()
mock_post_response.ok = True
mock_post_response.json.return_value = {
'assets': {
'items': [{'id': 'asset-1'}, {'id': 'asset-2'}],
}
}
mock_post.return_value = mock_post_response
request = self.factory.get('/api/integrations/immich/albums/album-1/')
force_authenticate(request, user=self.user)
response = self.album_view(request, albumid='album-1')
self.assertEqual(response.status_code, 200)
mock_post.assert_called_once()
_, kwargs = mock_post.call_args
self.assertEqual(kwargs['json']['albumIds'], ['album-1'])
self.assertEqual(len(response.data['results']), 2)
@patch('integrations.views.immich_view.requests.get')
def test_album_uses_inline_assets_when_available(self, mock_get):
mock_get_response = MagicMock()
mock_get_response.json.return_value = {
'id': 'album-1',
'albumName': 'Trip Photos',
'assets': [{'id': 'asset-legacy-1'}],
}
mock_get.return_value = mock_get_response
request = self.factory.get('/api/integrations/immich/albums/album-1/')
force_authenticate(request, user=self.user)
with patch('integrations.views.immich_view.requests.post') as mock_post:
response = self.album_view(request, albumid='album-1')
self.assertEqual(response.status_code, 200)
mock_post.assert_not_called()
self.assertEqual(len(response.data['results']), 1)

View File

@@ -1,4 +1,6 @@
import os
from datetime import datetime, timedelta
from rest_framework.response import Response
from rest_framework import viewsets, status
from integrations.serializers import ImmichIntegrationSerializer
@@ -14,6 +16,8 @@ import logging
logger = logging.getLogger(__name__)
IMMICH_SEARCH_SIZE = 1000
class ImmichIntegrationView(viewsets.ViewSet):
permission_classes = [IsAuthenticated]
pagination_class = StandardResultsSetPagination
@@ -48,6 +52,78 @@ class ImmichIntegrationView(viewsets.ViewSet):
return user_integrations.first()
def _immich_connection_error(self):
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
def _immich_no_items_error(self, code='immich.no_items_found', message='No items found.'):
return Response(
{
'message': message,
'error': True,
'code': code
},
status=status.HTTP_404_NOT_FOUND
)
def _build_date_range_arguments(self, date):
selected_date = datetime.strptime(date, '%Y-%m-%d')
next_day = selected_date + timedelta(days=1)
return {
'takenAfter': f'{selected_date.strftime("%Y-%m-%d")}T00:00:00.000Z',
'takenBefore': f'{next_day.strftime("%Y-%m-%d")}T00:00:00.000Z',
}
def _paginate_immich_assets(self, integration, request, items):
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
for item in items:
item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
paginator = self.pagination_class()
result_page = paginator.paginate_queryset(items, request)
return paginator.get_paginated_response(result_page)
def _fetch_immich_search_assets(
self,
integration,
request,
arguments,
search_type='metadata',
not_found_code='immich.no_items_found',
):
try:
url = f'{integration.server_url}/search/{search_type}'
immich_fetch = requests.post(
url,
headers={'x-api-key': integration.api_key},
json={'size': IMMICH_SEARCH_SIZE, **arguments},
)
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return self._immich_connection_error()
if not immich_fetch.ok:
logger.warning(
'Immich search request failed: status=%s response=%s',
immich_fetch.status_code,
res,
)
return self._immich_no_items_error(code=not_found_code)
if 'assets' in res and 'items' in res['assets']:
items = res['assets']['items']
if items:
return self._paginate_immich_assets(integration, request, items)
return self._immich_no_items_error(code=not_found_code)
@action(detail=False, methods=['get'], url_path='search')
def search(self, request):
"""
@@ -75,16 +151,8 @@ class ImmichIntegrationView(viewsets.ViewSet):
if query:
arguments['query'] = query
if date:
# Create date range for the entire selected day
from datetime import datetime, timedelta
try:
# Parse the date and create start/end of day
selected_date = datetime.strptime(date, '%Y-%m-%d')
start_of_day = selected_date.strftime('%Y-%m-%d')
end_of_day = (selected_date + timedelta(days=1)).strftime('%Y-%m-%d')
arguments['takenAfter'] = start_of_day
arguments['takenBefore'] = end_of_day
arguments.update(self._build_date_range_arguments(date))
except ValueError:
return Response(
{
@@ -95,43 +163,12 @@ class ImmichIntegrationView(viewsets.ViewSet):
status=status.HTTP_400_BAD_REQUEST
)
# check so if the server is down, it does not tweak out like a madman and crash the server with a 500 error code
try:
url = f'{integration.server_url}/search/{"smart" if query else "metadata"}'
immich_fetch = requests.post(url, headers={
'x-api-key': integration.api_key
},
json = arguments
)
res = immich_fetch.json()
except requests.exceptions.ConnectionError:
return Response(
{
'message': 'The Immich server is currently down or unreachable.',
'error': True,
'code': 'immich.server_down'
},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
if 'assets' in res and 'items' in res['assets']:
paginator = self.pagination_class()
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
for item in res['assets']['items']:
item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
result_page = paginator.paginate_queryset(res['assets']['items'], request)
return paginator.get_paginated_response(result_page)
else:
return Response(
{
'message': 'No items found.',
'error': True,
'code': 'immich.no_items_found'
},
status=status.HTTP_404_NOT_FOUND
)
return self._fetch_immich_search_assets(
integration,
request,
arguments,
search_type='smart' if query else 'metadata',
)
@action(detail=False, methods=['get'])
def albums(self, request):
@@ -171,7 +208,6 @@ class ImmichIntegrationView(viewsets.ViewSet):
"""
# Check for integration before proceeding
integration = self.check_integration(request)
print(integration.user)
if isinstance(integration, Response):
return integration
@@ -201,24 +237,17 @@ class ImmichIntegrationView(viewsets.ViewSet):
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
if 'assets' in res:
paginator = self.pagination_class()
# for each item in the items, we need to add the image url to the item so we can display it in the frontend
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
public_url = public_url.replace("'", "")
for item in res['assets']:
item['image_url'] = f'{public_url}/api/integrations/immich/{integration.id}/get/{item["id"]}'
result_page = paginator.paginate_queryset(res['assets'], request)
return paginator.get_paginated_response(result_page)
else:
return Response(
{
'message': 'No assets found in this album.',
'error': True,
'code': 'immich.no_assets_found'
},
status=status.HTTP_404_NOT_FOUND
)
album_assets = res.get('assets')
if isinstance(album_assets, list) and album_assets:
return self._paginate_immich_assets(integration, request, album_assets)
return self._fetch_immich_search_assets(
integration,
request,
{'albumIds': [albumid]},
search_type='metadata',
not_found_code='immich.no_assets_found',
)
@action(
detail=False,

View File

@@ -1,4 +1,4 @@
export let appVersion = 'v0.13.0-beta-072026';
export let appVersion = 'v0.13.0-beta-072126';
export let appCodename = 'Acadia';
export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.1';
export let appTitle = 'AdventureLog';