mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-03 19:17:11 -05:00 
			
		
		
		
	feat(server): de-duplication (#557)
* feat(server): remove un-used deviceAssetId cols. * feat(server): return 409 if asset is duplicated * feat(server): replace old unique constaint * feat(server): strip deviceId in file path * feat(server): skip duplicate asset * chore(server): revert changes * fix(server): asset test spec * fix(server): checksum generation for uploaded assets * fix(server): make sure generation queue run after migraion * feat(server): remove temp file * chore(server): remove dead code
This commit is contained in:
		
							parent
							
								
									2677ddccaa
								
							
						
					
					
						commit
						a467936e73
					
				@ -26,6 +26,7 @@ export interface IAssetRepository {
 | 
			
		||||
  getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]>;
 | 
			
		||||
  getAssetCountByTimeBucket(userId: string, timeBucket: TimeGroupEnum): Promise<AssetCountByTimeBucket[]>;
 | 
			
		||||
  getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
 | 
			
		||||
  getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const ASSET_REPOSITORY = 'ASSET_REPOSITORY';
 | 
			
		||||
@ -208,4 +209,20 @@ export class AssetRepository implements IAssetRepository {
 | 
			
		||||
 | 
			
		||||
    return res;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Get asset by checksum on the database
 | 
			
		||||
   * @param userId 
 | 
			
		||||
   * @param checksum 
 | 
			
		||||
   * 
 | 
			
		||||
   */
 | 
			
		||||
  getAssetByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity> {
 | 
			
		||||
    return this.assetRepository.findOneOrFail({
 | 
			
		||||
      where: {
 | 
			
		||||
        userId,
 | 
			
		||||
        checksum
 | 
			
		||||
      },
 | 
			
		||||
      relations: ['exifInfo'],
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -60,6 +60,7 @@ describe('AssetService', () => {
 | 
			
		||||
      getLocationsByUserId: jest.fn(),
 | 
			
		||||
      getSearchPropertiesByUserId: jest.fn(),
 | 
			
		||||
      getAssetByTimeBucket: jest.fn(),
 | 
			
		||||
      getAssetByChecksum: jest.fn(),
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    sui = new AssetService(assetRepositoryMock, a);
 | 
			
		||||
 | 
			
		||||
@ -10,7 +10,7 @@ import {
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
			
		||||
import { createHash } from 'node:crypto';
 | 
			
		||||
import { Repository } from 'typeorm';
 | 
			
		||||
import { QueryFailedError, 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';
 | 
			
		||||
@ -55,6 +55,8 @@ export class AssetService {
 | 
			
		||||
    mimeType: string,
 | 
			
		||||
  ): Promise<AssetEntity> {
 | 
			
		||||
    const checksum = await this.calculateChecksum(originalPath);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const assetEntity = await this._assetRepository.create(
 | 
			
		||||
        createAssetDto,
 | 
			
		||||
        authUser.id,
 | 
			
		||||
@ -64,6 +66,18 @@ export class AssetService {
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      return assetEntity;
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
      if (err instanceof QueryFailedError && (err as any).constraint === 'UQ_userid_checksum') {
 | 
			
		||||
        const [assetEntity, _] = await Promise.all([
 | 
			
		||||
          this._assetRepository.getAssetByChecksum(authUser.id, checksum),
 | 
			
		||||
          fs.unlink(originalPath)
 | 
			
		||||
        ]);
 | 
			
		||||
 | 
			
		||||
        return assetEntity;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      throw err;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ import { HttpException, HttpStatus } from '@nestjs/common';
 | 
			
		||||
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 | 
			
		||||
import { existsSync, mkdirSync } from 'fs';
 | 
			
		||||
import { diskStorage } from 'multer';
 | 
			
		||||
import { extname } from 'path';
 | 
			
		||||
import { extname, join } from 'path';
 | 
			
		||||
import { Request } from 'express';
 | 
			
		||||
import { randomUUID } from 'crypto';
 | 
			
		||||
 | 
			
		||||
@ -29,7 +29,7 @@ export const assetUploadOption: MulterOptions = {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const originalUploadFolder = `${basePath}/${req.user.id}/original/${req.body['deviceId']}`;
 | 
			
		||||
      const originalUploadFolder = join(basePath, req.user.id, 'original', req.body['deviceId']);
 | 
			
		||||
 | 
			
		||||
      if (!existsSync(originalUploadFolder)) {
 | 
			
		||||
        mkdirSync(originalUploadFolder, { recursive: true });
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,8 @@ export class MicroservicesService implements OnModuleInit {
 | 
			
		||||
  ) {}
 | 
			
		||||
 | 
			
		||||
  async onModuleInit() {
 | 
			
		||||
    await this.generateChecksumQueue.add({}, { jobId: randomUUID() },);
 | 
			
		||||
    await this.generateChecksumQueue.add({}, {
 | 
			
		||||
      jobId: randomUUID(), delay: 10000 // wait for migration
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -19,14 +19,12 @@ export class GenerateChecksumProcessor {
 | 
			
		||||
  async generateChecksum() {
 | 
			
		||||
    let hasNext = true;
 | 
			
		||||
    let pageSize = 200;
 | 
			
		||||
    let offset = 0;
 | 
			
		||||
 | 
			
		||||
    while (hasNext) {
 | 
			
		||||
      const assets = await this.assetRepository.find({
 | 
			
		||||
        where: {
 | 
			
		||||
          checksum: IsNull()
 | 
			
		||||
        },
 | 
			
		||||
        skip: offset,
 | 
			
		||||
        take: pageSize,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
@ -43,8 +41,6 @@ export class GenerateChecksumProcessor {
 | 
			
		||||
 | 
			
		||||
        if (assets.length < pageSize) {
 | 
			
		||||
          hasNext = false;
 | 
			
		||||
        } else {
 | 
			
		||||
          offset += pageSize;
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ import { ExifEntity } from './exif.entity';
 | 
			
		||||
import { SmartInfoEntity } from './smart-info.entity';
 | 
			
		||||
 | 
			
		||||
@Entity('assets')
 | 
			
		||||
@Unique(['deviceAssetId', 'userId', 'deviceId'])
 | 
			
		||||
@Unique('UQ_userid_checksum', ['userId', 'checksum'])
 | 
			
		||||
export class AssetEntity {
 | 
			
		||||
  @PrimaryGeneratedColumn('uuid')
 | 
			
		||||
  id!: string;
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { MigrationInterface, QueryRunner } from "typeorm";
 | 
			
		||||
import { MigrationInterface, QueryRunner } from 'typeorm';
 | 
			
		||||
 | 
			
		||||
export class AddAssetChecksum1661881837496 implements MigrationInterface {
 | 
			
		||||
  name = 'AddAssetChecksum1661881837496'
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,16 @@
 | 
			
		||||
import { MigrationInterface, QueryRunner } from 'typeorm';
 | 
			
		||||
 | 
			
		||||
export class UpdateAssetTableWithNewUniqueConstraint1661971370662 implements MigrationInterface {
 | 
			
		||||
  name = 'UpdateAssetTableWithNewUniqueConstraint1661971370662'
 | 
			
		||||
 | 
			
		||||
  public async up(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e"`);
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_userid_checksum" UNIQUE ("userId", "checksum")`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async down(queryRunner: QueryRunner): Promise<void> {
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_userid_checksum"`);
 | 
			
		||||
    await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_b599ab0bd9574958acb0b30a90e" UNIQUE ("deviceAssetId", "userId", "deviceId")`);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user