mirror of
				https://github.com/immich-app/immich.git
				synced 2025-10-31 18:58:56 -04:00 
			
		
		
		
	Fixes https://github.com/immich-app/immich/issues/5982. There are basically three options: 1. Search `originalFileName` by dropping a file extension from the query (if present). Lower fidelity but very easy - just a standard index & equality. 2. Search `originalPath` by adding an index on `reverse(originalPath)` and using `starts_with(reverse(query) + "/", reverse(originalPath)`. A weird index & query but high fidelity. 3. Add a new generated column called `originalFileNameWithExtension` or something. More storage, kinda jank. TBH, I think (1) is good enough and easy to make better in the future. For example, if I search "DSC_4242.jpg", I don't really think it matters if "DSC_4242.mov" also shows up. edit: There's a fourth approach that we discussed a bit in Discord and decided we could switch to it in the future: using a GIN. The minor issue is that Postgres doesn't tokenize paths in a useful (they're a single token and it won't match against partial components). We can solve that by tokenizing it ourselves. For example: ``` immich=# with vecs as (select to_tsvector('simple', array_to_string(string_to_array('upload/library/sushain/2015/2015-08-09/IMG_275.JPG', '/'), ' ')) as vec) select * from vecs where vec @@ phraseto_tsquery('simple', array_to_string(string_to_array('library/sushain', '/'), ' ')); vec ------------------------------------------------------------------------------- '-08':6 '-09':7 '2015':4,5 'img_275.jpg':8 'library':2 'sushain':3 'upload':1 (1 row) ``` The query is also tokenized with the 'split-by-slash-join-with-space' strategy. This strategy results in `IMG_275.JPG`, `2015`, `sushain` and `library/sushain` matching. But, `08` and `IMG_275` do not match. The former is because the token is `-08` and the latter because the `img_275.jpg` token is matched against exactly.
		
			
				
	
	
		
			293 lines
		
	
	
		
			8.0 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			293 lines
		
	
	
		
			8.0 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {
 | |
|   AssetResponseDto,
 | |
|   IAssetRepository,
 | |
|   ISmartInfoRepository,
 | |
|   LibraryResponseDto,
 | |
|   LoginResponseDto,
 | |
|   mapAsset,
 | |
| } from '@app/domain';
 | |
| import { SearchController } from '@app/immich';
 | |
| import { INestApplication } from '@nestjs/common';
 | |
| import { errorStub, searchStub } from '@test/fixtures';
 | |
| import request from 'supertest';
 | |
| import { api } from '../client';
 | |
| import { generateAsset, testApp } from '../utils';
 | |
| 
 | |
