feat: preload textual model

This commit is contained in:
martabal
2024-09-25 17:39:55 +02:00
594 changed files with 10932 additions and 8871 deletions

View File

@@ -293,8 +293,8 @@ export const defaults = Object.freeze<SystemConfig>({
},
map: {
enabled: true,
lightStyle: '',
darkStyle: '',
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: {
enabled: true,

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators';
import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto';
import {
AssetBulkDeleteDto,
@@ -31,6 +32,7 @@ export class AssetController {
@Get('random')
@Authenticated()
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
getRandom(@Auth() auth: AuthDto, @Query() dto: RandomAssetsDto): Promise<AssetResponseDto[]> {
return this.service.getRandom(auth, dto.count ?? 1);
}

View File

@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobIdParamDto, JobStatusDto } from 'src/dtos/job.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { JobService } from 'src/services/job.service';
@@ -15,6 +15,12 @@ export class JobController {
return this.service.getAllJobsStatus();
}
@Post()
@Authenticated({ admin: true })
createJob(@Body() dto: JobCreateDto): Promise<void> {
return this.service.create(dto);
}
@Put(':id')
@Authenticated({ admin: true })
sendJobCommand(@Param() { id }: JobIdParamDto, @Body() dto: JobCommandDto): Promise<JobStatusDto> {

View File

@@ -7,7 +7,6 @@ import {
MapReverseGeocodeDto,
MapReverseGeocodeResponseDto,
} from 'src/dtos/map.dto';
import { MapThemeDto } from 'src/dtos/system-config.dto';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { MapService } from 'src/services/map.service';
@@ -22,12 +21,6 @@ export class MapController {
return this.service.getMapMarkers(auth, options);
}
@Authenticated({ sharedLink: true })
@Get('style.json')
getMapStyle(@Query() dto: MapThemeDto) {
return this.service.getMapStyle(dto.theme);
}
@Authenticated()
@Get('reverse-geocode')
@HttpCode(HttpStatus.OK)

View File

@@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto';
import {
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchExploreResponseDto,
SearchPeopleDto,
SearchPlacesDto,
@@ -28,6 +29,13 @@ export class SearchController {
return this.service.searchMetadata(auth, dto);
}
@Post('random')
@HttpCode(HttpStatus.OK)
@Authenticated()
searchRandom(@Auth() auth: AuthDto, @Body() dto: RandomSearchDto): Promise<SearchResponseDto> {
return this.service.searchRandom(auth, dto);
}
@Post('smart')
@HttpCode(HttpStatus.OK)
@Authenticated()

View File

@@ -2,6 +2,7 @@ import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { TrashService } from 'src/services/trash.service';
@@ -12,23 +13,23 @@ export class TrashController {
constructor(private service: TrashService) {}
@Post('empty')
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.ASSET_DELETE })
emptyTrash(@Auth() auth: AuthDto): Promise<void> {
emptyTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.empty(auth);
}
@Post('restore')
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.ASSET_DELETE })
restoreTrash(@Auth() auth: AuthDto): Promise<void> {
restoreTrash(@Auth() auth: AuthDto): Promise<TrashResponseDto> {
return this.service.restore(auth);
}
@Post('restore/assets')
@HttpCode(HttpStatus.NO_CONTENT)
@HttpCode(HttpStatus.OK)
@Authenticated({ permission: Permission.ASSET_DELETE })
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<void> {
restoreAssets(@Auth() auth: AuthDto, @Body() dto: BulkIdsDto): Promise<TrashResponseDto> {
return this.service.restoreAssets(auth, dto);
}
}

View File

@@ -120,6 +120,10 @@ export class SystemConfigCore {
}
}
if (config.server.externalDomain.length > 0) {
config.server.externalDomain = new URL(config.server.externalDomain).origin;
}
if (!config.ffmpeg.acceptedVideoCodecs.includes(config.ffmpeg.targetVideoCodec)) {
config.ffmpeg.acceptedVideoCodecs.push(config.ffmpeg.targetVideoCodec);
}

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty } from 'class-validator';
import { ManualJobName } from 'src/enum';
import { JobCommand, QueueName } from 'src/interfaces/job.interface';
import { ValidateBoolean } from 'src/validation';
@@ -20,6 +21,12 @@ export class JobCommandDto {
force!: boolean;
}
export class JobCreateDto {
@IsEnum(ManualJobName)
@ApiProperty({ type: 'string', enum: ManualJobName, enumName: 'ManualJobName' })
name!: ManualJobName;
}
export class JobCountsDto {
@ApiProperty({ type: 'integer' })
active!: number;

View File

@@ -40,14 +40,14 @@ export class DuplicateDetectionConfig extends TaskConfig {
export class FacialRecognitionConfig extends ModelConfig {
@IsNumber()
@Min(0)
@Min(0.1)
@Max(1)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })
minScore!: number;
@IsNumber()
@Min(0)
@Min(0.1)
@Max(2)
@Type(() => Number)
@ApiProperty({ type: 'number', format: 'double' })

View File

@@ -119,7 +119,15 @@ class BaseSearchDto {
personIds?: string[];
}
export class MetadataSearchDto extends BaseSearchDto {
export class RandomSearchDto extends BaseSearchDto {
@ValidateBoolean({ optional: true })
withStacked?: boolean;
@ValidateBoolean({ optional: true })
withPeople?: boolean;
}
export class MetadataSearchDto extends RandomSearchDto {
@ValidateUUID({ optional: true })
id?: string;
@@ -133,12 +141,6 @@ export class MetadataSearchDto extends BaseSearchDto {
@Optional()
checksum?: string;
@ValidateBoolean({ optional: true })
withStacked?: boolean;
@ValidateBoolean({ optional: true })
withPeople?: boolean;
@IsString()
@IsNotEmpty()
@Optional()

View File

@@ -121,6 +121,8 @@ export class ServerConfigDto {
isInitialized!: boolean;
isOnboarded!: boolean;
externalDomain!: string;
mapDarkStyleUrl!: string;
mapLightStyleUrl!: string;
}
export class ServerFeaturesDto {

View File

@@ -296,10 +296,12 @@ class SystemConfigMapDto {
@ValidateBoolean()
enabled!: boolean;
@IsString()
@IsNotEmpty()
@IsUrl()
lightStyle!: string;
@IsString()
@IsNotEmpty()
@IsUrl()
darkStyle!: string;
}

View File

@@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class TrashResponseDto {
@ApiProperty({ type: 'integer' })
count!: number;
}

View File

@@ -8,12 +8,6 @@ export class CreateProfileImageDto {
export class CreateProfileImageResponseDto {
userId!: string;
profileChangedAt!: Date;
profileImagePath!: string;
}
export function mapCreateProfileImageResponse(userId: string, profileImagePath: string): CreateProfileImageResponseDto {
return {
userId,
profileImagePath,
};
}

View File

@@ -32,6 +32,7 @@ export class UserResponseDto {
profileImagePath!: string;
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor!: UserAvatarColor;
profileChangedAt!: Date;
}
export class UserLicense {
@@ -47,6 +48,7 @@ export const mapUser = (entity: UserEntity): UserResponseDto => {
name: entity.name,
profileImagePath: entity.profileImagePath,
avatarColor: getPreferences(entity).avatar.color,
profileChangedAt: entity.profileChangedAt,
};
};

View File

@@ -10,7 +10,7 @@ import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { StackEntity } from 'src/entities/stack.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import {
Column,
CreateDateColumn,
@@ -70,6 +70,9 @@ export class AssetEntity {
@Column()
type!: AssetType;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@Column()
originalPath!: string;

View File

@@ -67,4 +67,7 @@ export class UserEntity {
@OneToMany(() => UserMetadataEntity, (metadata) => metadata.user)
metadata!: UserMetadataEntity[];
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
profileChangedAt!: Date;
}

View File

@@ -182,7 +182,19 @@ export enum UserStatus {
DELETED = 'deleted',
}
export enum AssetStatus {
ACTIVE = 'active',
TRASHED = 'trashed',
DELETED = 'deleted',
}
export enum SourceType {
MACHINE_LEARNING = 'machine-learning',
EXIF = 'exif',
}
export enum ManualJobName {
PERSON_CLEANUP = 'person-cleanup',
TAG_CLEANUP = 'tag-cleanup',
USER_CLEANUP = 'user-cleanup',
}

View File

@@ -16,18 +16,15 @@ export interface AlbumInfoOptions {
export interface IAlbumRepository extends IBulkAsset {
getById(id: string, options: AlbumInfoOptions): Promise<AlbumEntity | null>;
getByIds(ids: string[]): Promise<AlbumEntity[]>;
getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]>;
removeAsset(assetId: string): Promise<void>;
getMetadataForIds(ids: string[]): Promise<AlbumAssetCount[]>;
getInvalidThumbnail(): Promise<string[]>;
getOwned(ownerId: string): Promise<AlbumEntity[]>;
getShared(ownerId: string): Promise<AlbumEntity[]>;
getNotShared(ownerId: string): Promise<AlbumEntity[]>;
restoreAll(userId: string): Promise<void>;
softDeleteAll(userId: string): Promise<void>;
deleteAll(userId: string): Promise<void>;
getAll(): Promise<AlbumEntity[]>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
update(album: Partial<AlbumEntity>): Promise<AlbumEntity>;
delete(id: string): Promise<void>;

View File

@@ -1,7 +1,7 @@
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { AssetFileType, AssetOrder, AssetType } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface';
import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm';
@@ -56,6 +56,7 @@ export interface AssetBuilderOptions {
userIds?: string[];
withStacked?: boolean;
exifInfo?: boolean;
status?: AssetStatus;
assetType?: AssetType;
}
@@ -147,8 +148,6 @@ export type AssetPathEntity = Pick<AssetEntity, 'id' | 'originalPath' | 'isOffli
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
getUniqueOriginalPaths(userId: string): Promise<string[]>;
create(asset: AssetCreate): Promise<AssetEntity>;
getByIds(
ids: string[],
@@ -176,7 +175,6 @@ export interface IAssetRepository {
withDeleted?: boolean,
): Paginated<AssetEntity>;
getRandom(userIds: string[], count: number): Promise<AssetEntity[]>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
getExternalLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated<AssetPathEntity>;
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise<AssetEntity | null>;
@@ -188,8 +186,6 @@ export interface IAssetRepository {
updateDuplicates(options: AssetUpdateDuplicateOptions): Promise<void>;
update(asset: AssetUpdateOptions): Promise<void>;
remove(asset: AssetEntity): Promise<void>;
softDeleteAll(ids: string[]): Promise<void>;
restoreAll(ids: string[]): Promise<void>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;

View File

@@ -27,6 +27,7 @@ type EmitEventMap = {
// asset bulk events
'assets.trash': [{ assetIds: string[]; userId: string }];
'assets.delete': [{ assetIds: string[]; userId: string }];
'assets.restore': [{ assetIds: string[]; userId: string }];
// session events

View File

@@ -60,6 +60,9 @@ export enum JobName {
STORAGE_TEMPLATE_MIGRATION = 'storage-template-migration',
STORAGE_TEMPLATE_MIGRATION_SINGLE = 'storage-template-migration-single',
// tags
TAG_CLEANUP = 'tag-cleanup',
// migration
QUEUE_MIGRATION = 'queue-migration',
MIGRATE_ASSET = 'migrate-asset',
@@ -90,6 +93,8 @@ export enum JobName {
QUEUE_SMART_SEARCH = 'queue-smart-search',
SMART_SEARCH = 'smart-search',
QUEUE_TRASH_EMPTY = 'queue-trash-empty',
// duplicate detection
QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection',
DUPLICATE_DETECTION = 'duplicate-detection',
@@ -250,6 +255,7 @@ export type JobItem =
// Smart Search
| { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob }
| { name: JobName.SMART_SEARCH; data: IEntityJob }
| { name: JobName.QUEUE_TRASH_EMPTY; data?: IBaseJob }
// Duplicate Detection
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }
@@ -262,6 +268,9 @@ export type JobItem =
| { name: JobName.CLEAN_OLD_AUDIT_LOGS; data?: IBaseJob }
| { name: JobName.CLEAN_OLD_SESSION_TOKENS; data?: IBaseJob }
// Tags
| { name: JobName.TAG_CLEANUP; data?: IBaseJob }
// Asset Deletion
| { name: JobName.PERSON_CLEANUP; data?: IBaseJob }
| { name: JobName.ASSET_DELETION; data: IAssetDeleteJob }
@@ -300,7 +309,6 @@ export interface IJobRepository {
addHandler(queueName: QueueName, concurrency: number, handler: JobItemHandler): void;
addCronJob(name: string, expression: string, onTick: () => void, start?: boolean): void;
updateCronJob(name: string, expression?: string, start?: boolean): void;
deleteCronJob(name: string): void;
setConcurrency(queueName: QueueName, concurrency: number): void;
queue(item: JobItem): Promise<void>;
queueAll(items: JobItem[]): Promise<void>;

View File

@@ -53,9 +53,4 @@ export interface IMetadataRepository {
readTags(path: string): Promise<ImmichTags>;
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
getCountries(userIds: string[]): Promise<Array<string | null>>;
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
}

View File

@@ -1,7 +1,7 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import { Paginated } from 'src/utils/pagination';
export const ISearchRepository = 'ISearchRepository';
@@ -61,6 +61,7 @@ export interface SearchStatusOptions {
isVisible?: boolean;
isNotInAlbum?: boolean;
type?: AssetType;
status?: AssetStatus;
withArchived?: boolean;
withDeleted?: boolean;
}
@@ -115,6 +116,7 @@ export interface SearchPeopleOptions {
export interface SearchOrderOptions {
orderDirection?: 'ASC' | 'DESC';
random?: boolean;
}
export interface SearchPaginationOptions {
@@ -181,4 +183,9 @@ export interface ISearchRepository {
deleteAllSearchEmbeddings(): Promise<void>;
getDimensionSize(): Promise<number>;
setDimensionSize(dimSize: number): Promise<void>;
getCountries(userIds: string[]): Promise<Array<string | null>>;
getStates(userIds: string[], country?: string): Promise<Array<string | null>>;
getCities(userIds: string[], country?: string, state?: string): Promise<Array<string | null>>;
getCameraMakes(userIds: string[], model?: string): Promise<Array<string | null>>;
getCameraModels(userIds: string[], make?: string): Promise<Array<string | null>>;
}

View File

@@ -35,7 +35,9 @@ export interface IStorageRepository {
createZipStream(): ImmichZipStream;
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
readFile(filepath: string, options?: FileReadOptions<Buffer>): Promise<Buffer>;
writeFile(filepath: string, buffer: Buffer): Promise<void>;
createFile(filepath: string, buffer: Buffer): Promise<void>;
createOrOverwriteFile(filepath: string, buffer: Buffer): Promise<void>;
overwriteFile(filepath: string, buffer: Buffer): Promise<void>;
realpath(filepath: string): Promise<string>;
unlink(filepath: string): Promise<void>;
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;

View File

@@ -17,4 +17,5 @@ export interface ITagRepository extends IBulkAsset {
upsertAssetTags({ assetId, tagIds }: { assetId: string; tagIds: string[] }): Promise<void>;
upsertAssetIds(items: AssetTagItem[]): Promise<AssetTagItem[]>;
deleteEmptyTags(): Promise<void>;
}

View File

@@ -0,0 +1,10 @@
import { Paginated, PaginationOptions } from 'src/utils/pagination';
export const ITrashRepository = 'ITrashRepository';
export interface ITrashRepository {
empty(userId: string): Promise<number>;
restore(userId: string): Promise<number>;
restoreAll(assetIds: string[]): Promise<number>;
getDeletedIds(pagination: PaginationOptions): Paginated<string>;
}

View File

@@ -0,0 +1,8 @@
import { AssetEntity } from 'src/entities/asset.entity';
export const IViewRepository = 'IViewRepository';
export interface IViewRepository {
getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]>;
getUniqueOriginalPaths(userId: string): Promise<string[]>;
}

View File

@@ -18,7 +18,9 @@ async function bootstrapImmichAdmin() {
function bootstrapWorker(name: string) {
console.log(`Starting ${name} worker`);
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
const execArgv = process.execArgv.map((arg) => (arg.startsWith('--inspect') ? '--inspect=0.0.0.0:9231' : arg));
const worker =
name === 'api' ? fork(`./dist/workers/${name}.js`, [], { execArgv }) : new Worker(`./dist/workers/${name}.js`);
worker.on('error', (error) => {
console.error(`${name} worker error: ${error}`);

View File

@@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddprofileChangedAt1726491047923 implements MigrationInterface {
name = 'AddprofileChangedAt1726491047923'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "profileChangedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "profileChangedAt"`);
}
}

View File

@@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddAssetStatus1726593009549 implements MigrationInterface {
name = 'AddAssetStatus1726593009549'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TYPE "assets_status_enum" AS ENUM('active', 'trashed', 'deleted')`);
await queryRunner.query(`ALTER TABLE "assets" ADD "status" "assets_status_enum" NOT NULL DEFAULT 'active'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "status"`);
await queryRunner.query(`DROP TYPE "assets_status_enum"`);
}
}

View File

@@ -23,7 +23,8 @@ SELECT
"ActivityEntity__ActivityEntity_user"."status" AS "ActivityEntity__ActivityEntity_user_status",
"ActivityEntity__ActivityEntity_user"."updatedAt" AS "ActivityEntity__ActivityEntity_user_updatedAt",
"ActivityEntity__ActivityEntity_user"."quotaSizeInBytes" AS "ActivityEntity__ActivityEntity_user_quotaSizeInBytes",
"ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes"
"ActivityEntity__ActivityEntity_user"."quotaUsageInBytes" AS "ActivityEntity__ActivityEntity_user_quotaUsageInBytes",
"ActivityEntity__ActivityEntity_user"."profileChangedAt" AS "ActivityEntity__ActivityEntity_user_profileChangedAt"
FROM
"activity" "ActivityEntity"
LEFT JOIN "users" "ActivityEntity__ActivityEntity_user" ON "ActivityEntity__ActivityEntity_user"."id" = "ActivityEntity"."userId"

View File

@@ -30,6 +30,7 @@ FROM
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
@@ -47,6 +48,7 @@ FROM
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@@ -80,64 +82,6 @@ ORDER BY
LIMIT
1
-- AlbumRepository.getByIds
SELECT
"AlbumEntity"."id" AS "AlbumEntity_id",
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId",
"AlbumEntity"."albumName" AS "AlbumEntity_albumName",
"AlbumEntity"."description" AS "AlbumEntity_description",
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt",
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt",
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_id",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."name" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_name",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."isAdmin" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_isAdmin",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."email" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_email",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."storageLabel" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_storageLabel",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."oauthId" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_oauthId",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileImagePath" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileImagePath",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."shouldChangePassword" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_shouldChangePassword",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."createdAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_createdAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_deletedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL
)
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
LEFT JOIN "users" "a641d58cf46d4a391ba060ac4dc337665c69ffea" ON "a641d58cf46d4a391ba060ac4dc337665c69ffea"."id" = "AlbumEntity__AlbumEntity_albumUsers"."usersId"
AND (
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."deletedAt" IS NULL
)
WHERE
((("AlbumEntity"."id" IN ($1))))
AND ("AlbumEntity"."deletedAt" IS NULL)
-- AlbumRepository.getByAssetId
SELECT
"AlbumEntity"."id" AS "AlbumEntity_id",
@@ -164,6 +108,7 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt",
"AlbumEntity__AlbumEntity_albumUsers"."albumsId" AS "AlbumEntity__AlbumEntity_albumUsers_albumsId",
"AlbumEntity__AlbumEntity_albumUsers"."usersId" AS "AlbumEntity__AlbumEntity_albumUsers_usersId",
"AlbumEntity__AlbumEntity_albumUsers"."role" AS "AlbumEntity__AlbumEntity_albumUsers_role",
@@ -180,7 +125,8 @@ SELECT
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."status" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_status",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes"
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt"
FROM
"albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
@@ -241,35 +187,6 @@ WHERE
GROUP BY
"album"."id"
-- AlbumRepository.getInvalidThumbnail
SELECT
"albums"."id" AS "albums_id"
FROM
"albums" "albums"
WHERE
(
"albums"."albumThumbnailAssetId" IS NULL
AND EXISTS (
SELECT
1
FROM
"albums_assets_assets" "albums_assets"
WHERE
"albums"."id" = "albums_assets"."albumsId"
)
OR "albums"."albumThumbnailAssetId" IS NOT NULL
AND NOT EXISTS (
SELECT
1
FROM
"albums_assets_assets" "albums_assets"
WHERE
"albums"."id" = "albums_assets"."albumsId"
AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"
)
)
AND ("albums"."deletedAt" IS NULL)
-- AlbumRepository.getOwned
SELECT
"AlbumEntity"."id" AS "AlbumEntity_id",
@@ -299,6 +216,7 @@ SELECT
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@@ -324,7 +242,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
@@ -372,6 +291,7 @@ SELECT
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."updatedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_updatedAt",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaSizeInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaSizeInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."quotaUsageInBytes" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_quotaUsageInBytes",
"a641d58cf46d4a391ba060ac4dc337665c69ffea"."profileChangedAt" AS "a641d58cf46d4a391ba060ac4dc337665c69ffea_profileChangedAt",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@@ -397,7 +317,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
@@ -495,7 +416,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_owner"."profileChangedAt" AS "AlbumEntity__AlbumEntity_owner_profileChangedAt"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity__AlbumEntity_albumUsers" ON "AlbumEntity__AlbumEntity_albumUsers"."albumsId" = "AlbumEntity"."id"
@@ -528,41 +450,6 @@ WHERE
ORDER BY
"AlbumEntity"."createdAt" DESC
-- AlbumRepository.getAll
SELECT
"AlbumEntity"."id" AS "AlbumEntity_id",
"AlbumEntity"."ownerId" AS "AlbumEntity_ownerId",
"AlbumEntity"."albumName" AS "AlbumEntity_albumName",
"AlbumEntity"."description" AS "AlbumEntity_description",
"AlbumEntity"."createdAt" AS "AlbumEntity_createdAt",
"AlbumEntity"."updatedAt" AS "AlbumEntity_updatedAt",
"AlbumEntity"."deletedAt" AS "AlbumEntity_deletedAt",
"AlbumEntity"."albumThumbnailAssetId" AS "AlbumEntity_albumThumbnailAssetId",
"AlbumEntity"."isActivityEnabled" AS "AlbumEntity_isActivityEnabled",
"AlbumEntity"."order" AS "AlbumEntity_order",
"AlbumEntity__AlbumEntity_owner"."id" AS "AlbumEntity__AlbumEntity_owner_id",
"AlbumEntity__AlbumEntity_owner"."name" AS "AlbumEntity__AlbumEntity_owner_name",
"AlbumEntity__AlbumEntity_owner"."isAdmin" AS "AlbumEntity__AlbumEntity_owner_isAdmin",
"AlbumEntity__AlbumEntity_owner"."email" AS "AlbumEntity__AlbumEntity_owner_email",
"AlbumEntity__AlbumEntity_owner"."storageLabel" AS "AlbumEntity__AlbumEntity_owner_storageLabel",
"AlbumEntity__AlbumEntity_owner"."oauthId" AS "AlbumEntity__AlbumEntity_owner_oauthId",
"AlbumEntity__AlbumEntity_owner"."profileImagePath" AS "AlbumEntity__AlbumEntity_owner_profileImagePath",
"AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword",
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
AND (
"AlbumEntity__AlbumEntity_owner"."deletedAt" IS NULL
)
WHERE
"AlbumEntity"."deletedAt" IS NULL
-- AlbumRepository.removeAsset
DELETE FROM "albums_assets_assets"
WHERE
@@ -596,16 +483,13 @@ UPDATE "albums"
SET
"albumThumbnailAssetId" = (
SELECT
"albums_assets2"."assetsId"
"album_assets"."assetsId"
FROM
"assets" "assets",
"albums_assets_assets" "albums_assets2"
"albums_assets_assets" "album_assets"
INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id"
AND "assets"."deletedAt" IS NULL
WHERE
(
"albums_assets2"."assetsId" = "assets"."id"
AND "albums_assets2"."albumsId" = "albums"."id"
)
AND ("assets"."deletedAt" IS NULL)
"album_assets"."albumsId" = "albums"."id"
ORDER BY
"assets"."fileCreatedAt" DESC
LIMIT
@@ -618,17 +502,21 @@ WHERE
SELECT
1
FROM
"albums_assets_assets" "albums_assets"
"albums_assets_assets" "album_assets"
INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id"
AND "assets"."deletedAt" IS NULL
WHERE
"albums"."id" = "albums_assets"."albumsId"
"album_assets"."albumsId" = "albums"."id"
)
OR "albums"."albumThumbnailAssetId" IS NOT NULL
AND NOT EXISTS (
SELECT
1
FROM
"albums_assets_assets" "albums_assets"
"albums_assets_assets" "album_assets"
INNER JOIN "assets" "assets" ON "album_assets"."assetsId" = "assets"."id"
AND "assets"."deletedAt" IS NULL
WHERE
"albums"."id" = "albums_assets"."albumsId"
AND "albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"
"album_assets"."albumsId" = "albums"."id"
AND "albums"."albumThumbnailAssetId" = "album_assets"."assetsId"
)

View File

@@ -24,6 +24,7 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes",
"APIKeyEntity__APIKeyEntity_user"."profileChangedAt" AS "APIKeyEntity__APIKeyEntity_user_profileChangedAt",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."userId" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_userId",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."key" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_key",
"7f5f7a38bf327bfbbf826778460704c9a50fe6f4"."value" AS "7f5f7a38bf327bfbbf826778460704c9a50fe6f4_value"

View File

@@ -8,6 +8,7 @@ SELECT
"entity"."libraryId" AS "entity_libraryId",
"entity"."deviceId" AS "entity_deviceId",
"entity"."type" AS "entity_type",
"entity"."status" AS "entity_status",
"entity"."originalPath" AS "entity_originalPath",
"entity"."thumbhash" AS "entity_thumbhash",
"entity"."encodedVideoPath" AS "entity_encodedVideoPath",
@@ -96,6 +97,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -130,6 +132,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -218,6 +221,7 @@ SELECT
"bd93d5747511a4dad4923546c51365bf1a803774"."libraryId" AS "bd93d5747511a4dad4923546c51365bf1a803774_libraryId",
"bd93d5747511a4dad4923546c51365bf1a803774"."deviceId" AS "bd93d5747511a4dad4923546c51365bf1a803774_deviceId",
"bd93d5747511a4dad4923546c51365bf1a803774"."type" AS "bd93d5747511a4dad4923546c51365bf1a803774_type",
"bd93d5747511a4dad4923546c51365bf1a803774"."status" AS "bd93d5747511a4dad4923546c51365bf1a803774_status",
"bd93d5747511a4dad4923546c51365bf1a803774"."originalPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalPath",
"bd93d5747511a4dad4923546c51365bf1a803774"."thumbhash" AS "bd93d5747511a4dad4923546c51365bf1a803774_thumbhash",
"bd93d5747511a4dad4923546c51365bf1a803774"."encodedVideoPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_encodedVideoPath",
@@ -305,6 +309,7 @@ FROM
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -402,6 +407,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -455,6 +461,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -527,6 +534,7 @@ SELECT
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -581,6 +589,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -640,6 +649,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@@ -719,6 +729,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -778,6 +789,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@@ -833,6 +845,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -892,6 +905,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@@ -997,6 +1011,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -1072,6 +1087,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -1134,109 +1150,6 @@ WHERE
AND "asset"."ownerId" IN ($1)
AND "asset"."updatedAt" > $2
-- AssetRepository.getAssetsByOriginalPath
SELECT
"asset"."id" AS "asset_id",
"asset"."deviceAssetId" AS "asset_deviceAssetId",
"asset"."ownerId" AS "asset_ownerId",
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"asset"."createdAt" AS "asset_createdAt",
"asset"."updatedAt" AS "asset_updatedAt",
"asset"."deletedAt" AS "asset_deletedAt",
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"asset"."localDateTime" AS "asset_localDateTime",
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"asset"."isFavorite" AS "asset_isFavorite",
"asset"."isArchived" AS "asset_isArchived",
"asset"."isExternal" AS "asset_isExternal",
"asset"."isOffline" AS "asset_isOffline",
"asset"."checksum" AS "asset_checksum",
"asset"."duration" AS "asset_duration",
"asset"."isVisible" AS "asset_isVisible",
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"asset"."originalFileName" AS "asset_originalFileName",
"asset"."sidecarPath" AS "asset_sidecarPath",
"asset"."stackId" AS "asset_stackId",
"asset"."duplicateId" AS "asset_duplicateId",
"exifInfo"."assetId" AS "exifInfo_assetId",
"exifInfo"."description" AS "exifInfo_description",
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"exifInfo"."orientation" AS "exifInfo_orientation",
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"exifInfo"."timeZone" AS "exifInfo_timeZone",
"exifInfo"."latitude" AS "exifInfo_latitude",
"exifInfo"."longitude" AS "exifInfo_longitude",
"exifInfo"."projectionType" AS "exifInfo_projectionType",
"exifInfo"."city" AS "exifInfo_city",
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"exifInfo"."state" AS "exifInfo_state",
"exifInfo"."country" AS "exifInfo_country",
"exifInfo"."make" AS "exifInfo_make",
"exifInfo"."model" AS "exifInfo_model",
"exifInfo"."lensModel" AS "exifInfo_lensModel",
"exifInfo"."fNumber" AS "exifInfo_fNumber",
"exifInfo"."focalLength" AS "exifInfo_focalLength",
"exifInfo"."iso" AS "exifInfo_iso",
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps",
"stack"."id" AS "stack_id",
"stack"."ownerId" AS "stack_ownerId",
"stack"."primaryAssetId" AS "stack_primaryAssetId",
"stackedAssets"."id" AS "stackedAssets_id",
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
"stackedAssets"."checksum" AS "stackedAssets_checksum",
"stackedAssets"."duration" AS "stackedAssets_duration",
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
"stackedAssets"."stackId" AS "stackedAssets_stackId",
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
FROM
"assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
AND ("stackedAssets"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" = $1
AND (
"asset"."originalPath" LIKE $2
AND "asset"."originalPath" NOT LIKE $3
)
ORDER BY
regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC
-- AssetRepository.upsertFile
INSERT INTO
"asset_files" (

View File

@@ -28,7 +28,8 @@ FROM
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes",
"LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@@ -68,7 +69,8 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes",
"LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@@ -104,7 +106,8 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes",
"LibraryEntity__LibraryEntity_owner"."profileChangedAt" AS "LibraryEntity__LibraryEntity_owner_profileChangedAt"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"

View File

@@ -1,56 +0,0 @@
-- NOTE: This file is auto generated by ./sql-generator
-- MetadataRepository.getCountries
SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "country"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
-- MetadataRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "state"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
-- MetadataRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "city"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
AND "exif"."state" = $3
-- MetadataRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "make"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."model" = $2
-- MetadataRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "model"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."make" = $2

View File

@@ -159,6 +159,7 @@ FROM
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",
@@ -260,6 +261,7 @@ FROM
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
"AssetEntity"."type" AS "AssetEntity_type",
"AssetEntity"."status" AS "AssetEntity_status",
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
@@ -391,6 +393,7 @@ SELECT
"AssetFaceEntity__AssetFaceEntity_asset"."libraryId" AS "AssetFaceEntity__AssetFaceEntity_asset_libraryId",
"AssetFaceEntity__AssetFaceEntity_asset"."deviceId" AS "AssetFaceEntity__AssetFaceEntity_asset_deviceId",
"AssetFaceEntity__AssetFaceEntity_asset"."type" AS "AssetFaceEntity__AssetFaceEntity_asset_type",
"AssetFaceEntity__AssetFaceEntity_asset"."status" AS "AssetFaceEntity__AssetFaceEntity_asset_status",
"AssetFaceEntity__AssetFaceEntity_asset"."originalPath" AS "AssetFaceEntity__AssetFaceEntity_asset_originalPath",
"AssetFaceEntity__AssetFaceEntity_asset"."thumbhash" AS "AssetFaceEntity__AssetFaceEntity_asset_thumbhash",
"AssetFaceEntity__AssetFaceEntity_asset"."encodedVideoPath" AS "AssetFaceEntity__AssetFaceEntity_asset_encodedVideoPath",

View File

@@ -13,6 +13,7 @@ FROM
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -43,6 +44,7 @@ FROM
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@@ -106,6 +108,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -136,6 +139,7 @@ SELECT
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
"stackedAssets"."type" AS "stackedAssets_type",
"stackedAssets"."status" AS "stackedAssets_status",
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
@@ -345,6 +349,7 @@ SELECT
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
@@ -401,3 +406,58 @@ FROM
INNER JOIN cte ON asset.id = cte."assetId"
ORDER BY
exif.city
-- SearchRepository.getCountries
SELECT DISTINCT
ON ("exif"."country") "exif"."country" AS "country"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
-- SearchRepository.getStates
SELECT DISTINCT
ON ("exif"."state") "exif"."state" AS "state"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
-- SearchRepository.getCities
SELECT DISTINCT
ON ("exif"."city") "exif"."city" AS "city"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."country" = $2
AND "exif"."state" = $3
-- SearchRepository.getCameraMakes
SELECT DISTINCT
ON ("exif"."make") "exif"."make" AS "make"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."model" = $2
-- SearchRepository.getCameraModels
SELECT DISTINCT
ON ("exif"."model") "exif"."model" AS "model"
FROM
"exif" "exif"
LEFT JOIN "assets" "asset" ON "asset"."id" = "exif"."assetId"
AND ("asset"."deletedAt" IS NULL)
WHERE
"asset"."ownerId" IN ($1)
AND "exif"."make" = $2

View File

@@ -39,6 +39,7 @@ FROM
"SessionEntity__SessionEntity_user"."updatedAt" AS "SessionEntity__SessionEntity_user_updatedAt",
"SessionEntity__SessionEntity_user"."quotaSizeInBytes" AS "SessionEntity__SessionEntity_user_quotaSizeInBytes",
"SessionEntity__SessionEntity_user"."quotaUsageInBytes" AS "SessionEntity__SessionEntity_user_quotaUsageInBytes",
"SessionEntity__SessionEntity_user"."profileChangedAt" AS "SessionEntity__SessionEntity_user_profileChangedAt",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."userId" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_userId",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."key" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_key",
"469e6aa7ff79eff78f8441f91ba15bb07d3634dd"."value" AS "469e6aa7ff79eff78f8441f91ba15bb07d3634dd_value"

View File

@@ -27,6 +27,7 @@ FROM
"SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId",
"SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId",
"SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type",
"SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status",
"SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath",
"SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash",
"SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath",
@@ -93,6 +94,7 @@ FROM
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."libraryId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_libraryId",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."deviceId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_deviceId",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."type" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_type",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."status" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_status",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalPath",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."thumbhash" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_thumbhash",
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."encodedVideoPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_encodedVideoPath",
@@ -156,7 +158,8 @@ FROM
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt"
FROM
"shared_links" "SharedLinkEntity"
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
@@ -213,6 +216,7 @@ SELECT
"SharedLinkEntity__SharedLinkEntity_assets"."libraryId" AS "SharedLinkEntity__SharedLinkEntity_assets_libraryId",
"SharedLinkEntity__SharedLinkEntity_assets"."deviceId" AS "SharedLinkEntity__SharedLinkEntity_assets_deviceId",
"SharedLinkEntity__SharedLinkEntity_assets"."type" AS "SharedLinkEntity__SharedLinkEntity_assets_type",
"SharedLinkEntity__SharedLinkEntity_assets"."status" AS "SharedLinkEntity__SharedLinkEntity_assets_status",
"SharedLinkEntity__SharedLinkEntity_assets"."originalPath" AS "SharedLinkEntity__SharedLinkEntity_assets_originalPath",
"SharedLinkEntity__SharedLinkEntity_assets"."thumbhash" AS "SharedLinkEntity__SharedLinkEntity_assets_thumbhash",
"SharedLinkEntity__SharedLinkEntity_assets"."encodedVideoPath" AS "SharedLinkEntity__SharedLinkEntity_assets_encodedVideoPath",
@@ -257,7 +261,8 @@ SELECT
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."profileChangedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_profileChangedAt"
FROM
"shared_links" "SharedLinkEntity"
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
@@ -309,7 +314,8 @@ FROM
"SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status",
"SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt",
"SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes",
"SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes"
"SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes",
"SharedLinkEntity__SharedLinkEntity_user"."profileChangedAt" AS "SharedLinkEntity__SharedLinkEntity_user_profileChangedAt"
FROM
"shared_links" "SharedLinkEntity"
LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId"

View File

@@ -15,7 +15,8 @@ SELECT
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
FROM
"users" "UserEntity"
WHERE
@@ -60,7 +61,8 @@ SELECT
"user"."status" AS "user_status",
"user"."updatedAt" AS "user_updatedAt",
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes",
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes"
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes",
"user"."profileChangedAt" AS "user_profileChangedAt"
FROM
"users" "user"
WHERE
@@ -82,7 +84,8 @@ SELECT
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
FROM
"users" "UserEntity"
WHERE
@@ -106,7 +109,8 @@ SELECT
"UserEntity"."status" AS "UserEntity_status",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt"
FROM
"users" "UserEntity"
WHERE

View File

@@ -0,0 +1,79 @@
-- NOTE: This file is auto generated by ./sql-generator
-- ViewRepository.getAssetsByOriginalPath
SELECT
"asset"."id" AS "asset_id",
"asset"."deviceAssetId" AS "asset_deviceAssetId",
"asset"."ownerId" AS "asset_ownerId",
"asset"."libraryId" AS "asset_libraryId",
"asset"."deviceId" AS "asset_deviceId",
"asset"."type" AS "asset_type",
"asset"."status" AS "asset_status",
"asset"."originalPath" AS "asset_originalPath",
"asset"."thumbhash" AS "asset_thumbhash",
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
"asset"."createdAt" AS "asset_createdAt",
"asset"."updatedAt" AS "asset_updatedAt",
"asset"."deletedAt" AS "asset_deletedAt",
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
"asset"."localDateTime" AS "asset_localDateTime",
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
"asset"."isFavorite" AS "asset_isFavorite",
"asset"."isArchived" AS "asset_isArchived",
"asset"."isExternal" AS "asset_isExternal",
"asset"."isOffline" AS "asset_isOffline",
"asset"."checksum" AS "asset_checksum",
"asset"."duration" AS "asset_duration",
"asset"."isVisible" AS "asset_isVisible",
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
"asset"."originalFileName" AS "asset_originalFileName",
"asset"."sidecarPath" AS "asset_sidecarPath",
"asset"."stackId" AS "asset_stackId",
"asset"."duplicateId" AS "asset_duplicateId",
"exifInfo"."assetId" AS "exifInfo_assetId",
"exifInfo"."description" AS "exifInfo_description",
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
"exifInfo"."orientation" AS "exifInfo_orientation",
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
"exifInfo"."timeZone" AS "exifInfo_timeZone",
"exifInfo"."latitude" AS "exifInfo_latitude",
"exifInfo"."longitude" AS "exifInfo_longitude",
"exifInfo"."projectionType" AS "exifInfo_projectionType",
"exifInfo"."city" AS "exifInfo_city",
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
"exifInfo"."state" AS "exifInfo_state",
"exifInfo"."country" AS "exifInfo_country",
"exifInfo"."make" AS "exifInfo_make",
"exifInfo"."model" AS "exifInfo_model",
"exifInfo"."lensModel" AS "exifInfo_lensModel",
"exifInfo"."fNumber" AS "exifInfo_fNumber",
"exifInfo"."focalLength" AS "exifInfo_focalLength",
"exifInfo"."iso" AS "exifInfo_iso",
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
"exifInfo"."colorspace" AS "exifInfo_colorspace",
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
"exifInfo"."rating" AS "exifInfo_rating",
"exifInfo"."fps" AS "exifInfo_fps"
FROM
"assets" "asset"
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
WHERE
(
(
"asset"."isVisible" = $1
AND "asset"."isArchived" = $2
AND "asset"."ownerId" = $3
)
AND (
"asset"."originalPath" LIKE $4
AND "asset"."originalPath" NOT LIKE $5
)
)
AND ("asset"."deletedAt" IS NULL)
ORDER BY
regexp_replace("asset"."originalPath", '.*/(.+)', '\1') ASC

View File

@@ -57,22 +57,6 @@ export class AlbumRepository implements IAlbumRepository {
return withoutDeletedUsers(album);
}
@GenerateSql({ params: [[DummyValue.UUID]] })
@ChunkedArray()
async getByIds(ids: string[]): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
where: {
id: In(ids),
},
relations: {
owner: true,
albumUsers: { user: true },
},
});
return albums.map((album) => withoutDeletedUsers(album));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async getByAssetId(ownerId: string, assetId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
@@ -116,34 +100,6 @@ export class AlbumRepository implements IAlbumRepository {
}));
}
/**
* Returns the album IDs that have an invalid thumbnail, when:
* - Thumbnail references an asset outside the album
* - Empty album still has a thumbnail set
*/
@GenerateSql()
async getInvalidThumbnail(): Promise<string[]> {
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = this.dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const albumContainsThumbnail = albumHasAssets
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
const albums = await this.repository
.createQueryBuilder('albums')
.select('albums.id')
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`)
.getMany();
return albums.map((album) => album.id);
}
@GenerateSql({ params: [DummyValue.UUID] })
async getOwned(ownerId: string): Promise<AlbumEntity[]> {
const albums = await this.repository.find({
@@ -199,15 +155,6 @@ export class AlbumRepository implements IAlbumRepository {
await this.repository.delete({ ownerId: userId });
}
@GenerateSql()
getAll(): Promise<AlbumEntity[]> {
return this.repository.find({
relations: {
owner: true,
},
});
}
@GenerateSql({ params: [DummyValue.UUID] })
async removeAsset(assetId: string): Promise<void> {
// Using dataSource, because there is no direct access to albums_assets_assets.
@@ -330,32 +277,26 @@ export class AlbumRepository implements IAlbumRepository {
@GenerateSql()
async updateThumbnails(): Promise<number | undefined> {
// Subquery for getting a new thumbnail.
const newThumbnail = this.assetRepository
.createQueryBuilder('assets')
.select('albums_assets2.assetsId')
.addFrom('albums_assets_assets', 'albums_assets2')
.where('albums_assets2.assetsId = assets.id')
.andWhere('albums_assets2.albumsId = "albums"."id"') // Reference to albums.id outside this query
.orderBy('assets.fileCreatedAt', 'DESC')
.limit(1);
// Using dataSource, because there is no direct access to albums_assets_assets.
const albumHasAssets = this.dataSource
.createQueryBuilder()
.select('1')
.from('albums_assets_assets', 'albums_assets')
.where('"albums"."id" = "albums_assets"."albumsId"');
const builder = this.dataSource
.createQueryBuilder('albums_assets_assets', 'album_assets')
.innerJoin('assets', 'assets', '"album_assets"."assetsId" = "assets"."id"')
.where('"album_assets"."albumsId" = "albums"."id"');
const albumContainsThumbnail = albumHasAssets
const newThumbnail = builder
.clone()
.andWhere('"albums"."albumThumbnailAssetId" = "albums_assets"."assetsId"');
.select('"album_assets"."assetsId"')
.orderBy('"assets"."fileCreatedAt"', 'DESC')
.limit(1);
const hasAssets = builder.clone().select('1');
const hasInvalidAsset = hasAssets.clone().andWhere('"albums"."albumThumbnailAssetId" = "album_assets"."assetsId"');
const updateAlbums = this.repository
.createQueryBuilder('albums')
.update(AlbumEntity)
.set({ albumThumbnailAssetId: () => `(${newThumbnail.getQuery()})` })
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${albumHasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${albumContainsThumbnail.getQuery()})`);
.where(`"albums"."albumThumbnailAssetId" IS NULL AND EXISTS (${hasAssets.getQuery()})`)
.orWhere(`"albums"."albumThumbnailAssetId" IS NOT NULL AND NOT EXISTS (${hasInvalidAsset.getQuery()})`);
const result = await updateAlbums.execute();

View File

@@ -6,7 +6,7 @@ import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { AssetFileType, AssetOrder, AssetType } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import {
AssetBuilderOptions,
AssetCreate,
@@ -295,16 +295,6 @@ export class AssetRepository implements IAssetRepository {
.execute();
}
@Chunked()
async softDeleteAll(ids: string[]): Promise<void> {
await this.repository.softDelete({ id: In(ids) });
}
@Chunked()
async restoreAll(ids: string[]): Promise<void> {
await this.repository.restore({ id: In(ids) });
}
async update(asset: AssetUpdateOptions): Promise<void> {
await this.repository.update(asset.id, asset);
}
@@ -571,13 +561,6 @@ export class AssetRepository implements IAssetRepository {
});
}
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
order: { fileCreatedAt: 'DESC' },
});
}
getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
return this.repository.findOne({
where: { albums: { id: albumId } },
@@ -604,7 +587,10 @@ export class AssetRepository implements IAssetRepository {
}
if (isTrashed !== undefined) {
builder.withDeleted().andWhere(`asset.deletedAt is not null`);
builder
.withDeleted()
.andWhere(`asset.deletedAt is not null`)
.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
}
const items = await builder.getRawMany();
@@ -762,6 +748,10 @@ export class AssetRepository implements IAssetRepository {
if (options.isTrashed !== undefined) {
builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
if (options.isTrashed) {
builder.andWhere('asset.status = :status', { status: AssetStatus.TRASHED });
}
}
if (options.isDuplicate !== undefined) {
@@ -836,50 +826,6 @@ export class AssetRepository implements IAssetRepository {
return builder.getMany();
}
async getUniqueOriginalPaths(userId: string): Promise<string[]> {
const builder = this.getBuilder({
userIds: [userId],
exifInfo: false,
withStacked: false,
isArchived: false,
isTrashed: false,
});
const results = await builder
.select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
.getRawMany();
return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
const builder = this.getBuilder({
userIds: [userId],
exifInfo: true,
withStacked: false,
isArchived: false,
isTrashed: false,
});
const assets = await builder
.where('asset.ownerId = :userId', { userId })
.andWhere(
new Brackets((qb) => {
qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
'asset.originalPath NOT LIKE :notLikePath',
{ notLikePath: `%${normalizedPath}/%/%` },
);
}),
)
.orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
.getMany();
return assets;
}
@GenerateSql({ params: [{ assetId: DummyValue.UUID, type: AssetFileType.PREVIEW, path: '/path/to/file' }] })
async upsertFile({ assetId, type, path }: { assetId: string; type: AssetFileType; path: string }): Promise<void> {
await this.fileRepository.upsert({ assetId, type, path }, { conflictPaths: ['assetId', 'type'] });

View File

@@ -29,7 +29,9 @@ import { IStackRepository } from 'src/interfaces/stack.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -61,7 +63,9 @@ import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [
{ provide: IAccessRepository, useClass: AccessRepository },
@@ -95,5 +99,7 @@ export const repositories = [
{ provide: IStorageRepository, useClass: StorageRepository },
{ provide: ISystemMetadataRepository, useClass: SystemMetadataRepository },
{ provide: ITagRepository, useClass: TagRepository },
{ provide: ITrashRepository, useClass: TrashRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IViewRepository, useClass: ViewRepository },
];

View File

@@ -41,6 +41,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.GENERATE_THUMBHASH]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_PERSON_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// tags
[JobName.TAG_CLEANUP]: QueueName.BACKGROUND_TASK,
// metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
[JobName.METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,
@@ -92,6 +95,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// Version check
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
// Trash
[JobName.QUEUE_TRASH_EMPTY]: QueueName.BACKGROUND_TASK,
};
@Instrumentation()
@@ -150,10 +156,6 @@ export class JobRepository implements IJobRepository {
}
}
deleteCronJob(name: string): void {
this.schedulerReqistry.deleteCronJob(name);
}
setConcurrency(queueName: QueueName, concurrency: number) {
const worker = this.workers[queueName];
if (!worker) {

View File

@@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
import geotz from 'geo-tz';
import { DummyValue, GenerateSql } from 'src/decorators';
import { ExifEntity } from 'src/entities/exif.entity';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
@@ -54,91 +53,4 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.warn(`Error writing exif data (${path}): ${error}`);
}
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getCountries(userIds: string[]): Promise<string[]> {
const results = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.country', 'country')
.distinctOn(['exif.country'])
.getRawMany<{ country: string }>();
return results.map(({ country }) => country).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.state', 'state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
const result = await query.getRawMany<{ state: string }>();
return result.map(({ state }) => state).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.city', 'city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
const results = await query.getRawMany<{ city: string }>();
return results.map(({ city }) => city).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.make', 'make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.model', 'model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
}
}

View File

@@ -4,6 +4,7 @@ import { getVectorExtension } from 'src/database.config';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
@@ -35,6 +36,7 @@ export class SearchRepository implements ISearchRepository {
constructor(
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
@InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository<GeodataPlacesEntity>,
@@ -71,8 +73,13 @@ export class SearchRepository implements ISearchRepository {
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
let builder = this.assetRepository.createQueryBuilder('asset');
builder = searchAssetBuilder(builder, options);
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
if (options.random) {
// TODO replace with complicated SQL magic after kysely migration
builder.addSelect('RANDOM() as r').orderBy('r');
}
return paginatedBuilder<AssetEntity>(builder, {
mode: PaginationMode.SKIP_TAKE,
skip: (pagination.page - 1) * pagination.size,
@@ -322,6 +329,93 @@ export class SearchRepository implements ISearchRepository {
return this.smartSearchRepository.clear();
}
@GenerateSql({ params: [[DummyValue.UUID]] })
async getCountries(userIds: string[]): Promise<string[]> {
const results = await this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.country', 'country')
.distinctOn(['exif.country'])
.getRawMany<{ country: string }>();
return results.map(({ country }) => country).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getStates(userIds: string[], country: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.state', 'state')
.distinctOn(['exif.state']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
const result = await query.getRawMany<{ state: string }>();
return result.map(({ state }) => state).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING, DummyValue.STRING] })
async getCities(userIds: string[], country: string | undefined, state: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.city', 'city')
.distinctOn(['exif.city']);
if (country) {
query.andWhere('exif.country = :country', { country });
}
if (state) {
query.andWhere('exif.state = :state', { state });
}
const results = await query.getRawMany<{ city: string }>();
return results.map(({ city }) => city).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraMakes(userIds: string[], model: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.make', 'make')
.distinctOn(['exif.make']);
if (model) {
query.andWhere('exif.model = :model', { model });
}
const results = await query.getRawMany<{ make: string }>();
return results.map(({ make }) => make).filter((item) => item !== '');
}
@GenerateSql({ params: [[DummyValue.UUID], DummyValue.STRING] })
async getCameraModels(userIds: string[], make: string | undefined): Promise<string[]> {
const query = this.exifRepository
.createQueryBuilder('exif')
.leftJoin('exif.asset', 'asset')
.where('asset.ownerId IN (:...userIds )', { userIds })
.select('exif.model', 'model')
.distinctOn(['exif.model']);
if (make) {
query.andWhere('exif.make = :make', { make });
}
const results = await query.getRawMany<{ model: string }>();
return results.map(({ model }) => model).filter((item) => item !== '');
}
private getRuntimeConfig(numResults?: number): string {
if (getVectorExtension() === DatabaseExtension.VECTOR) {
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall

View File

@@ -40,8 +40,16 @@ export class StorageRepository implements IStorageRepository {
return fs.stat(filepath);
}
writeFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer);
createFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer, { flag: 'wx' });
}
createOrOverwriteFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer, { flag: 'w' });
}
overwriteFile(filepath: string, buffer: Buffer) {
return fs.writeFile(filepath, buffer, { flag: 'r+' });
}
rename(source: string, target: string) {

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import { InjectDataSource, InjectRepository } from '@nestjs/typeorm';
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { TagEntity } from 'src/entities/tag.entity';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { DataSource, In, Repository } from 'typeorm';
import { DataSource, In, Repository, TreeRepository } from 'typeorm';
@Instrumentation()
@Injectable()
@@ -12,7 +13,11 @@ export class TagRepository implements ITagRepository {
constructor(
@InjectDataSource() private dataSource: DataSource,
@InjectRepository(TagEntity) private repository: Repository<TagEntity>,
) {}
@InjectRepository(TagEntity) private tree: TreeRepository<TagEntity>,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(TagRepository.name);
}
get(id: string): Promise<TagEntity | null> {
return this.repository.findOne({ where: { id } });
@@ -174,6 +179,34 @@ export class TagRepository implements ITagRepository {
});
}
async deleteEmptyTags() {
await this.dataSource.transaction(async (manager) => {
const ids = new Set<string>();
const tags = await manager.find(TagEntity);
for (const tag of tags) {
const count = await manager
.createQueryBuilder('assets', 'asset')
.innerJoin(
'asset.tags',
'asset_tags',
'asset_tags.id IN (SELECT id_descendant FROM tags_closure WHERE id_ancestor = :tagId)',
{ tagId: tag.id },
)
.getCount();
if (count === 0) {
this.logger.debug(`Found empty tag: ${tag.id} - ${tag.value}`);
ids.add(tag.id);
}
}
if (ids.size > 0) {
await manager.delete(TagEntity, { id: In([...ids]) });
this.logger.log(`Deleted ${ids.size} empty tags`);
}
});
}
private async save(partial: Partial<TagEntity>): Promise<TagEntity> {
const { id } = await this.repository.save(partial);
return this.repository.findOneOrFail({ where: { id } });

View File

@@ -0,0 +1,49 @@
import { InjectRepository } from '@nestjs/typeorm';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus } from 'src/enum';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { Paginated, paginatedBuilder, PaginationOptions } from 'src/utils/pagination';
import { In, IsNull, Not, Repository } from 'typeorm';
export class TrashRepository implements ITrashRepository {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
async getDeletedIds(pagination: PaginationOptions): Paginated<string> {
const { hasNextPage, items } = await paginatedBuilder(
this.assetRepository
.createQueryBuilder('asset')
.select('asset.id')
.where({ status: AssetStatus.DELETED })
.withDeleted(),
pagination,
);
return {
hasNextPage,
items: items.map((asset) => asset.id),
};
}
async restore(userId: string): Promise<number> {
const result = await this.assetRepository.update(
{ ownerId: userId, deletedAt: Not(IsNull()) },
{ status: AssetStatus.ACTIVE, deletedAt: null },
);
return result.affected || 0;
}
async empty(userId: string): Promise<number> {
const result = await this.assetRepository.update(
{ ownerId: userId, deletedAt: Not(IsNull()), status: AssetStatus.TRASHED },
{ status: AssetStatus.DELETED },
);
return result.affected || 0;
}
async restoreAll(ids: string[]): Promise<number> {
const result = await this.assetRepository.update({ id: In(ids) }, { status: AssetStatus.ACTIVE, deletedAt: null });
return result.affected ?? 0;
}
}

View File

@@ -0,0 +1,48 @@
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { IViewRepository } from 'src/interfaces/view.interface';
import { Brackets, Repository } from 'typeorm';
export class ViewRepository implements IViewRepository {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
async getUniqueOriginalPaths(userId: string): Promise<string[]> {
const results = await this.assetRepository
.createQueryBuilder('asset')
.where({
isVisible: true,
isArchived: false,
ownerId: userId,
})
.select("DISTINCT substring(asset.originalPath FROM '^(.*/)[^/]*$')", 'directoryPath')
.getRawMany();
return results.map((row: { directoryPath: string }) => row.directoryPath.replaceAll(/^\/|\/$/g, ''));
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
async getAssetsByOriginalPath(userId: string, partialPath: string): Promise<AssetEntity[]> {
const normalizedPath = partialPath.replaceAll(/^\/|\/$/g, '');
const assets = await this.assetRepository
.createQueryBuilder('asset')
.where({
isVisible: true,
isArchived: false,
ownerId: userId,
})
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
.andWhere(
new Brackets((qb) => {
qb.where('asset.originalPath LIKE :likePath', { likePath: `%${normalizedPath}/%` }).andWhere(
'asset.originalPath NOT LIKE :notLikePath',
{ notLikePath: `%${normalizedPath}/%/%` },
);
}),
)
.orderBy(String.raw`regexp_replace(asset.originalPath, '.*/(.+)', '\1')`, 'ASC')
.getMany();
return assets;
}
}

View File

@@ -67,7 +67,6 @@ describe(AlbumService.name, () => {
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, {});
expect(result).toHaveLength(2);
@@ -85,7 +84,6 @@ describe(AlbumService.name, () => {
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, { assetId: albumStub.oneAsset.id });
expect(result).toHaveLength(1);
@@ -98,7 +96,6 @@ describe(AlbumService.name, () => {
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.sharedWithUser.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, { shared: true });
expect(result).toHaveLength(1);
@@ -111,7 +108,6 @@ describe(AlbumService.name, () => {
albumMock.getMetadataForIds.mockResolvedValue([
{ albumId: albumStub.empty.id, assetCount: 0, startDate: undefined, endDate: undefined },
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, { shared: false });
expect(result).toHaveLength(1);
@@ -130,7 +126,6 @@ describe(AlbumService.name, () => {
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([]);
const result = await sut.getAll(authStub.admin, {});
@@ -139,48 +134,6 @@ describe(AlbumService.name, () => {
expect(albumMock.getOwned).toHaveBeenCalledTimes(1);
});
it('updates the album thumbnail by listing all albums', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.oneAssetInvalidThumbnail]);
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.oneAssetInvalidThumbnail.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.oneAssetInvalidThumbnail.id]);
albumMock.update.mockResolvedValue(albumStub.oneAssetValidThumbnail);
assetMock.getFirstAssetForAlbumId.mockResolvedValue(albumStub.oneAssetInvalidThumbnail.assets[0]);
const result = await sut.getAll(authStub.admin, {});
expect(result).toHaveLength(1);
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledTimes(1);
});
it('removes the thumbnail for an empty album', async () => {
albumMock.getOwned.mockResolvedValue([albumStub.emptyWithInvalidThumbnail]);
albumMock.getMetadataForIds.mockResolvedValue([
{
albumId: albumStub.emptyWithInvalidThumbnail.id,
assetCount: 1,
startDate: new Date('1970-01-01'),
endDate: new Date('1970-01-01'),
},
]);
albumMock.getInvalidThumbnail.mockResolvedValue([albumStub.emptyWithInvalidThumbnail.id]);
albumMock.update.mockResolvedValue(albumStub.emptyWithValidThumbnail);
assetMock.getFirstAssetForAlbumId.mockResolvedValue(null);
const result = await sut.getAll(authStub.admin, {});
expect(result).toHaveLength(1);
expect(albumMock.getInvalidThumbnail).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledTimes(1);
});
describe('create', () => {
it('creates album', async () => {
albumMock.create.mockResolvedValue(albumStub.empty);

View File

@@ -52,11 +52,7 @@ export class AlbumService {
}
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
for (const albumId of invalidAlbumIds) {
const newThumbnail = await this.assetRepository.getFirstAssetForAlbumId(albumId);
await this.albumRepository.update({ id: albumId, albumThumbnailAsset: newThumbnail });
}
await this.albumRepository.updateThumbnails();
let albums: AlbumEntity[];
if (assetId) {

View File

@@ -4,7 +4,7 @@ import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@@ -478,7 +478,10 @@ describe(AssetMediaService.name, () => {
}),
);
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]);
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -506,7 +509,10 @@ describe(AssetMediaService.name, () => {
id: 'copied-asset',
});
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -532,7 +538,10 @@ describe(AssetMediaService.name, () => {
id: 'copied-asset',
});
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
expect(assetMock.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(storageMock.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
@@ -561,7 +570,7 @@ describe(AssetMediaService.name, () => {
});
expect(assetMock.create).not.toHaveBeenCalled();
expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
expect(assetMock.updateAll).not.toHaveBeenCalled();
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: [updatedFile.originalPath, undefined] },

View File

@@ -27,7 +27,7 @@ import {
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetType, Permission } from 'src/enum';
import { AssetStatus, AssetType, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@@ -193,7 +193,7 @@ export class AssetMediaService {
// but the local variable holds the original file data paths.
const copiedPhoto = await this.createCopy(asset);
// and immediate trash it
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.TRASHED });
await this.eventRepository.emit('asset.trash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);

View File

@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetType } from 'src/enum';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
@@ -269,10 +269,10 @@ describe(AssetService.name, () => {
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: true });
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: 'asset1', deleteOnDisk: true } },
{ name: JobName.ASSET_DELETION, data: { id: 'asset2', deleteOnDisk: true } },
]);
expect(eventMock.emit).toHaveBeenCalledWith('assets.delete', {
assetIds: ['asset1', 'asset2'],
userId: 'user-id',
});
});
it('should soft delete a batch of assets', async () => {
@@ -280,7 +280,10 @@ describe(AssetService.name, () => {
await sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'], force: false });
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(assetMock.updateAll).toHaveBeenCalledWith(['asset1', 'asset2'], {
deletedAt: expect.any(Date),
status: AssetStatus.TRASHED,
});
expect(jobMock.queue.mock.calls).toEqual([]);
});
});

View File

@@ -20,7 +20,7 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { Permission } from 'src/enum';
import { AssetStatus, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@@ -302,18 +302,11 @@ export class AssetService {
const { ids, force } = dto;
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
if (force) {
await this.jobRepository.queueAll(
ids.map((id) => ({
name: JobName.ASSET_DELETION,
data: { id, deleteOnDisk: true },
})),
);
} else {
await this.assetRepository.softDeleteAll(ids);
await this.eventRepository.emit('assets.trash', { assetIds: ids, userId: auth.user.id });
}
await this.assetRepository.updateAll(ids, {
deletedAt: new Date(),
status: force ? AssetStatus.DELETED : AssetStatus.TRASHED,
});
await this.eventRepository.emit(force ? 'assets.delete' : 'assets.trash', { assetIds: ids, userId: auth.user.id });
}
async run(auth: AuthDto, dto: AssetJobsDto) {

View File

@@ -2,8 +2,8 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { snakeCase } from 'lodash';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType } from 'src/enum';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ManualJobName } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
import {
@@ -22,6 +22,26 @@ import { IMetricRepository } from 'src/interfaces/metric.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
const asJobItem = (dto: JobCreateDto): JobItem => {
switch (dto.name) {
case ManualJobName.TAG_CLEANUP: {
return { name: JobName.TAG_CLEANUP };
}
case ManualJobName.PERSON_CLEANUP: {
return { name: JobName.PERSON_CLEANUP };
}
case ManualJobName.USER_CLEANUP: {
return { name: JobName.USER_DELETE_CHECK };
}
default: {
throw new BadRequestException('Invalid job name');
}
}
};
@Injectable()
export class JobService {
private configCore: SystemConfigCore;
@@ -39,6 +59,10 @@ export class JobService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, logger);
}
async create(dto: JobCreateDto): Promise<void> {
await this.jobRepository.queue(asJobItem(dto));
}
async handleCommand(queueName: QueueName, dto: JobCommandDto): Promise<JobStatusDto> {
this.logger.debug(`Handling command: queue=${queueName},force=${dto.force}`);
@@ -162,11 +186,16 @@ export class JobService {
this.jobRepository.addHandler(queueName, concurrency, async (item: JobItem): Promise<void> => {
const { name, data } = item;
const handler = jobHandlers[name];
if (!handler) {
this.logger.warn(`Skipping unknown job: "${name}"`);
return;
}
const queueMetric = `immich.queues.${snakeCase(queueName)}.active`;
this.metricRepository.jobs.addToGauge(queueMetric, 1);
try {
const handler = jobHandlers[name];
const status = await handler(data);
const jobMetric = `immich.jobs.${name.replaceAll('-', '_')}.${status}`;
this.metricRepository.jobs.addToCounter(jobMetric, 1);

View File

@@ -43,17 +43,6 @@ export class MapService {
return this.mapRepository.getMapMarkers(userIds, albumIds, options);
}
async getMapStyle(theme: 'light' | 'dark') {
const { map } = await this.configCore.getConfig({ withCache: false });
const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle;
if (styleUrl) {
return this.mapRepository.fetchStyle(styleUrl);
}
return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`));
}
async reverseGeocode(dto: MapReverseGeocodeDto) {
const { lat: latitude, lon: longitude } = dto;
// eventually this should probably return an array of results

View File

@@ -511,7 +511,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]);
expect(storageMock.writeFile).not.toHaveBeenCalled();
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(assetMock.update).not.toHaveBeenCalledWith(
@@ -581,7 +581,7 @@ describe(MetadataService.name, () => {
type: AssetType.VIDEO,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoWithOriginalFileName.id,
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
@@ -624,7 +624,7 @@ describe(MetadataService.name, () => {
type: AssetType.VIDEO,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoWithOriginalFileName.id,
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
@@ -668,7 +668,7 @@ describe(MetadataService.name, () => {
type: AssetType.VIDEO,
});
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512);
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(storageMock.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(assetMock.update).toHaveBeenNthCalledWith(1, {
id: assetStub.livePhotoWithOriginalFileName.id,
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
@@ -716,7 +716,7 @@ describe(MetadataService.name, () => {
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(assetMock.create).toHaveBeenCalledTimes(0);
expect(storageMock.writeFile).toHaveBeenCalledTimes(0);
expect(storageMock.createOrOverwriteFile).toHaveBeenCalledTimes(0);
// The still asset gets saved by handleMetadataExtraction, but not the video
expect(assetMock.update).toHaveBeenCalledTimes(1);
expect(jobMock.queue).toHaveBeenCalledTimes(0);
@@ -1107,6 +1107,30 @@ describe(MetadataService.name, () => {
}),
);
});
it('should handle invalid rating value', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Rating: 6 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: null,
}),
);
});
it('should handle valid rating value', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
metadataMock.readTags.mockResolvedValue({ Rating: 5 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(assetMock.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
rating: 5,
}),
);
});
});
describe('handleQueueSidecar', () => {

View File

@@ -83,6 +83,18 @@ const validate = <T>(value: T): NonNullable<T> | null => {
return value ?? null;
};
const validateRange = (value: number | undefined, min: number, max: number): NonNullable<number> | null => {
// reutilizes the validate function
const val = validate(value);
// check if the value is within the range
if (val == null || val < min || val > max) {
return null;
}
return val;
};
@Injectable()
export class MetadataService {
private storageCore: StorageCore;
@@ -261,7 +273,7 @@ export class MetadataService {
// comments
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
profileDescription: exifTags.ProfileDescription || null,
rating: exifTags.Rating ?? null,
rating: validateRange(exifTags.Rating, 0, 5),
// grouping
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
@@ -529,7 +541,7 @@ export class MetadataService {
const existsOnDisk = await this.storageRepository.checkFileExists(motionAsset.originalPath);
if (!existsOnDisk) {
this.storageCore.ensureFolders(motionAsset.originalPath);
await this.storageRepository.writeFile(motionAsset.originalPath, video);
await this.storageRepository.createFile(motionAsset.originalPath, video);
this.logger.log(`Wrote motion photo video to ${motionAsset.originalPath}`);
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
}

View File

@@ -15,6 +15,8 @@ import { SessionService } from 'src/services/session.service';
import { SmartInfoService } from 'src/services/smart-info.service';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { TagService } from 'src/services/tag.service';
import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { otelShutdown } from 'src/utils/instrumentation';
@@ -34,6 +36,8 @@ export class MicroservicesService {
private sessionService: SessionService,
private storageTemplateService: StorageTemplateService,
private storageService: StorageService,
private tagService: TagService,
private trashService: TrashService,
private userService: UserService,
private duplicateService: DuplicateService,
private versionService: VersionService,
@@ -93,7 +97,9 @@ export class MicroservicesService {
[JobName.NOTIFY_ALBUM_INVITE]: (data) => this.notificationService.handleAlbumInvite(data),
[JobName.NOTIFY_ALBUM_UPDATE]: (data) => this.notificationService.handleAlbumUpdate(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.TAG_CLEANUP]: () => this.tagService.handleTagCleanup(),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
[JobName.QUEUE_TRASH_EMPTY]: () => this.trashService.handleQueueEmptyTrash(),
});
}

View File

@@ -3,7 +3,6 @@ import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
@@ -15,7 +14,6 @@ import { personStub } from 'test/fixtures/person.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
import { newSearchRepositoryMock } from 'test/repositories/search.repository.mock';
@@ -32,7 +30,6 @@ describe(SearchService.name, () => {
let personMock: Mocked<IPersonRepository>;
let searchMock: Mocked<ISearchRepository>;
let partnerMock: Mocked<IPartnerRepository>;
let metadataMock: Mocked<IMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
@@ -42,19 +39,9 @@ describe(SearchService.name, () => {
personMock = newPersonRepositoryMock();
searchMock = newSearchRepositoryMock();
partnerMock = newPartnerRepositoryMock();
metadataMock = newMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SearchService(
systemMock,
machineMock,
personMock,
searchMock,
assetMock,
partnerMock,
metadataMock,
loggerMock,
);
sut = new SearchService(systemMock, machineMock, personMock, searchMock, assetMock, partnerMock, loggerMock);
});
it('should work', () => {
@@ -99,19 +86,19 @@ describe(SearchService.name, () => {
describe('getSearchSuggestions', () => {
it('should return search suggestions (including null)', async () => {
metadataMock.getCountries.mockResolvedValue(['USA', null]);
searchMock.getCountries.mockResolvedValue(['USA', null]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: true, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA', null]);
expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
it('should return search suggestions (without null)', async () => {
metadataMock.getCountries.mockResolvedValue(['USA', null]);
searchMock.getCountries.mockResolvedValue(['USA', null]);
await expect(
sut.getSearchSuggestions(authStub.user1, { includeNull: false, type: SearchSuggestionType.COUNTRY }),
).resolves.toEqual(['USA']);
expect(metadataMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
expect(searchMock.getCountries).toHaveBeenCalledWith([authStub.user1.user.id]);
});
});
});

View File

@@ -6,6 +6,7 @@ import { PersonResponseDto } from 'src/dtos/person.dto';
import {
MetadataSearchDto,
PlacesResponseDto,
RandomSearchDto,
SearchPeopleDto,
SearchPlacesDto,
SearchResponseDto,
@@ -19,7 +20,6 @@ import { AssetOrder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { ISearchRepository, SearchExploreItem } from 'src/interfaces/search.interface';
@@ -38,7 +38,6 @@ export class SearchService {
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(SearchService.name);
@@ -95,6 +94,22 @@ export class SearchService {
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
}
async searchRandom(auth: AuthDto, dto: RandomSearchDto): Promise<SearchResponseDto> {
const userIds = await this.getUserIdsToSearch(auth);
const page = dto.page ?? 1;
const size = dto.size || 250;
const { hasNextPage, items } = await this.searchRepository.searchMetadata(
{ page, size },
{
...dto,
userIds,
random: true,
},
);
return this.mapResponse(items, hasNextPage ? (page + 1).toString() : null, { auth });
}
async searchSmart(auth: AuthDto, dto: SmartSearchDto): Promise<SearchResponseDto> {
const { machineLearning } = await this.configCore.getConfig({ withCache: false });
if (!isSmartSearchEnabled(machineLearning)) {
@@ -129,19 +144,19 @@ export class SearchService {
private getSuggestions(userIds: string[], dto: SearchSuggestionRequestDto) {
switch (dto.type) {
case SearchSuggestionType.COUNTRY: {
return this.metadataRepository.getCountries(userIds);
return this.searchRepository.getCountries(userIds);
}
case SearchSuggestionType.STATE: {
return this.metadataRepository.getStates(userIds, dto.country);
return this.searchRepository.getStates(userIds, dto.country);
}
case SearchSuggestionType.CITY: {
return this.metadataRepository.getCities(userIds, dto.country, dto.state);
return this.searchRepository.getCities(userIds, dto.country, dto.state);
}
case SearchSuggestionType.CAMERA_MAKE: {
return this.metadataRepository.getCameraMakes(userIds, dto.model);
return this.searchRepository.getCameraMakes(userIds, dto.model);
}
case SearchSuggestionType.CAMERA_MODEL: {
return this.metadataRepository.getCameraModels(userIds, dto.make);
return this.searchRepository.getCameraModels(userIds, dto.make);
}
default: {
return [];

View File

@@ -186,6 +186,8 @@ describe(ServerService.name, () => {
isInitialized: undefined,
isOnboarded: false,
externalDomain: '',
mapDarkStyleUrl: 'https://tiles.immich.cloud/v1/style/dark.json',
mapLightStyleUrl: 'https://tiles.immich.cloud/v1/style/light.json',
});
expect(systemMock.get).toHaveBeenCalled();
});

View File

@@ -129,6 +129,8 @@ export class ServerService {
isInitialized,
isOnboarded: onboarding?.isOnboarded || false,
externalDomain: config.server.externalDomain,
mapDarkStyleUrl: config.map.darkStyle,
mapLightStyleUrl: config.map.lightStyle,
};
}

View File

@@ -41,6 +41,11 @@ describe(StorageService.name, () => {
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile');
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer));
expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer));
expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer));
expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer));
expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer));
});
it('should throw an error if .immich is missing', async () => {
@@ -49,13 +54,13 @@ describe(StorageService.name, () => {
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
expect(storageMock.writeFile).not.toHaveBeenCalled();
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should throw an error if .immich is present but read-only', async () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');

View File

@@ -25,14 +25,15 @@ export class StorageService {
async onBootstrap() {
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
const enabled = flags.mountFiles ?? false;
this.logger.log('Verifying system mount folder checks');
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
// check each folder exists and is writable
for (const folder of Object.values(StorageFolder)) {
if (!flags.mountFiles) {
if (!enabled) {
this.logger.log(`Writing initial mount file for the ${folder} folder`);
await this.verifyWriteAccess(folder);
await this.createMountFile(folder);
}
await this.verifyReadAccess(folder);
@@ -81,17 +82,30 @@ export class StorageService {
}
}
private async verifyWriteAccess(folder: StorageFolder) {
private async createMountFile(folder: StorageFolder) {
const { folderPath, filePath } = this.getMountFilePaths(folder);
try {
this.storageRepository.mkdirSync(folderPath);
await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`));
await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`));
} catch (error) {
this.logger.error(`Failed to create ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
}
}
private async verifyWriteAccess(folder: StorageFolder) {
const { filePath } = this.getMountFilePaths(folder);
try {
await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`));
} catch (error) {
this.logger.error(`Failed to write ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<MEDIA_LOCATION>/${folder}")`);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
}
}

View File

@@ -100,8 +100,8 @@ const updatedConfig = Object.freeze<SystemConfig>({
},
map: {
enabled: true,
lightStyle: '',
darkStyle: '',
lightStyle: 'https://tiles.immich.cloud/v1/style/light.json',
darkStyle: 'https://tiles.immich.cloud/v1/style/dark.json',
},
reverseGeocoding: {
enabled: true,
@@ -289,6 +289,23 @@ describe(SystemConfigService.name, () => {
expect(config.machineLearning.url).toEqual('immich_machine_learning');
});
const externalDomainTests = [
{ should: 'with a trailing slash', externalDomain: 'https://demo.immich.app/' },
{ should: 'without a trailing slash', externalDomain: 'https://demo.immich.app' },
{ should: 'with a port', externalDomain: 'https://demo.immich.app:42', result: 'https://demo.immich.app:42' },
];
for (const { should, externalDomain, result } of externalDomainTests) {
it(`should normalize an external domain ${should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
const partialConfig = { server: { externalDomain } };
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
const config = await sut.getConfig();
expect(config.server.externalDomain).toEqual(result ?? 'https://demo.immich.app');
});
}
it('should warn for unknown options in yaml', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml';
const partialConfig = `

View File

@@ -140,6 +140,23 @@ describe(TagService.name, () => {
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
it('should upsert a tag and ignore leading and trailing slashes', async () => {
tagMock.getByValue.mockResolvedValueOnce(null);
tagMock.upsertValue.mockResolvedValueOnce(tagStub.parent);
tagMock.upsertValue.mockResolvedValueOnce(tagStub.child);
await expect(sut.upsert(authStub.admin, { tags: ['/Parent/Child/'] })).resolves.toBeDefined();
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(1, {
value: 'Parent',
userId: 'admin_id',
parent: undefined,
});
expect(tagMock.upsertValue).toHaveBeenNthCalledWith(2, {
value: 'Parent/Child',
userId: 'admin_id',
parent: expect.objectContaining({ id: 'tag-parent' }),
});
});
});
describe('remove', () => {

View File

@@ -14,6 +14,7 @@ import { TagEntity } from 'src/entities/tag.entity';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { JobStatus } from 'src/interfaces/job.interface';
import { AssetTagItem, ITagRepository } from 'src/interfaces/tag.interface';
import { checkAccess, requireAccess } from 'src/utils/access';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -138,6 +139,11 @@ export class TagService {
return results;
}
async handleTagCleanup() {
await this.repository.deleteEmptyTags();
return JobStatus.SUCCESS;
}
private async findOrFail(id: string) {
const tag = await this.repository.get(id);
if (!tag) {

View File

@@ -1,22 +1,24 @@
import { BadRequestException } from '@nestjs/common';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { TrashService } from 'src/services/trash.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newTrashRepositoryMock } from 'test/repositories/trash.repository.mock';
import { Mocked } from 'vitest';
describe(TrashService.name, () => {
let sut: TrashService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let jobMock: Mocked<IJobRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let trashMock: Mocked<ITrashRepository>;
let loggerMock: Mocked<ILoggerRepository>;
it('should work', () => {
expect(sut).toBeDefined();
@@ -24,11 +26,12 @@ describe(TrashService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
trashMock = newTrashRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new TrashService(accessMock, assetMock, jobMock, eventMock);
sut = new TrashService(accessMock, eventMock, jobMock, trashMock, loggerMock);
});
describe('restoreAssets', () => {
@@ -40,44 +43,70 @@ describe(TrashService.name, () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('should handle an empty list', async () => {
await expect(sut.restoreAssets(authStub.user1, { ids: [] })).resolves.toEqual({ count: 0 });
expect(accessMock.asset.checkOwnerAccess).not.toHaveBeenCalled();
});
it('should restore a batch of assets', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset1', 'asset2']));
await sut.restoreAssets(authStub.user1, { ids: ['asset1', 'asset2'] });
expect(assetMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(trashMock.restoreAll).toHaveBeenCalledWith(['asset1', 'asset2']);
expect(jobMock.queue.mock.calls).toEqual([]);
});
});
describe('restore', () => {
it('should handle an empty trash', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
expect(assetMock.restoreAll).not.toHaveBeenCalled();
expect(eventMock.clientSend).not.toHaveBeenCalled();
trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false });
trashMock.restore.mockResolvedValue(0);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 });
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
});
it('should restore and notify', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
expect(assetMock.restoreAll).toHaveBeenCalledWith([assetStub.image.id]);
expect(eventMock.emit).toHaveBeenCalledWith('assets.restore', { assetIds: ['asset-id'], userId: 'user-id' });
it('should restore', async () => {
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
trashMock.restore.mockResolvedValue(1);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
expect(trashMock.restore).toHaveBeenCalledWith('user-id');
});
});
describe('empty', () => {
it('should handle an empty trash', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
expect(jobMock.queueAll).toHaveBeenCalledWith([]);
trashMock.getDeletedIds.mockResolvedValue({ items: [], hasNextPage: false });
trashMock.empty.mockResolvedValue(0);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 });
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should empty the trash', async () => {
assetMock.getByUserId.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
await expect(sut.empty(authStub.user1)).resolves.toBeUndefined();
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-id'], hasNextPage: false });
trashMock.empty.mockResolvedValue(1);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
expect(trashMock.empty).toHaveBeenCalledWith('user-id');
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
});
});
describe('onAssetsDelete', () => {
it('should queue the empty trash job', async () => {
await expect(sut.onAssetsDelete()).resolves.toBeUndefined();
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
});
});
describe('handleQueueEmptyTrash', () => {
it('should queue asset delete jobs', async () => {
trashMock.getDeletedIds.mockResolvedValue({ items: ['asset-1'], hasNextPage: false });
await expect(sut.handleQueueEmptyTrash()).resolves.toEqual(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.ASSET_DELETION, data: { id: assetStub.image.id, deleteOnDisk: true } },
{
name: JobName.ASSET_DELETION,
data: { id: 'asset-1', deleteOnDisk: true },
},
]);
});
});

View File

@@ -1,69 +1,86 @@
import { Inject } from '@nestjs/common';
import { DateTime } from 'luxon';
import { OnEmit } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TrashResponseDto } from 'src/dtos/trash.dto';
import { Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { requireAccess } from 'src/utils/access';
import { usePagination } from 'src/utils/pagination';
export class TrashService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
) {}
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ITrashRepository) private trashRepository: ITrashRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(TrashService.name);
}
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<void> {
async restoreAssets(auth: AuthDto, dto: BulkIdsDto): Promise<TrashResponseDto> {
const { ids } = dto;
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.restoreAndSend(auth, ids);
}
async restore(auth: AuthDto): Promise<void> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, auth.user.id, {
trashedBefore: DateTime.now().toJSDate(),
}),
);
for await (const assets of assetPagination) {
const ids = assets.map((a) => a.id);
await this.restoreAndSend(auth, ids);
if (ids.length === 0) {
return { count: 0 };
}
await requireAccess(this.access, { auth, permission: Permission.ASSET_DELETE, ids });
await this.trashRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
this.logger.log(`Restored ${ids.length} assets from trash`);
return { count: ids.length };
}
async empty(auth: AuthDto): Promise<void> {
async restore(auth: AuthDto): Promise<TrashResponseDto> {
const count = await this.trashRepository.restore(auth.user.id);
if (count > 0) {
this.logger.log(`Restored ${count} assets from trash`);
}
return { count };
}
async empty(auth: AuthDto): Promise<TrashResponseDto> {
const count = await this.trashRepository.empty(auth.user.id);
if (count > 0) {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}
return { count };
}
@OnEmit({ event: 'assets.delete' })
async onAssetsDelete() {
await this.jobRepository.queue({ name: JobName.QUEUE_TRASH_EMPTY, data: {} });
}
async handleQueueEmptyTrash() {
let count = 0;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getByUserId(pagination, auth.user.id, {
trashedBefore: DateTime.now().toJSDate(),
withArchived: true,
}),
this.trashRepository.getDeletedIds(pagination),
);
for await (const assets of assetPagination) {
for await (const assetIds of assetPagination) {
this.logger.debug(`Queueing ${assetIds.length} assets for deletion from the trash`);
count += assetIds.length;
await this.jobRepository.queueAll(
assets.map((asset) => ({
assetIds.map((assetId) => ({
name: JobName.ASSET_DELETION,
data: {
id: asset.id,
id: assetId,
deleteOnDisk: true,
},
})),
);
}
}
private async restoreAndSend(auth: AuthDto, ids: string[]) {
if (ids.length === 0) {
return;
}
this.logger.log(`Queued ${count} assets for deletion from the trash`);
await this.assetRepository.restoreAll(ids);
await this.eventRepository.emit('assets.restore', { assetIds: ids, userId: auth.user.id });
return JobStatus.SUCCESS;
}
}

View File

@@ -7,7 +7,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
@@ -93,13 +93,23 @@ export class UserService {
return mapUser(user);
}
async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const updatedUser = await this.userRepository.update(auth.user.id, { profileImagePath: fileInfo.path });
const user = await this.userRepository.update(auth.user.id, {
profileImagePath: file.path,
profileChangedAt: new Date(),
});
if (oldpath !== '') {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [oldpath] } });
}
return mapCreateProfileImageResponse(updatedUser.id, updatedUser.profileImagePath);
return {
userId: user.id,
profileImagePath: user.profileImagePath,
profileChangedAt: user.profileChangedAt,
};
}
async deleteProfileImage(auth: AuthDto): Promise<void> {
@@ -107,7 +117,7 @@ export class UserService {
if (user.profileImagePath === '') {
throw new BadRequestException("Can't delete a missing profile Image");
}
await this.userRepository.update(auth.user.id, { profileImagePath: '' });
await this.userRepository.update(auth.user.id, { profileImagePath: '', profileChangedAt: new Date() });
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files: [user.profileImagePath] } });
}

View File

@@ -1,21 +1,20 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { ViewService } from 'src/services/view.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newViewRepositoryMock } from 'test/repositories/view.repository.mock';
import { Mocked } from 'vitest';
describe(ViewService.name, () => {
let sut: ViewService;
let assetMock: Mocked<IAssetRepository>;
let viewMock: Mocked<IViewRepository>;
beforeEach(() => {
assetMock = newAssetRepositoryMock();
viewMock = newViewRepositoryMock();
sut = new ViewService(assetMock);
sut = new ViewService(viewMock);
});
it('should work', () => {
@@ -25,12 +24,12 @@ describe(ViewService.name, () => {
describe('getUniqueOriginalPaths', () => {
it('should return unique original paths', async () => {
const mockPaths = ['path1', 'path2', 'path3'];
assetMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
viewMock.getUniqueOriginalPaths.mockResolvedValue(mockPaths);
const result = await sut.getUniqueOriginalPaths(authStub.admin);
expect(result).toEqual(mockPaths);
expect(assetMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
expect(viewMock.getUniqueOriginalPaths).toHaveBeenCalledWith(authStub.admin.user.id);
});
});
@@ -45,11 +44,11 @@ describe(ViewService.name, () => {
const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin }));
assetMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
viewMock.getAssetsByOriginalPath.mockResolvedValue(mockAssets);
const result = await sut.getAssetsByOriginalPath(authStub.admin, path);
expect(result).toEqual(mockAssetReponseDto);
await expect(assetMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
await expect(viewMock.getAssetsByOriginalPath(authStub.admin.user.id, path)).resolves.toEqual(mockAssets);
});
});
});

View File

@@ -1,18 +1,17 @@
import { Inject } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
export class ViewService {
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
constructor(@Inject(IViewRepository) private viewRepository: IViewRepository) {}
getUniqueOriginalPaths(auth: AuthDto): Promise<string[]> {
return this.assetRepository.getUniqueOriginalPaths(auth.user.id);
return this.viewRepository.getUniqueOriginalPaths(auth.user.id);
}
async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
const assets = await this.assetRepository.getAssetsByOriginalPath(auth.user.id, path);
const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path);
return assets.map((asset) => mapAsset(asset, { auth }));
}
}

View File

@@ -8,7 +8,7 @@ export const upsertTags = async (repository: ITagRepository, { userId, tags }: U
const results: TagEntity[] = [];
for (const tag of tags) {
const parts = tag.split('/');
const parts = tag.split('/').filter(Boolean);
let parent: TagEntity | undefined;
for (const part of parts) {