diff --git a/backend/server/integrations/tests/test_immich_view.py b/backend/server/integrations/tests/test_immich_view.py index 95479619..698e0a0c 100644 --- a/backend/server/integrations/tests/test_immich_view.py +++ b/backend/server/integrations/tests/test_immich_view.py @@ -43,6 +43,9 @@ class ImmichViewTests(TestCase): _, 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') + self.assertEqual(kwargs['json']['visibility'], 'timeline') + self.assertEqual(kwargs['json']['page'], 1) + self.assertEqual(kwargs['json']['size'], 1000) @patch('integrations.views.immich_view.requests.get') @patch('integrations.views.immich_view.requests.post') @@ -72,8 +75,53 @@ class ImmichViewTests(TestCase): mock_post.assert_called_once() _, kwargs = mock_post.call_args self.assertEqual(kwargs['json']['albumIds'], ['album-1']) + self.assertEqual(kwargs['json']['visibility'], 'timeline') self.assertEqual(len(response.data['results']), 2) + @patch('integrations.views.immich_view.requests.get') + @patch('integrations.views.immich_view.requests.post') + def test_album_metadata_search_paginates_past_first_page(self, mock_post, mock_get): + mock_get_response = MagicMock() + mock_get_response.json.return_value = { + 'id': 'album-1', + 'albumName': 'Large Album', + } + mock_get.return_value = mock_get_response + + first_page_response = MagicMock() + first_page_response.ok = True + first_page_response.json.return_value = { + 'assets': { + 'items': [{'id': f'asset-{index}'} for index in range(1000)], + 'nextPage': '2', + } + } + + second_page_response = MagicMock() + second_page_response.ok = True + second_page_response.json.return_value = { + 'assets': { + 'items': [{'id': 'asset-1000'}, {'id': 'asset-1001'}], + 'nextPage': None, + } + } + + mock_post.side_effect = [first_page_response, second_page_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) + self.assertEqual(mock_post.call_count, 2) + first_call_kwargs = mock_post.call_args_list[0].kwargs + second_call_kwargs = mock_post.call_args_list[1].kwargs + self.assertEqual(first_call_kwargs['json']['page'], 1) + self.assertEqual(second_call_kwargs['json']['page'], 2) + self.assertEqual(len(response.data['results']), 25) + self.assertEqual(response.data['count'], 1002) + @patch('integrations.views.immich_view.requests.get') def test_album_uses_inline_assets_when_available(self, mock_get): mock_get_response = MagicMock() diff --git a/backend/server/integrations/views/immich_view.py b/backend/server/integrations/views/immich_view.py index 260f3930..fb3605ab 100644 --- a/backend/server/integrations/views/immich_view.py +++ b/backend/server/integrations/views/immich_view.py @@ -17,6 +17,7 @@ import logging logger = logging.getLogger(__name__) IMMICH_SEARCH_SIZE = 1000 +IMMICH_SEARCH_VISIBILITY = 'timeline' class ImmichIntegrationView(viewsets.ViewSet): permission_classes = [IsAuthenticated] @@ -98,29 +99,60 @@ class ImmichIntegrationView(viewsets.ViewSet): 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() + url = f'{integration.server_url}/search/{search_type}' + items = [] + page = 1 - 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) + while True: + try: + immich_fetch = requests.post( + url, + headers={'x-api-key': integration.api_key}, + json={ + 'size': IMMICH_SEARCH_SIZE, + 'page': page, + 'visibility': IMMICH_SEARCH_VISIBILITY, + **arguments, + }, + ) + res = immich_fetch.json() + except requests.exceptions.ConnectionError: + return self._immich_connection_error() - if 'assets' in res and 'items' in res['assets']: - items = res['assets']['items'] - if items: - return self._paginate_immich_assets(integration, request, items) + 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) + + assets = res.get('assets') + if not isinstance(assets, dict): + break + + page_items = assets.get('items') or [] + if page_items: + items.extend(page_items) + + next_page = assets.get('nextPage') + if next_page: + try: + page = int(next_page) + continue + except (TypeError, ValueError): + logger.warning( + 'Immich search returned invalid nextPage value: %s', + next_page, + ) + + if len(page_items) < IMMICH_SEARCH_SIZE: + break + + page += 1 + + if items: + return self._paginate_immich_assets(integration, request, items) return self._immich_no_items_error(code=not_found_code) diff --git a/frontend/src/lib/config.ts b/frontend/src/lib/config.ts index 93b54a0c..c1bf9063 100644 --- a/frontend/src/lib/config.ts +++ b/frontend/src/lib/config.ts @@ -1,4 +1,4 @@ -export let appVersion = 'v0.13.0-beta-072126'; +export let appVersion = 'v0.13.0-beta-072226'; export let appCodename = 'Acadia'; export let versionChangelog = 'https://github.com/seanmorley15/AdventureLog/releases/tag/v0.12.1'; export let appTitle = 'AdventureLog';