| describe(`${SearchController.name}`, () => {
 | |
|   let app: INestApplication;
 | |
|   let server: any;
 | |
|   let loginResponse: LoginResponseDto;
 | |
|   let accessToken: string;
 | |
|   let libraries: LibraryResponseDto[];
 | |
|   let assetRepository: IAssetRepository;
 | |
|   let smartInfoRepository: ISmartInfoRepository;
 | |
|   let asset1: AssetResponseDto;
 | |
| 
 | |
|   beforeAll(async () => {
 | |
|     app = await testApp.create();
 | |
|     server = app.getHttpServer();
 | |
|     assetRepository = app.get<IAssetRepository>(IAssetRepository);
 | |
|     smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
 | |
|   });
 | |
| 
 | |
|   afterAll(async () => {
 | |
|     await testApp.teardown();
 | |
|   });
 | |
| 
 | |
|   beforeEach(async () => {
 | |
|     await testApp.reset();
 | |
|     await api.authApi.adminSignUp(server);
 | |
|     loginResponse = await api.authApi.adminLogin(server);
 | |
|     accessToken = loginResponse.accessToken;
 | |
|     libraries = await api.libraryApi.getAll(server, accessToken);
 | |
|   });
 | |
| 
 | |
|   describe('GET /search (exif)', () => {
 | |
|     beforeEach(async () => {
 | |
|       const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
 | |
|       await assetRepository.upsertExif({ assetId, ...searchStub.exif });
 | |
| 
 | |
|       const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
 | |
|       if (!assetWithMetadata) {
 | |
|         throw new Error('Asset not found');
 | |
|       }
 | |
|       asset1 = mapAsset(assetWithMetadata);
 | |
|     });
 | |
| 
 | |
|     it('should require authentication', async () => {
 | |
|       const { status, body } = await request(server).get('/search');
 | |
| 
 | |
|       expect(status).toBe(401);
 | |
|       expect(body).toEqual(errorStub.unauthorized);
 | |
|     });
 | |
| 
 | |
|     it('should return assets when searching by exif', async () => {
 | |
|       if (!asset1?.exifInfo?.make) {
 | |
|         throw new Error('Asset 1 does not have exif info');
 | |
|       }
 | |
| 
 | |
|       const { status, body } = await request(server)
 | |
|         .get('/search')
 | |
|         .set('Authorization', `Bearer ${accessToken}`)
 | |
|         .query({ q: asset1.exifInfo.make });
 | |
| 
 | |
|       expect(status).toBe(200);
 | |
|       expect(body).toMatchObject({
 | |
|         albums: {
 | |
|           total: 0,
 | |
|           count: 0,
 | |
|           items: [],
 | |
|           facets: [],
 | |
|         },
 | |
|         assets: {
 | |
|           total: 1,
 | |
|           count: 1,
 | |
|           items: [
 | |
|             {
 | |
|               id: asset1.id,
 | |
|               exifInfo: {
 | |
|                 make: asset1.exifInfo.make,
 | |
|               },
 | |
|             },
 | |
|           ],
 | |
|           facets: [],
 | |
|         },
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     it('should be case-insensitive for metadata search', async () => {
 | |
|       if (!asset1?.exifInfo?.make) {
 | |
|         throw new Error('Asset 1 does not have exif info');
 | |
|       }
 | |
| 
 | |
|       const { status, body } = await request(server)
 | |
|         .get('/search')
 | |
|         .set('Authorization', `Bearer ${accessToken}`)
 | |
|         .query({ q: asset1.exifInfo.make.toLowerCase() });
 | |
| 
 | |
|       expect(status).toBe(200);
 | |
|       expect(body).toMatchObject({
 | |
|         albums: {
 | |
|           total: 0,
 | |
|           count: 0,
 | |
|           items: [],
 | |
|           facets: [],
 | |
|         },
 | |
|         assets: {
 | |
|           total: 1,
 | |
|           count: 1,
 | |
|           items: [
 | |
|             {
 | |
|               id: asset1.id,
 | |
|               exifInfo: {
 | |
|                 make: asset1.exifInfo.make,
 | |
|               },
 | |
|             },
 | |
|           ],
 | |
|           facets: [],
 | |
|         },
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     it('should be whitespace-insensitive for metadata search', async () => {
 | |
|       if (!asset1?.exifInfo?.make) {
 | |
|         throw new Error('Asset 1 does not have exif info');
 | |
|       }
 | |
| 
 | |
|       const { status, body } = await request(server)
 | |
|         .get('/search')
 | |
|         .set('Authorization', `Bearer ${accessToken}`)
 | |
|         .query({ q: ` ${asset1.exifInfo.make}  ` });
 | |
| 
 | |
|       expect(status).toBe(200);
 | |
|       expect(body).toMatchObject({
 | |
|         albums: {
 | |
|           total: 0,
 | |
|           count: 0,
 | |
|           items: [],
 | |
|           facets: [],
 | |
|         },
 | |
|         assets: {
 | |
|           total: 1,
 | |
|           count: 1,
 | |
|           items: [
 | |
|             {
 | |
|               id: asset1.id,
 | |
|               exifInfo: {
 | |
|                 make: asset1.exifInfo.make,
 | |
|               },
 | |
|             },
 | |
|           ],
 | |
|           facets: [],
 | |
|         },
 | |
|       });
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('GET /search (smart info)', () => {
 | |
|     beforeEach(async () => {
 | |
|       const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
 | |
|       await assetRepository.upsertExif({ assetId, ...searchStub.exif });
 | |
|       await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
 | |
| 
 | |
|       const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
 | |
|       if (!assetWithMetadata) {
 | |
|         throw new Error('Asset not found');
 | |
|       }
 | |
|       asset1 = mapAsset(assetWithMetadata);
 | |
|     });
 | |
| 
 | |
|     it('should return assets when searching by object', async () => {
 | |
|       if (!asset1?.smartInfo?.objects) {
 | |
|         throw new Error('Asset 1 does not have smart info');
 | |
|       }
 | |
| 
 | |
|       const { status, body } = await request(server)
 | |
|         .get('/search')
 | |
|         .set('Authorization', `Bearer ${accessToken}`)
 | |
|         .query({ q: asset1.smartInfo.objects[0] });
 | |
| 
 | |
|       expect(status).toBe(200);
 | |
|       expect(body).toMatchObject({
 | |
|         albums: {
 | |
|           total: 0,
 | |
|           count: 0,
 | |
|           items: [],
 | |
|           facets: [],
 | |
|         },
 | |
|         assets: {
 | |
|           total: 1,
 | |
|           count: 1,
 | |
|           items: [
 | |
|             {
 | |
|               id: asset1.id,
 | |
|               smartInfo: {
 | |
|                 objects: asset1.smartInfo.objects,
 | |
|                 tags: asset1.smartInfo.tags,
 | |
|               },
 | |
|             },
 | |
|           ],
 | |
|           facets: [],
 | |
|         },
 | |
|       });
 | |
|     });
 | |
|   });
 | |
| 
 | |
|   describe('GET /search (file name)', () => {
 | |
|     beforeEach(async () => {
 | |
|       const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
 | |
|       await assetRepository.upsertExif({ assetId, ...searchStub.exif });
 | |
| 
 | |
|       const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
 | |
|       if (!assetWithMetadata) {
 | |
|         throw new Error('Asset not found');
 | |
|       }
 | |
|       asset1 = mapAsset(assetWithMetadata);
 | |
|     });
 | |
| 
 | |
|     it('should return assets when searching by file name', async () => {
 | |
|       if (asset1?.originalFileName.length === 0) {
 | |
|         throw new Error('Asset 1 does not have an original file name');
 | |
|       }
 | |
| 
 | |
|       const { status, body } = await request(server)
 | |
|         .get('/search')
 | |
|         .set('Authorization', `Bearer ${accessToken}`)
 | |
|         .query({ q: asset1.originalFileName });
 | |
| 
 | |
|       expect(status).toBe(200);
 | |
|       expect(body).toMatchObject({
 | |
|         albums: {
 | |
|           total: 0,
 | |
|           count: 0,
 | |
|           items: [],
 | |
|           facets: [],
 | |
|         },
 | |
|         assets: {
 | |
|           total: 1,
 | |
|           count: 1,
 | |
|           items: [
 | |
|             {
 | |
|               id: asset1.id,
 | |
|               originalFileName: asset1.originalFileName,
 | |
|             },
 | |
|           ],
 | |
|           facets: [],
 | |
|         },
 | |
|       });
 | |
|     });
 | |
| 
 | |
|     it('should return assets when searching by file name with extension', async () => {
 | |
|       if (asset1?.originalFileName.length === 0) {
 | |
|         throw new Error('Asset 1 does not have an original file name');
 | |
|       }
 | |
| 
 | |
|       const { status, body } = await request(server)
 | |
|         .get('/search')
 | |
|         .set('Authorization', `Bearer ${accessToken}`)
 | |
|         .query({ q: asset1.originalFileName + '.jpg' });
 | |
| 
 | |
|       expect(status).toBe(200);
 | |
|       expect(body).toMatchObject({
 | |
|         albums: {
 | |
|           total: 0,
 | |
|           count: 0,
 | |
|           items: [],
 | |
|           facets: [],
 | |
|         },
 | |
|         assets: {
 | |
|           total: 1,
 | |
|           count: 1,
 | |
|           items: [
 | |
|             {
 | |
|               id: asset1.id,
 | |
|               originalFileName: asset1.originalFileName,
 | |
|             },
 | |
|           ],
 | |
|           facets: [],
 | |
|         },
 | |
|       });
 | |
|     });
 | |
|   });
 | |
| });
 |