2023-12-08 17:15:46 +01:00
|
|
|
import {
|
|
|
|
AssetResponseDto,
|
|
|
|
IAssetRepository,
|
|
|
|
ISmartInfoRepository,
|
|
|
|
LibraryResponseDto,
|
|
|
|
LoginResponseDto,
|
|
|
|
mapAsset,
|
|
|
|
} from '@app/domain';
|
|
|
|
import { SearchController } from '@app/immich';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
2023-12-18 03:16:08 +01:00
|
|
|
import { errorStub, searchStub } from '@test/fixtures';
|
2023-12-08 17:15:46 +01:00
|
|
|
import request from 'supertest';
|
2024-01-24 23:24:53 +01:00
|
|
|
import { api } from '../../client';
|
2024-01-10 05:04:16 +01:00
|
|
|
import { generateAsset, testApp } from '../utils';
|
2023-12-08 17:15:46 +01:00
|
|
|
|
|
|
|
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 () => {
|
2023-12-19 03:29:26 +01:00
|
|
|
app = await testApp.create();
|
|
|
|
server = app.getHttpServer();
|
2023-12-08 17:15:46 +01:00
|
|
|
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);
|
|
|
|
});
|
|
|
|
|
2023-12-18 03:16:08 +01:00
|
|
|
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);
|
|
|
|
});
|
2023-12-08 17:15:46 +01:00
|
|
|
|
|
|
|
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: [],
|
|
|
|
},
|
|
|
|
});
|
|
|
|
});
|
2023-12-18 03:16:08 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
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);
|
|
|
|
});
|
2023-12-08 17:15:46 +01:00
|
|
|
|
|
|
|
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: [],
|
|
|
|
},
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
fix(server): add filename search (#6394)
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.
2024-01-15 21:40:28 +01:00
|
|
|
|
|
|
|
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: [],
|
|
|
|
},
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
2023-12-08 17:15:46 +01:00
|
|
|
});
|