mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	Add asset repository and refactor asset service (#540)
* build endpoint to get asset count by month * Added asset repository * Added create asset * get asset by device ID * Added test for existing methods * Refactor additional endpoint * Refactor database api to get curated locations and curated objects * Refactor get search properties * Fixed cookies parsing for websocket * Added API to get asset count by time group * Remove unused code
This commit is contained in:
		
							
								
								
									
										187
									
								
								server/apps/immich/src/api-v1/asset/asset-repository.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								server/apps/immich/src/api-v1/asset/asset-repository.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,187 @@
 | 
			
		||||
import { SearchPropertiesDto } from './dto/search-properties.dto';
 | 
			
		||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 | 
			
		||||
import { BadRequestException, Injectable } from '@nestjs/common';
 | 
			
		||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { Repository } from 'typeorm/repository/Repository';
 | 
			
		||||
import { CreateAssetDto } from './dto/create-asset.dto';
 | 
			
		||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 | 
			
		||||
import { AssetCountByTimeGroupDto } from './response-dto/asset-count-by-time-group-response.dto';
 | 
			
		||||
import { TimeGroupEnum } from './dto/get-asset-count-by-time-group.dto';
 | 
			
		||||
 | 
			
		||||
export interface IAssetRepository {
 | 
			
		||||
  create(createAssetDto: CreateAssetDto, ownerId: string, originalPath: string, mimeType: string): Promise<AssetEntity>;
 | 
			
		||||
  getAllByUserId(userId: string): Promise<AssetEntity[]>;
 | 
			
		||||
  getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
 | 
			
		||||
  getById(assetId: string): Promise<AssetEntity>;
 | 
			
		||||
  getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]>;
 | 
			
		||||
  getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]>;
 | 
			
		||||
  getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
 | 
			
		||||
  getAssetCountByTimeGroup(userId: string, timeGroup: TimeGroupEnum): Promise<AssetCountByTimeGroupDto[]>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class AssetRepository implements IAssetRepository {
 | 
			
		||||
  constructor(
 | 
			
		||||
    @InjectRepository(AssetEntity)
 | 
			
		||||
    private assetRepository: Repository<AssetEntity>,
 | 
			
		||||
  ) {}
 | 
			
		||||
  async getAssetCountByTimeGroup(userId: string, timeGroup: TimeGroupEnum) {
 | 
			
		||||
    let result: AssetCountByTimeGroupDto[] = [];
 | 
			
		||||
 | 
			
		||||
    if (timeGroup === TimeGroupEnum.Month) {
 | 
			
		||||
      result = await this.assetRepository
 | 
			
		||||
        .createQueryBuilder('asset')
 | 
			
		||||
        .select(`COUNT(asset.id)::int`, 'count')
 | 
			
		||||
        .addSelect(`to_char(date_trunc('month', "createdAt"::timestamptz), 'YYYY_MM')`, 'timeGroup')
 | 
			
		||||
        .where('"userId" = :userId', { userId: userId })
 | 
			
		||||
        .groupBy(`date_trunc('month', "createdAt"::timestamptz)`)
 | 
			
		||||
        .orderBy(`date_trunc('month', "createdAt"::timestamptz)`, 'DESC')
 | 
			
		||||
        .getRawMany();
 | 
			
		||||
    } else if (timeGroup === TimeGroupEnum.Day) {
 | 
			
		||||
      result = await this.assetRepository
 | 
			
		||||
        .createQueryBuilder('asset')
 | 
			
		||||
        .select(`COUNT(asset.id)::int`, 'count')
 | 
			
		||||
        .addSelect(`to_char(date_trunc('day', "createdAt"::timestamptz), 'YYYY_MM_DD')`, 'timeGroup')
 | 
			
		||||
        .where('"userId" = :userId', { userId: userId })
 | 
			
		||||
        .groupBy(`date_trunc('day', "createdAt"::timestamptz)`)
 | 
			
		||||
        .orderBy(`date_trunc('day', "createdAt"::timestamptz)`, 'DESC')
 | 
			
		||||
        .getRawMany();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
 | 
			
		||||
    return await this.assetRepository
 | 
			
		||||
      .createQueryBuilder('asset')
 | 
			
		||||
      .where('asset.userId = :userId', { userId: userId })
 | 
			
		||||
      .leftJoin('asset.exifInfo', 'ei')
 | 
			
		||||
      .leftJoin('asset.smartInfo', 'si')
 | 
			
		||||
      .select('si.tags', 'tags')
 | 
			
		||||
      .addSelect('si.objects', 'objects')
 | 
			
		||||
      .addSelect('asset.type', 'assetType')
 | 
			
		||||
      .addSelect('ei.orientation', 'orientation')
 | 
			
		||||
      .addSelect('ei."lensModel"', 'lensModel')
 | 
			
		||||
      .addSelect('ei.make', 'make')
 | 
			
		||||
      .addSelect('ei.model', 'model')
 | 
			
		||||
      .addSelect('ei.city', 'city')
 | 
			
		||||
      .addSelect('ei.state', 'state')
 | 
			
		||||
      .addSelect('ei.country', 'country')
 | 
			
		||||
      .distinctOn(['si.tags'])
 | 
			
		||||
      .getRawMany();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getDetectedObjectsByUserId(userId: string): Promise<CuratedObjectsResponseDto[]> {
 | 
			
		||||
    return await this.assetRepository.query(
 | 
			
		||||
      `
 | 
			
		||||
        SELECT DISTINCT ON (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
 | 
			
		||||
        FROM assets a
 | 
			
		||||
        LEFT JOIN smart_info si ON a.id = si."assetId"
 | 
			
		||||
        WHERE a."userId" = $1
 | 
			
		||||
        AND si.objects IS NOT NULL
 | 
			
		||||
      `,
 | 
			
		||||
      [userId],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getLocationsByUserId(userId: string): Promise<CuratedLocationsResponseDto[]> {
 | 
			
		||||
    return await this.assetRepository.query(
 | 
			
		||||
      `
 | 
			
		||||
        SELECT DISTINCT ON (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
 | 
			
		||||
        FROM assets a
 | 
			
		||||
        LEFT JOIN exif e ON a.id = e."assetId"
 | 
			
		||||
        WHERE a."userId" = $1
 | 
			
		||||
        AND e.city IS NOT NULL
 | 
			
		||||
        AND a.type = 'IMAGE';
 | 
			
		||||
      `,
 | 
			
		||||
      [userId],
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get a single asset information by its ID
 | 
			
		||||
   * - include exif info
 | 
			
		||||
   * @param assetId
 | 
			
		||||
   */
 | 
			
		||||
  async getById(assetId: string): Promise<AssetEntity> {
 | 
			
		||||
    return await this.assetRepository.findOneOrFail({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: assetId,
 | 
			
		||||
      },
 | 
			
		||||
      relations: ['exifInfo'],
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get all assets belong to the user on the database
 | 
			
		||||
   * @param userId
 | 
			
		||||
   */
 | 
			
		||||
  async getAllByUserId(userId: string): Promise<AssetEntity[]> {
 | 
			
		||||
    const query = this.assetRepository
 | 
			
		||||
      .createQueryBuilder('asset')
 | 
			
		||||
      .where('asset.userId = :userId', { userId: userId })
 | 
			
		||||
      .andWhere('asset.resizePath is not NULL')
 | 
			
		||||
      .leftJoinAndSelect('asset.exifInfo', 'exifInfo')
 | 
			
		||||
      .orderBy('asset.createdAt', 'DESC');
 | 
			
		||||
 | 
			
		||||
    return await query.getMany();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Create new asset information in database
 | 
			
		||||
   * @param createAssetDto
 | 
			
		||||
   * @param ownerId
 | 
			
		||||
   * @param originalPath
 | 
			
		||||
   * @param mimeType
 | 
			
		||||
   * @returns Promise<AssetEntity>
 | 
			
		||||
   */
 | 
			
		||||
  async create(
 | 
			
		||||
    createAssetDto: CreateAssetDto,
 | 
			
		||||
    ownerId: string,
 | 
			
		||||
    originalPath: string,
 | 
			
		||||
    mimeType: string,
 | 
			
		||||
  ): Promise<AssetEntity> {
 | 
			
		||||
    const asset = new AssetEntity();
 | 
			
		||||
    asset.deviceAssetId = createAssetDto.deviceAssetId;
 | 
			
		||||
    asset.userId = ownerId;
 | 
			
		||||
    asset.deviceId = createAssetDto.deviceId;
 | 
			
		||||
    asset.type = createAssetDto.assetType || AssetType.OTHER;
 | 
			
		||||
    asset.originalPath = originalPath;
 | 
			
		||||
    asset.createdAt = createAssetDto.createdAt;
 | 
			
		||||
    asset.modifiedAt = createAssetDto.modifiedAt;
 | 
			
		||||
    asset.isFavorite = createAssetDto.isFavorite;
 | 
			
		||||
    asset.mimeType = mimeType;
 | 
			
		||||
    asset.duration = createAssetDto.duration || null;
 | 
			
		||||
 | 
			
		||||
    const createdAsset = await this.assetRepository.save(asset);
 | 
			
		||||
 | 
			
		||||
    if (!createdAsset) {
 | 
			
		||||
      throw new BadRequestException('Asset not created');
 | 
			
		||||
    }
 | 
			
		||||
    return createdAsset;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get assets by device's Id on the database
 | 
			
		||||
   * @param userId
 | 
			
		||||
   * @param deviceId
 | 
			
		||||
   *
 | 
			
		||||
   * @returns Promise<string[]> - Array of assetIds belong to the device
 | 
			
		||||
   */
 | 
			
		||||
  async getAllByDeviceId(userId: string, deviceId: string): Promise<string[]> {
 | 
			
		||||
    const rows = await this.assetRepository.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId: userId,
 | 
			
		||||
        deviceId: deviceId,
 | 
			
		||||
      },
 | 
			
		||||
      select: ['deviceAssetId'],
 | 
			
		||||
    });
 | 
			
		||||
    const res: string[] = [];
 | 
			
		||||
    rows.forEach((v) => res.push(v.deviceAssetId));
 | 
			
		||||
 | 
			
		||||
    return res;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@@ -2,7 +2,6 @@ import {
 | 
			
		||||
  Controller,
 | 
			
		||||
  Post,
 | 
			
		||||
  UseInterceptors,
 | 
			
		||||
  UploadedFiles,
 | 
			
		||||
  Body,
 | 
			
		||||
  UseGuards,
 | 
			
		||||
  Get,
 | 
			
		||||
@@ -44,6 +43,8 @@ import { CreateAssetDto } from './dto/create-asset.dto';
 | 
			
		||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
 | 
			
		||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
 | 
			
		||||
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
 | 
			
		||||
import { AssetCountByTimeGroupResponseDto } from './response-dto/asset-count-by-time-group-response.dto';
 | 
			
		||||
import { GetAssetCountByTimeGroupDto } from './dto/get-asset-count-by-time-group.dto';
 | 
			
		||||
 | 
			
		||||
@UseGuards(JwtAuthGuard)
 | 
			
		||||
@ApiBearerAuth()
 | 
			
		||||
@@ -117,17 +118,17 @@ export class AssetController {
 | 
			
		||||
    return this.assetService.getAssetThumbnail(assetId, query);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('/allObjects')
 | 
			
		||||
  @Get('/curated-objects')
 | 
			
		||||
  async getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
 | 
			
		||||
    return this.assetService.getCuratedObject(authUser);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('/allLocation')
 | 
			
		||||
  @Get('/curated-locations')
 | 
			
		||||
  async getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
 | 
			
		||||
    return this.assetService.getCuratedLocation(authUser);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('/searchTerm')
 | 
			
		||||
  @Get('/search-terms')
 | 
			
		||||
  async getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise<string[]> {
 | 
			
		||||
    return this.assetService.getAssetSearchTerm(authUser);
 | 
			
		||||
  }
 | 
			
		||||
@@ -140,6 +141,14 @@ export class AssetController {
 | 
			
		||||
    return this.assetService.searchAsset(authUser, searchAssetDto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @Get('/count-by-date')
 | 
			
		||||
  async getAssetCountByTimeGroup(
 | 
			
		||||
    @GetAuthUser() authUser: AuthUserDto,
 | 
			
		||||
    @Body(ValidationPipe) getAssetCountByTimeGroupDto: GetAssetCountByTimeGroupDto,
 | 
			
		||||
  ): Promise<AssetCountByTimeGroupResponseDto> {
 | 
			
		||||
    return this.assetService.getAssetCountByTimeGroup(authUser, getAssetCountByTimeGroupDto);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get all AssetEntity belong to the user
 | 
			
		||||
   */
 | 
			
		||||
 
 | 
			
		||||
@@ -8,6 +8,7 @@ import { BackgroundTaskModule } from '../../modules/background-task/background-t
 | 
			
		||||
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
 | 
			
		||||
import { CommunicationModule } from '../communication/communication.module';
 | 
			
		||||
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
 | 
			
		||||
import { AssetRepository, ASSET_REPOSITORY } from './asset-repository';
 | 
			
		||||
 | 
			
		||||
@Module({
 | 
			
		||||
  imports: [
 | 
			
		||||
@@ -24,7 +25,14 @@ import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
 | 
			
		||||
    }),
 | 
			
		||||
  ],
 | 
			
		||||
  controllers: [AssetController],
 | 
			
		||||
  providers: [AssetService, BackgroundTaskService],
 | 
			
		||||
  providers: [
 | 
			
		||||
    AssetService,
 | 
			
		||||
    BackgroundTaskService,
 | 
			
		||||
    {
 | 
			
		||||
      provide: ASSET_REPOSITORY,
 | 
			
		||||
      useClass: AssetRepository,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
  exports: [AssetService],
 | 
			
		||||
})
 | 
			
		||||
export class AssetModule {}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										92
									
								
								server/apps/immich/src/api-v1/asset/asset.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								server/apps/immich/src/api-v1/asset/asset.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
			
		||||
import { AssetRepository, IAssetRepository } from './asset-repository';
 | 
			
		||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
			
		||||
import { AssetService } from './asset.service';
 | 
			
		||||
import { Repository } from 'typeorm';
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 | 
			
		||||
import { CreateAssetDto } from './dto/create-asset.dto';
 | 
			
		||||
 | 
			
		||||
describe('AssetService', () => {
 | 
			
		||||
  let sui: AssetService;
 | 
			
		||||
  let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
 | 
			
		||||
  let assetRepositoryMock: jest.Mocked<IAssetRepository>;
 | 
			
		||||
 | 
			
		||||
  const authUser: AuthUserDto = Object.freeze({
 | 
			
		||||
    id: '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd',
 | 
			
		||||
    email: 'auth@test.com',
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  const _getCreateAssetDto = (): CreateAssetDto => {
 | 
			
		||||
    const createAssetDto = new CreateAssetDto();
 | 
			
		||||
    createAssetDto.deviceAssetId = 'deviceAssetId';
 | 
			
		||||
    createAssetDto.deviceId = 'deviceId';
 | 
			
		||||
    createAssetDto.assetType = AssetType.OTHER;
 | 
			
		||||
    createAssetDto.createdAt = '2022-06-19T23:41:36.910Z';
 | 
			
		||||
    createAssetDto.modifiedAt = '2022-06-19T23:41:36.910Z';
 | 
			
		||||
    createAssetDto.isFavorite = false;
 | 
			
		||||
    createAssetDto.duration = '0:00:00.000000';
 | 
			
		||||
 | 
			
		||||
    return createAssetDto;
 | 
			
		||||
  };
 | 
			
		||||
  const _getAsset = () => {
 | 
			
		||||
    const assetEntity = new AssetEntity();
 | 
			
		||||
 | 
			
		||||
    assetEntity.id = 'e8edabfd-7d8a-45d0-9d61-7c7ca60f2c67';
 | 
			
		||||
    assetEntity.userId = '3ea54709-e168-42b7-90b0-a0dfe8a7ecbd';
 | 
			
		||||
    assetEntity.deviceAssetId = '4967046344801';
 | 
			
		||||
    assetEntity.deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
 | 
			
		||||
    assetEntity.type = AssetType.VIDEO;
 | 
			
		||||
    assetEntity.originalPath =
 | 
			
		||||
      'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
 | 
			
		||||
    assetEntity.resizePath = '';
 | 
			
		||||
    assetEntity.createdAt = '2022-06-19T23:41:36.910Z';
 | 
			
		||||
    assetEntity.modifiedAt = '2022-06-19T23:41:36.910Z';
 | 
			
		||||
    assetEntity.isFavorite = false;
 | 
			
		||||
    assetEntity.mimeType = 'image/jpeg';
 | 
			
		||||
    assetEntity.webpPath = '';
 | 
			
		||||
    assetEntity.encodedVideoPath = '';
 | 
			
		||||
    assetEntity.duration = '0:00:00.000000';
 | 
			
		||||
 | 
			
		||||
    return assetEntity;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  beforeAll(() => {
 | 
			
		||||
    assetRepositoryMock = {
 | 
			
		||||
      create: jest.fn(),
 | 
			
		||||
      getAllByUserId: jest.fn(),
 | 
			
		||||
      getAllByDeviceId: jest.fn(),
 | 
			
		||||
      getAssetCountByTimeGroup: jest.fn(),
 | 
			
		||||
      getById: jest.fn(),
 | 
			
		||||
      getDetectedObjectsByUserId: jest.fn(),
 | 
			
		||||
      getLocationsByUserId: jest.fn(),
 | 
			
		||||
      getSearchPropertiesByUserId: jest.fn(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    sui = new AssetService(assetRepositoryMock, a);
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('create an asset', async () => {
 | 
			
		||||
    const assetEntity = _getAsset();
 | 
			
		||||
 | 
			
		||||
    assetRepositoryMock.create.mockImplementation(() => Promise.resolve<AssetEntity>(assetEntity));
 | 
			
		||||
 | 
			
		||||
    const originalPath =
 | 
			
		||||
      'upload/3ea54709-e168-42b7-90b0-a0dfe8a7ecbd/original/116766fd-2ef2-52dc-a3ef-149988997291/51c97f95-244f-462d-bdf0-e1dc19913516.jpg';
 | 
			
		||||
    const mimeType = 'image/jpeg';
 | 
			
		||||
    const createAssetDto = _getCreateAssetDto();
 | 
			
		||||
    const result = await sui.createUserAsset(authUser, createAssetDto, originalPath, mimeType);
 | 
			
		||||
 | 
			
		||||
    expect(result.userId).toEqual(authUser.id);
 | 
			
		||||
    expect(result.resizePath).toEqual('');
 | 
			
		||||
    expect(result.webpPath).toEqual('');
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  it('get assets by device id', async () => {
 | 
			
		||||
    assetRepositoryMock.getAllByDeviceId.mockImplementation(() => Promise.resolve<string[]>(['4967046344801']));
 | 
			
		||||
 | 
			
		||||
    const deviceId = '116766fd-2ef2-52dc-a3ef-149988997291';
 | 
			
		||||
    const result = await sui.getUserAssetsByDeviceId(authUser, deviceId);
 | 
			
		||||
 | 
			
		||||
    expect(result.length).toEqual(1);
 | 
			
		||||
    expect(result[0]).toEqual('4967046344801');
 | 
			
		||||
  });
 | 
			
		||||
});
 | 
			
		||||
@@ -1,5 +1,7 @@
 | 
			
		||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
 | 
			
		||||
import {
 | 
			
		||||
  BadRequestException,
 | 
			
		||||
  Inject,
 | 
			
		||||
  Injectable,
 | 
			
		||||
  InternalServerErrorException,
 | 
			
		||||
  Logger,
 | 
			
		||||
@@ -7,7 +9,7 @@ import {
 | 
			
		||||
  StreamableFile,
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { IsNull, Not, Repository } from 'typeorm';
 | 
			
		||||
import { Repository } from 'typeorm';
 | 
			
		||||
import { AuthUserDto } from '../../decorators/auth-user.decorator';
 | 
			
		||||
import { AssetEntity, AssetType } from '@app/database/entities/asset.entity';
 | 
			
		||||
import { constants, createReadStream, ReadStream, stat } from 'fs';
 | 
			
		||||
@@ -25,83 +27,49 @@ import { CreateAssetDto } from './dto/create-asset.dto';
 | 
			
		||||
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
 | 
			
		||||
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
 | 
			
		||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
 | 
			
		||||
import { ASSET_REPOSITORY, IAssetRepository } from './asset-repository';
 | 
			
		||||
import { SearchPropertiesDto } from './dto/search-properties.dto';
 | 
			
		||||
import {
 | 
			
		||||
  AssetCountByTimeGroupResponseDto,
 | 
			
		||||
  mapAssetCountByTimeGroupResponse,
 | 
			
		||||
} from './response-dto/asset-count-by-time-group-response.dto';
 | 
			
		||||
import { GetAssetCountByTimeGroupDto } from './dto/get-asset-count-by-time-group.dto';
 | 
			
		||||
 | 
			
		||||
const fileInfo = promisify(stat);
 | 
			
		||||
 | 
			
		||||
@Injectable()
 | 
			
		||||
export class AssetService {
 | 
			
		||||
  constructor(
 | 
			
		||||
    @Inject(ASSET_REPOSITORY)
 | 
			
		||||
    private _assetRepository: IAssetRepository,
 | 
			
		||||
 | 
			
		||||
    @InjectRepository(AssetEntity)
 | 
			
		||||
    private assetRepository: Repository<AssetEntity>,
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  public async updateThumbnailInfo(asset: AssetEntity, thumbnailPath: string): Promise<AssetEntity> {
 | 
			
		||||
    const updatedAsset = await this.assetRepository
 | 
			
		||||
      .createQueryBuilder('assets')
 | 
			
		||||
      .update<AssetEntity>(AssetEntity, { ...asset, resizePath: thumbnailPath })
 | 
			
		||||
      .where('assets.id = :id', { id: asset.id })
 | 
			
		||||
      .returning('*')
 | 
			
		||||
      .updateEntity(true)
 | 
			
		||||
      .execute();
 | 
			
		||||
 | 
			
		||||
    return updatedAsset.raw[0];
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async createUserAsset(
 | 
			
		||||
    authUser: AuthUserDto,
 | 
			
		||||
    assetInfo: CreateAssetDto,
 | 
			
		||||
    path: string,
 | 
			
		||||
    createAssetDto: CreateAssetDto,
 | 
			
		||||
    originalPath: string,
 | 
			
		||||
    mimeType: string,
 | 
			
		||||
  ): Promise<AssetEntity | undefined> {
 | 
			
		||||
    const asset = new AssetEntity();
 | 
			
		||||
    asset.deviceAssetId = assetInfo.deviceAssetId;
 | 
			
		||||
    asset.userId = authUser.id;
 | 
			
		||||
    asset.deviceId = assetInfo.deviceId;
 | 
			
		||||
    asset.type = assetInfo.assetType || AssetType.OTHER;
 | 
			
		||||
    asset.originalPath = path;
 | 
			
		||||
    asset.createdAt = assetInfo.createdAt;
 | 
			
		||||
    asset.modifiedAt = assetInfo.modifiedAt;
 | 
			
		||||
    asset.isFavorite = assetInfo.isFavorite;
 | 
			
		||||
    asset.mimeType = mimeType;
 | 
			
		||||
    asset.duration = assetInfo.duration || null;
 | 
			
		||||
  ): Promise<AssetEntity> {
 | 
			
		||||
    const assetEntity = await this._assetRepository.create(createAssetDto, authUser.id, originalPath, mimeType);
 | 
			
		||||
 | 
			
		||||
    const createdAsset = await this.assetRepository.save(asset);
 | 
			
		||||
    if (!createdAsset) {
 | 
			
		||||
      throw new Error('Asset not created');
 | 
			
		||||
    }
 | 
			
		||||
    return createdAsset;
 | 
			
		||||
    return assetEntity;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
 | 
			
		||||
    const rows = await this.assetRepository.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId: authUser.id,
 | 
			
		||||
        deviceId: deviceId,
 | 
			
		||||
      },
 | 
			
		||||
      select: ['deviceAssetId'],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    const res: string[] = [];
 | 
			
		||||
    rows.forEach((v) => res.push(v.deviceAssetId));
 | 
			
		||||
    return res;
 | 
			
		||||
    return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getAllAssets(authUser: AuthUserDto): Promise<AssetResponseDto[]> {
 | 
			
		||||
    const assets = await this.assetRepository.find({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId: authUser.id,
 | 
			
		||||
        resizePath: Not(IsNull()),
 | 
			
		||||
      },
 | 
			
		||||
      relations: ['exifInfo'],
 | 
			
		||||
      order: {
 | 
			
		||||
        createdAt: 'DESC',
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
    const assets = await this._assetRepository.getAllByUserId(authUser.id);
 | 
			
		||||
 | 
			
		||||
    return assets.map((asset) => mapAsset(asset));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async findAssetOfDevice(deviceId: string, assetId: string): Promise<AssetResponseDto> {
 | 
			
		||||
  // TODO - Refactor this to get asset by its own id
 | 
			
		||||
  private async findAssetOfDevice(deviceId: string, assetId: string): Promise<AssetResponseDto> {
 | 
			
		||||
    const rows = await this.assetRepository.query(
 | 
			
		||||
      'SELECT * FROM assets a WHERE a."deviceAssetId" = $1 AND a."deviceId" = $2',
 | 
			
		||||
      [assetId, deviceId],
 | 
			
		||||
@@ -117,16 +85,7 @@ export class AssetService {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
 | 
			
		||||
    const asset = await this.assetRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        id: assetId,
 | 
			
		||||
      },
 | 
			
		||||
      relations: ['exifInfo'],
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    if (!asset) {
 | 
			
		||||
      throw new NotFoundException('Asset not found');
 | 
			
		||||
    }
 | 
			
		||||
    const asset = await this._assetRepository.getById(assetId);
 | 
			
		||||
 | 
			
		||||
    return mapAsset(asset);
 | 
			
		||||
  }
 | 
			
		||||
@@ -394,45 +353,35 @@ export class AssetService {
 | 
			
		||||
 | 
			
		||||
  async getAssetSearchTerm(authUser: AuthUserDto): Promise<string[]> {
 | 
			
		||||
    const possibleSearchTerm = new Set<string>();
 | 
			
		||||
    // TODO: should use query builder
 | 
			
		||||
    const rows = await this.assetRepository.query(
 | 
			
		||||
      `
 | 
			
		||||
      SELECT DISTINCT si.tags, si.objects, e.orientation, e."lensModel", e.make, e.model , a.type, e.city, e.state, e.country
 | 
			
		||||
      FROM assets a
 | 
			
		||||
      LEFT JOIN exif e ON a.id = e."assetId"
 | 
			
		||||
      LEFT JOIN smart_info si ON a.id = si."assetId"
 | 
			
		||||
      WHERE a."userId" = $1;
 | 
			
		||||
      `,
 | 
			
		||||
      [authUser.id],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    rows.forEach((row: { [x: string]: any }) => {
 | 
			
		||||
    const rows = await this._assetRepository.getSearchPropertiesByUserId(authUser.id);
 | 
			
		||||
    rows.forEach((row: SearchPropertiesDto) => {
 | 
			
		||||
      // tags
 | 
			
		||||
      row['tags']?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase()));
 | 
			
		||||
      row.tags?.map((tag: string) => possibleSearchTerm.add(tag?.toLowerCase()));
 | 
			
		||||
 | 
			
		||||
      // objects
 | 
			
		||||
      row['objects']?.map((object: string) => possibleSearchTerm.add(object?.toLowerCase()));
 | 
			
		||||
      row.objects?.map((object: string) => possibleSearchTerm.add(object?.toLowerCase()));
 | 
			
		||||
 | 
			
		||||
      // asset's tyoe
 | 
			
		||||
      possibleSearchTerm.add(row['type']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row.assetType?.toLowerCase() || '');
 | 
			
		||||
 | 
			
		||||
      // image orientation
 | 
			
		||||
      possibleSearchTerm.add(row['orientation']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row.orientation?.toLowerCase() || '');
 | 
			
		||||
 | 
			
		||||
      // Lens model
 | 
			
		||||
      possibleSearchTerm.add(row['lensModel']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row.lensModel?.toLowerCase() || '');
 | 
			
		||||
 | 
			
		||||
      // Make and model
 | 
			
		||||
      possibleSearchTerm.add(row['make']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row['model']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row.make?.toLowerCase() || '');
 | 
			
		||||
      possibleSearchTerm.add(row.model?.toLowerCase() || '');
 | 
			
		||||
 | 
			
		||||
      // Location
 | 
			
		||||
      possibleSearchTerm.add(row['city']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row['state']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row['country']?.toLowerCase());
 | 
			
		||||
      possibleSearchTerm.add(row.city?.toLowerCase() || '');
 | 
			
		||||
      possibleSearchTerm.add(row.state?.toLowerCase() || '');
 | 
			
		||||
      possibleSearchTerm.add(row.country?.toLowerCase() || '');
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return Array.from(possibleSearchTerm).filter((x) => x != null);
 | 
			
		||||
    return Array.from(possibleSearchTerm).filter((x) => x != null && x != '');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async searchAsset(authUser: AuthUserDto, searchAssetDto: SearchAssetDto): Promise<AssetResponseDto[]> {
 | 
			
		||||
@@ -459,33 +408,12 @@ export class AssetService {
 | 
			
		||||
    return searchResults.map((asset) => mapAsset(asset));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getCuratedLocation(authUser: AuthUserDto) {
 | 
			
		||||
    return await this.assetRepository.query(
 | 
			
		||||
      `
 | 
			
		||||
        SELECT DISTINCT ON (e.city) a.id, e.city, a."resizePath", a."deviceAssetId", a."deviceId"
 | 
			
		||||
        FROM assets a
 | 
			
		||||
        LEFT JOIN exif e ON a.id = e."assetId"
 | 
			
		||||
        WHERE a."userId" = $1
 | 
			
		||||
        AND e.city IS NOT NULL
 | 
			
		||||
        AND a.type = 'IMAGE';
 | 
			
		||||
      `,
 | 
			
		||||
      [authUser.id],
 | 
			
		||||
    );
 | 
			
		||||
  async getCuratedLocation(authUser: AuthUserDto): Promise<CuratedLocationsResponseDto[]> {
 | 
			
		||||
    return this._assetRepository.getLocationsByUserId(authUser.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getCuratedObject(authUser: AuthUserDto): Promise<CuratedObjectsResponseDto[]> {
 | 
			
		||||
    const curatedObjects: CuratedObjectsResponseDto[] = await this.assetRepository.query(
 | 
			
		||||
      `
 | 
			
		||||
        SELECT DISTINCT ON (unnest(si.objects)) a.id, unnest(si.objects) as "object", a."resizePath", a."deviceAssetId", a."deviceId"
 | 
			
		||||
        FROM assets a
 | 
			
		||||
        LEFT JOIN smart_info si ON a.id = si."assetId"
 | 
			
		||||
        WHERE a."userId" = $1
 | 
			
		||||
        AND si.objects IS NOT NULL
 | 
			
		||||
      `,
 | 
			
		||||
      [authUser.id],
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return curatedObjects;
 | 
			
		||||
    return this._assetRepository.getDetectedObjectsByUserId(authUser.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkDuplicatedAsset(
 | 
			
		||||
@@ -504,4 +432,16 @@ export class AssetService {
 | 
			
		||||
 | 
			
		||||
    return new CheckDuplicateAssetResponseDto(isDuplicated, res?.id);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async getAssetCountByTimeGroup(
 | 
			
		||||
    authUser: AuthUserDto,
 | 
			
		||||
    getAssetCountByTimeGroupDto: GetAssetCountByTimeGroupDto,
 | 
			
		||||
  ): Promise<AssetCountByTimeGroupResponseDto> {
 | 
			
		||||
    const result = await this._assetRepository.getAssetCountByTimeGroup(
 | 
			
		||||
      authUser.id,
 | 
			
		||||
      getAssetCountByTimeGroupDto.timeGroup,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    return mapAssetCountByTimeGroupResponse(result);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,16 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
import { IsNotEmpty } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export enum TimeGroupEnum {
 | 
			
		||||
  Day = 'day',
 | 
			
		||||
  Month = 'month',
 | 
			
		||||
}
 | 
			
		||||
export class GetAssetCountByTimeGroupDto {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  @ApiProperty({
 | 
			
		||||
    type: String,
 | 
			
		||||
    enum: TimeGroupEnum,
 | 
			
		||||
    enumName: 'TimeGroupEnum',
 | 
			
		||||
  })
 | 
			
		||||
  timeGroup!: TimeGroupEnum;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,12 @@
 | 
			
		||||
export class SearchPropertiesDto {
 | 
			
		||||
  tags?: string[];
 | 
			
		||||
  objects?: string[];
 | 
			
		||||
  assetType?: string;
 | 
			
		||||
  orientation?: string;
 | 
			
		||||
  lensModel?: string;
 | 
			
		||||
  make?: string;
 | 
			
		||||
  model?: string;
 | 
			
		||||
  city?: string;
 | 
			
		||||
  state?: string;
 | 
			
		||||
  country?: string;
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,23 @@
 | 
			
		||||
import { ApiProperty } from '@nestjs/swagger';
 | 
			
		||||
 | 
			
		||||
export class AssetCountByTimeGroupDto {
 | 
			
		||||
  @ApiProperty({ type: 'string' })
 | 
			
		||||
  timeGroup!: string;
 | 
			
		||||
 | 
			
		||||
  @ApiProperty({ type: 'integer' })
 | 
			
		||||
  count!: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class AssetCountByTimeGroupResponseDto {
 | 
			
		||||
  groups!: AssetCountByTimeGroupDto[];
 | 
			
		||||
 | 
			
		||||
  @ApiProperty({ type: 'integer' })
 | 
			
		||||
  totalAssets!: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function mapAssetCountByTimeGroupResponse(result: AssetCountByTimeGroupDto[]): AssetCountByTimeGroupResponseDto {
 | 
			
		||||
  return {
 | 
			
		||||
    groups: result,
 | 
			
		||||
    totalAssets: result.map((group) => group.count).reduce((a, b) => a + b, 0),
 | 
			
		||||
  };
 | 
			
		||||
}
 | 
			
		||||
@@ -5,7 +5,7 @@ import { Logger } from '@nestjs/common';
 | 
			
		||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { UserEntity } from '@app/database/entities/user.entity';
 | 
			
		||||
import { Repository } from 'typeorm';
 | 
			
		||||
 | 
			
		||||
import cookieParser from 'cookie';
 | 
			
		||||
@WebSocketGateway({ cors: true })
 | 
			
		||||
export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisconnect {
 | 
			
		||||
  constructor(
 | 
			
		||||
@@ -26,8 +26,24 @@ export class CommunicationGateway implements OnGatewayConnection, OnGatewayDisco
 | 
			
		||||
  async handleConnection(client: Socket) {
 | 
			
		||||
    try {
 | 
			
		||||
      Logger.log(`New websocket connection: ${client.id}`, 'WebsocketConnectionEvent');
 | 
			
		||||
      let accessToken = '';
 | 
			
		||||
 | 
			
		||||
      const accessToken = client.handshake.headers.authorization?.split(' ')[1];
 | 
			
		||||
      if (client.handshake.headers.cookie != undefined) {
 | 
			
		||||
        const cookies = cookieParser.parse(client.handshake.headers.cookie);
 | 
			
		||||
        if (cookies.immich_access_token) {
 | 
			
		||||
          accessToken = cookies.immich_access_token;
 | 
			
		||||
        } else {
 | 
			
		||||
          client.emit('error', 'unauthorized');
 | 
			
		||||
          client.disconnect();
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
      } else if (client.handshake.headers.authorization != undefined) {
 | 
			
		||||
        accessToken = client.handshake.headers.authorization.split(' ')[1];
 | 
			
		||||
      } else {
 | 
			
		||||
        client.emit('error', 'unauthorized');
 | 
			
		||||
        client.disconnect();
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const res: JwtValidationResult = accessToken
 | 
			
		||||
        ? await this.immichJwtService.validateToken(accessToken)
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user