Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 388e132fca | |||
| 43e0a59f93 | |||
| da34bd714e | |||
| 77da10e3ab |
@@ -1,7 +1,5 @@
|
||||
import {
|
||||
Action,
|
||||
AssetBulkUploadCheckResult,
|
||||
AssetFileUploadResponseDto,
|
||||
addAssetsToAlbum,
|
||||
checkBulkUpload,
|
||||
createAlbum,
|
||||
@@ -10,320 +8,445 @@ import {
|
||||
getSupportedMediaTypes,
|
||||
} from '@immich/sdk';
|
||||
import byteSize from 'byte-size';
|
||||
import { Presets, SingleBar } from 'cli-progress';
|
||||
import { chunk } from 'lodash-es';
|
||||
import { Stats, createReadStream } from 'node:fs';
|
||||
import { stat, unlink } from 'node:fs/promises';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { chunk, zip } from 'lodash-es';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs, { createReadStream } from 'node:fs';
|
||||
import { access, constants, stat, unlink } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path, { basename } from 'node:path';
|
||||
import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils';
|
||||
import { basename } from 'node:path';
|
||||
import { CrawlService } from 'src/services/crawl.service';
|
||||
import { BaseOptions, authenticate } from 'src/utils';
|
||||
|
||||
const s = (count: number) => (count === 1 ? '' : 's');
|
||||
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
|
||||
|
||||
// TODO figure out why `id` is missing
|
||||
type AssetBulkUploadCheckResults = Array<AssetBulkUploadCheckResult & { id: string }>;
|
||||
type Asset = { id: string; filepath: string };
|
||||
enum CheckResponseStatus {
|
||||
ACCEPT = 'accept',
|
||||
REJECT = 'reject',
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
|
||||
interface UploadOptionsDto {
|
||||
recursive?: boolean;
|
||||
exclusionPatterns?: string[];
|
||||
dryRun?: boolean;
|
||||
skipHash?: boolean;
|
||||
delete?: boolean;
|
||||
album?: boolean;
|
||||
class Asset {
|
||||
readonly path: string;
|
||||
|
||||
id?: string;
|
||||
deviceAssetId?: string;
|
||||
fileCreatedAt?: Date;
|
||||
fileModifiedAt?: Date;
|
||||
sidecarPath?: string;
|
||||
fileSize?: number;
|
||||
albumName?: string;
|
||||
includeHidden?: boolean;
|
||||
concurrency: number;
|
||||
}
|
||||
|
||||
class UploadFile extends File {
|
||||
constructor(
|
||||
private filepath: string,
|
||||
private _size: number,
|
||||
) {
|
||||
super([], basename(filepath));
|
||||
constructor(path: string) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this._size;
|
||||
async prepare() {
|
||||
const stats = await stat(this.path);
|
||||
this.deviceAssetId = `${basename(this.path)}-${stats.size}`.replaceAll(/\s+/g, '');
|
||||
this.fileCreatedAt = stats.mtime;
|
||||
this.fileModifiedAt = stats.mtime;
|
||||
this.fileSize = stats.size;
|
||||
this.albumName = this.extractAlbumName();
|
||||
}
|
||||
|
||||
stream() {
|
||||
return createReadStream(this.filepath) as any;
|
||||
async getUploadFormData(): Promise<FormData> {
|
||||
if (!this.deviceAssetId) {
|
||||
throw new Error('Device asset id not set');
|
||||
}
|
||||
if (!this.fileCreatedAt) {
|
||||
throw new Error('File created at not set');
|
||||
}
|
||||
if (!this.fileModifiedAt) {
|
||||
throw new Error('File modified at not set');
|
||||
}
|
||||
|
||||
// TODO: doesn't xmp replace the file extension? Will need investigation
|
||||
const sideCarPath = `${this.path}.xmp`;
|
||||
let sidecarData: Blob | undefined = undefined;
|
||||
try {
|
||||
await access(sideCarPath, constants.R_OK);
|
||||
sidecarData = new File([await fs.openAsBlob(sideCarPath)], basename(sideCarPath));
|
||||
} catch {}
|
||||
|
||||
const data: any = {
|
||||
assetData: new File([await fs.openAsBlob(this.path)], basename(this.path)),
|
||||
deviceAssetId: this.deviceAssetId,
|
||||
deviceId: 'CLI',
|
||||
fileCreatedAt: this.fileCreatedAt.toISOString(),
|
||||
fileModifiedAt: this.fileModifiedAt.toISOString(),
|
||||
isFavorite: String(false),
|
||||
};
|
||||
const formData = new FormData();
|
||||
|
||||
for (const property in data) {
|
||||
formData.append(property, data[property]);
|
||||
}
|
||||
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
async delete(): Promise<void> {
|
||||
return unlink(this.path);
|
||||
}
|
||||
|
||||
public async hash(): Promise<string> {
|
||||
const sha1 = (filePath: string) => {
|
||||
const hash = createHash('sha1');
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const rs = createReadStream(filePath);
|
||||
rs.on('error', reject);
|
||||
rs.on('data', (chunk) => hash.update(chunk));
|
||||
rs.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
};
|
||||
|
||||
return await sha1(this.path);
|
||||
}
|
||||
|
||||
private extractAlbumName(): string | undefined {
|
||||
return os.platform() === 'win32' ? this.path.split('\\').at(-2) : this.path.split('/').at(-2);
|
||||
}
|
||||
}
|
||||
|
||||
export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => {
|
||||
await authenticate(baseOptions);
|
||||
class UploadOptionsDto {
|
||||
recursive? = false;
|
||||
exclusionPatterns?: string[] = [];
|
||||
dryRun? = false;
|
||||
skipHash? = false;
|
||||
delete? = false;
|
||||
album? = false;
|
||||
albumName? = '';
|
||||
includeHidden? = false;
|
||||
concurrency? = 4;
|
||||
}
|
||||
|
||||
const files = await scan(paths, options);
|
||||
if (files.length === 0) {
|
||||
console.log('No files found, exiting');
|
||||
return;
|
||||
export const upload = (paths: string[], baseOptions: BaseOptions, uploadOptions: UploadOptionsDto) =>
|
||||
new UploadCommand().run(paths, baseOptions, uploadOptions);
|
||||
|
||||
// TODO refactor this
|
||||
class UploadCommand {
|
||||
public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise<void> {
|
||||
await authenticate(baseOptions);
|
||||
|
||||
console.log('Crawling for assets...');
|
||||
const files = await this.getFiles(paths, options);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No assets found, exiting');
|
||||
return;
|
||||
}
|
||||
|
||||
const assetsToCheck = files.map((path) => new Asset(path));
|
||||
|
||||
const { newAssets, duplicateAssets } = await this.checkAssets(assetsToCheck, options.concurrency ?? 4);
|
||||
|
||||
const totalSizeUploaded = await this.upload(newAssets, options);
|
||||
const messageStart = options.dryRun ? 'Would have' : 'Successfully';
|
||||
if (newAssets.length === 0) {
|
||||
console.log('All assets were already uploaded, nothing to do.');
|
||||
} else {
|
||||
console.log(
|
||||
`${messageStart} uploaded ${newAssets.length} asset${newAssets.length === 1 ? '' : 's'} (${byteSize(totalSizeUploaded)})`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.album || options.albumName) {
|
||||
const { createdAlbumCount, updatedAssetCount } = await this.updateAlbums(
|
||||
[...newAssets, ...duplicateAssets],
|
||||
options,
|
||||
);
|
||||
console.log(`${messageStart} created ${createdAlbumCount} new album${createdAlbumCount === 1 ? '' : 's'}`);
|
||||
console.log(`${messageStart} updated ${updatedAssetCount} asset${updatedAssetCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
if (!options.delete) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`Would now have deleted assets, but skipped due to dry run`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Deleting assets that have been uploaded...');
|
||||
|
||||
await this.deleteAssets(newAssets, options);
|
||||
}
|
||||
|
||||
const { newFiles, duplicates } = await checkForDuplicates(files, options);
|
||||
public async checkAssets(
|
||||
assetsToCheck: Asset[],
|
||||
concurrency: number,
|
||||
): Promise<{ newAssets: Asset[]; duplicateAssets: Asset[]; rejectedAssets: Asset[] }> {
|
||||
for (const assets of chunk(assetsToCheck, concurrency)) {
|
||||
await Promise.all(assets.map((asset: Asset) => asset.prepare()));
|
||||
}
|
||||
|
||||
const newAssets = await uploadFiles(newFiles, options);
|
||||
await updateAlbums([...newAssets, ...duplicates], options);
|
||||
await deleteFiles(newFiles, options);
|
||||
};
|
||||
const checkProgress = new cliProgress.SingleBar(
|
||||
{ format: 'Checking assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
checkProgress.start(assetsToCheck.length, 0);
|
||||
|
||||
const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => {
|
||||
const { image, video } = await getSupportedMediaTypes();
|
||||
|
||||
console.log('Crawling for assets...');
|
||||
const files = await crawl({
|
||||
pathsToCrawl,
|
||||
recursive: options.recursive,
|
||||
exclusionPatterns: options.exclusionPatterns,
|
||||
includeHidden: options.includeHidden,
|
||||
extensions: [...image, ...video],
|
||||
});
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
const checkForDuplicates = async (files: string[], { concurrency }: UploadOptionsDto) => {
|
||||
const progressBar = new SingleBar(
|
||||
{ format: 'Checking files | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
Presets.shades_classic,
|
||||
);
|
||||
|
||||
progressBar.start(files.length, 0);
|
||||
|
||||
const newFiles: string[] = [];
|
||||
const duplicates: Asset[] = [];
|
||||
|
||||
try {
|
||||
// TODO refactor into a queue
|
||||
for (const items of chunk(files, concurrency)) {
|
||||
const dto = await Promise.all(items.map(async (filepath) => ({ id: filepath, checksum: await sha1(filepath) })));
|
||||
const { results } = await checkBulkUpload({ assetBulkUploadCheckDto: { assets: dto } });
|
||||
|
||||
for (const { id: filepath, assetId, action } of results as AssetBulkUploadCheckResults) {
|
||||
if (action === Action.Accept) {
|
||||
newFiles.push(filepath);
|
||||
} else {
|
||||
// rejects are always duplicates
|
||||
duplicates.push({ id: assetId as string, filepath });
|
||||
const newAssets = [];
|
||||
const duplicateAssets = [];
|
||||
const rejectedAssets = [];
|
||||
try {
|
||||
for (const assets of chunk(assetsToCheck, concurrency)) {
|
||||
const checkedAssets = await this.getStatus(assets);
|
||||
for (const checked of checkedAssets) {
|
||||
if (checked.status === CheckResponseStatus.ACCEPT) {
|
||||
newAssets.push(checked.asset);
|
||||
} else if (checked.status === CheckResponseStatus.DUPLICATE) {
|
||||
duplicateAssets.push(checked.asset);
|
||||
} else {
|
||||
rejectedAssets.push(checked.asset);
|
||||
}
|
||||
checkProgress.increment();
|
||||
}
|
||||
progressBar.increment();
|
||||
}
|
||||
} finally {
|
||||
checkProgress.stop();
|
||||
}
|
||||
|
||||
return { newAssets, duplicateAssets, rejectedAssets };
|
||||
}
|
||||
|
||||
public async upload(assetsToUpload: Asset[], options: UploadOptionsDto): Promise<number> {
|
||||
let totalSize = 0;
|
||||
|
||||
// Compute total size first
|
||||
for (const asset of assetsToUpload) {
|
||||
totalSize += asset.fileSize ?? 0;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
return totalSize;
|
||||
}
|
||||
|
||||
const uploadProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
uploadProgress.start(totalSize, 0);
|
||||
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
||||
|
||||
let totalSizeUploaded = 0;
|
||||
try {
|
||||
for (const assets of chunk(assetsToUpload, options.concurrency)) {
|
||||
const ids = await this.uploadAssets(assets);
|
||||
for (const [asset, id] of zipDefined(assets, ids)) {
|
||||
asset.id = id;
|
||||
if (asset.fileSize) {
|
||||
totalSizeUploaded += asset.fileSize ?? 0;
|
||||
} else {
|
||||
console.log(`Could not determine file size for ${asset.path}`);
|
||||
}
|
||||
}
|
||||
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
|
||||
}
|
||||
} finally {
|
||||
uploadProgress.stop();
|
||||
}
|
||||
|
||||
return totalSizeUploaded;
|
||||
}
|
||||
|
||||
public async getFiles(paths: string[], options: UploadOptionsDto): Promise<string[]> {
|
||||
const inputFiles: string[] = [];
|
||||
for (const pathArgument of paths) {
|
||||
const fileStat = await fs.promises.lstat(pathArgument);
|
||||
if (fileStat.isFile()) {
|
||||
inputFiles.push(pathArgument);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
progressBar.stop();
|
||||
|
||||
const files: string[] = await this.crawl(paths, options);
|
||||
files.push(...inputFiles);
|
||||
return files;
|
||||
}
|
||||
|
||||
console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`);
|
||||
public async getAlbums(): Promise<Map<string, string>> {
|
||||
const existingAlbums = await getAllAlbums({});
|
||||
|
||||
return { newFiles, duplicates };
|
||||
};
|
||||
|
||||
const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise<Asset[]> => {
|
||||
if (files.length === 0) {
|
||||
console.log('All assets were already uploaded, nothing to do.');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Compute total size first
|
||||
let totalSize = 0;
|
||||
const statsMap = new Map<string, Stats>();
|
||||
for (const filepath of files) {
|
||||
const stats = await stat(filepath);
|
||||
statsMap.set(filepath, stats);
|
||||
totalSize += stats.size;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`Would have uploaded ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const uploadProgress = new SingleBar(
|
||||
{ format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' },
|
||||
Presets.shades_classic,
|
||||
);
|
||||
uploadProgress.start(totalSize, 0);
|
||||
uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) });
|
||||
|
||||
let totalSizeUploaded = 0;
|
||||
const newAssets: Asset[] = [];
|
||||
try {
|
||||
for (const items of chunk(files, concurrency)) {
|
||||
await Promise.all(
|
||||
items.map(async (filepath) => {
|
||||
const stats = statsMap.get(filepath) as Stats;
|
||||
const response = await uploadFile(filepath, stats);
|
||||
totalSizeUploaded += stats.size ?? 0;
|
||||
uploadProgress.update(totalSizeUploaded, { value_formatted: byteSize(totalSizeUploaded) });
|
||||
newAssets.push({ id: response.id, filepath });
|
||||
return response;
|
||||
}),
|
||||
);
|
||||
const albumMapping = new Map<string, string>();
|
||||
for (const album of existingAlbums) {
|
||||
albumMapping.set(album.albumName, album.id);
|
||||
}
|
||||
} finally {
|
||||
uploadProgress.stop();
|
||||
|
||||
return albumMapping;
|
||||
}
|
||||
|
||||
console.log(`Successfully uploaded ${newAssets.length} asset${s(newAssets.length)} (${byteSize(totalSizeUploaded)})`);
|
||||
return newAssets;
|
||||
};
|
||||
|
||||
const uploadFile = async (input: string, stats: Stats): Promise<AssetFileUploadResponseDto> => {
|
||||
const { baseUrl, headers } = defaults;
|
||||
|
||||
const assetPath = path.parse(input);
|
||||
const noExtension = path.join(assetPath.dir, assetPath.name);
|
||||
|
||||
const sidecarsFiles = await Promise.all(
|
||||
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
|
||||
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
|
||||
try {
|
||||
const stats = await stat(sidecarPath);
|
||||
return new UploadFile(sidecarPath, stats.size);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
|
||||
formData.append('deviceId', 'CLI');
|
||||
formData.append('fileCreatedAt', stats.mtime.toISOString());
|
||||
formData.append('fileModifiedAt', stats.mtime.toISOString());
|
||||
formData.append('fileSize', String(stats.size));
|
||||
formData.append('isFavorite', 'false');
|
||||
formData.append('assetData', new UploadFile(input, stats.size));
|
||||
|
||||
if (sidecarData) {
|
||||
formData.append('sidecarData', sidecarData);
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/asset/upload`, {
|
||||
method: 'post',
|
||||
redirect: 'error',
|
||||
headers: headers as Record<string, string>,
|
||||
body: formData,
|
||||
});
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const deleteFiles = async (files: string[], options: UploadOptionsDto): Promise<void> => {
|
||||
if (!options.delete) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.dryRun) {
|
||||
console.log(`Would now have deleted assets, but skipped due to dry run`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Deleting assets that have been uploaded...');
|
||||
|
||||
const deletionProgress = new SingleBar(
|
||||
{ format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
Presets.shades_classic,
|
||||
);
|
||||
deletionProgress.start(files.length, 0);
|
||||
|
||||
try {
|
||||
for (const assetBatch of chunk(files, options.concurrency)) {
|
||||
await Promise.all(assetBatch.map((input: string) => unlink(input)));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
} finally {
|
||||
deletionProgress.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const updateAlbums = async (assets: Asset[], options: UploadOptionsDto) => {
|
||||
if (!options.album && !options.albumName) {
|
||||
return;
|
||||
}
|
||||
const { dryRun, concurrency } = options;
|
||||
|
||||
const albums = await getAllAlbums({});
|
||||
const existingAlbums = new Map(albums.map((album) => [album.albumName, album.id]));
|
||||
const newAlbums: Set<string> = new Set();
|
||||
for (const { filepath } of assets) {
|
||||
const albumName = getAlbumName(filepath, options);
|
||||
if (albumName && !existingAlbums.has(albumName)) {
|
||||
newAlbums.add(albumName);
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
// TODO print asset counts for new albums
|
||||
console.log(`Would have created ${newAlbums.size} new album${s(newAlbums.size)}`);
|
||||
console.log(`Would have updated ${assets.length} asset${s(assets.length)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const progressBar = new SingleBar(
|
||||
{ format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums' },
|
||||
Presets.shades_classic,
|
||||
);
|
||||
progressBar.start(newAlbums.size, 0);
|
||||
|
||||
try {
|
||||
for (const albumNames of chunk([...newAlbums], concurrency)) {
|
||||
const items = await Promise.all(
|
||||
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } })),
|
||||
);
|
||||
for (const { id, albumName } of items) {
|
||||
existingAlbums.set(albumName, id);
|
||||
}
|
||||
progressBar.increment(albumNames.length);
|
||||
}
|
||||
} finally {
|
||||
progressBar.stop();
|
||||
}
|
||||
|
||||
console.log(`Successfully created ${newAlbums.size} new album${s(newAlbums.size)}`);
|
||||
console.log(`Successfully updated ${assets.length} asset${s(assets.length)}`);
|
||||
|
||||
const albumToAssets = new Map<string, string[]>();
|
||||
for (const asset of assets) {
|
||||
const albumName = getAlbumName(asset.filepath, options);
|
||||
if (!albumName) {
|
||||
continue;
|
||||
}
|
||||
const albumId = existingAlbums.get(albumName);
|
||||
if (albumId) {
|
||||
if (!albumToAssets.has(albumId)) {
|
||||
albumToAssets.set(albumId, []);
|
||||
}
|
||||
albumToAssets.get(albumId)?.push(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
const albumUpdateProgress = new SingleBar(
|
||||
{ format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' },
|
||||
Presets.shades_classic,
|
||||
);
|
||||
albumUpdateProgress.start(assets.length, 0);
|
||||
|
||||
try {
|
||||
for (const [albumId, assets] of albumToAssets.entries()) {
|
||||
for (const assetBatch of chunk(assets, Math.min(1000 * concurrency, 65_000))) {
|
||||
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
|
||||
albumUpdateProgress.increment(assetBatch.length);
|
||||
public async updateAlbums(
|
||||
assets: Asset[],
|
||||
options: UploadOptionsDto,
|
||||
): Promise<{ createdAlbumCount: number; updatedAssetCount: number }> {
|
||||
if (options.albumName) {
|
||||
for (const asset of assets) {
|
||||
asset.albumName = options.albumName;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
albumUpdateProgress.stop();
|
||||
}
|
||||
};
|
||||
|
||||
const getAlbumName = (filepath: string, options: UploadOptionsDto) => {
|
||||
const folderName = os.platform() === 'win32' ? filepath.split('\\').at(-2) : filepath.split('/').at(-2);
|
||||
return options.albumName ?? folderName;
|
||||
};
|
||||
const existingAlbums = await this.getAlbums();
|
||||
const assetsToUpdate = assets.filter(
|
||||
(asset): asset is Asset & { albumName: string; id: string } => !!(asset.albumName && asset.id),
|
||||
);
|
||||
|
||||
const newAlbumsSet: Set<string> = new Set();
|
||||
for (const asset of assetsToUpdate) {
|
||||
if (!existingAlbums.has(asset.albumName)) {
|
||||
newAlbumsSet.add(asset.albumName);
|
||||
}
|
||||
}
|
||||
|
||||
const newAlbums = [...newAlbumsSet];
|
||||
|
||||
if (options.dryRun) {
|
||||
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
|
||||
}
|
||||
|
||||
const albumCreationProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Creating albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} albums',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
albumCreationProgress.start(newAlbums.length, 0);
|
||||
|
||||
try {
|
||||
for (const albumNames of chunk(newAlbums, options.concurrency)) {
|
||||
const newAlbumIds = await Promise.all(
|
||||
albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } }).then((r) => r.id)),
|
||||
);
|
||||
|
||||
for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
|
||||
existingAlbums.set(albumName, albumId);
|
||||
}
|
||||
|
||||
albumCreationProgress.increment(albumNames.length);
|
||||
}
|
||||
} finally {
|
||||
albumCreationProgress.stop();
|
||||
}
|
||||
|
||||
const albumToAssets = new Map<string, string[]>();
|
||||
for (const asset of assetsToUpdate) {
|
||||
const albumId = existingAlbums.get(asset.albumName);
|
||||
if (albumId) {
|
||||
if (!albumToAssets.has(albumId)) {
|
||||
albumToAssets.set(albumId, []);
|
||||
}
|
||||
albumToAssets.get(albumId)?.push(asset.id);
|
||||
}
|
||||
}
|
||||
|
||||
const albumUpdateProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Adding assets to albums | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
albumUpdateProgress.start(assetsToUpdate.length, 0);
|
||||
|
||||
try {
|
||||
for (const [albumId, assets] of albumToAssets.entries()) {
|
||||
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
|
||||
await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
|
||||
albumUpdateProgress.increment(assetBatch.length);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
albumUpdateProgress.stop();
|
||||
}
|
||||
|
||||
return { createdAlbumCount: newAlbums.length, updatedAssetCount: assetsToUpdate.length };
|
||||
}
|
||||
|
||||
public async deleteAssets(assets: Asset[], options: UploadOptionsDto): Promise<void> {
|
||||
const deletionProgress = new cliProgress.SingleBar(
|
||||
{
|
||||
format: 'Deleting local assets | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets',
|
||||
},
|
||||
cliProgress.Presets.shades_classic,
|
||||
);
|
||||
deletionProgress.start(assets.length, 0);
|
||||
|
||||
try {
|
||||
for (const assetBatch of chunk(assets, options.concurrency)) {
|
||||
await Promise.all(assetBatch.map((asset: Asset) => asset.delete()));
|
||||
deletionProgress.update(assetBatch.length);
|
||||
}
|
||||
} finally {
|
||||
deletionProgress.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async getStatus(assets: Asset[]): Promise<{ asset: Asset; status: CheckResponseStatus }[]> {
|
||||
const checkResponse = await this.checkHashes(assets);
|
||||
|
||||
const responses = [];
|
||||
for (const [check, asset] of zipDefined(checkResponse, assets)) {
|
||||
if (check.assetId) {
|
||||
asset.id = check.assetId;
|
||||
}
|
||||
|
||||
if (check.action === 'accept') {
|
||||
responses.push({ asset, status: CheckResponseStatus.ACCEPT });
|
||||
} else if (check.reason === 'duplicate') {
|
||||
responses.push({ asset, status: CheckResponseStatus.DUPLICATE });
|
||||
} else {
|
||||
responses.push({ asset, status: CheckResponseStatus.REJECT });
|
||||
}
|
||||
}
|
||||
|
||||
return responses;
|
||||
}
|
||||
|
||||
private async checkHashes(assetsToCheck: Asset[]): Promise<AssetBulkUploadCheckResult[]> {
|
||||
const checksums = await Promise.all(assetsToCheck.map((asset) => asset.hash()));
|
||||
const assetBulkUploadCheckDto = {
|
||||
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
|
||||
};
|
||||
const checkResponse = await checkBulkUpload({ assetBulkUploadCheckDto });
|
||||
return checkResponse.results;
|
||||
}
|
||||
|
||||
private async uploadAssets(assets: Asset[]): Promise<string[]> {
|
||||
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
|
||||
const results = await Promise.all(fileRequests.map((request) => this.uploadAsset(request)));
|
||||
return results.map((response) => response.id);
|
||||
}
|
||||
|
||||
private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
|
||||
const formatResponse = await getSupportedMediaTypes();
|
||||
const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
|
||||
|
||||
return crawlService.crawl({
|
||||
pathsToCrawl: paths,
|
||||
recursive: options.recursive,
|
||||
exclusionPatterns: options.exclusionPatterns,
|
||||
includeHidden: options.includeHidden,
|
||||
});
|
||||
}
|
||||
|
||||
private async uploadAsset(data: FormData): Promise<{ id: string }> {
|
||||
const { baseUrl, headers } = defaults;
|
||||
|
||||
const response = await fetch(`${baseUrl}/asset/upload`, {
|
||||
method: 'post',
|
||||
redirect: 'error',
|
||||
headers: headers as Record<string, string>,
|
||||
body: data,
|
||||
});
|
||||
if (response.status !== 200 && response.status !== 201) {
|
||||
throw new Error(await response.text());
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,12 @@ import { existsSync } from 'node:fs';
|
||||
import { mkdir, unlink } from 'node:fs/promises';
|
||||
import { BaseOptions, connect, getAuthFilePath, logError, withError, writeAuthFile } from 'src/utils';
|
||||
|
||||
export const login = async (url: string, key: string, options: BaseOptions) => {
|
||||
console.log(`Logging in to ${url}`);
|
||||
export const login = async (instanceUrl: string, apiKey: string, options: BaseOptions) => {
|
||||
console.log(`Logging in to ${instanceUrl}`);
|
||||
|
||||
const { configDirectory: configDir } = options;
|
||||
|
||||
await connect(url, key);
|
||||
await connect(instanceUrl, apiKey);
|
||||
|
||||
const [error, userInfo] = await withError(getMyUserInfo());
|
||||
if (error) {
|
||||
@@ -27,7 +27,7 @@ export const login = async (url: string, key: string, options: BaseOptions) => {
|
||||
}
|
||||
}
|
||||
|
||||
await writeAuthFile(configDir, { url, key });
|
||||
await writeAuthFile(configDir, { instanceUrl, apiKey });
|
||||
|
||||
console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ const program = new Command()
|
||||
.default(defaultConfigDirectory),
|
||||
)
|
||||
.addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
|
||||
.addOption(new Option('-k, --key [key]', 'Immich API key').env('IMMICH_API_KEY'));
|
||||
.addOption(new Option('-k, --key [apiKey]', 'Immich API key').env('IMMICH_API_KEY'));
|
||||
|
||||
program
|
||||
.command('login')
|
||||
|
||||
@@ -1,31 +1,14 @@
|
||||
import mockfs from 'mock-fs';
|
||||
import { CrawlOptions, crawl } from 'src/utils';
|
||||
import { CrawlOptions, CrawlService } from './crawl.service';
|
||||
|
||||
interface Test {
|
||||
test: string;
|
||||
options: Omit<CrawlOptions, 'extensions'>;
|
||||
options: CrawlOptions;
|
||||
files: Record<string, boolean>;
|
||||
}
|
||||
|
||||
const cwd = process.cwd();
|
||||
|
||||
const extensions = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.heif',
|
||||
'.heic',
|
||||
'.tif',
|
||||
'.nef',
|
||||
'.webp',
|
||||
'.tiff',
|
||||
'.dng',
|
||||
'.gif',
|
||||
'.mov',
|
||||
'.mp4',
|
||||
'.webm',
|
||||
];
|
||||
|
||||
const tests: Test[] = [
|
||||
{
|
||||
test: 'should return empty when crawling an empty path list',
|
||||
@@ -268,7 +251,12 @@ const tests: Test[] = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('crawl', () => {
|
||||
describe(CrawlService.name, () => {
|
||||
const sut = new CrawlService(
|
||||
['.jpg', '.jpeg', '.png', '.heif', '.heic', '.tif', '.nef', '.webp', '.tiff', '.dng', '.gif'],
|
||||
['.mov', '.mp4', '.webm'],
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
mockfs.restore();
|
||||
});
|
||||
@@ -278,7 +266,7 @@ describe('crawl', () => {
|
||||
it(test, async () => {
|
||||
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
|
||||
|
||||
const actual = await crawl({ ...options, extensions });
|
||||
const actual = await sut.crawl(options);
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([file]) => file);
|
||||
@@ -0,0 +1,70 @@
|
||||
import { glob } from 'glob';
|
||||
import * as fs from 'node:fs';
|
||||
|
||||
export class CrawlOptions {
|
||||
pathsToCrawl!: string[];
|
||||
recursive? = false;
|
||||
includeHidden? = false;
|
||||
exclusionPatterns?: string[];
|
||||
}
|
||||
|
||||
export class CrawlService {
|
||||
private readonly extensions!: string[];
|
||||
|
||||
constructor(image: string[], video: string[]) {
|
||||
this.extensions = [...image, ...video].map((extension) => extension.replace('.', ''));
|
||||
}
|
||||
|
||||
async crawl(options: CrawlOptions): Promise<string[]> {
|
||||
const { recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
|
||||
|
||||
if (!pathsToCrawl) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patterns: string[] = [];
|
||||
const crawledFiles: string[] = [];
|
||||
|
||||
for await (const currentPath of pathsToCrawl) {
|
||||
try {
|
||||
const stats = await fs.promises.stat(currentPath);
|
||||
if (stats.isFile() || stats.isSymbolicLink()) {
|
||||
crawledFiles.push(currentPath);
|
||||
} else {
|
||||
patterns.push(currentPath);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
patterns.push(currentPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let searchPattern: string;
|
||||
if (patterns.length === 1) {
|
||||
searchPattern = patterns[0];
|
||||
} else if (patterns.length === 0) {
|
||||
return crawledFiles;
|
||||
} else {
|
||||
searchPattern = '{' + patterns.join(',') + '}';
|
||||
}
|
||||
|
||||
if (recursive) {
|
||||
searchPattern = searchPattern + '/**/';
|
||||
}
|
||||
|
||||
searchPattern = `${searchPattern}/*.{${this.extensions.join(',')}}`;
|
||||
|
||||
const globbedFiles = await glob(searchPattern, {
|
||||
absolute: true,
|
||||
nocase: true,
|
||||
nodir: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
});
|
||||
|
||||
return [...crawledFiles, ...globbedFiles].sort();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +1,48 @@
|
||||
import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
|
||||
import { glob } from 'glob';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import yaml from 'yaml';
|
||||
|
||||
export interface BaseOptions {
|
||||
configDirectory: string;
|
||||
key?: string;
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
instanceUrl?: string;
|
||||
}
|
||||
|
||||
export type AuthDto = { url: string; key: string };
|
||||
type OldAuthDto = { instanceUrl: string; apiKey: string };
|
||||
export interface AuthDto {
|
||||
instanceUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export const authenticate = async (options: BaseOptions): Promise<void> => {
|
||||
const { configDirectory: configDir, url, key } = options;
|
||||
const { configDirectory: configDir, instanceUrl, apiKey } = options;
|
||||
|
||||
// provided in command
|
||||
if (url && key) {
|
||||
await connect(url, key);
|
||||
if (instanceUrl && apiKey) {
|
||||
await connect(instanceUrl, apiKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// fallback to auth file
|
||||
// fallback to file
|
||||
const config = await readAuthFile(configDir);
|
||||
await connect(config.url, config.key);
|
||||
await connect(config.instanceUrl, config.apiKey);
|
||||
};
|
||||
|
||||
export const connect = async (url: string, key: string): Promise<void> => {
|
||||
const wellKnownUrl = new URL('.well-known/immich', url);
|
||||
export const connect = async (instanceUrl: string, apiKey: string): Promise<void> => {
|
||||
const wellKnownUrl = new URL('.well-known/immich', instanceUrl);
|
||||
try {
|
||||
const wellKnown = await fetch(wellKnownUrl).then((response) => response.json());
|
||||
const endpoint = new URL(wellKnown.api.endpoint, url).toString();
|
||||
if (endpoint !== url) {
|
||||
const endpoint = new URL(wellKnown.api.endpoint, instanceUrl).toString();
|
||||
if (endpoint !== instanceUrl) {
|
||||
console.debug(`Discovered API at ${endpoint}`);
|
||||
}
|
||||
url = endpoint;
|
||||
instanceUrl = endpoint;
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
|
||||
defaults.baseUrl = url;
|
||||
defaults.headers = { 'x-api-key': key };
|
||||
defaults.baseUrl = instanceUrl;
|
||||
defaults.headers = { 'x-api-key': apiKey };
|
||||
|
||||
const [error] = await withError(getMyUserInfo());
|
||||
if (isHttpError(error)) {
|
||||
@@ -67,12 +66,7 @@ export const readAuthFile = async (dir: string) => {
|
||||
try {
|
||||
const data = await readFile(getAuthFilePath(dir));
|
||||
// TODO add class-transform/validation
|
||||
const auth = yaml.parse(data.toString()) as AuthDto | OldAuthDto;
|
||||
const { instanceUrl, apiKey } = auth as OldAuthDto;
|
||||
if (instanceUrl && apiKey) {
|
||||
return { url: instanceUrl, key: apiKey };
|
||||
}
|
||||
return auth as AuthDto;
|
||||
return yaml.parse(data.toString()) as AuthDto;
|
||||
} catch (error: Error | any) {
|
||||
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
||||
console.log('No auth file exists. Please login first.');
|
||||
@@ -93,74 +87,3 @@ export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefin
|
||||
return [error, undefined];
|
||||
}
|
||||
};
|
||||
|
||||
export interface CrawlOptions {
|
||||
pathsToCrawl: string[];
|
||||
recursive?: boolean;
|
||||
includeHidden?: boolean;
|
||||
exclusionPatterns?: string[];
|
||||
extensions: string[];
|
||||
}
|
||||
export const crawl = async (options: CrawlOptions): Promise<string[]> => {
|
||||
const { extensions: extensionsWithPeriod, recursive, pathsToCrawl, exclusionPatterns, includeHidden } = options;
|
||||
const extensions = extensionsWithPeriod.map((extension) => extension.replace('.', ''));
|
||||
|
||||
if (pathsToCrawl.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const patterns: string[] = [];
|
||||
const crawledFiles: string[] = [];
|
||||
|
||||
for await (const currentPath of pathsToCrawl) {
|
||||
try {
|
||||
const stats = await stat(currentPath);
|
||||
if (stats.isFile() || stats.isSymbolicLink()) {
|
||||
crawledFiles.push(currentPath);
|
||||
} else {
|
||||
patterns.push(currentPath);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
patterns.push(currentPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let searchPattern: string;
|
||||
if (patterns.length === 1) {
|
||||
searchPattern = patterns[0];
|
||||
} else if (patterns.length === 0) {
|
||||
return crawledFiles;
|
||||
} else {
|
||||
searchPattern = '{' + patterns.join(',') + '}';
|
||||
}
|
||||
|
||||
if (recursive) {
|
||||
searchPattern = searchPattern + '/**/';
|
||||
}
|
||||
|
||||
searchPattern = `${searchPattern}/*.{${extensions.join(',')}}`;
|
||||
|
||||
const globbedFiles = await glob(searchPattern, {
|
||||
absolute: true,
|
||||
nocase: true,
|
||||
nodir: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
});
|
||||
|
||||
return [...crawledFiles, ...globbedFiles].sort();
|
||||
};
|
||||
|
||||
export const sha1 = (filepath: string) => {
|
||||
const hash = createHash('sha1');
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const rs = createReadStream(filepath);
|
||||
rs.on('error', reject);
|
||||
rs.on('data', (chunk) => hash.update(chunk));
|
||||
rs.on('end', () => resolve(hash.digest('hex')));
|
||||
});
|
||||
};
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.7 MiB |
@@ -90,7 +90,7 @@ services:
|
||||
command: ['./run.sh', '-disable-reporting']
|
||||
ports:
|
||||
- 3000:3000
|
||||
image: grafana/grafana:10.4.1-ubuntu@sha256:65e0e7d0f0b001cb0478bce5093bff917677dc308dd27a0aa4b3ac38e4fd877c
|
||||
image: grafana/grafana:10.4.0-ubuntu@sha256:c1f582b7cc4c1b9805d187b5600ce7879550a12ef6d29571da133c3d3fc67a9c
|
||||
volumes:
|
||||
- grafana-data:/var/lib/grafana
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ Hello everyone, it is my pleasure to deliver the new release of Immich to you. T
|
||||
|
||||
Some notable features are:
|
||||
|
||||
- OAuth integration
|
||||
- LivePhoto support on iOS
|
||||
- [OAuth integration](#livephoto-ios-support-)
|
||||
- [LivePhoto support on iOS](#oauth-integration-)
|
||||
- User config system
|
||||
|
||||
<!--truncate-->
|
||||
|
||||
@@ -288,11 +288,7 @@ Immich components are typically deployed using docker. To see logs for deployed
|
||||
### How can I run Immich as a non-root user?
|
||||
|
||||
You can change the user in the container by setting the `user` argument in `docker-compose.yml` for each service.
|
||||
You may need to add mount points or docker volumes for the following internal container paths:
|
||||
|
||||
- `immich-machine-learning:/.config`
|
||||
- `immich-machine-learning:/.cache`
|
||||
- `redis:/data`
|
||||
You may need to add an additional volume to `immich-microservices` that mounts internally to `/usr/src/app/.reverse-geocoding-dump`.
|
||||
|
||||
The non-root user/group needs read/write access to the volume mounts, including `UPLOAD_LOCATION`.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,31 +0,0 @@
|
||||
# Repair Page
|
||||
|
||||
The repair page is designed to give information to the system administrator about files that are not tracked, or offline paths.
|
||||
|
||||
## Natural State
|
||||
|
||||
In this situation, everything is in its place and there is no problem that the system administrator should be aware of.
|
||||
|
||||
<img src={require('./img/repair-page.png').default} title="server statistic" />
|
||||
|
||||
## Any Other Situation
|
||||
|
||||
:::note RAM Usage
|
||||
Several users report a situation where the page fails to load. In order to solve this problem you should try to allocate more RAM to Immich, if the problem continues, you should stop using the reverse proxy while loading the page.
|
||||
:::
|
||||
|
||||
In any other situation, there are 3 different options that can appear:
|
||||
|
||||
- MATCHES - These files are matched by their checksums.
|
||||
|
||||
- OFFLINE PATHS - These files are the result of manually deleting files in the upload library or a failed file move in the past (losing track of a file).
|
||||
|
||||
:::tip
|
||||
To get rid of Offline paths you can follow this [guide](/docs/guides/remove-offline-files.md)
|
||||
:::
|
||||
|
||||
- UNTRACKED FILES - These files are not tracked by the application. They can be the result of failed moves, interrupted uploads, or left behind due to a bug.
|
||||
|
||||
In addition, you can download the information from a page, mark everything (in order to check hashing) and correct the problem if a match is found in the hashing.
|
||||
|
||||
<img src={require('./img/repair-page-1.png').default} title="server statistic" />
|
||||
@@ -1,13 +0,0 @@
|
||||
# Server Stats
|
||||
|
||||
Server statistics to show the total number of videos, photos, and usage per user.
|
||||
|
||||
:::info
|
||||
If a storage quota has been defined for the user, the usage number will be displayed as a percentage of the total storage quota allocated to him.
|
||||
:::
|
||||
|
||||
:::info External library
|
||||
External library is not included in the storage quota.
|
||||
:::
|
||||
|
||||
<img src={require('./img/server-stats.png').default} title="server statistic" />
|
||||
@@ -1,6 +1,6 @@
|
||||
# The Immich CLI
|
||||
|
||||
Immich has a command line interface (CLI) that allows you to perform certain actions from the command line.
|
||||
Immich has a CLI that allows you to perform certain actions from the command line. This CLI replaces the [legacy CLI](https://github.com/immich-app/CLI) that was previously available. The CLI is hosted in the [cli folder of the the main Immich github repository](https://github.com/immich-app/immich/tree/main/cli).
|
||||
|
||||
## Features
|
||||
|
||||
@@ -54,19 +54,16 @@ Usage: immich [options] [command]
|
||||
Command line interface for Immich
|
||||
|
||||
Options:
|
||||
-V, --version output the version number
|
||||
-d, --config-directory <directory> Configuration directory where auth.yml will be stored (default: "~/.config/immich/", env:
|
||||
IMMICH_CONFIG_DIR)
|
||||
-u, --url [url] Immich server URL (env: IMMICH_INSTANCE_URL)
|
||||
-k, --key [key] Immich API key (env: IMMICH_API_KEY)
|
||||
-h, --help display help for command
|
||||
-V, --version output the version number
|
||||
-d, --config Configuration directory (env: IMMICH_CONFIG_DIR)
|
||||
-h, --help display help for command
|
||||
|
||||
Commands:
|
||||
login|login-key <url> <key> Login using an API key
|
||||
logout Remove stored credentials
|
||||
server-info Display server information
|
||||
upload [options] [paths...] Upload assets
|
||||
help [command] display help for command
|
||||
upload [options] [paths...] Upload assets
|
||||
server-info Display server information
|
||||
login-key [instanceUrl] [apiKey] Login using an API key
|
||||
logout Remove stored credentials
|
||||
help [command] display help for command
|
||||
```
|
||||
|
||||
## Commands
|
||||
@@ -74,24 +71,23 @@ Commands:
|
||||
The upload command supports the following options:
|
||||
|
||||
```
|
||||
Usage: immich upload [paths...] [options]
|
||||
Usage: immich upload [options] [paths...]
|
||||
|
||||
Upload assets
|
||||
|
||||
Arguments:
|
||||
paths One or more paths to assets to be uploaded
|
||||
paths One or more paths to assets to be uploaded
|
||||
|
||||
Options:
|
||||
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
|
||||
-i, --ignore [paths...] Paths to ignore (default: [], env: IMMICH_IGNORE_PATHS)
|
||||
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
|
||||
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
|
||||
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
|
||||
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
|
||||
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
|
||||
-c, --concurrency <number> Number of assets to upload at the same time (default: 4, env: IMMICH_UPLOAD_CONCURRENCY)
|
||||
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
|
||||
--help display help for command
|
||||
-r, --recursive Recursive (default: false, env: IMMICH_RECURSIVE)
|
||||
-i, --ignore [paths...] Paths to ignore (env: IMMICH_IGNORE_PATHS)
|
||||
-h, --skip-hash Don't hash files before upload (default: false, env: IMMICH_SKIP_HASH)
|
||||
-H, --include-hidden Include hidden folders (default: false, env: IMMICH_INCLUDE_HIDDEN)
|
||||
-a, --album Automatically create albums based on folder name (default: false, env: IMMICH_AUTO_CREATE_ALBUM)
|
||||
-A, --album-name <name> Add all assets to specified album (env: IMMICH_ALBUM_NAME)
|
||||
-n, --dry-run Don't perform any actions, just show what will be done (default: false, env: IMMICH_DRY_RUN)
|
||||
--delete Delete local assets after upload (env: IMMICH_DELETE_ASSETS)
|
||||
--help display help for command
|
||||
```
|
||||
|
||||
Note that the above options can read from environment variables as well.
|
||||
@@ -101,13 +97,13 @@ Note that the above options can read from environment variables as well.
|
||||
You begin by authenticating to your Immich server.
|
||||
|
||||
```bash
|
||||
immich login [url] [key]
|
||||
immich login-key [instanceUrl] [apiKey]
|
||||
```
|
||||
|
||||
For instance,
|
||||
|
||||
```bash
|
||||
immich login http://192.168.1.216:2283/api HFEJ38DNSDUEG
|
||||
immich login-key http://192.168.1.216:2283/api HFEJ38DNSDUEG
|
||||
```
|
||||
|
||||
This will store your credentials in a `auth.yml` file in the configuration directory which defaults to `~/.config/`. The directory can be set with the `-d` option or the environment variable `IMMICH_CONFIG_DIR`. Please keep the file secure, either by performing the logout command after you are done, or deleting it manually.
|
||||
|
||||
@@ -2,25 +2,25 @@ import { stat } from 'node:fs/promises';
|
||||
import { app, immichCli, utils } from 'src/utils';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich login`, () => {
|
||||
describe(`immich login-key`, () => {
|
||||
beforeEach(async () => {
|
||||
await utils.resetDatabase();
|
||||
});
|
||||
|
||||
it('should require a url', async () => {
|
||||
const { stderr, exitCode } = await immichCli(['login']);
|
||||
const { stderr, exitCode } = await immichCli(['login-key']);
|
||||
expect(stderr).toBe("error: missing required argument 'url'");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should require a key', async () => {
|
||||
const { stderr, exitCode } = await immichCli(['login', app]);
|
||||
const { stderr, exitCode } = await immichCli(['login-key', app]);
|
||||
expect(stderr).toBe("error: missing required argument 'key'");
|
||||
expect(exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('should require a valid key', async () => {
|
||||
const { stderr, exitCode } = await immichCli(['login', app, 'immich-is-so-cool']);
|
||||
const { stderr, exitCode } = await immichCli(['login-key', app, 'immich-is-so-cool']);
|
||||
expect(stderr).toContain('Failed to connect to server');
|
||||
expect(stderr).toContain('Invalid API key');
|
||||
expect(stderr).toContain('401');
|
||||
@@ -30,7 +30,7 @@ describe(`immich login`, () => {
|
||||
it('should login and save auth.yml with 600', async () => {
|
||||
const admin = await utils.adminSetup();
|
||||
const key = await utils.createApiKey(admin.accessToken);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login', app, `${key.secret}`]);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login-key', app, `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
'Logging in to http://127.0.0.1:2283/api',
|
||||
'Logged in as admin@immich.cloud',
|
||||
@@ -47,7 +47,7 @@ describe(`immich login`, () => {
|
||||
it('should login without /api in the url', async () => {
|
||||
const admin = await utils.adminSetup();
|
||||
const key = await utils.createApiKey(admin.accessToken);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login', app.replaceAll('/api', ''), `${key.secret}`]);
|
||||
const { stdout, stderr, exitCode } = await immichCli(['login-key', app.replaceAll('/api', ''), `${key.secret}`]);
|
||||
expect(stdout.split('\n')).toEqual([
|
||||
'Logging in to http://127.0.0.1:2283',
|
||||
'Discovered API at http://127.0.0.1:2283/api',
|
||||
|
||||
@@ -4,8 +4,7 @@ import { beforeAll, describe, expect, it } from 'vitest';
|
||||
describe(`immich server-info`, () => {
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
const admin = await utils.adminSetup();
|
||||
await utils.cliLogin(admin.accessToken);
|
||||
await utils.cliLogin();
|
||||
});
|
||||
|
||||
it('should return the server info', async () => {
|
||||
|
||||
@@ -1,69 +1,20 @@
|
||||
import { LoginResponseDto, getAllAlbums, getAllAssets } from '@immich/sdk';
|
||||
import { getAllAlbums, getAllAssets } from '@immich/sdk';
|
||||
import { mkdir, readdir, rm, symlink } from 'node:fs/promises';
|
||||
import { asKeyAuth, immichCli, testAssetDir, utils } from 'src/utils';
|
||||
import { beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(`immich upload`, () => {
|
||||
let admin: LoginResponseDto;
|
||||
let key: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
await utils.resetDatabase();
|
||||
|
||||
admin = await utils.adminSetup();
|
||||
key = await utils.cliLogin(admin.accessToken);
|
||||
key = await utils.cliLogin();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await utils.resetDatabase(['assets', 'albums']);
|
||||
});
|
||||
|
||||
describe(`immich upload /path/to/file.jpg`, () => {
|
||||
it('should upload a single file', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(1);
|
||||
});
|
||||
|
||||
it('should skip a duplicate file', async () => {
|
||||
const first = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(first.stderr).toBe('');
|
||||
expect(first.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([expect.stringContaining('Successfully uploaded 1 asset')]),
|
||||
);
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(1);
|
||||
|
||||
const second = await immichCli(['upload', `${testAssetDir}/albums/nature/silver_fir.jpg`]);
|
||||
expect(second.stderr).toBe('');
|
||||
expect(second.stdout.split('\n')).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('Found 0 new files and 1 duplicate'),
|
||||
expect.stringContaining('All assets were already uploaded, nothing to do'),
|
||||
]),
|
||||
);
|
||||
expect(first.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('should skip files that do not exist', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `/path/to/file`]);
|
||||
expect(stderr).toBe('');
|
||||
expect(stdout.split('\n')).toEqual(expect.arrayContaining([expect.stringContaining('No files found, exiting')]));
|
||||
expect(exitCode).toBe(0);
|
||||
|
||||
const assets = await getAllAssets({}, { headers: asKeyAuth(key) });
|
||||
expect(assets.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('immich upload --recursive', () => {
|
||||
it('should upload a folder recursively', async () => {
|
||||
const { stderr, stdout, exitCode } = await immichCli(['upload', `${testAssetDir}/albums/nature/`, '--recursive']);
|
||||
|
||||
@@ -404,9 +404,10 @@ export const utils = {
|
||||
},
|
||||
]),
|
||||
|
||||
cliLogin: async (accessToken: string) => {
|
||||
const key = await utils.createApiKey(accessToken);
|
||||
await immichCli(['login', app, `${key.secret}`]);
|
||||
cliLogin: async () => {
|
||||
const admin = await utils.adminSetup();
|
||||
const key = await utils.createApiKey(admin.accessToken);
|
||||
await immichCli(['login-key', app, `${key.secret}`]);
|
||||
return key.secret;
|
||||
},
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -1,27 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#FA2921"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m52.25,43.55c3.4,3.08 6.15,6.39 7.91,9.5 3.03,-5.55 5.06,-12.14 5.08,-16.34 0,-0.03 0,-0.06 0,-0.08 0,-6.21 -6.06,-8.63 -11.28,-8.63 -5.22,0 -11.28,2.42 -11.28,8.63 0,0.08 0,0.2 0,0.34 2.91,1.32 6.36,3.69 9.56,6.59z" />
|
||||
<path
|
||||
android:fillColor="#ED79B5"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m33.68,60.5c2.13,-2.42 5.39,-5.05 9.08,-7.27 3.92,-2.36 7.84,-4.01 11.28,-4.76 -4.22,-4.67 -9.72,-8.67 -13.62,-10 -0.03,-0.01 -0.05,-0.02 -0.08,-0.03 -5.78,-1.92 -9.9,3.23 -11.51,8.31 -1.61,5.08 -1.24,11.72 4.54,13.64 0.08,0.03 0.18,0.06 0.31,0.1z" />
|
||||
<path
|
||||
android:fillColor="#FFB400"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M79.17,46.67C77.56,41.59 73.44,36.44 67.66,38.36c-0.08,0.03 -0.19,0.06 -0.31,0.1 -0.33,3.24 -1.46,7.33 -3.17,11.34 -1.81,4.27 -4.04,7.96 -6.39,10.64 6.1,1.24 12.85,1.17 16.76,-0.1 0.03,-0.01 0.05,-0.02 0.08,-0.03 5.78,-1.92 6.15,-8.56 4.54,-13.64z" />
|
||||
<path
|
||||
android:fillColor="#1E83F7"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m48.82,65.46c-0.98,-4.54 -1.3,-8.87 -0.94,-12.45 -5.64,2.67 -11.07,6.78 -13.5,10.16 -0.02,0.02 -0.03,0.05 -0.05,0.07 -3.57,5.03 -0.06,10.63 4.16,13.77 4.22,3.14 10.51,4.83 14.09,-0.2 0.05,-0.07 0.11,-0.16 0.19,-0.27 -1.59,-2.82 -3.03,-6.81 -3.95,-11.08z" />
|
||||
<path
|
||||
android:fillColor="#18C249"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m73.58,62.92c-3.11,0.68 -7.26,0.84 -11.52,0.42 -4.53,-0.45 -8.64,-1.47 -11.86,-2.93 0.73,6.31 2.88,12.87 5.28,16.28 0.02,0.02 0.03,0.05 0.05,0.07 3.57,5.03 9.86,3.34 14.09,0.2 4.22,-3.14 7.74,-8.74 4.16,-13.77 -0.05,-0.07 -0.11,-0.16 -0.19,-0.27z" />
|
||||
<vector android:height="200dp"
|
||||
android:viewportHeight="1300"
|
||||
android:viewportWidth="1300"
|
||||
android:width="200dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#4081ef"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578,922.6c-2.2,-0.2 -5.5,-1 -9.7,-2.2c-52.4,-15.7 -99,-46.5 -133.8,-88.5c-8.8,-10.7 -17.2,-22.4 -19.4,-27.5c-8.1,-18.1 -6.3,-38.7 4.8,-55.4c5,-7.5 13.2,-15 20.5,-18.7c1.2,-0.6 54.1,-20 55.8,-20.4c0.5,-0.1 0.5,0.2 -0.3,2.1c-0.7,1.7 -1,3.1 -1.1,5.5l-0.1,3.2l2.8,5.8c8.7,17.9 19.2,32.7 33.2,46.4c6.3,6.2 7.8,7.6 13.8,12.3c22.7,18.1 52,30.7 79.9,34.3c2.5,0.3 5,0.8 5.7,1c2.8,0.9 7.7,-0.8 11,-3.7l1.8,-1.6l-0.2,4.8c-0.1,2.7 -0.6,15.4 -1,28.3c-0.6,20.3 -0.8,24 -1.5,27.5c-3.9,20.7 -18.6,37.5 -38.4,44.1c-4.6,1.5 -8,2.2 -13.1,2.7c-4.6,0.5 -5.9,0.4 -10.7,0Z" />
|
||||
<path
|
||||
android:fillColor="#31a452"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M707.3,922.4c-4,-0.4 -9.4,-1.6 -13.2,-2.9c-3.4,-1.2 -10,-4.4 -12.5,-6.1c-10.9,-7.4 -19,-17.9 -23.1,-30c-2.2,-6.7 -2.3,-7.5 -3.3,-36.9c-0.5,-14.9 -0.9,-27.9 -0.9,-28.9l-0,-1.9l2.3,1.8c2.6,2 6.6,3.4 8.5,3.1c0.6,-0.1 3,-0.5 5.3,-0.8c37.7,-5.3 71.2,-22.2 97.4,-49.1c12.2,-12.5 21.4,-25.5 29.9,-42.4l3.5,-7l0,-3.6c0,-3.1 -0.1,-3.8 -1,-5.7c-0.5,-1.2 -0.9,-2.1 -0.9,-2.2c0.2,-0.2 55.3,20.1 56.9,20.9c2.6,1.3 6.6,4.1 9.9,7c9.2,7.7 16.1,19.4 18.8,31.8c0.7,3.1 0.8,4.8 0.8,11.3c0,8.6 -0.5,11.7 -2.9,18.7c-1.7,5 -2.9,7.2 -7.1,13.1c-7.6,11 -15.3,20.5 -25.2,31.2c-32.8,35.4 -76.5,62.5 -123.4,76.3c-8,2.5 -12.4,3 -19.8,2.3Z" />
|
||||
<path
|
||||
android:fillColor="#de7fb3"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M623.1,811c-25.9,-4.2 -50.7,-14.9 -71.7,-31c-5.2,-4 -8.7,-7.1 -14.1,-12.4c-12.7,-12.5 -21.9,-24.9 -30.5,-41.4c-2.3,-4.4 -2.4,-4.7 -2.4,-7.1c0,-8.8 8.5,-15.2 16.9,-12.7c5.6,1.7 9.6,6.8 9.7,12.2c0,2.6 -0.8,4.6 -2.6,6.2c-1.2,1.1 -3.2,1.9 -4.6,1.9c-1.2,0 -3.3,-0.8 -4.3,-1.6c-2.1,-1.8 -2,-1 0.4,3.2c19.3,33.8 52.3,59.1 90,69.1c5.7,1.5 11.5,2.7 11.8,2.4c0.1,-0.1 -0.4,-0.8 -1.3,-1.6c-5.1,-4.5 -2.3,-11.7 5,-12.8c5.4,-0.8 11.4,2.7 13.9,8c0.8,1.7 1,2.5 1,5.3c0,2.8 -0.1,3.5 -1,5.3c-2,4.3 -6.8,7.9 -10.3,7.8c-0.9,-0.1 -3.6,-0.5 -5.9,-0.8Z" />
|
||||
<path
|
||||
android:fillColor="#4081ef"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M665.1,811.2c-3.4,-1.3 -6.4,-4.3 -7.8,-8.1c-1.1,-2.9 -0.9,-7.3 0.5,-10.2c2.6,-5.3 8.7,-8.5 14.4,-7.5c2.9,0.5 4.7,1.9 6,4.3c0.8,1.6 1,2.2 0.8,3.6c-0.3,2.2 -0.9,3.3 -2.7,4.8c-0.8,0.7 -1.4,1.4 -1.3,1.5c0.5,0.5 13.4,-2.7 21.3,-5.4c33.6,-11.3 62.5,-35.1 80.4,-66.1c2.5,-4.4 2.6,-5 0.5,-3.2c-2.8,2.4 -7,1.9 -9.6,-1c-4,-4.6 -0.7,-13.8 6.1,-16.9c2,-0.9 2.7,-1 5.5,-1c2.9,0 3.5,0.1 5.6,1.1c4.4,2.1 7.4,6.4 7.8,11c0.2,2.2 0.1,2.3 -2.2,6.9c-23,45.9 -67,78.1 -117.2,85.9c-5.5,0.9 -6.3,1 -8.1,0.3Z" />
|
||||
<path
|
||||
android:fillColor="#31a452"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.6,771.5c-4.7,-0.9 -8.7,-2.7 -12.9,-5.9c-10.8,-8.1 -13.5,-22.3 -6.6,-33.7c0.7,-1.2 1.1,-2.2 1,-2.4c-0.2,-0.2 -1.2,-0.6 -2.3,-1.1c-7.6,-3 -13,-10.6 -13.5,-19.1c-0.5,-7.4 3.1,-15 9,-19.4c1,-0.7 2.2,-1.5 2.6,-1.8c0.8,-0.4 68.9,-22.7 69.4,-22.7c0.2,0 0.7,0.7 1.2,1.5c0.5,0.8 1.6,2.3 2.4,3.3c1.2,1.4 1.5,1.9 1.2,2.3c-0.2,0.3 -6.9,9.5 -14.8,20.5c-15.9,21.9 -15.5,21.3 -13.4,23.4c1.3,1.3 2.9,1.4 4.4,0.3c0.6,-0.4 7.5,-9.7 15.5,-20.7c11.2,-15.4 14.6,-19.9 15,-19.7c0.9,0.4 5.5,1.9 6.6,2.1l1,0.2l-0,35.3c-0,39.7 -0,38.8 -2.5,44c-2.6,5.3 -7.2,9.3 -12.7,11.2c-3.7,1.3 -6.8,1.6 -10.2,1c-5.5,-0.9 -9.8,-3.2 -13.7,-7.4l-2.2,-2.4l-0.6,0.9c-3,4.3 -8.6,8.1 -14,9.5c-2.8,0.9 -7.8,1.2 -9.9,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#ffb800"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M710.4,771.5c-5.5,-0.9 -9.9,-3.2 -14.3,-7.6l-3.2,-3.2l-0.7,1c-2.3,3.3 -6.8,6.5 -11.1,7.9c-3.7,1.2 -9.2,1.4 -12.6,0.3c-7.1,-2.1 -12.7,-7.4 -15.2,-14.3l-0.9,-2.6l0,-74.2l1.8,-0.4c1,-0.2 2.7,-0.8 3.9,-1.2c1.1,-0.5 2.1,-0.8 2.2,-0.7c0.1,0.1 6.5,9 14.4,19.9c7.8,10.9 14.7,20.1 15.2,20.5c2.2,1.9 5.4,0.4 5.4,-2.6c0,-1.4 -1,-2.9 -13.8,-20.5c-7.6,-10.5 -14.2,-19.6 -14.7,-20.4l-0.9,-1.3l1.4,-1.7c0.8,-0.9 1.9,-2.5 2.5,-3.4l1,-1.6l34.4,11.2c18.9,6.2 35.1,11.6 35.9,12.1c6.8,4 11.1,11.3 11.1,19.1c0,4.1 -0.5,6.4 -2.4,10.2c-2,4.1 -5.5,7.6 -9.6,9.7c-1.6,0.8 -3.2,1.5 -3.4,1.5c-1,0 -0.9,0.7 0.3,2.6c2.8,4.3 4,8.5 3.9,13.7c0,8.1 -3.7,15.2 -10.6,20.3c-6.5,4.8 -13.4,6.7 -20,5.7Z" />
|
||||
<path
|
||||
android:fillColor="#de7fb3"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M421.4,714.9c-0.5,-0.1 -2.3,-0.4 -3.9,-0.7c-15.6,-2.6 -30.4,-12.6 -38.8,-26.2c-3.5,-5.7 -6.4,-13.2 -7.8,-19.9c-1.2,-6.1 -0.8,-28.1 0.8,-43.1c4.5,-43 19,-84.3 42.2,-120.7c6.5,-10.2 14.9,-21.5 18.2,-24.6c17.8,-16.6 43.1,-20.5 64.8,-10c4.3,2.1 8.8,5.1 12.7,8.6c2.8,2.4 5.8,6.1 20.9,25.5c9.7,12.5 17.8,22.8 17.9,23c0.2,0.2 -0.9,0.4 -3.2,0.4c-2.5,0 -4.1,0.2 -5.7,0.7c-2.1,0.7 -2.6,1.1 -7.9,6.3c-8.2,8.1 -14.4,15.3 -20.3,23.9c-15.5,22.2 -25.4,47.7 -28.8,74.8c-2.2,16.9 -1.6,37.5 1.6,52.3c0.3,1.4 0.5,2.8 0.4,3c-0.1,0.2 0.2,1.3 0.8,2.4c1.1,2.4 4.3,5.7 6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2 -13.1,3.8 -27.6,8c-16.4,4.7 -27.7,7.8 -29.8,8.1c-3.1,0.4 -11.1,0.6 -13.3,0.2Z" />
|
||||
<path
|
||||
android:fillColor="#ffb800"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M862.2,714.7c-2.1,-0.3 -33.8,-9.1 -56.5,-15.8l-2.5,-0.7l1.6,-0.8c3.4,-1.7 7.2,-6.6 7.3,-9.6c0,-0.7 0.4,-3.3 0.8,-5.8c3.9,-22.7 3.1,-46.1 -2.5,-68.4c-6.4,-25.5 -18.6,-49.2 -35.8,-69.1c-4.6,-5.3 -14.8,-15.4 -16.4,-16.1c-2.4,-1.1 -5.1,-1.6 -8,-1.4l-2.7,0.2l1.2,-1.5c0.7,-0.8 8.5,-10.8 17.5,-22.3c8.9,-11.5 17.2,-21.8 18.5,-23.1c2.6,-2.7 7,-6.2 10.3,-8.2c19.3,-11.6 43,-11.1 61.6,1.2c5.4,3.6 8.2,6.2 12.3,11.7c26.4,34.5 44,73.7 52.3,116.2c3.4,17.6 4.9,33.3 5,52.4c0,13 -0.2,14.8 -2.5,21.8c-8.4,26.2 -34.5,42.8 -61.5,39.3Z" />
|
||||
<path
|
||||
android:fillColor="#e64132"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M501.4,691.5c-2,-0.5 -4.6,-1.9 -6,-3.3c-2.5,-2.4 -3.1,-3.5 -3.7,-7.3c-4.4,-27.3 -2.2,-54 6.7,-79.3c5.3,-15.1 13.5,-30.5 23,-43.1c5.8,-7.8 16.6,-19.5 19,-20.7c4.7,-2.4 11.3,-1.2 15.2,2.7c5.4,5.4 5.2,13.9 -0.3,19.1c-4.3,4 -9.4,4.4 -12.6,0.9c-1.7,-1.9 -2.2,-3.9 -1.7,-6.4c0.2,-1.1 0.3,-2 0.2,-2.2c-0.3,-0.3 -3.6,3.3 -8.3,9.1c-17.6,21.8 -28.5,48 -31.9,76.5c-1.1,9.3 -1,26.4 0.1,34.6c0.3,1.8 0.8,1.9 1.4,0.1c0.9,-2.6 4,-4.7 6.8,-4.7c3,-0 5.9,2.2 7.5,5.7c0.6,1.3 0.8,2.3 0.8,5.2c-0,3.3 -0.1,3.8 -1.1,5.7c-1.4,2.7 -4.6,5.7 -7.1,6.6c-2.5,0.9 -6.1,1.2 -8,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#31a452"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M790.1,691.5c-3.7,-0.6 -7.7,-3.6 -9.4,-7.1c-3.8,-7.5 0.1,-16.9 6.9,-16.9c3.1,0 5.8,2 6.9,5.2c0.4,1.2 0.5,1.3 0.7,0.7c1.3,-3.7 1.7,-26.4 0.6,-35.7c-3.6,-29.6 -14.5,-55.3 -33,-77.9c-5.5,-6.7 -8.4,-9.4 -7.1,-6.6c0.7,1.4 0.5,4.3 -0.3,5.9c-0.9,1.7 -3.2,3.5 -5,3.8c-3.2,0.6 -7.9,-1.6 -10.2,-4.8c-6.5,-8.8 -0.5,-21.2 10.4,-21.4c4.6,-0.1 5.2,0.3 11.2,6.4c12.1,12.3 21.1,24.9 28.8,40.3c13.2,26.3 18.6,54.9 16.1,84.5c-0.5,5.6 -2,15.7 -2.6,17.1c-1.3,2.8 -4.8,5.5 -8.4,6.5c-2.3,0.4 -3.1,0.4 -5.6,0Z" />
|
||||
<path
|
||||
android:fillColor="#4081ef"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M545.7,680.2c-6,-1.3 -12.2,-6.2 -14.9,-11.7c-3.4,-7 -3.1,-15.1 0.9,-21.6c0.7,-1.2 1.2,-2.3 1.1,-2.4c-0.1,-0.1 -1.1,-0.6 -2.1,-1c-3.9,-1.5 -8.1,-4.8 -10.7,-8.3c-4.6,-6.2 -6.1,-14.6 -3.9,-22.1c2.9,-10.3 9.4,-16.8 19.1,-19.3c2.8,-0.7 9,-0.8 11.7,-0c1.1,0.3 2.2,0.5 2.4,0.5c0.2,-0 0.3,-0.7 0.3,-1.5c0,-2.9 0.8,-5.8 2.4,-9.2c5.2,-10.8 18.1,-15.5 29,-10.5c2.7,1.2 6.2,3.8 7.8,5.8c0.7,0.8 10.3,14 21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1 -1.9,2.6 -2.5,3.5c-0.6,1 -1.2,1.7 -1.5,1.6c-4.5,-1.7 -46.7,-15 -47.7,-15c-1.9,0 -3.1,1.3 -3.1,3.2c0,1 0.2,1.7 0.8,2.3c0.6,0.6 7.8,3.1 24.5,8.5l23.7,7.7l-0.2,8.6l-32.6,10.5c-18,5.9 -33.9,10.9 -35.2,11.2c-3.1,0.7 -6.6,0.7 -9.6,0.1Z" />
|
||||
<path
|
||||
android:fillColor="#e64132"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M740,679.8c-1.8,-0.5 -17.5,-5.6 -35,-11.3l-31.8,-10.4l1,-4.3l0,-4.3l22.6,-7.7c15,-4.9 24,-8 24.6,-8.5c0.7,-0.6 0.9,-1.1 0.9,-2.2c-0,-2 -1.2,-3.3 -3.1,-3.3c-0.9,-0 -10.5,2.9 -24.7,7.5c-12.8,4.1 -23.4,7.5 -23.6,7.5c-0.1,-0 -0.7,-0.8 -1.3,-1.9c-0.6,-1 -1.6,-2.5 -2.2,-3.2c-0.7,-0.7 -1.2,-1.5 -1.2,-1.6c0,-0.2 9.6,-13.5 21.4,-29.6c18.9,-26 21.6,-29.6 23.6,-31.1c5.7,-4.4 13.1,-5.8 19.7,-3.9c9,2.7 16.1,11.6 16.1,20.3c0,2.3 -0.1,2.3 3.1,1.5c4.7,-1.1 11.5,-0.5 16,1.5c4.6,2 9,6 11.5,10.2c2.1,3.6 3.9,9.4 4.2,13.2c0.3,5.2 -1.1,10.7 -4,15.3c-2.6,4.1 -7.8,8.3 -12.1,9.8c-0.9,0.3 -1.7,0.8 -1.7,1c0,0.2 0.4,1 0.9,1.7c2.4,3.6 3.6,7.7 3.5,12.7c0,5.8 -2.1,10.7 -6.4,15.1c-4,4.1 -8.9,6.3 -14.9,6.5c-3.3,0.4 -4.3,0.3 -7.1,-0.5Z" />
|
||||
<path
|
||||
android:fillColor="#f2f5fb"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M643.7,671.9c-6.1,-1.6 -11.4,-6.8 -13.2,-12.9c-0.7,-2.4 -0.7,-7.5 0,-9.9c1.7,-5.8 6.6,-10.8 12.3,-12.5c2.7,-0.8 7.2,-0.9 10,-0.2c6.2,1.6 11.6,7.1 13.2,13.3c1.6,6 -0.3,12.6 -5,17.3c-4.6,4.6 -11.3,6.5 -17.3,4.9Z" />
|
||||
<path
|
||||
android:fillColor="#de7fb3"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M615.8,602.8c-13.3,-18.3 -21.2,-29.6 -22,-31.1c-1.4,-3 -1.9,-5.5 -1.9,-9.4c0,-14.1 13.1,-24.4 27.1,-21.4c1.4,0.3 2.6,0.5 2.7,0.5c0.1,0 0.3,-1.3 0.4,-2.8c0.8,-10.7 8.4,-19.6 18.9,-22.4c3.9,-1 10.6,-1 14.5,-0c8.9,2.3 15.9,9.3 18.2,18.2c0.4,1.5 0.7,3.7 0.7,4.9c-0,1.2 0.1,2.1 0.3,2.1c0.2,-0 1.5,-0.3 3,-0.6c7.4,-1.6 15.2,0.7 20.5,6c4.3,4.3 6.6,9.6 6.6,15.6c-0,4 -0.6,6.5 -2.4,10c-0.6,1.2 -10.4,15 -21.7,30.7c-17.8,24.5 -20.8,28.5 -21.4,28.3c-0.4,-0.1 -1.9,-0.6 -3.4,-1.1c-1.5,-0.5 -2.9,-0.9 -3.3,-0.9c-0.7,-0 -0.7,-0.8 -0.3,-25.5l-0,-25.5l-1.4,-0.9c-1,-1.1 -2.5,-1.5 -3.8,-0.9c-2,0.8 -2,-0.5 -1.8,27.2l-0,25.8l-1.2,-0c-0.5,-0.2 -2.4,0.3 -4,0.9c-1.6,0.6 -3.1,1.1 -3.2,1.1c-0.2,-0.1 -9.6,-13 -21.1,-28.8Z" />
|
||||
<path
|
||||
android:fillColor="#ffb800"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.4,537.8c-4.1,-0.9 -7.7,-3.6 -9.6,-7.4c-1.4,-2.8 -1.7,-7.3 -0.5,-10.3c1.7,-4.5 3.9,-6.1 15.6,-11.2c15.8,-7 31.4,-11.1 49.2,-12.9c7.3,-0.8 23.2,-0.8 30.6,0c17.4,1.8 33.3,6 49.1,13c7.3,3.2 12.5,6.1 13.6,7.5c4.3,5.6 3.8,12.7 -1.1,17.6c-5.1,5.1 -12.9,5.4 -18.1,0.7c-2,-1.8 -3,-3.5 -3.4,-5.6c-0.7,-4 2.9,-8.1 7.3,-8.2c1.4,0 1.5,-0.1 1.1,-0.5c-0.3,-0.3 -2.2,-1.2 -4.3,-2.1c-33.2,-14.5 -70.5,-16.4 -105,-5.4c-7.5,2.4 -19,7.2 -18.6,7.7c0.1,0.2 0.8,0.3 1.6,0.3c5.6,0 9.1,6.2 6.1,10.8c-2.9,4.5 -8.6,7.1 -13.6,6Z" />
|
||||
<path
|
||||
android:fillColor="#e64132"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M542.2,496.4c-8.9,-13.1 -16.8,-25.1 -17.5,-26.6c-1.6,-3.3 -3.6,-9.2 -4.4,-13c-2.6,-12.5 -0.9,-25.8 5,-37.5c4.2,-8.3 11.2,-16.3 18.6,-21.3c5,-3.4 6.1,-3.9 12.8,-6.3c23.1,-8.2 47.2,-13.1 73.4,-15c7.5,-0.6 28.5,-0.6 36.3,-0c25.5,1.8 50.6,6.9 73,14.8c6.4,2.2 8.2,3.1 13.1,6.5c9.8,6.6 18.1,17.5 22,29.2c2.2,6.5 2.7,10 2.7,17.9c0,7.9 -0.5,11.3 -2.7,17.9c-2.3,6.8 -3.7,9.1 -20.3,33.6l-16.1,23.8l-0.4,-2.2c-0.2,-1.2 -0.9,-3 -1.4,-4c-1,-1.8 -4.4,-5.6 -4.7,-5.2c-0.1,0.1 -1.2,-0.4 -2.4,-1.1c-9.1,-5.2 -21.9,-10.5 -33.2,-13.9c-37,-11 -77.2,-8.8 -113,6.1c-4.9,2.1 -17.7,8.4 -19.2,9.5c-2.2,1.6 -5.1,6.8 -5.1,9c0,0.4 -0.1,1 -0.3,1.2c0.1,0.2 -6.2,-8.8 -16.2,-23.4Z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,27 +1,70 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m52.25,43.55c3.4,3.08 6.15,6.39 7.91,9.5 3.03,-5.55 5.06,-12.14 5.08,-16.34 0,-0.03 0,-0.06 0,-0.08 0,-6.21 -6.06,-8.63 -11.28,-8.63 -5.22,0 -11.28,2.42 -11.28,8.63 0,0.08 0,0.2 0,0.34 2.91,1.32 6.36,3.69 9.56,6.59z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m33.68,60.5c2.13,-2.42 5.39,-5.05 9.08,-7.27 3.92,-2.36 7.84,-4.01 11.28,-4.76 -4.22,-4.67 -9.72,-8.67 -13.62,-10 -0.03,-0.01 -0.05,-0.02 -0.08,-0.03 -5.78,-1.92 -9.9,3.23 -11.51,8.31 -1.61,5.08 -1.24,11.72 4.54,13.64 0.08,0.03 0.18,0.06 0.31,0.1z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M79.17,46.67C77.56,41.59 73.44,36.44 67.66,38.36c-0.08,0.03 -0.19,0.06 -0.31,0.1 -0.33,3.24 -1.46,7.33 -3.17,11.34 -1.81,4.27 -4.04,7.96 -6.39,10.64 6.1,1.24 12.85,1.17 16.76,-0.1 0.03,-0.01 0.05,-0.02 0.08,-0.03 5.78,-1.92 6.15,-8.56 4.54,-13.64z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m48.82,65.46c-0.98,-4.54 -1.3,-8.87 -0.94,-12.45 -5.64,2.67 -11.07,6.78 -13.5,10.16 -0.02,0.02 -0.03,0.05 -0.05,0.07 -3.57,5.03 -0.06,10.63 4.16,13.77 4.22,3.14 10.51,4.83 14.09,-0.2 0.05,-0.07 0.11,-0.16 0.19,-0.27 -1.59,-2.82 -3.03,-6.81 -3.95,-11.08z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="m73.58,62.92c-3.11,0.68 -7.26,0.84 -11.52,0.42 -4.53,-0.45 -8.64,-1.47 -11.86,-2.93 0.73,6.31 2.88,12.87 5.28,16.28 0.02,0.02 0.03,0.05 0.05,0.07 3.57,5.03 9.86,3.34 14.09,0.2 4.22,-3.14 7.74,-8.74 4.16,-13.77 -0.05,-0.07 -0.11,-0.16 -0.19,-0.27z" />
|
||||
<vector android:height="200dp"
|
||||
android:viewportHeight="1300"
|
||||
android:viewportWidth="1300"
|
||||
android:width="200dp"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578,922.6c-2.2,-0.2 -5.5,-1 -9.7,-2.2c-52.4,-15.7 -99,-46.5 -133.8,-88.5c-8.8,-10.7 -17.2,-22.4 -19.4,-27.5c-8.1,-18.1 -6.3,-38.7 4.8,-55.4c5,-7.5 13.2,-15 20.5,-18.7c1.2,-0.6 54.1,-20 55.8,-20.4c0.5,-0.1 0.5,0.2 -0.3,2.1c-0.7,1.7 -1,3.1 -1.1,5.5l-0.1,3.2l2.8,5.8c8.7,17.9 19.2,32.7 33.2,46.4c6.3,6.2 7.8,7.6 13.8,12.3c22.7,18.1 52,30.7 79.9,34.3c2.5,0.3 5,0.8 5.7,1c2.8,0.9 7.7,-0.8 11,-3.7l1.8,-1.6l-0.2,4.8c-0.1,2.7 -0.6,15.4 -1,28.3c-0.6,20.3 -0.8,24 -1.5,27.5c-3.9,20.7 -18.6,37.5 -38.4,44.1c-4.6,1.5 -8,2.2 -13.1,2.7c-4.6,0.5 -5.9,0.4 -10.7,0Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M707.3,922.4c-4,-0.4 -9.4,-1.6 -13.2,-2.9c-3.4,-1.2 -10,-4.4 -12.5,-6.1c-10.9,-7.4 -19,-17.9 -23.1,-30c-2.2,-6.7 -2.3,-7.5 -3.3,-36.9c-0.5,-14.9 -0.9,-27.9 -0.9,-28.9l-0,-1.9l2.3,1.8c2.6,2 6.6,3.4 8.5,3.1c0.6,-0.1 3,-0.5 5.3,-0.8c37.7,-5.3 71.2,-22.2 97.4,-49.1c12.2,-12.5 21.4,-25.5 29.9,-42.4l3.5,-7l0,-3.6c0,-3.1 -0.1,-3.8 -1,-5.7c-0.5,-1.2 -0.9,-2.1 -0.9,-2.2c0.2,-0.2 55.3,20.1 56.9,20.9c2.6,1.3 6.6,4.1 9.9,7c9.2,7.7 16.1,19.4 18.8,31.8c0.7,3.1 0.8,4.8 0.8,11.3c0,8.6 -0.5,11.7 -2.9,18.7c-1.7,5 -2.9,7.2 -7.1,13.1c-7.6,11 -15.3,20.5 -25.2,31.2c-32.8,35.4 -76.5,62.5 -123.4,76.3c-8,2.5 -12.4,3 -19.8,2.3Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M623.1,811c-25.9,-4.2 -50.7,-14.9 -71.7,-31c-5.2,-4 -8.7,-7.1 -14.1,-12.4c-12.7,-12.5 -21.9,-24.9 -30.5,-41.4c-2.3,-4.4 -2.4,-4.7 -2.4,-7.1c0,-8.8 8.5,-15.2 16.9,-12.7c5.6,1.7 9.6,6.8 9.7,12.2c0,2.6 -0.8,4.6 -2.6,6.2c-1.2,1.1 -3.2,1.9 -4.6,1.9c-1.2,0 -3.3,-0.8 -4.3,-1.6c-2.1,-1.8 -2,-1 0.4,3.2c19.3,33.8 52.3,59.1 90,69.1c5.7,1.5 11.5,2.7 11.8,2.4c0.1,-0.1 -0.4,-0.8 -1.3,-1.6c-5.1,-4.5 -2.3,-11.7 5,-12.8c5.4,-0.8 11.4,2.7 13.9,8c0.8,1.7 1,2.5 1,5.3c0,2.8 -0.1,3.5 -1,5.3c-2,4.3 -6.8,7.9 -10.3,7.8c-0.9,-0.1 -3.6,-0.5 -5.9,-0.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M665.1,811.2c-3.4,-1.3 -6.4,-4.3 -7.8,-8.1c-1.1,-2.9 -0.9,-7.3 0.5,-10.2c2.6,-5.3 8.7,-8.5 14.4,-7.5c2.9,0.5 4.7,1.9 6,4.3c0.8,1.6 1,2.2 0.8,3.6c-0.3,2.2 -0.9,3.3 -2.7,4.8c-0.8,0.7 -1.4,1.4 -1.3,1.5c0.5,0.5 13.4,-2.7 21.3,-5.4c33.6,-11.3 62.5,-35.1 80.4,-66.1c2.5,-4.4 2.6,-5 0.5,-3.2c-2.8,2.4 -7,1.9 -9.6,-1c-4,-4.6 -0.7,-13.8 6.1,-16.9c2,-0.9 2.7,-1 5.5,-1c2.9,0 3.5,0.1 5.6,1.1c4.4,2.1 7.4,6.4 7.8,11c0.2,2.2 0.1,2.3 -2.2,6.9c-23,45.9 -67,78.1 -117.2,85.9c-5.5,0.9 -6.3,1 -8.1,0.3Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.6,771.5c-4.7,-0.9 -8.7,-2.7 -12.9,-5.9c-10.8,-8.1 -13.5,-22.3 -6.6,-33.7c0.7,-1.2 1.1,-2.2 1,-2.4c-0.2,-0.2 -1.2,-0.6 -2.3,-1.1c-7.6,-3 -13,-10.6 -13.5,-19.1c-0.5,-7.4 3.1,-15 9,-19.4c1,-0.7 2.2,-1.5 2.6,-1.8c0.8,-0.4 68.9,-22.7 69.4,-22.7c0.2,0 0.7,0.7 1.2,1.5c0.5,0.8 1.6,2.3 2.4,3.3c1.2,1.4 1.5,1.9 1.2,2.3c-0.2,0.3 -6.9,9.5 -14.8,20.5c-15.9,21.9 -15.5,21.3 -13.4,23.4c1.3,1.3 2.9,1.4 4.4,0.3c0.6,-0.4 7.5,-9.7 15.5,-20.7c11.2,-15.4 14.6,-19.9 15,-19.7c0.9,0.4 5.5,1.9 6.6,2.1l1,0.2l-0,35.3c-0,39.7 -0,38.8 -2.5,44c-2.6,5.3 -7.2,9.3 -12.7,11.2c-3.7,1.3 -6.8,1.6 -10.2,1c-5.5,-0.9 -9.8,-3.2 -13.7,-7.4l-2.2,-2.4l-0.6,0.9c-3,4.3 -8.6,8.1 -14,9.5c-2.8,0.9 -7.8,1.2 -9.9,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M710.4,771.5c-5.5,-0.9 -9.9,-3.2 -14.3,-7.6l-3.2,-3.2l-0.7,1c-2.3,3.3 -6.8,6.5 -11.1,7.9c-3.7,1.2 -9.2,1.4 -12.6,0.3c-7.1,-2.1 -12.7,-7.4 -15.2,-14.3l-0.9,-2.6l0,-74.2l1.8,-0.4c1,-0.2 2.7,-0.8 3.9,-1.2c1.1,-0.5 2.1,-0.8 2.2,-0.7c0.1,0.1 6.5,9 14.4,19.9c7.8,10.9 14.7,20.1 15.2,20.5c2.2,1.9 5.4,0.4 5.4,-2.6c0,-1.4 -1,-2.9 -13.8,-20.5c-7.6,-10.5 -14.2,-19.6 -14.7,-20.4l-0.9,-1.3l1.4,-1.7c0.8,-0.9 1.9,-2.5 2.5,-3.4l1,-1.6l34.4,11.2c18.9,6.2 35.1,11.6 35.9,12.1c6.8,4 11.1,11.3 11.1,19.1c0,4.1 -0.5,6.4 -2.4,10.2c-2,4.1 -5.5,7.6 -9.6,9.7c-1.6,0.8 -3.2,1.5 -3.4,1.5c-1,0 -0.9,0.7 0.3,2.6c2.8,4.3 4,8.5 3.9,13.7c0,8.1 -3.7,15.2 -10.6,20.3c-6.5,4.8 -13.4,6.7 -20,5.7Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M421.4,714.9c-0.5,-0.1 -2.3,-0.4 -3.9,-0.7c-15.6,-2.6 -30.4,-12.6 -38.8,-26.2c-3.5,-5.7 -6.4,-13.2 -7.8,-19.9c-1.2,-6.1 -0.8,-28.1 0.8,-43.1c4.5,-43 19,-84.3 42.2,-120.7c6.5,-10.2 14.9,-21.5 18.2,-24.6c17.8,-16.6 43.1,-20.5 64.8,-10c4.3,2.1 8.8,5.1 12.7,8.6c2.8,2.4 5.8,6.1 20.9,25.5c9.7,12.5 17.8,22.8 17.9,23c0.2,0.2 -0.9,0.4 -3.2,0.4c-2.5,0 -4.1,0.2 -5.7,0.7c-2.1,0.7 -2.6,1.1 -7.9,6.3c-8.2,8.1 -14.4,15.3 -20.3,23.9c-15.5,22.2 -25.4,47.7 -28.8,74.8c-2.2,16.9 -1.6,37.5 1.6,52.3c0.3,1.4 0.5,2.8 0.4,3c-0.1,0.2 0.2,1.3 0.8,2.4c1.1,2.4 4.3,5.7 6.5,6.8l1.5,0.8l-1.2,0.4c-0.7,0.2 -13.1,3.8 -27.6,8c-16.4,4.7 -27.7,7.8 -29.8,8.1c-3.1,0.4 -11.1,0.6 -13.3,0.2Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M862.2,714.7c-2.1,-0.3 -33.8,-9.1 -56.5,-15.8l-2.5,-0.7l1.6,-0.8c3.4,-1.7 7.2,-6.6 7.3,-9.6c0,-0.7 0.4,-3.3 0.8,-5.8c3.9,-22.7 3.1,-46.1 -2.5,-68.4c-6.4,-25.5 -18.6,-49.2 -35.8,-69.1c-4.6,-5.3 -14.8,-15.4 -16.4,-16.1c-2.4,-1.1 -5.1,-1.6 -8,-1.4l-2.7,0.2l1.2,-1.5c0.7,-0.8 8.5,-10.8 17.5,-22.3c8.9,-11.5 17.2,-21.8 18.5,-23.1c2.6,-2.7 7,-6.2 10.3,-8.2c19.3,-11.6 43,-11.1 61.6,1.2c5.4,3.6 8.2,6.2 12.3,11.7c26.4,34.5 44,73.7 52.3,116.2c3.4,17.6 4.9,33.3 5,52.4c0,13 -0.2,14.8 -2.5,21.8c-8.4,26.2 -34.5,42.8 -61.5,39.3Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M501.4,691.5c-2,-0.5 -4.6,-1.9 -6,-3.3c-2.5,-2.4 -3.1,-3.5 -3.7,-7.3c-4.4,-27.3 -2.2,-54 6.7,-79.3c5.3,-15.1 13.5,-30.5 23,-43.1c5.8,-7.8 16.6,-19.5 19,-20.7c4.7,-2.4 11.3,-1.2 15.2,2.7c5.4,5.4 5.2,13.9 -0.3,19.1c-4.3,4 -9.4,4.4 -12.6,0.9c-1.7,-1.9 -2.2,-3.9 -1.7,-6.4c0.2,-1.1 0.3,-2 0.2,-2.2c-0.3,-0.3 -3.6,3.3 -8.3,9.1c-17.6,21.8 -28.5,48 -31.9,76.5c-1.1,9.3 -1,26.4 0.1,34.6c0.3,1.8 0.8,1.9 1.4,0.1c0.9,-2.6 4,-4.7 6.8,-4.7c3,-0 5.9,2.2 7.5,5.7c0.6,1.3 0.8,2.3 0.8,5.2c-0,3.3 -0.1,3.8 -1.1,5.7c-1.4,2.7 -4.6,5.7 -7.1,6.6c-2.5,0.9 -6.1,1.2 -8,0.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M790.1,691.5c-3.7,-0.6 -7.7,-3.6 -9.4,-7.1c-3.8,-7.5 0.1,-16.9 6.9,-16.9c3.1,0 5.8,2 6.9,5.2c0.4,1.2 0.5,1.3 0.7,0.7c1.3,-3.7 1.7,-26.4 0.6,-35.7c-3.6,-29.6 -14.5,-55.3 -33,-77.9c-5.5,-6.7 -8.4,-9.4 -7.1,-6.6c0.7,1.4 0.5,4.3 -0.3,5.9c-0.9,1.7 -3.2,3.5 -5,3.8c-3.2,0.6 -7.9,-1.6 -10.2,-4.8c-6.5,-8.8 -0.5,-21.2 10.4,-21.4c4.6,-0.1 5.2,0.3 11.2,6.4c12.1,12.3 21.1,24.9 28.8,40.3c13.2,26.3 18.6,54.9 16.1,84.5c-0.5,5.6 -2,15.7 -2.6,17.1c-1.3,2.8 -4.8,5.5 -8.4,6.5c-2.3,0.4 -3.1,0.4 -5.6,0Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M545.7,680.2c-6,-1.3 -12.2,-6.2 -14.9,-11.7c-3.4,-7 -3.1,-15.1 0.9,-21.6c0.7,-1.2 1.2,-2.3 1.1,-2.4c-0.1,-0.1 -1.1,-0.6 -2.1,-1c-3.9,-1.5 -8.1,-4.8 -10.7,-8.3c-4.6,-6.2 -6.1,-14.6 -3.9,-22.1c2.9,-10.3 9.4,-16.8 19.1,-19.3c2.8,-0.7 9,-0.8 11.7,-0c1.1,0.3 2.2,0.5 2.4,0.5c0.2,-0 0.3,-0.7 0.3,-1.5c0,-2.9 0.8,-5.8 2.4,-9.2c5.2,-10.8 18.1,-15.5 29,-10.5c2.7,1.2 6.2,3.8 7.8,5.8c0.7,0.8 10.3,14 21.5,29.4l20.3,27.9l-1.5,1.8c-0.8,1 -1.9,2.6 -2.5,3.5c-0.6,1 -1.2,1.7 -1.5,1.6c-4.5,-1.7 -46.7,-15 -47.7,-15c-1.9,0 -3.1,1.3 -3.1,3.2c0,1 0.2,1.7 0.8,2.3c0.6,0.6 7.8,3.1 24.5,8.5l23.7,7.7l-0.2,8.6l-32.6,10.5c-18,5.9 -33.9,10.9 -35.2,11.2c-3.1,0.7 -6.6,0.7 -9.6,0.1Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M740,679.8c-1.8,-0.5 -17.5,-5.6 -35,-11.3l-31.8,-10.4l1,-4.3l0,-4.3l22.6,-7.7c15,-4.9 24,-8 24.6,-8.5c0.7,-0.6 0.9,-1.1 0.9,-2.2c-0,-2 -1.2,-3.3 -3.1,-3.3c-0.9,-0 -10.5,2.9 -24.7,7.5c-12.8,4.1 -23.4,7.5 -23.6,7.5c-0.1,-0 -0.7,-0.8 -1.3,-1.9c-0.6,-1 -1.6,-2.5 -2.2,-3.2c-0.7,-0.7 -1.2,-1.5 -1.2,-1.6c0,-0.2 9.6,-13.5 21.4,-29.6c18.9,-26 21.6,-29.6 23.6,-31.1c5.7,-4.4 13.1,-5.8 19.7,-3.9c9,2.7 16.1,11.6 16.1,20.3c0,2.3 -0.1,2.3 3.1,1.5c4.7,-1.1 11.5,-0.5 16,1.5c4.6,2 9,6 11.5,10.2c2.1,3.6 3.9,9.4 4.2,13.2c0.3,5.2 -1.1,10.7 -4,15.3c-2.6,4.1 -7.8,8.3 -12.1,9.8c-0.9,0.3 -1.7,0.8 -1.7,1c0,0.2 0.4,1 0.9,1.7c2.4,3.6 3.6,7.7 3.5,12.7c0,5.8 -2.1,10.7 -6.4,15.1c-4,4.1 -8.9,6.3 -14.9,6.5c-3.3,0.4 -4.3,0.3 -7.1,-0.5Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M643.7,671.9c-6.1,-1.6 -11.4,-6.8 -13.2,-12.9c-0.7,-2.4 -0.7,-7.5 0,-9.9c1.7,-5.8 6.6,-10.8 12.3,-12.5c2.7,-0.8 7.2,-0.9 10,-0.2c6.2,1.6 11.6,7.1 13.2,13.3c1.6,6 -0.3,12.6 -5,17.3c-4.6,4.6 -11.3,6.5 -17.3,4.9Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M615.8,602.8c-13.3,-18.3 -21.2,-29.6 -22,-31.1c-1.4,-3 -1.9,-5.5 -1.9,-9.4c0,-14.1 13.1,-24.4 27.1,-21.4c1.4,0.3 2.6,0.5 2.7,0.5c0.1,0 0.3,-1.3 0.4,-2.8c0.8,-10.7 8.4,-19.6 18.9,-22.4c3.9,-1 10.6,-1 14.5,-0c8.9,2.3 15.9,9.3 18.2,18.2c0.4,1.5 0.7,3.7 0.7,4.9c-0,1.2 0.1,2.1 0.3,2.1c0.2,-0 1.5,-0.3 3,-0.6c7.4,-1.6 15.2,0.7 20.5,6c4.3,4.3 6.6,9.6 6.6,15.6c-0,4 -0.6,6.5 -2.4,10c-0.6,1.2 -10.4,15 -21.7,30.7c-17.8,24.5 -20.8,28.5 -21.4,28.3c-0.4,-0.1 -1.9,-0.6 -3.4,-1.1c-1.5,-0.5 -2.9,-0.9 -3.3,-0.9c-0.7,-0 -0.7,-0.8 -0.3,-25.5l-0,-25.5l-1.4,-0.9c-1,-1.1 -2.5,-1.5 -3.8,-0.9c-2,0.8 -2,-0.5 -1.8,27.2l-0,25.8l-1.2,-0c-0.5,-0.2 -2.4,0.3 -4,0.9c-1.6,0.6 -3.1,1.1 -3.2,1.1c-0.2,-0.1 -9.6,-13 -21.1,-28.8Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M578.4,537.8c-4.1,-0.9 -7.7,-3.6 -9.6,-7.4c-1.4,-2.8 -1.7,-7.3 -0.5,-10.3c1.7,-4.5 3.9,-6.1 15.6,-11.2c15.8,-7 31.4,-11.1 49.2,-12.9c7.3,-0.8 23.2,-0.8 30.6,0c17.4,1.8 33.3,6 49.1,13c7.3,3.2 12.5,6.1 13.6,7.5c4.3,5.6 3.8,12.7 -1.1,17.6c-5.1,5.1 -12.9,5.4 -18.1,0.7c-2,-1.8 -3,-3.5 -3.4,-5.6c-0.7,-4 2.9,-8.1 7.3,-8.2c1.4,0 1.5,-0.1 1.1,-0.5c-0.3,-0.3 -2.2,-1.2 -4.3,-2.1c-33.2,-14.5 -70.5,-16.4 -105,-5.4c-7.5,2.4 -19,7.2 -18.6,7.7c0.1,0.2 0.8,0.3 1.6,0.3c5.6,0 9.1,6.2 6.1,10.8c-2.9,4.5 -8.6,7.1 -13.6,6Z" />
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M542.2,496.4c-8.9,-13.1 -16.8,-25.1 -17.5,-26.6c-1.6,-3.3 -3.6,-9.2 -4.4,-13c-2.6,-12.5 -0.9,-25.8 5,-37.5c4.2,-8.3 11.2,-16.3 18.6,-21.3c5,-3.4 6.1,-3.9 12.8,-6.3c23.1,-8.2 47.2,-13.1 73.4,-15c7.5,-0.6 28.5,-0.6 36.3,-0c25.5,1.8 50.6,6.9 73,14.8c6.4,2.2 8.2,3.1 13.1,6.5c9.8,6.6 18.1,17.5 22,29.2c2.2,6.5 2.7,10 2.7,17.9c0,7.9 -0.5,11.3 -2.7,17.9c-2.3,6.8 -3.7,9.1 -20.3,33.6l-16.1,23.8l-0.4,-2.2c-0.2,-1.2 -0.9,-3 -1.4,-4c-1,-1.8 -4.4,-5.6 -4.7,-5.2c-0.1,0.1 -1.2,-0.4 -2.4,-1.1c-9.1,-5.2 -21.9,-10.5 -33.2,-13.9c-37,-11 -77.2,-8.8 -113,6.1c-4.9,2.1 -17.7,8.4 -19.2,9.5c-2.2,1.6 -5.1,6.8 -5.1,9c0,0.4 -0.1,1 -0.3,1.2c0.1,0.2 -6.2,-8.8 -16.2,-23.4Z" />
|
||||
</vector>
|
||||
|
||||
@@ -2,5 +2,4 @@
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
||||
@@ -17,6 +17,9 @@ PODS:
|
||||
- fluttertoast (0.0.2):
|
||||
- Flutter
|
||||
- Toast
|
||||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
@@ -36,7 +39,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- path_provider_ios (0.0.1):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
- permission_handler_apple (9.1.1):
|
||||
- Flutter
|
||||
- photo_manager (2.0.0):
|
||||
- Flutter
|
||||
@@ -50,7 +53,7 @@ PODS:
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.3):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- FMDB (>= 2.7.5)
|
||||
- Toast (4.0.0)
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
@@ -81,13 +84,14 @@ DEPENDENCIES:
|
||||
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- FMDB
|
||||
- MapLibre
|
||||
- ReachabilitySwift
|
||||
- SAMKeychain
|
||||
@@ -135,7 +139,7 @@ EXTERNAL SOURCES:
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/darwin"
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
video_player_avfoundation:
|
||||
@@ -151,23 +155,24 @@ SPEC CHECKSUMS:
|
||||
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
|
||||
flutter_udid: a2482c67a61b9c806ef59dd82ed8d007f1b7ac04
|
||||
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||
fluttertoast: 31b00dabfa7fb7bacd9e7dbee580d7a2ff4bf265
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
geolocator_apple: 9157311f654584b9bb72686c55fc02a97b73f461
|
||||
image_picker_ios: 99dfe1854b4fa34d0364e74a78448a0151025425
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
|
||||
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
permission_handler_apple: 036b856153a2b1f61f21030ff725f3e6fece2b78
|
||||
permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6
|
||||
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
|
||||
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
|
||||
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
|
||||
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
|
||||
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
|
||||
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
|
||||
video_player_avfoundation: 02011213dab73ae3687df27ce441fbbcc82b5579
|
||||
@@ -175,4 +180,4 @@ SPEC CHECKSUMS:
|
||||
|
||||
PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d
|
||||
|
||||
COCOAPODS: 1.15.2
|
||||
COCOAPODS: 1.12.1
|
||||
|
||||
@@ -172,7 +172,7 @@
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 1510;
|
||||
LastUpgradeCheck = 1430;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
97C146ED1CF9000F007C117D = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1510"
|
||||
LastUpgradeVersion = "1430"
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
|
||||
@@ -80,6 +80,7 @@ class _AssetDragRegionState extends State<AssetDragRegion> {
|
||||
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
|
||||
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
|
||||
recognizer.onLongPressUp = _onLongPressEnd;
|
||||
recognizer.onLongPressCancel = _onLongPressEnd;
|
||||
}
|
||||
|
||||
AssetIndex? _getValueKeyAtPositon(Offset position) {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
final recentlyAddedProvider = FutureProvider<List<Asset>>((ref) async {
|
||||
final user = ref.read(currentUserProvider);
|
||||
if (user == null) return [];
|
||||
|
||||
return ref
|
||||
.watch(dbProvider)
|
||||
.assets
|
||||
.where()
|
||||
.ownerIdEqualToAnyChecksum(user.isarId)
|
||||
.sortByFileCreatedAtDesc()
|
||||
.findAll();
|
||||
});
|
||||
|
||||
@@ -10,13 +10,17 @@ RUN npm ci && \
|
||||
rm -rf node_modules/@img/sharp-libvips* && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||
COPY server .
|
||||
|
||||
WORKDIR /usr/src/app/server
|
||||
RUN npm run prisma:generate
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV PATH="${PATH}:/usr/src/app/bin" \
|
||||
NODE_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
||||
|
||||
|
||||
FROM dev AS prod
|
||||
|
||||
RUN npm run build
|
||||
@@ -46,17 +50,6 @@ WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install --no-install-recommends -yqq curl unzip && \
|
||||
curl -fsSL https://bun.sh/install | bash && \
|
||||
apt-get purge -yqq curl unzip && \
|
||||
apt-get autoremove -yqq && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PATH="${PATH}:~/.bun/bin"
|
||||
|
||||
COPY --from=prod /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=prod /usr/src/app/dist ./dist
|
||||
COPY --from=prod /usr/src/app/bin ./bin
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
node /usr/src/app/node_modules/.bin/immich "$@"
|
||||
@@ -30,10 +30,12 @@
|
||||
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/database.config.js",
|
||||
"typeorm:schema:drop": "typeorm query -d ./dist/database.config.js 'DROP schema public cascade; CREATE schema public;'",
|
||||
"typeorm:schema:reset": "npm run typeorm:schema:drop && npm run typeorm:migrations:run",
|
||||
"sql:generate": "node ./dist/utils/sql.js"
|
||||
"sql:generate": "node ./dist/utils/sql.js",
|
||||
"prisma:generate": "prisma generate --schema=./src/prisma/schema.prisma"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.22.11",
|
||||
"@immich/cli": "^2.0.7",
|
||||
"@nestjs/bullmq": "^10.0.1",
|
||||
"@nestjs/common": "^10.2.2",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
@@ -48,6 +50,7 @@
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.43.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.49.0",
|
||||
"@opentelemetry/sdk-node": "^0.49.0",
|
||||
"@prisma/client": "^5.11.0",
|
||||
"@socket.io/postgres-adapter": "^0.3.1",
|
||||
"@types/picomatch": "^2.3.3",
|
||||
"archiver": "^7.0.0",
|
||||
@@ -69,6 +72,7 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.10.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"kysely": "^0.27.3",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
"mnemonist": "^0.39.8",
|
||||
@@ -77,6 +81,7 @@
|
||||
"openid-client": "^5.4.3",
|
||||
"pg": "^8.11.3",
|
||||
"picomatch": "^4.0.0",
|
||||
"prisma-extension-kysely": "^2.1.0",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
@@ -119,6 +124,8 @@
|
||||
"mock-fs": "^5.2.0",
|
||||
"prettier": "^3.0.2",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prisma": "^5.11.0",
|
||||
"prisma-kysely": "^1.8.0",
|
||||
"rimraf": "^5.0.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"sql-formatter": "^15.0.0",
|
||||
|
||||
@@ -43,9 +43,9 @@ import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IAuditRepository } from 'src/interfaces/audit.interface';
|
||||
import { ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository } from 'src/interfaces/job.interface';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||
@@ -74,9 +74,9 @@ import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
|
||||
import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { CommunicationRepository } from 'src/repositories/communication.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { FilesystemProvider } from 'src/repositories/filesystem.provider';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository';
|
||||
@@ -123,6 +123,7 @@ import { TrashService } from 'src/services/trash.service';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { otelConfig } from 'src/utils/instrumentation';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
import { PrismaRepository } from './repositories/prisma.repository';
|
||||
|
||||
const commands = [
|
||||
ResetAdminPasswordCommand,
|
||||
@@ -200,9 +201,9 @@ const repositories: Provider[] = [
|
||||
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
||||
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
||||
{ provide: IAuditRepository, useClass: AuditRepository },
|
||||
{ provide: ICommunicationRepository, useClass: CommunicationRepository },
|
||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||
{ provide: IDatabaseRepository, useClass: DatabaseRepository },
|
||||
{ provide: IEventRepository, useClass: EventRepository },
|
||||
{ provide: IJobRepository, useClass: JobRepository },
|
||||
{ provide: ILibraryRepository, useClass: LibraryRepository },
|
||||
{ provide: IKeyRepository, useClass: ApiKeyRepository },
|
||||
@@ -221,6 +222,7 @@ const repositories: Provider[] = [
|
||||
{ provide: IMediaRepository, useClass: MediaRepository },
|
||||
{ provide: IUserRepository, useClass: UserRepository },
|
||||
{ provide: IUserTokenRepository, useClass: UserTokenRepository },
|
||||
PrismaRepository,
|
||||
];
|
||||
|
||||
const middleware = [
|
||||
|
||||
@@ -3,8 +3,6 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { Version } from 'src/utils/version';
|
||||
|
||||
export const SALT_ROUNDS = 10;
|
||||
|
||||
const { version } = JSON.parse(readFileSync('./package.json', 'utf8'));
|
||||
export const serverVersion = Version.fromString(version);
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Body, Controller, Get, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { MapThemeDto, SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto';
|
||||
import { MapThemeDto } from 'src/dtos/system-config-map-theme.dto';
|
||||
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config-storage-template.dto';
|
||||
import { SystemConfigDto } from 'src/dtos/system-config.dto';
|
||||
import { AdminRoute, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { UserResponseDto } from 'src/dtos/user.dto';
|
||||
import { LibraryType } from 'src/entities/library.entity';
|
||||
import { UserEntity } from 'src/entities/user.entity';
|
||||
@@ -8,6 +7,8 @@ import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
let instance: UserCore | null;
|
||||
|
||||
export class UserCore {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { OnEvent, OnEventType } from '@nestjs/event-emitter';
|
||||
import { OnEventOptions } from '@nestjs/event-emitter/dist/interfaces';
|
||||
import _ from 'lodash';
|
||||
import { ServerAsyncEvent, ServerEvent } from 'src/interfaces/event.interface';
|
||||
import { setUnion } from 'src/utils/set';
|
||||
|
||||
// PostgreSQL uses a 16-bit integer to indicate the number of bound parameters. This means that the
|
||||
@@ -126,5 +125,5 @@ export interface GenerateSqlQueries {
|
||||
/** Decorator to enable versioning/tracking of generated Sql */
|
||||
export const GenerateSql = (...options: GenerateSqlQueries[]) => SetMetadata(GENERATE_SQL_KEY, options);
|
||||
|
||||
export const OnServerEvent = (event: ServerEvent | ServerAsyncEvent, options?: OnEventOptions) =>
|
||||
export const OnEventInternal = (event: OnEventType, options?: OnEventOptions) =>
|
||||
OnEvent(event, { suppressErrors: false, ...options });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
|
||||
import type { DateTime } from 'luxon';
|
||||
import { FeatureFlags } from 'src/cores/system-config.core';
|
||||
import { SystemConfigThemeDto } from 'src/dtos/system-config.dto';
|
||||
import { SystemConfigThemeDto } from 'src/dtos/system-config-theme.dto';
|
||||
import { IVersion, VersionType } from 'src/utils/version';
|
||||
|
||||
export class ServerPingResponse {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsString, Max, Min } from 'class-validator';
|
||||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
} from 'src/entities/system-config.entity';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigFFmpegDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(51)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
crf!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
threads!: number;
|
||||
|
||||
@IsString()
|
||||
preset!: string;
|
||||
|
||||
@IsEnum(VideoCodec)
|
||||
@ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec })
|
||||
targetVideoCodec!: VideoCodec;
|
||||
|
||||
@IsEnum(VideoCodec, { each: true })
|
||||
@ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec, isArray: true })
|
||||
acceptedVideoCodecs!: VideoCodec[];
|
||||
|
||||
@IsEnum(AudioCodec)
|
||||
@ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec })
|
||||
targetAudioCodec!: AudioCodec;
|
||||
|
||||
@IsEnum(AudioCodec, { each: true })
|
||||
@ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec, isArray: true })
|
||||
acceptedAudioCodecs!: AudioCodec[];
|
||||
|
||||
@IsString()
|
||||
targetResolution!: string;
|
||||
|
||||
@IsString()
|
||||
maxBitrate!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(-1)
|
||||
@Max(16)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
bframes!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
refs!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
gopSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
npl!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
temporalAQ!: boolean;
|
||||
|
||||
@IsEnum(CQMode)
|
||||
@ApiProperty({ enumName: 'CQMode', enum: CQMode })
|
||||
cqMode!: CQMode;
|
||||
|
||||
@ValidateBoolean()
|
||||
twoPass!: boolean;
|
||||
|
||||
@IsString()
|
||||
preferredHwDevice!: string;
|
||||
|
||||
@IsEnum(TranscodePolicy)
|
||||
@ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy })
|
||||
transcode!: TranscodePolicy;
|
||||
|
||||
@IsEnum(TranscodeHWAccel)
|
||||
@ApiProperty({ enumName: 'TranscodeHWAccel', enum: TranscodeHWAccel })
|
||||
accel!: TranscodeHWAccel;
|
||||
|
||||
@IsEnum(ToneMapping)
|
||||
@ApiProperty({ enumName: 'ToneMapping', enum: ToneMapping })
|
||||
tonemap!: ToneMapping;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
|
||||
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
|
||||
|
||||
export class JobSettingsDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
concurrency!: number;
|
||||
}
|
||||
|
||||
export class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto> {
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.THUMBNAIL_GENERATION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.METADATA_EXTRACTION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.VIDEO_CONVERSION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SMART_SEARCH]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.MIGRATION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.BACKGROUND_TASK]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SEARCH]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.FACE_DETECTION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SIDECAR]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.LIBRARY]!: JobSettingsDto;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsString,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { ValidateBoolean, validateCronExpression } from 'src/validation';
|
||||
|
||||
const isEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
|
||||
|
||||
@ValidatorConstraint({ name: 'cronValidator' })
|
||||
class CronValidator implements ValidatorConstraintInterface {
|
||||
validate(expression: string): boolean {
|
||||
return validateCronExpression(expression);
|
||||
}
|
||||
}
|
||||
|
||||
export class SystemConfigLibraryScanDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@Validate(CronValidator, { message: 'Invalid cron expression' })
|
||||
@IsString()
|
||||
cronExpression!: string;
|
||||
}
|
||||
|
||||
export class SystemConfigLibraryWatchDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
export class SystemConfigLibraryDto {
|
||||
@Type(() => SystemConfigLibraryScanDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
scan!: SystemConfigLibraryScanDto;
|
||||
|
||||
@Type(() => SystemConfigLibraryWatchDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
watch!: SystemConfigLibraryWatchDto;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { LogLevel } from 'src/entities/system-config.entity';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigLoggingDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ApiProperty({ enum: LogLevel, enumName: 'LogLevel' })
|
||||
@IsEnum(LogLevel)
|
||||
level!: LogLevel;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsObject, IsUrl, ValidateIf, ValidateNested } from 'class-validator';
|
||||
import { CLIPConfig, RecognitionConfig } from 'src/dtos/model-config.dto';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigMachineLearningDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsUrl({ require_tld: false, allow_underscores: true })
|
||||
@ValidateIf((dto) => dto.enabled)
|
||||
url!: string;
|
||||
|
||||
@Type(() => CLIPConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
clip!: CLIPConfig;
|
||||
|
||||
@Type(() => RecognitionConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
facialRecognition!: RecognitionConfig;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum } from 'class-validator';
|
||||
|
||||
export enum MapTheme {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
}
|
||||
|
||||
export class MapThemeDto {
|
||||
@IsEnum(MapTheme)
|
||||
@ApiProperty({ enum: MapTheme, enumName: 'MapTheme' })
|
||||
theme!: MapTheme;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigMapDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsString()
|
||||
lightStyle!: string;
|
||||
|
||||
@IsString()
|
||||
darkStyle!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigNewVersionCheckDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { IsNotEmpty, IsNumber, IsString, IsUrl, Min, ValidateIf } from 'class-validator';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
const isEnabled = (config: SystemConfigOAuthDto) => config.enabled;
|
||||
const isOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
|
||||
|
||||
export class SystemConfigOAuthDto {
|
||||
@ValidateBoolean()
|
||||
autoLaunch!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
autoRegister!: boolean;
|
||||
|
||||
@IsString()
|
||||
buttonText!: string;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientSecret!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
defaultStorageQuota!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
issuerUrl!: string;
|
||||
|
||||
@ValidateBoolean()
|
||||
mobileOverrideEnabled!: boolean;
|
||||
|
||||
@ValidateIf(isOverrideEnabled)
|
||||
@IsUrl()
|
||||
mobileRedirectUri!: string;
|
||||
|
||||
@IsString()
|
||||
scope!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
signingAlgorithm!: string;
|
||||
|
||||
@IsString()
|
||||
storageLabelClaim!: string;
|
||||
|
||||
@IsString()
|
||||
storageQuotaClaim!: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigPasswordLoginDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigReverseGeocodingDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SystemConfigServerDto {
|
||||
@IsString()
|
||||
externalDomain!: string;
|
||||
|
||||
@IsString()
|
||||
loginPageMessage!: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigStorageTemplateDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
hashVerificationEnabled!: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
template!: string;
|
||||
}
|
||||
|
||||
export class SystemConfigTemplateStorageOptionDto {
|
||||
yearOptions!: string[];
|
||||
monthOptions!: string[];
|
||||
weekOptions!: string[];
|
||||
dayOptions!: string[];
|
||||
hourOptions!: string[];
|
||||
minuteOptions!: string[];
|
||||
secondOptions!: string[];
|
||||
presetOptions!: string[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class SystemConfigThemeDto {
|
||||
@IsString()
|
||||
customCss!: string;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsEnum, IsInt, Max, Min } from 'class-validator';
|
||||
import { Colorspace } from 'src/entities/system-config.entity';
|
||||
|
||||
export class SystemConfigThumbnailDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
webpSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
jpegSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
quality!: number;
|
||||
|
||||
@IsEnum(Colorspace)
|
||||
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
|
||||
colorspace!: Colorspace;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
import { ValidateBoolean } from 'src/validation';
|
||||
|
||||
export class SystemConfigTrashDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
days!: number;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
export class SystemConfigUserDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
deleteDelay!: number;
|
||||
}
|
||||
@@ -1,433 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsPositive,
|
||||
IsString,
|
||||
IsUrl,
|
||||
Max,
|
||||
Min,
|
||||
Validate,
|
||||
ValidateIf,
|
||||
ValidateNested,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
import { CLIPConfig, RecognitionConfig } from 'src/dtos/model-config.dto';
|
||||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
Colorspace,
|
||||
LogLevel,
|
||||
SystemConfig,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
} from 'src/entities/system-config.entity';
|
||||
import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface';
|
||||
import { ValidateBoolean, validateCronExpression } from 'src/validation';
|
||||
|
||||
@ValidatorConstraint({ name: 'cronValidator' })
|
||||
class CronValidator implements ValidatorConstraintInterface {
|
||||
validate(expression: string): boolean {
|
||||
return validateCronExpression(expression);
|
||||
}
|
||||
}
|
||||
|
||||
const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enabled;
|
||||
const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled;
|
||||
const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled;
|
||||
|
||||
export class SystemConfigFFmpegDto {
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(51)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
crf!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
threads!: number;
|
||||
|
||||
@IsString()
|
||||
preset!: string;
|
||||
|
||||
@IsEnum(VideoCodec)
|
||||
@ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec })
|
||||
targetVideoCodec!: VideoCodec;
|
||||
|
||||
@IsEnum(VideoCodec, { each: true })
|
||||
@ApiProperty({ enumName: 'VideoCodec', enum: VideoCodec, isArray: true })
|
||||
acceptedVideoCodecs!: VideoCodec[];
|
||||
|
||||
@IsEnum(AudioCodec)
|
||||
@ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec })
|
||||
targetAudioCodec!: AudioCodec;
|
||||
|
||||
@IsEnum(AudioCodec, { each: true })
|
||||
@ApiProperty({ enumName: 'AudioCodec', enum: AudioCodec, isArray: true })
|
||||
acceptedAudioCodecs!: AudioCodec[];
|
||||
|
||||
@IsString()
|
||||
targetResolution!: string;
|
||||
|
||||
@IsString()
|
||||
maxBitrate!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(-1)
|
||||
@Max(16)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
bframes!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(6)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
refs!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
gopSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
npl!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
temporalAQ!: boolean;
|
||||
|
||||
@IsEnum(CQMode)
|
||||
@ApiProperty({ enumName: 'CQMode', enum: CQMode })
|
||||
cqMode!: CQMode;
|
||||
|
||||
@ValidateBoolean()
|
||||
twoPass!: boolean;
|
||||
|
||||
@IsString()
|
||||
preferredHwDevice!: string;
|
||||
|
||||
@IsEnum(TranscodePolicy)
|
||||
@ApiProperty({ enumName: 'TranscodePolicy', enum: TranscodePolicy })
|
||||
transcode!: TranscodePolicy;
|
||||
|
||||
@IsEnum(TranscodeHWAccel)
|
||||
@ApiProperty({ enumName: 'TranscodeHWAccel', enum: TranscodeHWAccel })
|
||||
accel!: TranscodeHWAccel;
|
||||
|
||||
@IsEnum(ToneMapping)
|
||||
@ApiProperty({ enumName: 'ToneMapping', enum: ToneMapping })
|
||||
tonemap!: ToneMapping;
|
||||
}
|
||||
|
||||
class JobSettingsDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
@ApiProperty({ type: 'integer' })
|
||||
concurrency!: number;
|
||||
}
|
||||
|
||||
class SystemConfigJobDto implements Record<ConcurrentQueueName, JobSettingsDto> {
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.THUMBNAIL_GENERATION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.METADATA_EXTRACTION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.VIDEO_CONVERSION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SMART_SEARCH]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.MIGRATION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.BACKGROUND_TASK]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SEARCH]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.FACE_DETECTION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.SIDECAR]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.LIBRARY]!: JobSettingsDto;
|
||||
}
|
||||
|
||||
class SystemConfigLibraryScanDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isLibraryScanEnabled)
|
||||
@IsNotEmpty()
|
||||
@Validate(CronValidator, { message: 'Invalid cron expression' })
|
||||
@IsString()
|
||||
cronExpression!: string;
|
||||
}
|
||||
|
||||
class SystemConfigLibraryWatchDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigLibraryDto {
|
||||
@Type(() => SystemConfigLibraryScanDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
scan!: SystemConfigLibraryScanDto;
|
||||
|
||||
@Type(() => SystemConfigLibraryWatchDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
watch!: SystemConfigLibraryWatchDto;
|
||||
}
|
||||
|
||||
class SystemConfigLoggingDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ApiProperty({ enum: LogLevel, enumName: 'LogLevel' })
|
||||
@IsEnum(LogLevel)
|
||||
level!: LogLevel;
|
||||
}
|
||||
|
||||
class SystemConfigMachineLearningDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsUrl({ require_tld: false, allow_underscores: true })
|
||||
@ValidateIf((dto) => dto.enabled)
|
||||
url!: string;
|
||||
|
||||
@Type(() => CLIPConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
clip!: CLIPConfig;
|
||||
|
||||
@Type(() => RecognitionConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
facialRecognition!: RecognitionConfig;
|
||||
}
|
||||
|
||||
enum MapTheme {
|
||||
LIGHT = 'light',
|
||||
DARK = 'dark',
|
||||
}
|
||||
|
||||
export class MapThemeDto {
|
||||
@IsEnum(MapTheme)
|
||||
@ApiProperty({ enum: MapTheme, enumName: 'MapTheme' })
|
||||
theme!: MapTheme;
|
||||
}
|
||||
|
||||
class SystemConfigMapDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsString()
|
||||
lightStyle!: string;
|
||||
|
||||
@IsString()
|
||||
darkStyle!: string;
|
||||
}
|
||||
|
||||
class SystemConfigNewVersionCheckDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigOAuthDto {
|
||||
@ValidateBoolean()
|
||||
autoLaunch!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
autoRegister!: boolean;
|
||||
|
||||
@IsString()
|
||||
buttonText!: string;
|
||||
|
||||
@ValidateIf(isOAuthEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientId!: string;
|
||||
|
||||
@ValidateIf(isOAuthEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
clientSecret!: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
defaultStorageQuota!: number;
|
||||
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateIf(isOAuthEnabled)
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
issuerUrl!: string;
|
||||
|
||||
@ValidateBoolean()
|
||||
mobileOverrideEnabled!: boolean;
|
||||
|
||||
@ValidateIf(isOAuthOverrideEnabled)
|
||||
@IsUrl()
|
||||
mobileRedirectUri!: string;
|
||||
|
||||
@IsString()
|
||||
scope!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
signingAlgorithm!: string;
|
||||
|
||||
@IsString()
|
||||
storageLabelClaim!: string;
|
||||
|
||||
@IsString()
|
||||
storageQuotaClaim!: string;
|
||||
}
|
||||
|
||||
class SystemConfigPasswordLoginDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigReverseGeocodingDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
|
||||
class SystemConfigServerDto {
|
||||
@IsString()
|
||||
externalDomain!: string;
|
||||
|
||||
@IsString()
|
||||
loginPageMessage!: string;
|
||||
}
|
||||
|
||||
class SystemConfigStorageTemplateDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@ValidateBoolean()
|
||||
hashVerificationEnabled!: boolean;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
template!: string;
|
||||
}
|
||||
|
||||
export class SystemConfigTemplateStorageOptionDto {
|
||||
yearOptions!: string[];
|
||||
monthOptions!: string[];
|
||||
weekOptions!: string[];
|
||||
dayOptions!: string[];
|
||||
hourOptions!: string[];
|
||||
minuteOptions!: string[];
|
||||
secondOptions!: string[];
|
||||
presetOptions!: string[];
|
||||
}
|
||||
|
||||
export class SystemConfigThemeDto {
|
||||
@IsString()
|
||||
customCss!: string;
|
||||
}
|
||||
|
||||
class SystemConfigThumbnailDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
webpSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
jpegSize!: number;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
quality!: number;
|
||||
|
||||
@IsEnum(Colorspace)
|
||||
@ApiProperty({ enumName: 'Colorspace', enum: Colorspace })
|
||||
colorspace!: Colorspace;
|
||||
}
|
||||
|
||||
class SystemConfigTrashDto {
|
||||
@ValidateBoolean()
|
||||
enabled!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
days!: number;
|
||||
}
|
||||
|
||||
class SystemConfigUserDto {
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
deleteDelay!: number;
|
||||
}
|
||||
import { IsObject, ValidateNested } from 'class-validator';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config-ffmpeg.dto';
|
||||
import { SystemConfigJobDto } from 'src/dtos/system-config-job.dto';
|
||||
import { SystemConfigLibraryDto } from 'src/dtos/system-config-library.dto';
|
||||
import { SystemConfigLoggingDto } from 'src/dtos/system-config-logging.dto';
|
||||
import { SystemConfigMachineLearningDto } from 'src/dtos/system-config-machine-learning.dto';
|
||||
import { SystemConfigMapDto } from 'src/dtos/system-config-map.dto';
|
||||
import { SystemConfigNewVersionCheckDto } from 'src/dtos/system-config-new-version-check.dto';
|
||||
import { SystemConfigOAuthDto } from 'src/dtos/system-config-oauth.dto';
|
||||
import { SystemConfigPasswordLoginDto } from 'src/dtos/system-config-password-login.dto';
|
||||
import { SystemConfigReverseGeocodingDto } from 'src/dtos/system-config-reverse-geocoding.dto';
|
||||
import { SystemConfigServerDto } from 'src/dtos/system-config-server.dto';
|
||||
import { SystemConfigStorageTemplateDto } from 'src/dtos/system-config-storage-template.dto';
|
||||
import { SystemConfigThemeDto } from 'src/dtos/system-config-theme.dto';
|
||||
import { SystemConfigThumbnailDto } from 'src/dtos/system-config-thumbnail.dto';
|
||||
import { SystemConfigTrashDto } from 'src/dtos/system-config-trash.dto';
|
||||
import { SystemConfigUserDto } from 'src/dtos/system-config-user.dto';
|
||||
import { SystemConfig } from 'src/entities/system-config.entity';
|
||||
|
||||
export class SystemConfigDto implements SystemConfig {
|
||||
@Type(() => SystemConfigFFmpegDto)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AssetOrder } from 'src/entities/album.entity';
|
||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
@@ -78,22 +79,6 @@ export interface TimeBucketItem {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type AssetCreate = Pick<
|
||||
AssetEntity,
|
||||
| 'deviceAssetId'
|
||||
| 'ownerId'
|
||||
| 'libraryId'
|
||||
| 'deviceId'
|
||||
| 'type'
|
||||
| 'originalPath'
|
||||
| 'fileCreatedAt'
|
||||
| 'localDateTime'
|
||||
| 'fileModifiedAt'
|
||||
| 'checksum'
|
||||
| 'originalFileName'
|
||||
> &
|
||||
Partial<AssetEntity>;
|
||||
|
||||
export type AssetWithoutRelations = Omit<
|
||||
AssetEntity,
|
||||
| 'livePhotoVideo'
|
||||
@@ -109,6 +94,22 @@ export type AssetWithoutRelations = Omit<
|
||||
| 'tags'
|
||||
>;
|
||||
|
||||
export type AssetCreate = Pick<
|
||||
AssetEntity,
|
||||
| 'deviceAssetId'
|
||||
| 'ownerId'
|
||||
| 'libraryId'
|
||||
| 'deviceId'
|
||||
| 'type'
|
||||
| 'originalPath'
|
||||
| 'fileCreatedAt'
|
||||
| 'localDateTime'
|
||||
| 'fileModifiedAt'
|
||||
| 'checksum'
|
||||
| 'originalFileName'
|
||||
> &
|
||||
Partial<AssetWithoutRelations>;
|
||||
|
||||
export type AssetUpdateOptions = Pick<AssetWithoutRelations, 'id'> & Partial<AssetWithoutRelations>;
|
||||
|
||||
export type AssetUpdateAllOptions = Omit<Partial<AssetWithoutRelations>, 'id'>;
|
||||
@@ -139,17 +140,13 @@ export const IAssetRepository = 'IAssetRepository';
|
||||
|
||||
export interface IAssetRepository {
|
||||
create(asset: AssetCreate): Promise<AssetEntity>;
|
||||
getByIds(
|
||||
ids: string[],
|
||||
relations?: FindOptionsRelations<AssetEntity>,
|
||||
select?: FindOptionsSelect<AssetEntity>,
|
||||
): Promise<AssetEntity[]>;
|
||||
getByIds(ids: string[], relations?: Prisma.AssetsInclude): Promise<AssetEntity[]>;
|
||||
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
|
||||
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
|
||||
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
|
||||
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
|
||||
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
getById(id: string, relations?: FindOptionsRelations<AssetEntity>): Promise<AssetEntity | null>;
|
||||
getById(id: string, relations?: Prisma.AssetsInclude): Promise<AssetEntity | null>;
|
||||
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
|
||||
getWith(pagination: PaginationOptions, property: WithProperty, libraryId?: string): Paginated<AssetEntity>;
|
||||
getRandom(userId: string, count: number): Promise<AssetEntity[]>;
|
||||
@@ -161,7 +158,7 @@ export interface IAssetRepository {
|
||||
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
|
||||
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
|
||||
updateAll(ids: string[], options: Partial<AssetUpdateAllOptions>): Promise<void>;
|
||||
update(asset: AssetUpdateOptions): Promise<void>;
|
||||
update(asset: AssetUpdateOptions): Promise<AssetEntity>;
|
||||
remove(asset: AssetEntity): Promise<void>;
|
||||
softDeleteAll(ids: string[]): Promise<void>;
|
||||
restoreAll(ids: string[]): Promise<void>;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server-info.dto';
|
||||
import { SystemConfig } from 'src/entities/system-config.entity';
|
||||
|
||||
export const IEventRepository = 'IEventRepository';
|
||||
export const ICommunicationRepository = 'ICommunicationRepository';
|
||||
|
||||
export enum ClientEvent {
|
||||
UPLOAD_SUCCESS = 'on_upload_success',
|
||||
@@ -19,6 +19,18 @@ export enum ClientEvent {
|
||||
NEW_RELEASE = 'on_new_release',
|
||||
}
|
||||
|
||||
export enum ServerEvent {
|
||||
CONFIG_UPDATE = 'config:update',
|
||||
}
|
||||
|
||||
export enum InternalEvent {
|
||||
VALIDATE_CONFIG = 'validate_config',
|
||||
}
|
||||
|
||||
export interface InternalEventMap {
|
||||
[InternalEvent.VALIDATE_CONFIG]: { newConfig: SystemConfig; oldConfig: SystemConfig };
|
||||
}
|
||||
|
||||
export interface ClientEventMap {
|
||||
[ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto;
|
||||
[ClientEvent.USER_DELETE]: string;
|
||||
@@ -34,39 +46,15 @@ export interface ClientEventMap {
|
||||
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
|
||||
}
|
||||
|
||||
export enum ServerEvent {
|
||||
CONFIG_UPDATE = 'config.update',
|
||||
WEBSOCKET_CONNECT = 'websocket.connect',
|
||||
}
|
||||
export type OnConnectCallback = (userId: string) => void | Promise<void>;
|
||||
export type OnServerEventCallback = () => Promise<void>;
|
||||
|
||||
export interface ServerEventMap {
|
||||
[ServerEvent.CONFIG_UPDATE]: null;
|
||||
[ServerEvent.WEBSOCKET_CONNECT]: { userId: string };
|
||||
}
|
||||
|
||||
export enum ServerAsyncEvent {
|
||||
CONFIG_VALIDATE = 'config.validate',
|
||||
}
|
||||
|
||||
export interface ServerAsyncEventMap {
|
||||
[ServerAsyncEvent.CONFIG_VALIDATE]: { newConfig: SystemConfig; oldConfig: SystemConfig };
|
||||
}
|
||||
|
||||
export interface IEventRepository {
|
||||
/**
|
||||
* Send to connected clients for a specific user
|
||||
*/
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]): void;
|
||||
/**
|
||||
* Send to all connected clients
|
||||
*/
|
||||
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]): void;
|
||||
/**
|
||||
* Notify listeners in this and connected processes. Subscribe to an event with `@OnServerEvent`
|
||||
*/
|
||||
serverSend<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]): boolean;
|
||||
/**
|
||||
* Notify and wait for responses from listeners in this process. Subscribe to an event with `@OnServerEvent`
|
||||
*/
|
||||
serverSendAsync<E extends keyof ServerAsyncEventMap>(event: E, data: ServerAsyncEventMap[E]): Promise<any>;
|
||||
export interface ICommunicationRepository {
|
||||
send<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]): void;
|
||||
broadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]): void;
|
||||
on(event: 'connect', callback: OnConnectCallback): void;
|
||||
on(event: ServerEvent, callback: OnServerEventCallback): void;
|
||||
sendServerEvent(event: ServerEvent): void;
|
||||
emit<E extends keyof InternalEventMap>(event: E, data: InternalEventMap[E]): boolean;
|
||||
emitAsync<E extends keyof InternalEventMap>(event: E, data: InternalEventMap[E]): Promise<any>;
|
||||
}
|
||||
@@ -8,5 +8,4 @@ export interface ICryptoRepository {
|
||||
hashSha1(data: string | Buffer): Buffer;
|
||||
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
|
||||
compareBcrypt(data: string | Buffer, encrypted: string): boolean;
|
||||
newPassword(bytes: number): string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity';
|
||||
import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
||||
import { Paginated } from 'src/utils/pagination';
|
||||
|
||||
export const ISearchRepository = 'ISearchRepository';
|
||||
@@ -174,7 +175,7 @@ export type SmartSearchOptions = SearchDateOptions &
|
||||
export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
||||
hasPerson?: boolean;
|
||||
numResults: number;
|
||||
maxDistance?: number;
|
||||
maxDistance: number;
|
||||
}
|
||||
|
||||
export interface FaceSearchResult {
|
||||
|
||||
@@ -71,6 +71,18 @@ async function bootstrapApi() {
|
||||
logger.log(`Immich Server is listening on ${await app.getUrl()} [v${serverVersion}] [${envName}] `);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface BigInt {
|
||||
toJSON(): number | string;
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
BigInt.prototype.toJSON = function () {
|
||||
return this.valueOf() > MAX_SAFE_INTEGER ? this.toString() : Number(this.valueOf());
|
||||
};
|
||||
|
||||
const immichApp = process.argv[2] || process.env.IMMICH_APP;
|
||||
|
||||
if (process.argv[2] === immichApp) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
const excludeDeleted = ({ args, query }: { args: any; query: any }) => {
|
||||
if (args.where === undefined) {
|
||||
args.where = { deletedAt: null };
|
||||
} else if (args.where.deletedAt === undefined) {
|
||||
args.where.deletedAt = null;
|
||||
}
|
||||
|
||||
return query(args);
|
||||
};
|
||||
|
||||
const findNonDeleted = {
|
||||
findFirst: excludeDeleted,
|
||||
findFirstOrThrow: excludeDeleted,
|
||||
findMany: excludeDeleted,
|
||||
findUnique: excludeDeleted,
|
||||
findUniqueOrThrow: excludeDeleted,
|
||||
};
|
||||
|
||||
export const findNonDeletedExtension = Prisma.defineExtension({
|
||||
query: {
|
||||
albums: findNonDeleted,
|
||||
assets: findNonDeleted,
|
||||
libraries: findNonDeleted,
|
||||
users: findNonDeleted,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { ColumnType } from "kysely";
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
: ColumnType<T, T | undefined, T>;
|
||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||
|
||||
export type Activity = {
|
||||
id: Generated<string>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
assetId: string | null;
|
||||
comment: string | null;
|
||||
isLiked: Generated<boolean>;
|
||||
};
|
||||
export type Albums = {
|
||||
id: Generated<string>;
|
||||
ownerId: string;
|
||||
albumName: Generated<string>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
albumThumbnailAssetId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
description: Generated<string>;
|
||||
deletedAt: Timestamp | null;
|
||||
isActivityEnabled: Generated<boolean>;
|
||||
order: Generated<string>;
|
||||
};
|
||||
export type AlbumsAssetsAssets = {
|
||||
albumsId: string;
|
||||
assetsId: string;
|
||||
};
|
||||
export type AlbumsSharedUsersUsers = {
|
||||
albumsId: string;
|
||||
usersId: string;
|
||||
};
|
||||
export type ApiKeys = {
|
||||
name: string;
|
||||
key: string;
|
||||
userId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
id: Generated<string>;
|
||||
};
|
||||
export type AssetFaces = {
|
||||
assetId: string;
|
||||
personId: string | null;
|
||||
imageWidth: Generated<number>;
|
||||
imageHeight: Generated<number>;
|
||||
boundingBoxX1: Generated<number>;
|
||||
boundingBoxY1: Generated<number>;
|
||||
boundingBoxX2: Generated<number>;
|
||||
boundingBoxY2: Generated<number>;
|
||||
id: Generated<string>;
|
||||
};
|
||||
export type AssetJobStatus = {
|
||||
assetId: string;
|
||||
facesRecognizedAt: Timestamp | null;
|
||||
metadataExtractedAt: Timestamp | null;
|
||||
};
|
||||
export type Assets = {
|
||||
id: Generated<string>;
|
||||
deviceAssetId: string;
|
||||
ownerId: string;
|
||||
deviceId: string;
|
||||
type: string;
|
||||
originalPath: string;
|
||||
resizePath: string | null;
|
||||
fileCreatedAt: Timestamp;
|
||||
fileModifiedAt: Timestamp;
|
||||
isFavorite: Generated<boolean>;
|
||||
duration: string | null;
|
||||
webpPath: Generated<string | null>;
|
||||
encodedVideoPath: Generated<string | null>;
|
||||
checksum: Buffer;
|
||||
isVisible: Generated<boolean>;
|
||||
livePhotoVideoId: string | null;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
isArchived: Generated<boolean>;
|
||||
originalFileName: string;
|
||||
sidecarPath: string | null;
|
||||
isReadOnly: Generated<boolean>;
|
||||
thumbhash: Buffer | null;
|
||||
isOffline: Generated<boolean>;
|
||||
libraryId: string;
|
||||
isExternal: Generated<boolean>;
|
||||
deletedAt: Timestamp | null;
|
||||
localDateTime: Timestamp;
|
||||
stackId: string | null;
|
||||
truncatedDate: Generated<Timestamp>;
|
||||
};
|
||||
export type AssetStack = {
|
||||
id: Generated<string>;
|
||||
primaryAssetId: string;
|
||||
};
|
||||
export type Audit = {
|
||||
id: Generated<number>;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
action: string;
|
||||
ownerId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
};
|
||||
export type Exif = {
|
||||
assetId: string;
|
||||
make: string | null;
|
||||
model: string | null;
|
||||
exifImageWidth: number | null;
|
||||
exifImageHeight: number | null;
|
||||
fileSizeInByte: string | null;
|
||||
orientation: string | null;
|
||||
dateTimeOriginal: Timestamp | null;
|
||||
modifyDate: Timestamp | null;
|
||||
lensModel: string | null;
|
||||
fNumber: number | null;
|
||||
focalLength: number | null;
|
||||
iso: number | null;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
city: string | null;
|
||||
state: string | null;
|
||||
country: string | null;
|
||||
description: Generated<string>;
|
||||
fps: number | null;
|
||||
exposureTime: string | null;
|
||||
livePhotoCID: string | null;
|
||||
timeZone: string | null;
|
||||
projectionType: string | null;
|
||||
profileDescription: string | null;
|
||||
colorspace: string | null;
|
||||
bitsPerSample: number | null;
|
||||
autoStackId: string | null;
|
||||
};
|
||||
export type GeodataPlaces = {
|
||||
id: number;
|
||||
name: string;
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
countryCode: string;
|
||||
admin1Code: string | null;
|
||||
admin2Code: string | null;
|
||||
modificationDate: Timestamp;
|
||||
admin1Name: string | null;
|
||||
admin2Name: string | null;
|
||||
alternateNames: string | null;
|
||||
};
|
||||
export type Libraries = {
|
||||
id: Generated<string>;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
type: string;
|
||||
importPaths: string[];
|
||||
exclusionPatterns: string[];
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
deletedAt: Timestamp | null;
|
||||
refreshedAt: Timestamp | null;
|
||||
isVisible: Generated<boolean>;
|
||||
};
|
||||
export type MoveHistory = {
|
||||
id: Generated<string>;
|
||||
entityId: string;
|
||||
pathType: string;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
};
|
||||
export type Partners = {
|
||||
sharedById: string;
|
||||
sharedWithId: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
inTimeline: Generated<boolean>;
|
||||
};
|
||||
export type Person = {
|
||||
id: Generated<string>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
ownerId: string;
|
||||
name: Generated<string>;
|
||||
thumbnailPath: Generated<string>;
|
||||
isHidden: Generated<boolean>;
|
||||
birthDate: Timestamp | null;
|
||||
faceAssetId: string | null;
|
||||
};
|
||||
export type SharedLinkAsset = {
|
||||
assetsId: string;
|
||||
sharedLinksId: string;
|
||||
};
|
||||
export type SharedLinks = {
|
||||
id: Generated<string>;
|
||||
description: string | null;
|
||||
userId: string;
|
||||
key: Buffer;
|
||||
type: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
expiresAt: Timestamp | null;
|
||||
allowUpload: Generated<boolean>;
|
||||
albumId: string | null;
|
||||
allowDownload: Generated<boolean>;
|
||||
showExif: Generated<boolean>;
|
||||
password: string | null;
|
||||
};
|
||||
export type SmartInfo = {
|
||||
assetId: string;
|
||||
tags: string[];
|
||||
objects: string[];
|
||||
};
|
||||
export type SmartSearch = {
|
||||
assetId: string;
|
||||
};
|
||||
export type SocketIoAttachments = {
|
||||
id: Generated<string>;
|
||||
created_at: Generated<Timestamp | null>;
|
||||
payload: Buffer | null;
|
||||
};
|
||||
export type SystemConfig = {
|
||||
key: string;
|
||||
value: string | null;
|
||||
};
|
||||
export type SystemMetadata = {
|
||||
key: string;
|
||||
value: Generated<unknown>;
|
||||
};
|
||||
export type TagAsset = {
|
||||
assetsId: string;
|
||||
tagsId: string;
|
||||
};
|
||||
export type Tags = {
|
||||
id: Generated<string>;
|
||||
type: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
renameTagId: string | null;
|
||||
};
|
||||
export type Users = {
|
||||
id: Generated<string>;
|
||||
email: string;
|
||||
password: Generated<string>;
|
||||
createdAt: Generated<Timestamp>;
|
||||
profileImagePath: Generated<string>;
|
||||
isAdmin: Generated<boolean>;
|
||||
shouldChangePassword: Generated<boolean>;
|
||||
deletedAt: Timestamp | null;
|
||||
oauthId: Generated<string>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
storageLabel: string | null;
|
||||
memoriesEnabled: Generated<boolean>;
|
||||
name: Generated<string>;
|
||||
avatarColor: string | null;
|
||||
quotaSizeInBytes: string | null;
|
||||
quotaUsageInBytes: Generated<string>;
|
||||
status: Generated<string>;
|
||||
};
|
||||
export type UserToken = {
|
||||
id: Generated<string>;
|
||||
token: string;
|
||||
createdAt: Generated<Timestamp>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
userId: string;
|
||||
deviceType: Generated<string>;
|
||||
deviceOS: Generated<string>;
|
||||
};
|
||||
export type DB = {
|
||||
activity: Activity;
|
||||
albums: Albums;
|
||||
albums_assets_assets: AlbumsAssetsAssets;
|
||||
albums_shared_users_users: AlbumsSharedUsersUsers;
|
||||
api_keys: ApiKeys;
|
||||
asset_faces: AssetFaces;
|
||||
asset_job_status: AssetJobStatus;
|
||||
asset_stack: AssetStack;
|
||||
assets: Assets;
|
||||
audit: Audit;
|
||||
exif: Exif;
|
||||
geodata_places: GeodataPlaces;
|
||||
libraries: Libraries;
|
||||
move_history: MoveHistory;
|
||||
partners: Partners;
|
||||
person: Person;
|
||||
shared_link__asset: SharedLinkAsset;
|
||||
shared_links: SharedLinks;
|
||||
smart_info: SmartInfo;
|
||||
smart_search: SmartSearch;
|
||||
socket_io_attachments: SocketIoAttachments;
|
||||
system_config: SystemConfig;
|
||||
system_metadata: SystemMetadata;
|
||||
tag_asset: TagAsset;
|
||||
tags: Tags;
|
||||
user_token: UserToken;
|
||||
users: Users;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { DeduplicateJoinsPlugin, Kysely, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler } from 'kysely';
|
||||
import kyselyExt from 'prisma-extension-kysely';
|
||||
import type { DB } from './generated/types';
|
||||
|
||||
export const kyselyExtension = kyselyExt({
|
||||
kysely: (driver) =>
|
||||
new Kysely<DB>({
|
||||
dialect: {
|
||||
createDriver: () => driver,
|
||||
createAdapter: () => new PostgresAdapter(),
|
||||
createIntrospector: (db) => new PostgresIntrospector(db),
|
||||
createQueryCompiler: () => new PostgresQueryCompiler(),
|
||||
},
|
||||
plugins: [new DeduplicateJoinsPlugin()],
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import util from 'node:util';
|
||||
|
||||
export const metricsExtension = Prisma.defineExtension({
|
||||
query: {
|
||||
$allModels: {
|
||||
async $allOperations({ operation, model, args, query }) {
|
||||
const start = performance.now();
|
||||
const result = await query(args);
|
||||
const end = performance.now();
|
||||
const time = end - start;
|
||||
console.log(util.inspect({ model, operation, args, time }, { showHidden: false, depth: null, colors: true }));
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["postgresqlExtensions", "relationJoins"]
|
||||
}
|
||||
|
||||
generator kysely {
|
||||
provider = "prisma-kysely"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DB_URL")
|
||||
extensions = [cube, earthdistance, pg_trgm, unaccent, uuid_ossp(map: "uuid-ossp", schema: "public"), vectors(map: "vectors", schema: "vectors")]
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
model Activity {
|
||||
id String @id(map: "PK_24625a1d6b1b089c8ae206fe467") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
albumId String @db.Uuid
|
||||
userId String @db.Uuid
|
||||
assetId String? @db.Uuid
|
||||
comment String?
|
||||
isLiked Boolean @default(false)
|
||||
albums Albums @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "FK_1af8519996fbfb3684b58df280b")
|
||||
users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_3571467bcbe021f66e2bdce96ea")
|
||||
assets Assets? @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_8091ea76b12338cb4428d33d782")
|
||||
|
||||
@@map(name: "activity")
|
||||
}
|
||||
|
||||
/// This model or at least one of its fields has comments in the database, and requires an additional setup for migrations: Read more: https://pris.ly/d/database-comments
|
||||
model Albums {
|
||||
id String @id(map: "PK_7f71c7b5bc7c87b8f94c9a93a00") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
ownerId String @db.Uuid
|
||||
albumName String @default("Untitled Album") @db.VarChar
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
albumThumbnailAssetId String? @db.Uuid
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
description String @default("")
|
||||
deletedAt DateTime? @db.Timestamptz(6)
|
||||
isActivityEnabled Boolean @default(true)
|
||||
order String @default("desc") @db.VarChar
|
||||
activity Activity[]
|
||||
assets Assets? @relation(fields: [albumThumbnailAssetId], references: [id], map: "FK_05895aa505a670300d4816debce")
|
||||
users Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_b22c53f35ef20c28c21637c85f4")
|
||||
albums_assets_assets AlbumsAssetsAssets[]
|
||||
albums_shared_users_users AlbumsSharedUsersUsers[]
|
||||
shared_links SharedLinks[]
|
||||
|
||||
@@map(name: "albums")
|
||||
}
|
||||
|
||||
model AlbumsAssetsAssets {
|
||||
albumsId String @db.Uuid
|
||||
assetsId String @db.Uuid
|
||||
assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_4bd1303d199f4e72ccdf998c621")
|
||||
albums Albums @relation(fields: [albumsId], references: [id], onDelete: Cascade, map: "FK_e590fa396c6898fcd4a50e40927")
|
||||
|
||||
@@id([albumsId, assetsId], map: "PK_c67bc36fa845fb7b18e0e398180")
|
||||
@@index([assetsId], map: "IDX_4bd1303d199f4e72ccdf998c62")
|
||||
@@index([albumsId], map: "IDX_e590fa396c6898fcd4a50e4092")
|
||||
@@map(name: "albums_assets_assets")
|
||||
}
|
||||
|
||||
model AlbumsSharedUsersUsers {
|
||||
albumsId String @db.Uuid
|
||||
usersId String @db.Uuid
|
||||
albums Albums @relation(fields: [albumsId], references: [id], onDelete: Cascade, map: "FK_427c350ad49bd3935a50baab737")
|
||||
users Users @relation(fields: [usersId], references: [id], onDelete: Cascade, map: "FK_f48513bf9bccefd6ff3ad30bd06")
|
||||
|
||||
@@id([albumsId, usersId], map: "PK_7df55657e0b2e8b626330a0ebc8")
|
||||
@@index([albumsId], map: "IDX_427c350ad49bd3935a50baab73")
|
||||
@@index([usersId], map: "IDX_f48513bf9bccefd6ff3ad30bd0")
|
||||
@@map(name: "albums_shared_users_users")
|
||||
}
|
||||
|
||||
model ApiKeys {
|
||||
name String @db.VarChar
|
||||
key String @db.VarChar
|
||||
userId String @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
id String @id(map: "PK_5c8a79801b44bd27b79228e1dad") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_6c2e267ae764a9413b863a29342")
|
||||
|
||||
@@map(name: "api_keys")
|
||||
}
|
||||
|
||||
model AssetFaces {
|
||||
assetId String @db.Uuid
|
||||
personId String? @db.Uuid
|
||||
embedding Unsupported("vector")
|
||||
imageWidth Int @default(0)
|
||||
imageHeight Int @default(0)
|
||||
boundingBoxX1 Int @default(0)
|
||||
boundingBoxY1 Int @default(0)
|
||||
boundingBoxX2 Int @default(0)
|
||||
boundingBoxY2 Int @default(0)
|
||||
id String @id(map: "PK_6df76ab2eb6f5b57b7c2f1fc684") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_02a43fd0b3c50fb6d7f0cb7282c")
|
||||
person Person? @relation("asset_faces_personIdToperson", fields: [personId], references: [id], map: "FK_95ad7106dd7b484275443f580f9")
|
||||
person_person_faceAssetIdToasset_faces Person[] @relation("person_faceAssetIdToasset_faces")
|
||||
|
||||
@@index([assetId, personId], map: "IDX_asset_faces_assetId_personId")
|
||||
@@index([assetId], map: "IDX_asset_faces_on_assetId")
|
||||
@@index([personId], map: "IDX_asset_faces_personId")
|
||||
@@index([personId, assetId], map: "IDX_bf339a24070dac7e71304ec530")
|
||||
@@index([embedding], map: "face_index")
|
||||
@@map(name: "asset_faces")
|
||||
}
|
||||
|
||||
model AssetJobStatus {
|
||||
assetId String @id(map: "PK_420bec36fc02813bddf5c8b73d4") @db.Uuid
|
||||
facesRecognizedAt DateTime? @db.Timestamptz(6)
|
||||
metadataExtractedAt DateTime? @db.Timestamptz(6)
|
||||
assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, map: "FK_420bec36fc02813bddf5c8b73d4")
|
||||
|
||||
@@map(name: "asset_job_status")
|
||||
}
|
||||
|
||||
model AssetStack {
|
||||
id String @id(map: "PK_74a27e7fcbd5852463d0af3034b") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
primaryAssetId String @unique(map: "REL_91704e101438fd0653f582426d") @db.Uuid
|
||||
primaryAsset Assets @relation("asset_stack_primaryAssetIdToassets", fields: [primaryAssetId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_91704e101438fd0653f582426dc")
|
||||
assets Assets[] @relation("assets_stackIdToasset_stack")
|
||||
|
||||
@@map(name: "asset_stack")
|
||||
}
|
||||
|
||||
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
|
||||
model Assets {
|
||||
id String @id(map: "PK_da96729a8b113377cfb6a62439c") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
deviceAssetId String @db.VarChar
|
||||
ownerId String @db.Uuid
|
||||
deviceId String @db.VarChar
|
||||
type String @db.VarChar
|
||||
originalPath String @db.VarChar
|
||||
resizePath String? @db.VarChar
|
||||
fileCreatedAt DateTime @db.Timestamptz(6)
|
||||
fileModifiedAt DateTime @db.Timestamptz(6)
|
||||
isFavorite Boolean @default(false)
|
||||
duration String? @db.VarChar
|
||||
webpPath String? @default("") @db.VarChar
|
||||
encodedVideoPath String? @default("") @db.VarChar
|
||||
checksum Bytes
|
||||
isVisible Boolean @default(true)
|
||||
livePhotoVideoId String? @unique(map: "UQ_16294b83fa8c0149719a1f631ef") @db.Uuid
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
isArchived Boolean @default(false)
|
||||
originalFileName String @db.VarChar
|
||||
sidecarPath String? @db.VarChar
|
||||
isReadOnly Boolean @default(false)
|
||||
thumbhash Bytes?
|
||||
isOffline Boolean @default(false)
|
||||
libraryId String @db.Uuid
|
||||
isExternal Boolean @default(false)
|
||||
deletedAt DateTime? @db.Timestamptz(6)
|
||||
localDateTime DateTime @db.Timestamptz(6)
|
||||
stackId String? @db.Uuid
|
||||
truncatedDate DateTime @default(dbgenerated("date_trunc('day', \"localDateTime\" at time zone 'UTC') at time zone 'UTC'")) @db.Timestamptz(6)
|
||||
activity Activity[]
|
||||
albums Albums[]
|
||||
albumsAssetsAssets AlbumsAssetsAssets[]
|
||||
faces AssetFaces[]
|
||||
assetJobStatus AssetJobStatus?
|
||||
assetStackAssetStackPrimaryAssetIdToAssets AssetStack? @relation("asset_stack_primaryAssetIdToassets")
|
||||
livePhotoVideo Assets? @relation("assetsToassets", fields: [livePhotoVideoId], references: [id], map: "FK_16294b83fa8c0149719a1f631ef")
|
||||
otherAssets Assets? @relation("assetsToassets")
|
||||
owner Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_2c5ac0d6fb58b238fd2068de67d")
|
||||
library Libraries @relation(fields: [libraryId], references: [id], onDelete: Cascade, map: "FK_9977c3c1de01c3d848039a6b90c")
|
||||
stack AssetStack? @relation("assets_stackIdToasset_stack", fields: [stackId], references: [id], map: "FK_f15d48fa3ea5e4bda05ca8ab207")
|
||||
exifInfo Exif?
|
||||
sharedLinks SharedLinkAsset[]
|
||||
smartInfo SmartInfo?
|
||||
smartSearch SmartSearch?
|
||||
tags TagAsset[]
|
||||
|
||||
@@unique([ownerId, libraryId, checksum], map: "UQ_assets_owner_library_checksum")
|
||||
@@index([originalFileName], map: "IDX_4d66e76dada1ca180f67a205dc")
|
||||
@@index([checksum], map: "IDX_8d3efe36c0755849395e6ea866")
|
||||
@@index([id, stackId], map: "IDX_asset_id_stackId")
|
||||
@@index([originalPath, libraryId], map: "IDX_originalPath_libraryId")
|
||||
@@index([fileCreatedAt], map: "idx_asset_file_created_at")
|
||||
@@map(name: "assets")
|
||||
}
|
||||
|
||||
model Audit {
|
||||
id Int @id(map: "PK_1d3d120ddaf7bc9b1ed68ed463a") @default(autoincrement())
|
||||
entityType String @db.VarChar
|
||||
entityId String @db.Uuid
|
||||
action String @db.VarChar
|
||||
ownerId String @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
|
||||
@@index([ownerId, createdAt], map: "IDX_ownerId_createdAt")
|
||||
@@map(name: "audit")
|
||||
}
|
||||
|
||||
model Exif {
|
||||
assetId String @id(map: "PK_c0117fdbc50b917ef9067740c44") @db.Uuid
|
||||
make String? @db.VarChar
|
||||
model String? @db.VarChar
|
||||
exifImageWidth Int?
|
||||
exifImageHeight Int?
|
||||
fileSizeInByte BigInt?
|
||||
orientation String? @db.VarChar
|
||||
dateTimeOriginal DateTime? @db.Timestamptz(6)
|
||||
modifyDate DateTime? @db.Timestamptz(6)
|
||||
lensModel String? @db.VarChar
|
||||
fNumber Float?
|
||||
focalLength Float?
|
||||
iso Int?
|
||||
latitude Float?
|
||||
longitude Float?
|
||||
city String? @db.VarChar
|
||||
state String? @db.VarChar
|
||||
country String? @db.VarChar
|
||||
description String @default("")
|
||||
fps Float?
|
||||
exposureTime String? @db.VarChar
|
||||
livePhotoCID String? @db.VarChar
|
||||
timeZone String? @db.VarChar
|
||||
exifTextSearchableColumn Unsupported("tsvector") @default(dbgenerated("to_tsvector('english'::regconfig, (((((((((((((COALESCE(make, ''::character varying))::text || ' '::text) || (COALESCE(model, ''::character varying))::text) || ' '::text) || (COALESCE(orientation, ''::character varying))::text) || ' '::text) || (COALESCE(\"lensModel\", ''::character varying))::text) || ' '::text) || (COALESCE(city, ''::character varying))::text) || ' '::text) || (COALESCE(state, ''::character varying))::text) || ' '::text) || (COALESCE(country, ''::character varying))::text))"))
|
||||
projectionType String? @db.VarChar
|
||||
profileDescription String? @db.VarChar
|
||||
colorspace String? @db.VarChar
|
||||
bitsPerSample Int?
|
||||
autoStackId String? @db.VarChar
|
||||
assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_c0117fdbc50b917ef9067740c44")
|
||||
|
||||
@@index([autoStackId], map: "IDX_auto_stack_id")
|
||||
@@index([livePhotoCID], map: "IDX_live_photo_cid")
|
||||
@@index([city], map: "exif_city")
|
||||
@@map(name: "exif")
|
||||
}
|
||||
|
||||
/// This model contains an expression index which requires additional setup for migrations. Visit https://pris.ly/d/expression-indexes for more info.
|
||||
model GeodataPlaces {
|
||||
id Int @id(map: "PK_c29918988912ef4036f3d7fbff4")
|
||||
name String @db.VarChar(200)
|
||||
longitude Float
|
||||
latitude Float
|
||||
countryCode String @db.Char(2)
|
||||
admin1Code String? @db.VarChar(20)
|
||||
admin2Code String? @db.VarChar(80)
|
||||
modificationDate DateTime @db.Date
|
||||
earthCoord Unsupported("cube")? @default(dbgenerated("ll_to_earth(latitude, longitude)"))
|
||||
admin1Name String? @db.VarChar
|
||||
admin2Name String? @db.VarChar
|
||||
alternateNames String? @db.VarChar
|
||||
|
||||
@@index([earthCoord], map: "IDX_geodata_gist_earthcoord", type: Gist)
|
||||
@@map(name: "geodata_places")
|
||||
}
|
||||
|
||||
model Libraries {
|
||||
id String @id(map: "PK_505fedfcad00a09b3734b4223de") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
name String @db.VarChar
|
||||
ownerId String @db.Uuid
|
||||
type String @db.VarChar
|
||||
importPaths String[]
|
||||
exclusionPatterns String[]
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
deletedAt DateTime? @db.Timestamptz(6)
|
||||
refreshedAt DateTime? @db.Timestamptz(6)
|
||||
isVisible Boolean @default(true)
|
||||
assets Assets[]
|
||||
owner Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_0f6fc2fb195f24d19b0fb0d57c1")
|
||||
|
||||
@@map(name: "libraries")
|
||||
}
|
||||
|
||||
model MoveHistory {
|
||||
id String @id(map: "PK_af608f132233acf123f2949678d") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
entityId String @db.VarChar
|
||||
pathType String @db.VarChar
|
||||
oldPath String @db.VarChar
|
||||
newPath String @unique(map: "UQ_newPath") @db.VarChar
|
||||
|
||||
@@unique([entityId, pathType], map: "UQ_entityId_pathType")
|
||||
@@map(name: "move_history")
|
||||
}
|
||||
|
||||
model Partners {
|
||||
sharedById String @db.Uuid
|
||||
sharedWithId String @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
inTimeline Boolean @default(false)
|
||||
sharedBy Users @relation("partners_sharedByIdTousers", fields: [sharedById], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_7e077a8b70b3530138610ff5e04")
|
||||
sharedWith Users @relation("partners_sharedWithIdTousers", fields: [sharedWithId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_d7e875c6c60e661723dbf372fd3")
|
||||
|
||||
@@id([sharedById, sharedWithId], map: "PK_f1cc8f73d16b367f426261a8736")
|
||||
@@map(name: "partners")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
model Person {
|
||||
id String @id(map: "PK_5fdaf670315c4b7e70cce85daa3") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
ownerId String @db.Uuid
|
||||
name String @default("") @db.VarChar
|
||||
thumbnailPath String @default("") @db.VarChar
|
||||
isHidden Boolean @default(false)
|
||||
birthDate DateTime? @db.Date
|
||||
faceAssetId String? @db.Uuid
|
||||
asset_faces_asset_faces_personIdToperson AssetFaces[] @relation("asset_faces_personIdToperson")
|
||||
asset_faces_person_faceAssetIdToasset_faces AssetFaces? @relation("person_faceAssetIdToasset_faces", fields: [faceAssetId], references: [id], onUpdate: NoAction, map: "FK_2bbabe31656b6778c6b87b61023")
|
||||
users Users @relation(fields: [ownerId], references: [id], onDelete: Cascade, map: "FK_5527cc99f530a547093f9e577b6")
|
||||
|
||||
@@map(name: "person")
|
||||
}
|
||||
|
||||
model SharedLinkAsset {
|
||||
assetsId String @db.Uuid
|
||||
sharedLinksId String @db.Uuid
|
||||
assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_5b7decce6c8d3db9593d6111a66")
|
||||
sharedLinks SharedLinks @relation(fields: [sharedLinksId], references: [id], onDelete: Cascade, map: "FK_c9fab4aa97ffd1b034f3d6581ab")
|
||||
|
||||
@@id([assetsId, sharedLinksId], map: "PK_9b4f3687f9b31d1e311336b05e3")
|
||||
@@index([assetsId], map: "IDX_5b7decce6c8d3db9593d6111a6")
|
||||
@@index([sharedLinksId], map: "IDX_c9fab4aa97ffd1b034f3d6581a")
|
||||
@@map(name: "shared_link__asset")
|
||||
}
|
||||
|
||||
model SharedLinks {
|
||||
id String @id(map: "PK_642e2b0f619e4876e5f90a43465") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
description String? @db.VarChar
|
||||
userId String @db.Uuid
|
||||
key Bytes @unique(map: "UQ_sharedlink_key")
|
||||
type String @db.VarChar
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
expiresAt DateTime? @db.Timestamptz(6)
|
||||
allowUpload Boolean @default(false)
|
||||
albumId String? @db.Uuid
|
||||
allowDownload Boolean @default(true)
|
||||
showExif Boolean @default(true)
|
||||
password String? @db.VarChar
|
||||
assets SharedLinkAsset[]
|
||||
albums Albums? @relation(fields: [albumId], references: [id], onDelete: Cascade, map: "FK_0c6ce9058c29f07cdf7014eac66")
|
||||
users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_66fe3837414c5a9f1c33ca49340")
|
||||
|
||||
@@index([albumId], map: "IDX_sharedlink_albumId")
|
||||
@@index([key], map: "IDX_sharedlink_key")
|
||||
@@map(name: "shared_links")
|
||||
}
|
||||
|
||||
model SmartInfo {
|
||||
assetId String @id(map: "PK_5e3753aadd956110bf3ec0244ac") @db.Uuid
|
||||
tags String[]
|
||||
objects String[]
|
||||
smartInfoTextSearchableColumn Unsupported("tsvector") @default(dbgenerated("to_tsvector('english'::regconfig, f_concat_ws(' '::text, (COALESCE(tags, ARRAY[]::text[]) || COALESCE(objects, ARRAY[]::text[]))))"))
|
||||
assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "FK_5e3753aadd956110bf3ec0244ac")
|
||||
|
||||
@@index([tags], map: "si_tags", type: Gin)
|
||||
@@index([smartInfoTextSearchableColumn], map: "smart_info_text_searchable_idx", type: Gin)
|
||||
@@map(name: "smart_info")
|
||||
}
|
||||
|
||||
model SmartSearch {
|
||||
assetId String @id @db.Uuid
|
||||
embedding Unsupported("vector")
|
||||
assets Assets @relation(fields: [assetId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@index([embedding], map: "clip_index")
|
||||
@@map(name: "smart_search")
|
||||
}
|
||||
|
||||
model SocketIoAttachments {
|
||||
id BigInt @unique @default(autoincrement())
|
||||
created_at DateTime? @default(now()) @db.Timestamptz(6)
|
||||
payload Bytes?
|
||||
|
||||
@@map(name: "socket_io_attachments")
|
||||
}
|
||||
|
||||
model SystemConfig {
|
||||
key String @id(map: "PK_aab69295b445016f56731f4d535") @db.VarChar
|
||||
value String? @db.VarChar
|
||||
|
||||
@@map(name: "system_config")
|
||||
}
|
||||
|
||||
model SystemMetadata {
|
||||
key String @id(map: "PK_fa94f6857470fb5b81ec6084465") @db.VarChar
|
||||
value Json @default("{}")
|
||||
|
||||
@@map(name: "system_metadata")
|
||||
}
|
||||
|
||||
model TagAsset {
|
||||
assetsId String @db.Uuid
|
||||
tagsId String @db.Uuid
|
||||
tags Tags @relation(fields: [tagsId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_e99f31ea4cdf3a2c35c7287eb42")
|
||||
assets Assets @relation(fields: [assetsId], references: [id], onDelete: Cascade, map: "FK_f8e8a9e893cb5c54907f1b798e9")
|
||||
|
||||
@@id([assetsId, tagsId], map: "PK_ef5346fe522b5fb3bc96454747e")
|
||||
@@index([tagsId], map: "IDX_e99f31ea4cdf3a2c35c7287eb4")
|
||||
@@index([assetsId], map: "IDX_f8e8a9e893cb5c54907f1b798e")
|
||||
@@index([assetsId, tagsId], map: "IDX_tag_asset_assetsId_tagsId")
|
||||
@@map(name: "tag_asset")
|
||||
}
|
||||
|
||||
model Tags {
|
||||
id String @id(map: "PK_e7dc17249a1148a1970748eda99") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
type String @db.VarChar
|
||||
name String @db.VarChar
|
||||
userId String @db.Uuid
|
||||
renameTagId String? @db.Uuid
|
||||
tags TagAsset[]
|
||||
users Users @relation(fields: [userId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "FK_92e67dc508c705dd66c94615576")
|
||||
|
||||
@@unique([name, userId], map: "UQ_tag_name_userId")
|
||||
@@map(name: "tags")
|
||||
}
|
||||
|
||||
model UserToken {
|
||||
id String @id(map: "PK_48cb6b5c20faa63157b3c1baf7f") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
token String @db.VarChar
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
userId String @db.Uuid
|
||||
deviceType String @default("") @db.VarChar
|
||||
deviceOS String @default("") @db.VarChar
|
||||
users Users @relation(fields: [userId], references: [id], onDelete: Cascade, map: "FK_d37db50eecdf9b8ce4eedd2f918")
|
||||
|
||||
@@map(name: "user_token")
|
||||
}
|
||||
|
||||
model Users {
|
||||
id String @id(map: "PK_a3ffb1c0c8416b9fc6f907b7433") @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
|
||||
email String @unique(map: "UQ_97672ac88f789774dd47f7c8be3") @db.VarChar
|
||||
password String @default("") @db.VarChar
|
||||
createdAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
profileImagePath String @default("") @db.VarChar
|
||||
isAdmin Boolean @default(false)
|
||||
shouldChangePassword Boolean @default(true)
|
||||
deletedAt DateTime? @db.Timestamptz(6)
|
||||
oauthId String @default("") @db.VarChar
|
||||
updatedAt DateTime @default(now()) @db.Timestamptz(6)
|
||||
storageLabel String? @unique(map: "UQ_b309cf34fa58137c416b32cea3a") @db.VarChar
|
||||
memoriesEnabled Boolean @default(true)
|
||||
name String @default("") @db.VarChar
|
||||
avatarColor String? @db.VarChar
|
||||
quotaSizeInBytes BigInt?
|
||||
quotaUsageInBytes BigInt @default(0)
|
||||
status String @default("active") @db.VarChar
|
||||
activity Activity[]
|
||||
albums Albums[]
|
||||
albumsSharedUsersUsers AlbumsSharedUsersUsers[]
|
||||
apiKeys ApiKeys[]
|
||||
assets Assets[]
|
||||
libraries Libraries[]
|
||||
sharedBy Partners[] @relation("partners_sharedByIdTousers")
|
||||
sharedWith Partners[] @relation("partners_sharedWithIdTousers")
|
||||
person Person[]
|
||||
sharedLinks SharedLinks[]
|
||||
tags Tags[]
|
||||
userToken UserToken[]
|
||||
|
||||
@@map(name: "users")
|
||||
}
|
||||
@@ -1,5 +1,81 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- AssetRepository.getByDate
|
||||
SELECT
|
||||
"AssetEntity"."id" AS "AssetEntity_id",
|
||||
"AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId",
|
||||
"AssetEntity"."ownerId" AS "AssetEntity_ownerId",
|
||||
"AssetEntity"."libraryId" AS "AssetEntity_libraryId",
|
||||
"AssetEntity"."deviceId" AS "AssetEntity_deviceId",
|
||||
"AssetEntity"."type" AS "AssetEntity_type",
|
||||
"AssetEntity"."originalPath" AS "AssetEntity_originalPath",
|
||||
"AssetEntity"."resizePath" AS "AssetEntity_resizePath",
|
||||
"AssetEntity"."webpPath" AS "AssetEntity_webpPath",
|
||||
"AssetEntity"."thumbhash" AS "AssetEntity_thumbhash",
|
||||
"AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath",
|
||||
"AssetEntity"."createdAt" AS "AssetEntity_createdAt",
|
||||
"AssetEntity"."updatedAt" AS "AssetEntity_updatedAt",
|
||||
"AssetEntity"."deletedAt" AS "AssetEntity_deletedAt",
|
||||
"AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt",
|
||||
"AssetEntity"."localDateTime" AS "AssetEntity_localDateTime",
|
||||
"AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt",
|
||||
"AssetEntity"."isFavorite" AS "AssetEntity_isFavorite",
|
||||
"AssetEntity"."isArchived" AS "AssetEntity_isArchived",
|
||||
"AssetEntity"."isExternal" AS "AssetEntity_isExternal",
|
||||
"AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly",
|
||||
"AssetEntity"."isOffline" AS "AssetEntity_isOffline",
|
||||
"AssetEntity"."checksum" AS "AssetEntity_checksum",
|
||||
"AssetEntity"."duration" AS "AssetEntity_duration",
|
||||
"AssetEntity"."isVisible" AS "AssetEntity_isVisible",
|
||||
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||
"AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId",
|
||||
"AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description",
|
||||
"AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth",
|
||||
"AssetEntity__AssetEntity_exifInfo"."exifImageHeight" AS "AssetEntity__AssetEntity_exifInfo_exifImageHeight",
|
||||
"AssetEntity__AssetEntity_exifInfo"."fileSizeInByte" AS "AssetEntity__AssetEntity_exifInfo_fileSizeInByte",
|
||||
"AssetEntity__AssetEntity_exifInfo"."orientation" AS "AssetEntity__AssetEntity_exifInfo_orientation",
|
||||
"AssetEntity__AssetEntity_exifInfo"."dateTimeOriginal" AS "AssetEntity__AssetEntity_exifInfo_dateTimeOriginal",
|
||||
"AssetEntity__AssetEntity_exifInfo"."modifyDate" AS "AssetEntity__AssetEntity_exifInfo_modifyDate",
|
||||
"AssetEntity__AssetEntity_exifInfo"."timeZone" AS "AssetEntity__AssetEntity_exifInfo_timeZone",
|
||||
"AssetEntity__AssetEntity_exifInfo"."latitude" AS "AssetEntity__AssetEntity_exifInfo_latitude",
|
||||
"AssetEntity__AssetEntity_exifInfo"."longitude" AS "AssetEntity__AssetEntity_exifInfo_longitude",
|
||||
"AssetEntity__AssetEntity_exifInfo"."projectionType" AS "AssetEntity__AssetEntity_exifInfo_projectionType",
|
||||
"AssetEntity__AssetEntity_exifInfo"."city" AS "AssetEntity__AssetEntity_exifInfo_city",
|
||||
"AssetEntity__AssetEntity_exifInfo"."livePhotoCID" AS "AssetEntity__AssetEntity_exifInfo_livePhotoCID",
|
||||
"AssetEntity__AssetEntity_exifInfo"."autoStackId" AS "AssetEntity__AssetEntity_exifInfo_autoStackId",
|
||||
"AssetEntity__AssetEntity_exifInfo"."state" AS "AssetEntity__AssetEntity_exifInfo_state",
|
||||
"AssetEntity__AssetEntity_exifInfo"."country" AS "AssetEntity__AssetEntity_exifInfo_country",
|
||||
"AssetEntity__AssetEntity_exifInfo"."make" AS "AssetEntity__AssetEntity_exifInfo_make",
|
||||
"AssetEntity__AssetEntity_exifInfo"."model" AS "AssetEntity__AssetEntity_exifInfo_model",
|
||||
"AssetEntity__AssetEntity_exifInfo"."lensModel" AS "AssetEntity__AssetEntity_exifInfo_lensModel",
|
||||
"AssetEntity__AssetEntity_exifInfo"."fNumber" AS "AssetEntity__AssetEntity_exifInfo_fNumber",
|
||||
"AssetEntity__AssetEntity_exifInfo"."focalLength" AS "AssetEntity__AssetEntity_exifInfo_focalLength",
|
||||
"AssetEntity__AssetEntity_exifInfo"."iso" AS "AssetEntity__AssetEntity_exifInfo_iso",
|
||||
"AssetEntity__AssetEntity_exifInfo"."exposureTime" AS "AssetEntity__AssetEntity_exifInfo_exposureTime",
|
||||
"AssetEntity__AssetEntity_exifInfo"."profileDescription" AS "AssetEntity__AssetEntity_exifInfo_profileDescription",
|
||||
"AssetEntity__AssetEntity_exifInfo"."colorspace" AS "AssetEntity__AssetEntity_exifInfo_colorspace",
|
||||
"AssetEntity__AssetEntity_exifInfo"."bitsPerSample" AS "AssetEntity__AssetEntity_exifInfo_bitsPerSample",
|
||||
"AssetEntity__AssetEntity_exifInfo"."fps" AS "AssetEntity__AssetEntity_exifInfo_fps"
|
||||
FROM
|
||||
"assets" "AssetEntity"
|
||||
LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id"
|
||||
WHERE
|
||||
(
|
||||
(
|
||||
("AssetEntity"."ownerId" = $1)
|
||||
AND ("AssetEntity"."isVisible" = $2)
|
||||
AND ("AssetEntity"."isArchived" = $3)
|
||||
AND (NOT ("AssetEntity"."resizePath" IS NULL))
|
||||
AND ("AssetEntity"."fileCreatedAt" BETWEEN $4 AND $5)
|
||||
)
|
||||
)
|
||||
AND ("AssetEntity"."deletedAt" IS NULL)
|
||||
ORDER BY
|
||||
"AssetEntity"."fileCreatedAt" DESC
|
||||
|
||||
-- AssetRepository.getByIds
|
||||
SELECT
|
||||
"AssetEntity"."id" AS "AssetEntity_id",
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import {
|
||||
ClientEventMap,
|
||||
IEventRepository,
|
||||
ServerAsyncEventMap,
|
||||
ClientEvent,
|
||||
ICommunicationRepository,
|
||||
InternalEventMap,
|
||||
OnConnectCallback,
|
||||
OnServerEventCallback,
|
||||
ServerEvent,
|
||||
ServerEventMap,
|
||||
} from 'src/interfaces/event.interface';
|
||||
} from 'src/interfaces/communication.interface';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
@@ -24,8 +25,14 @@ import { ImmichLogger } from 'src/utils/logger';
|
||||
path: '/api/socket.io',
|
||||
transports: ['websocket'],
|
||||
})
|
||||
export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, IEventRepository {
|
||||
private logger = new ImmichLogger(EventRepository.name);
|
||||
export class CommunicationRepository
|
||||
implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit, ICommunicationRepository
|
||||
{
|
||||
private logger = new ImmichLogger(CommunicationRepository.name);
|
||||
private onConnectCallbacks: OnConnectCallback[] = [];
|
||||
private onServerEventCallbacks: Record<ServerEvent, OnServerEventCallback[]> = {
|
||||
[ServerEvent.CONFIG_UPDATE]: [],
|
||||
};
|
||||
|
||||
@WebSocketServer()
|
||||
private server?: Server;
|
||||
@@ -39,14 +46,27 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
this.logger.log('Initialized websocket server');
|
||||
|
||||
for (const event of Object.values(ServerEvent)) {
|
||||
if (event === ServerEvent.WEBSOCKET_CONNECT) {
|
||||
continue;
|
||||
server.on(event, async () => {
|
||||
this.logger.debug(`Server event: ${event} (receive)`);
|
||||
const callbacks = this.onServerEventCallbacks[event];
|
||||
for (const callback of callbacks) {
|
||||
await callback();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
on(event: 'connect' | ServerEvent, callback: OnConnectCallback | OnServerEventCallback) {
|
||||
switch (event) {
|
||||
case 'connect': {
|
||||
this.onConnectCallbacks.push(callback);
|
||||
break;
|
||||
}
|
||||
|
||||
server.on(event, (data: unknown) => {
|
||||
this.logger.debug(`Server event: ${event} (receive)`);
|
||||
this.eventEmitter.emit(event, data);
|
||||
});
|
||||
default: {
|
||||
this.onServerEventCallbacks[event].push(callback as OnServerEventCallback);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +75,9 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
||||
const auth = await this.authService.validate(client.request.headers, {});
|
||||
await client.join(auth.user.id);
|
||||
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
|
||||
for (const callback of this.onConnectCallbacks) {
|
||||
await callback(auth.user.id);
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
||||
client.emit('error', 'unauthorized');
|
||||
@@ -68,21 +90,24 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
||||
await client.leave(client.nsp.name);
|
||||
}
|
||||
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]) {
|
||||
send(event: ClientEvent, userId: string, data: any) {
|
||||
this.server?.to(userId).emit(event, data);
|
||||
}
|
||||
|
||||
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) {
|
||||
broadcast(event: ClientEvent, data: any) {
|
||||
this.server?.emit(event, data);
|
||||
}
|
||||
|
||||
serverSend<E extends keyof ServerEventMap>(event: E, data: ServerEventMap[E]) {
|
||||
sendServerEvent(event: ServerEvent) {
|
||||
this.logger.debug(`Server event: ${event} (send)`);
|
||||
this.server?.serverSideEmit(event, data);
|
||||
this.server?.serverSideEmit(event);
|
||||
}
|
||||
|
||||
emit<E extends keyof InternalEventMap>(event: E, data: InternalEventMap[E]): boolean {
|
||||
return this.eventEmitter.emit(event, data);
|
||||
}
|
||||
|
||||
serverSendAsync<E extends keyof ServerAsyncEventMap, R = any[]>(event: E, data: ServerAsyncEventMap[E]): Promise<R> {
|
||||
emitAsync<E extends keyof InternalEventMap, R = any[]>(event: E, data: InternalEventMap[E]): Promise<R> {
|
||||
return this.eventEmitter.emitAsync(event, data) as Promise<R>;
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,4 @@ export class CryptoRepository implements ICryptoRepository {
|
||||
stream.on('end', () => resolve(hash.digest()));
|
||||
});
|
||||
}
|
||||
|
||||
newPassword(bytes: number) {
|
||||
return randomBytes(bytes).toString('base64').replaceAll(/\W/g, '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { findNonDeletedExtension } from '../prisma/find-non-deleted';
|
||||
import { metricsExtension } from '../prisma/metrics';
|
||||
import { kyselyExtension } from 'src/prisma/kysely';
|
||||
|
||||
function extendClient(base: PrismaClient) {
|
||||
return base.$extends(metricsExtension).$extends(findNonDeletedExtension).$extends(kyselyExtension);
|
||||
}
|
||||
|
||||
class UntypedExtendedClient extends PrismaClient {
|
||||
constructor(options?: ConstructorParameters<typeof PrismaClient>[0]) {
|
||||
super(options);
|
||||
|
||||
return extendClient(this) as this;
|
||||
}
|
||||
}
|
||||
|
||||
const ExtendedPrismaClient = UntypedExtendedClient as unknown as new (
|
||||
options?: ConstructorParameters<typeof PrismaClient>[0],
|
||||
) => ReturnType<typeof extendClient>;
|
||||
|
||||
@Injectable()
|
||||
export class PrismaRepository extends ExtendedPrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { vectorExt } from 'src/database.config';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Kysely, OrderByDirectionExpression, sql } from 'kysely';
|
||||
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
|
||||
import _ from 'lodash';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.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';
|
||||
import { DatabaseExtension } from 'src/interfaces/database.interface';
|
||||
import {
|
||||
AssetSearchBuilderOptions,
|
||||
AssetSearchOptions,
|
||||
FaceEmbeddingSearch,
|
||||
FaceSearchResult,
|
||||
@@ -16,41 +15,21 @@ import {
|
||||
SearchPaginationOptions,
|
||||
SmartSearchOptions,
|
||||
} from 'src/interfaces/search.interface';
|
||||
import { asVector, searchAssetBuilder } from 'src/utils/database';
|
||||
import { DB } from 'src/prisma/generated/types';
|
||||
import { asVector } from 'src/utils/database';
|
||||
import { Instrumentation } from 'src/utils/instrumentation';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
import { getCLIPModelInfo } from 'src/utils/misc';
|
||||
import { Paginated, PaginationMode, PaginationResult, paginatedBuilder } from 'src/utils/pagination';
|
||||
import { Paginated } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
import { PrismaRepository } from './prisma.repository';
|
||||
|
||||
@Instrumentation()
|
||||
@Injectable()
|
||||
export class SearchRepository implements ISearchRepository {
|
||||
private logger = new ImmichLogger(SearchRepository.name);
|
||||
private faceColumns: string[];
|
||||
private assetsByCityQuery: string;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(SmartInfoEntity) private repository: Repository<SmartInfoEntity>,
|
||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||
@InjectRepository(AssetFaceEntity) private assetFaceRepository: Repository<AssetFaceEntity>,
|
||||
@InjectRepository(SmartSearchEntity) private smartSearchRepository: Repository<SmartSearchEntity>,
|
||||
@InjectRepository(GeodataPlacesEntity) private readonly geodataPlacesRepository: Repository<GeodataPlacesEntity>,
|
||||
) {
|
||||
this.faceColumns = this.assetFaceRepository.manager.connection
|
||||
.getMetadata(AssetFaceEntity)
|
||||
.ownColumns.map((column) => column.propertyName)
|
||||
.filter((propertyName) => propertyName !== 'embedding');
|
||||
this.assetsByCityQuery =
|
||||
assetsByCityCte +
|
||||
this.assetRepository
|
||||
.createQueryBuilder('asset')
|
||||
.innerJoinAndSelect('asset.exifInfo', 'exif')
|
||||
.withDeleted()
|
||||
.getQuery() +
|
||||
' INNER JOIN cte ON asset.id = cte."assetId"';
|
||||
}
|
||||
constructor(@Inject(PrismaRepository) private prismaRepository: PrismaRepository) {}
|
||||
|
||||
async init(modelName: string): Promise<void> {
|
||||
const { dimSize } = getCLIPModelInfo(modelName);
|
||||
@@ -77,23 +56,16 @@ export class SearchRepository implements ISearchRepository {
|
||||
],
|
||||
})
|
||||
async searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity> {
|
||||
let builder = this.assetRepository.createQueryBuilder('asset');
|
||||
builder = searchAssetBuilder(builder, options);
|
||||
const orderDirection = (options.orderDirection?.toLowerCase() || 'desc') as OrderByDirectionExpression;
|
||||
const builder = this.searchAssetBuilder(options)
|
||||
.orderBy('assets.fileCreatedAt', orderDirection)
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size);
|
||||
|
||||
builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC');
|
||||
return paginatedBuilder<AssetEntity>(builder, {
|
||||
mode: PaginationMode.SKIP_TAKE,
|
||||
skip: (pagination.page - 1) * pagination.size,
|
||||
take: pagination.size,
|
||||
});
|
||||
}
|
||||
|
||||
private createPersonFilter(builder: SelectQueryBuilder<AssetFaceEntity>, personIds: string[]) {
|
||||
return builder
|
||||
.select(`${builder.alias}."assetId"`)
|
||||
.where(`${builder.alias}."personId" IN (:...personIds)`, { personIds })
|
||||
.groupBy(`${builder.alias}."assetId"`)
|
||||
.having(`COUNT(DISTINCT ${builder.alias}."personId") = :personCount`, { personCount: personIds.length });
|
||||
const items = (await builder.execute()) as any as AssetEntity[];
|
||||
const hasNextPage = items.length > pagination.size;
|
||||
items.splice(pagination.size);
|
||||
return { items, hasNextPage };
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
@@ -113,35 +85,25 @@ export class SearchRepository implements ISearchRepository {
|
||||
pagination: SearchPaginationOptions,
|
||||
{ embedding, userIds, personIds, ...options }: SmartSearchOptions,
|
||||
): Paginated<AssetEntity> {
|
||||
let results: PaginationResult<AssetEntity> = { items: [], hasNextPage: false };
|
||||
if (!isValidInteger(pagination.size, { min: 1, max: 1000 })) {
|
||||
throw new Error(`Invalid value for 'size': ${pagination.size}`);
|
||||
}
|
||||
|
||||
await this.assetRepository.manager.transaction(async (manager) => {
|
||||
let builder = manager.createQueryBuilder(AssetEntity, 'asset');
|
||||
let items: AssetEntity[] = [];
|
||||
await this.prismaRepository.$transaction(async (tx) => {
|
||||
await tx.$queryRawUnsafe(`SET LOCAL vectors.hnsw_ef_search = ${pagination.size + 1}`);
|
||||
let builder = this.searchAssetBuilder(options, tx.$kysely)
|
||||
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
|
||||
.orderBy(sql`smart_search.embedding <=> ${asVector(embedding)}::vector`)
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size);
|
||||
|
||||
if (personIds?.length) {
|
||||
const assetFaceBuilder = manager.createQueryBuilder(AssetFaceEntity, 'asset_face');
|
||||
const cte = this.createPersonFilter(assetFaceBuilder, personIds);
|
||||
builder
|
||||
.addCommonTableExpression(cte, 'asset_face_ids')
|
||||
.innerJoin('asset_face_ids', 'a', 'a."assetId" = asset.id');
|
||||
}
|
||||
|
||||
builder = searchAssetBuilder(builder, options);
|
||||
builder
|
||||
.innerJoin('asset.smartSearch', 'search')
|
||||
.andWhere('asset.ownerId IN (:...userIds )')
|
||||
.orderBy('search.embedding <=> :embedding')
|
||||
.setParameters({ userIds, embedding: asVector(embedding) });
|
||||
|
||||
await manager.query(this.getRuntimeConfig(pagination.size));
|
||||
results = await paginatedBuilder<AssetEntity>(builder, {
|
||||
mode: PaginationMode.LIMIT_OFFSET,
|
||||
skip: (pagination.page - 1) * pagination.size,
|
||||
take: pagination.size,
|
||||
});
|
||||
items = (await builder.execute()) as any as AssetEntity[];
|
||||
});
|
||||
|
||||
return results;
|
||||
const hasNextPage = items.length > pagination.size;
|
||||
items.splice(pagination.size);
|
||||
return { items, hasNextPage };
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
@@ -154,103 +116,104 @@ export class SearchRepository implements ISearchRepository {
|
||||
},
|
||||
],
|
||||
})
|
||||
async searchFaces({
|
||||
searchFaces({
|
||||
userIds,
|
||||
embedding,
|
||||
numResults,
|
||||
maxDistance,
|
||||
hasPerson,
|
||||
}: FaceEmbeddingSearch): Promise<FaceSearchResult[]> {
|
||||
if (!isValidInteger(numResults, { min: 1 })) {
|
||||
if (!isValidInteger(numResults, { min: 1, max: 1000 })) {
|
||||
throw new Error(`Invalid value for 'numResults': ${numResults}`);
|
||||
}
|
||||
|
||||
// setting this too low messes with prefilter recall
|
||||
numResults = Math.max(numResults, 64);
|
||||
|
||||
let results: Array<AssetFaceEntity & { distance: number }> = [];
|
||||
await this.assetRepository.manager.transaction(async (manager) => {
|
||||
const cte = manager
|
||||
.createQueryBuilder(AssetFaceEntity, 'faces')
|
||||
.select('faces.embedding <=> :embedding', 'distance')
|
||||
.innerJoin('faces.asset', 'asset')
|
||||
.where('asset.ownerId IN (:...userIds )')
|
||||
.orderBy('faces.embedding <=> :embedding')
|
||||
.setParameters({ userIds, embedding: asVector(embedding) });
|
||||
|
||||
cte.limit(numResults);
|
||||
|
||||
if (hasPerson) {
|
||||
cte.andWhere('faces."personId" IS NOT NULL');
|
||||
}
|
||||
|
||||
for (const col of this.faceColumns) {
|
||||
cte.addSelect(`faces.${col}`, col);
|
||||
}
|
||||
|
||||
await manager.query(this.getRuntimeConfig(numResults));
|
||||
results = await manager
|
||||
.createQueryBuilder()
|
||||
.select('res.*')
|
||||
.addCommonTableExpression(cte, 'cte')
|
||||
.from('cte', 'res')
|
||||
.where('res.distance <= :maxDistance', { maxDistance })
|
||||
.orderBy('res.distance')
|
||||
.getRawMany();
|
||||
const vector = asVector(embedding);
|
||||
return this.prismaRepository.$transaction(async (tx) => {
|
||||
await tx.$queryRawUnsafe(`SET LOCAL vectors.hnsw_ef_search = ${numResults}`);
|
||||
return tx.$kysely
|
||||
.with('cte', (qb) =>
|
||||
qb
|
||||
.selectFrom('asset_faces')
|
||||
.select([
|
||||
(eb) => eb.fn.toJson(sql`asset_faces.*`).as('face'),
|
||||
sql<number>`asset_faces.embedding <=> ${vector}::vector`.as('distance'),
|
||||
])
|
||||
.innerJoin('assets', 'assets.id', 'asset_faces.assetId')
|
||||
.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${userIds}]::uuid[])`)
|
||||
.$if(!!hasPerson, (qb) => qb.where('asset_faces.personId', 'is not', null))
|
||||
.orderBy(sql`asset_faces.embedding <=> ${vector}::vector`)
|
||||
.limit(numResults),
|
||||
)
|
||||
.selectFrom('cte')
|
||||
.where('cte.distance', '<=', maxDistance)
|
||||
.execute() as any as Array<{ face: AssetFaceEntity; distance: number }>;
|
||||
});
|
||||
return results.map((row) => ({
|
||||
face: this.assetFaceRepository.create(row),
|
||||
distance: row.distance,
|
||||
}));
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.STRING] })
|
||||
async searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
|
||||
return await this.geodataPlacesRepository
|
||||
.createQueryBuilder('geoplaces')
|
||||
.where(`f_unaccent(name) %>> f_unaccent(:placeName)`)
|
||||
.orWhere(`f_unaccent("admin2Name") %>> f_unaccent(:placeName)`)
|
||||
.orWhere(`f_unaccent("admin1Name") %>> f_unaccent(:placeName)`)
|
||||
.orWhere(`f_unaccent("alternateNames") %>> f_unaccent(:placeName)`)
|
||||
.orderBy(
|
||||
`
|
||||
COALESCE(f_unaccent(name) <->>> f_unaccent(:placeName), 0) +
|
||||
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(:placeName), 0) +
|
||||
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(:placeName), 0) +
|
||||
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(:placeName), 0)
|
||||
`,
|
||||
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]> {
|
||||
const contains = '%>>' as any as 'ilike';
|
||||
return this.prismaRepository.$kysely
|
||||
.selectFrom('geodata_places')
|
||||
.selectAll()
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(eb.fn('f_unaccent', ['name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||
eb(eb.fn('f_unaccent', ['admin2Name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||
eb(eb.fn('f_unaccent', ['admin1Name']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||
eb(eb.fn('f_unaccent', ['alternateNames']), contains, eb.fn('f_unaccent', [eb.val(placeName)])),
|
||||
]),
|
||||
)
|
||||
.orderBy(
|
||||
sql`COALESCE(f_unaccent(name) <->>> f_unaccent(${placeName}), 0) +
|
||||
COALESCE(f_unaccent("admin2Name") <->>> f_unaccent(${placeName}), 0) +
|
||||
COALESCE(f_unaccent("admin1Name") <->>> f_unaccent(${placeName}), 0) +
|
||||
COALESCE(f_unaccent("alternateNames") <->>> f_unaccent(${placeName}), 0)`,
|
||||
)
|
||||
.setParameters({ placeName })
|
||||
.limit(20)
|
||||
.getMany();
|
||||
.execute() as Promise<GeodataPlacesEntity[]>;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID]] })
|
||||
async getAssetsByCity(userIds: string[]): Promise<AssetEntity[]> {
|
||||
const parameters = [userIds.join(', '), true, false, AssetType.IMAGE];
|
||||
const rawRes = await this.repository.query(this.assetsByCityQuery, parameters);
|
||||
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
|
||||
return this.prismaRepository.$queryRaw`WITH RECURSIVE cte AS (
|
||||
(
|
||||
SELECT city, "assetId"
|
||||
FROM exif
|
||||
INNER JOIN assets ON exif."assetId" = assets.id
|
||||
WHERE "ownerId" = ANY(ARRAY[${userIds}]::uuid[]) AND "isVisible" = true AND "isArchived" = false AND type = 'IMAGE'
|
||||
ORDER BY city
|
||||
LIMIT 1
|
||||
)
|
||||
|
||||
const items: AssetEntity[] = [];
|
||||
for (const res of rawRes) {
|
||||
const item = { exifInfo: {} as Record<string, any> } as Record<string, any>;
|
||||
for (const [key, value] of Object.entries(res)) {
|
||||
if (key.startsWith('exif_')) {
|
||||
item.exifInfo[key.replace('exif_', '')] = value;
|
||||
} else {
|
||||
item[key.replace('asset_', '')] = value;
|
||||
}
|
||||
}
|
||||
items.push(item as AssetEntity);
|
||||
}
|
||||
UNION ALL
|
||||
|
||||
return items;
|
||||
SELECT l.city, l."assetId"
|
||||
FROM cte c
|
||||
, LATERAL (
|
||||
SELECT city, "assetId"
|
||||
FROM exif
|
||||
INNER JOIN assets ON exif."assetId" = assets.id
|
||||
WHERE city > c.city AND "ownerId" = ANY(ARRAY[${userIds}]::uuid[]) AND "isVisible" = true AND "isArchived" = false AND type = 'IMAGE'
|
||||
ORDER BY city
|
||||
LIMIT 1
|
||||
) l
|
||||
)
|
||||
select "assets".*, json_strip_nulls(to_json(exif.*)) as "exifInfo"
|
||||
from "assets"
|
||||
inner join "exif" on "assets"."id" = "exif"."assetId"
|
||||
inner join "cte" on "assets"."id" = "cte"."assetId"`;
|
||||
}
|
||||
|
||||
async upsert(assetId: string, embedding: number[]): Promise<void> {
|
||||
await this.smartSearchRepository.upsert(
|
||||
{ assetId, embedding: () => asVector(embedding, true) },
|
||||
{ conflictPaths: ['assetId'] },
|
||||
);
|
||||
await this.prismaRepository.$kysely
|
||||
.insertInto('smart_search')
|
||||
.values({ assetId, embedding: asVector(embedding, true) } as any)
|
||||
.onConflict((oc) => oc.column('assetId').doUpdateSet({ embedding: asVector(embedding, true) } as any))
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async updateDimSize(dimSize: number): Promise<void> {
|
||||
@@ -265,27 +228,27 @@ export class SearchRepository implements ISearchRepository {
|
||||
|
||||
this.logger.log(`Updating database CLIP dimension size to ${dimSize}.`);
|
||||
|
||||
await this.smartSearchRepository.manager.transaction(async (manager) => {
|
||||
await manager.clear(SmartSearchEntity);
|
||||
await manager.query(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
|
||||
this.prismaRepository.$transaction(async (tx) => {
|
||||
await tx.$queryRawUnsafe(`TRUNCATE smart_search`);
|
||||
await tx.$queryRawUnsafe(`ALTER TABLE smart_search ALTER COLUMN embedding SET DATA TYPE vector(${dimSize})`);
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully updated database CLIP dimension size from ${curDimSize} to ${dimSize}.`);
|
||||
}
|
||||
|
||||
deleteAllSearchEmbeddings(): Promise<void> {
|
||||
return this.smartSearchRepository.clear();
|
||||
return this.prismaRepository.$queryRawUnsafe(`TRUNCATE smart_search`);
|
||||
}
|
||||
|
||||
private async getDimSize(): Promise<number> {
|
||||
const res = await this.smartSearchRepository.manager.query(`
|
||||
const res = await this.prismaRepository.$queryRaw<[{ dimsize: number }]>`
|
||||
SELECT atttypmod as dimsize
|
||||
FROM pg_attribute f
|
||||
JOIN pg_class c ON c.oid = f.attrelid
|
||||
WHERE c.relkind = 'r'::char
|
||||
AND f.attnum > 0
|
||||
AND c.relname = 'smart_search'
|
||||
AND f.attname = 'embedding'`);
|
||||
AND f.attname = 'embedding'`;
|
||||
|
||||
const dimSize = res[0]['dimsize'];
|
||||
if (!isValidInteger(dimSize, { min: 1, max: 2 ** 16 })) {
|
||||
@@ -294,43 +257,110 @@ export class SearchRepository implements ISearchRepository {
|
||||
return dimSize;
|
||||
}
|
||||
|
||||
private getRuntimeConfig(numResults?: number): string {
|
||||
if (vectorExt === DatabaseExtension.VECTOR) {
|
||||
return 'SET LOCAL hnsw.ef_search = 1000;'; // mitigate post-filter recall
|
||||
}
|
||||
private searchAssetBuilder(options: AssetSearchBuilderOptions, kysely: Kysely<DB> = this.prismaRepository.$kysely) {
|
||||
const withExif = (eb: ExpressionBuilder<DB, 'assets'>) =>
|
||||
jsonObjectFrom(eb.selectFrom('exif').selectAll().whereRef('exif.assetId', '=', 'assets.id')).as('exifInfo');
|
||||
|
||||
let runtimeConfig = 'SET LOCAL vectors.enable_prefilter=on; SET LOCAL vectors.search_mode=vbase;';
|
||||
if (numResults) {
|
||||
runtimeConfig += ` SET LOCAL vectors.hnsw_ef_search = ${numResults};`;
|
||||
}
|
||||
const withSmartInfo = (eb: ExpressionBuilder<DB, 'assets'>) =>
|
||||
jsonObjectFrom(eb.selectFrom('smart_info').selectAll().whereRef('smart_info.assetId', '=', 'assets.id')).as(
|
||||
'smartInfo',
|
||||
);
|
||||
|
||||
return runtimeConfig;
|
||||
const withFaces = (eb: ExpressionBuilder<DB, 'assets'>) =>
|
||||
jsonArrayFrom(eb.selectFrom('asset_faces').selectAll().whereRef('asset_faces.assetId', '=', 'assets.id')).as(
|
||||
'faces',
|
||||
);
|
||||
|
||||
const withPeople = (eb: ExpressionBuilder<DB, 'assets' | 'asset_faces'>) =>
|
||||
jsonObjectFrom(eb.selectFrom('person').selectAll().whereRef('asset_faces.personId', '=', 'person.id')).as(
|
||||
'people',
|
||||
);
|
||||
|
||||
options.isArchived ??= options.withArchived ? undefined : false;
|
||||
options.withDeleted ??= !!(options.trashedAfter || options.trashedBefore);
|
||||
const query = kysely
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore as Date))
|
||||
.$if(!!options.createdAfter, (qb) => qb.where('assets.createdAt', '>=', options.createdAfter as Date))
|
||||
.$if(!!options.updatedBefore, (qb) => qb.where('assets.updatedAt', '<=', options.updatedBefore as Date))
|
||||
.$if(!!options.updatedAfter, (qb) => qb.where('assets.updatedAt', '>=', options.updatedAfter as Date))
|
||||
.$if(!!options.trashedBefore, (qb) => qb.where('assets.deletedAt', '<=', options.trashedBefore as Date))
|
||||
.$if(!!options.trashedAfter, (qb) => qb.where('assets.deletedAt', '>=', options.trashedAfter as Date))
|
||||
.$if(!!options.takenBefore, (qb) => qb.where('assets.fileCreatedAt', '<=', options.takenBefore as Date))
|
||||
.$if(!!options.takenAfter, (qb) => qb.where('assets.fileCreatedAt', '>=', options.takenAfter as Date))
|
||||
.$if(!!options.city, (qb) =>
|
||||
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.city', '=', options.city as string),
|
||||
)
|
||||
.$if(!!options.country, (qb) =>
|
||||
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.country', '=', options.country as string),
|
||||
)
|
||||
.$if(!!options.lensModel, (qb) =>
|
||||
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.lensModel', '=', options.lensModel as string),
|
||||
)
|
||||
.$if(!!options.make, (qb) =>
|
||||
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.make', '=', options.make as string),
|
||||
)
|
||||
.$if(!!options.model, (qb) =>
|
||||
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.model', '=', options.model as string),
|
||||
)
|
||||
.$if(!!options.state, (qb) =>
|
||||
qb.leftJoin('exif', 'exif.assetId', 'assets.id').where('exif.state', '=', options.state as string),
|
||||
)
|
||||
.$if(!!options.checksum, (qb) => qb.where('assets.checksum', '=', options.checksum as Buffer))
|
||||
.$if(!!options.deviceAssetId, (qb) => qb.where('assets.deviceAssetId', '=', options.deviceAssetId as string))
|
||||
.$if(!!options.deviceId, (qb) => qb.where('assets.deviceId', '=', options.deviceId as string))
|
||||
.$if(!!options.id, (qb) => qb.where('assets.id', '=', options.id as string))
|
||||
.$if(!!options.libraryId, (qb) => qb.where('assets.libraryId', '=', options.libraryId as string))
|
||||
.$if(!!options.userIds, (qb) =>
|
||||
qb.where('assets.ownerId', '=', sql<string>`ANY(ARRAY[${options.userIds}]::uuid[])`),
|
||||
)
|
||||
.$if(!!options.encodedVideoPath, (qb) =>
|
||||
qb.where('assets.encodedVideoPath', '=', options.encodedVideoPath as string),
|
||||
)
|
||||
.$if(!!options.originalPath, (qb) => qb.where('assets.originalPath', '=', options.originalPath as string))
|
||||
.$if(!!options.resizePath, (qb) => qb.where('assets.resizePath', '=', options.resizePath as string))
|
||||
.$if(!!options.webpPath, (qb) => qb.where('assets.webpPath', '=', options.webpPath as string))
|
||||
.$if(!!options.originalFileName, (qb) =>
|
||||
qb.where(sql`f_unaccent(assets.originalFileName)`, 'ilike', sql`f_unaccent(${options.originalFileName})`),
|
||||
)
|
||||
.$if(!!options.isExternal, (qb) => qb.where('assets.isExternal', '=', options.isExternal as boolean))
|
||||
.$if(!!options.isFavorite, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite as boolean))
|
||||
.$if(!!options.isOffline, (qb) => qb.where('assets.isOffline', '=', options.isOffline as boolean))
|
||||
.$if(!!options.isReadOnly, (qb) => qb.where('assets.isReadOnly', '=', options.isReadOnly as boolean))
|
||||
.$if(!!options.isVisible, (qb) => qb.where('assets.isVisible', '=', options.isVisible as boolean))
|
||||
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type as AssetType))
|
||||
.$if(!!options.isArchived, (qb) => qb.where('assets.isArchived', '=', options.isArchived as boolean))
|
||||
.$if(!!options.isEncoded, (qb) => qb.where('assets.encodedVideoPath', 'is not', null))
|
||||
.$if(!!options.isMotion, (qb) => qb.where('assets.livePhotoVideoId', 'is not', null))
|
||||
.$if(!!options.isNotInAlbum, (qb) =>
|
||||
qb
|
||||
.leftJoin('albums_assets_assets', 'albums_assets_assets.assetsId', 'assets.id')
|
||||
.where('albums_assets_assets.assetsId', 'is', null),
|
||||
)
|
||||
.$if(!!options.withExif, (qb) => qb.select((eb) => withExif(eb)))
|
||||
.$if(!!options.withSmartInfo, (qb) => qb.select((eb) => withSmartInfo(eb)))
|
||||
.$if(!(!options.withFaces || options.withPeople), (qb) =>
|
||||
qb.select((eb) => withFaces(eb)).$if(!!options.withPeople, (qb) => qb.select((eb) => withPeople(eb) as any)),
|
||||
)
|
||||
.$if(!!options.personIds && options.personIds.length > 0, (qb) =>
|
||||
qb.innerJoin(
|
||||
(eb: any) =>
|
||||
eb
|
||||
.selectFrom('asset_faces')
|
||||
.select('asset_faces.assetId')
|
||||
.where('asset_faces.personId', '=', sql`ANY(ARRAY[${options.personIds}]::uuid[])`)
|
||||
.groupBy('asset_faces.assetId')
|
||||
.having(
|
||||
(eb: any) => eb.fn.count('asset_faces.personId').distinct(),
|
||||
'=',
|
||||
(options.personIds as string[]).length,
|
||||
)
|
||||
.as('personAssetIds'),
|
||||
(join) => join.onRef('personAssetIds.assetId' as any, '=', 'assets.id' as any),
|
||||
),
|
||||
)
|
||||
.$if(!options.withDeleted, (qb) => qb.where('assets.deletedAt', 'is', null));
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
// the performance difference between this and the normal way is too huge to ignore, e.g. 3s vs 4ms
|
||||
const assetsByCityCte = `
|
||||
WITH RECURSIVE cte AS (
|
||||
(
|
||||
SELECT city, "assetId"
|
||||
FROM exif
|
||||
INNER JOIN assets ON exif."assetId" = assets.id
|
||||
WHERE "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
|
||||
ORDER BY city
|
||||
LIMIT 1
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT l.city, l."assetId"
|
||||
FROM cte c
|
||||
, LATERAL (
|
||||
SELECT city, "assetId"
|
||||
FROM exif
|
||||
INNER JOIN assets ON exif."assetId" = assets.id
|
||||
WHERE city > c.city AND "ownerId" IN ($1) AND "isVisible" = $2 AND "isArchived" = $3 AND type = $4
|
||||
ORDER BY city
|
||||
LIMIT 1
|
||||
) l
|
||||
)
|
||||
`;
|
||||
|
||||
@@ -27,7 +27,7 @@ describe(APIKeyService.name, () => {
|
||||
name: 'Test Key',
|
||||
userId: authStub.admin.user.id,
|
||||
});
|
||||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ describe(APIKeyService.name, () => {
|
||||
name: 'API Key',
|
||||
userId: authStub.admin.user.id,
|
||||
});
|
||||
expect(cryptoMock.newPassword).toHaveBeenCalled();
|
||||
expect(cryptoMock.randomBytes).toHaveBeenCalled();
|
||||
expect(cryptoMock.hashSha256).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export class APIKeyService {
|
||||
) {}
|
||||
|
||||
async create(auth: AuthDto, dto: APIKeyCreateDto): Promise<APIKeyCreateResponseDto> {
|
||||
const secret = this.crypto.newPassword(32);
|
||||
const secret = this.crypto.randomBytes(32).toString('base64').replaceAll(/\W/g, '');
|
||||
const entity = await this.repository.create({
|
||||
key: this.crypto.hashSha256(secret),
|
||||
name: dto.name || 'API Key',
|
||||
|
||||
@@ -341,7 +341,7 @@ export class AssetServiceV1 {
|
||||
isArchived: dto.isArchived ?? false,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity),
|
||||
livePhotoVideoId: livePhotoAssetId,
|
||||
originalFileName: file.originalName,
|
||||
sidecarPath: sidecarPath || null,
|
||||
isReadOnly: dto.isReadOnly ?? false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/a
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { AssetStats, IAssetRepository, TimeBucketSize } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { IJobRepository, JobItem, JobName } from 'src/interfaces/job.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
@@ -20,7 +20,7 @@ import { userStub } from 'test/fixtures/user.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newAssetStackRepositoryMock } from 'test/repositories/asset-stack.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newCommunicationRepositoryMock } from 'test/repositories/communication.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
@@ -152,7 +152,7 @@ describe(AssetService.name, () => {
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
let eventMock: jest.Mocked<IEventRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let partnerMock: jest.Mocked<IPartnerRepository>;
|
||||
let assetStackMock: jest.Mocked<IAssetStackRepository>;
|
||||
@@ -164,7 +164,7 @@ describe(AssetService.name, () => {
|
||||
beforeEach(() => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
@@ -179,7 +179,7 @@ describe(AssetService.name, () => {
|
||||
configMock,
|
||||
storageMock,
|
||||
userMock,
|
||||
eventMock,
|
||||
communicationMock,
|
||||
partnerMock,
|
||||
assetStackMock,
|
||||
);
|
||||
@@ -704,7 +704,7 @@ describe(AssetService.name, () => {
|
||||
stackParentId: 'parent',
|
||||
});
|
||||
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [
|
||||
expect(communicationMock.send).toHaveBeenCalledWith(ClientEvent.ASSET_STACK_UPDATE, authStub.user1.user.id, [
|
||||
'asset-1',
|
||||
'parent',
|
||||
]);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { LibraryType } from 'src/entities/library.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { IAssetRepository, TimeBucketOptions } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import {
|
||||
IAssetDeletionJob,
|
||||
IJobRepository,
|
||||
@@ -75,7 +75,7 @@ export class AssetService {
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
@Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository,
|
||||
) {
|
||||
@@ -280,11 +280,15 @@ export class AssetService {
|
||||
smartInfo: true,
|
||||
owner: true,
|
||||
faces: {
|
||||
person: true,
|
||||
include: { person: true },
|
||||
},
|
||||
stack: {
|
||||
assets: {
|
||||
exifInfo: true,
|
||||
include: {
|
||||
assets: {
|
||||
include: {
|
||||
exifInfo: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -316,16 +320,7 @@ export class AssetService {
|
||||
const { description, dateTimeOriginal, latitude, longitude, ...rest } = dto;
|
||||
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude });
|
||||
|
||||
await this.assetRepository.update({ id, ...rest });
|
||||
const asset = await this.assetRepository.getById(id, {
|
||||
exifInfo: true,
|
||||
owner: true,
|
||||
smartInfo: true,
|
||||
tags: true,
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
});
|
||||
const asset = await this.assetRepository.update({ id, ...rest });
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
@@ -351,14 +346,16 @@ export class AssetService {
|
||||
} else if (options.stackParentId) {
|
||||
//Creating new stack if parent doesn't have one already. If it does, then we add to the existing stack
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, options.stackParentId);
|
||||
const primaryAsset = await this.assetRepository.getById(options.stackParentId, { stack: { assets: true } });
|
||||
const primaryAsset = await this.assetRepository.getById(options.stackParentId, {
|
||||
stack: { include: { assets: true } },
|
||||
});
|
||||
if (!primaryAsset) {
|
||||
throw new BadRequestException('Asset not found for given stackParentId');
|
||||
}
|
||||
let stack = primaryAsset.stack;
|
||||
|
||||
ids.push(options.stackParentId);
|
||||
const assets = await this.assetRepository.getByIds(ids, { stack: { assets: true } });
|
||||
const assets = await this.assetRepository.getByIds(ids, { stack: { include: { assets: true } } });
|
||||
stackIdsToCheckForDelete.push(
|
||||
...new Set(assets.filter((a) => !!a.stackId && stack?.id !== a.stackId).map((a) => a.stackId!)),
|
||||
);
|
||||
@@ -395,7 +392,7 @@ export class AssetService {
|
||||
.flatMap((stack) => (stack ? [stack] : []))
|
||||
.filter((stack) => stack.assets.length < 2);
|
||||
await Promise.all(stacksToDelete.map((as) => this.assetStackRepository.delete(as.id)));
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids);
|
||||
}
|
||||
|
||||
async handleAssetDeletionCheck(): Promise<JobStatus> {
|
||||
@@ -422,10 +419,10 @@ export class AssetService {
|
||||
|
||||
const asset = await this.assetRepository.getById(id, {
|
||||
faces: {
|
||||
person: true,
|
||||
include: { person: true },
|
||||
},
|
||||
library: true,
|
||||
stack: { assets: true },
|
||||
stack: { include: { assets: true } },
|
||||
exifInfo: true,
|
||||
});
|
||||
|
||||
@@ -454,7 +451,7 @@ export class AssetService {
|
||||
|
||||
await this.assetRepository.remove(asset);
|
||||
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_DELETE, asset.ownerId, id);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_DELETE, asset.ownerId, id);
|
||||
|
||||
// TODO refactor this to use cascades
|
||||
if (asset.livePhotoVideoId) {
|
||||
@@ -482,7 +479,7 @@ export class AssetService {
|
||||
await this.jobRepository.queueAll(ids.map((id) => ({ name: JobName.ASSET_DELETION, data: { id } })));
|
||||
} else {
|
||||
await this.assetRepository.softDeleteAll(ids);
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_TRASH, auth.user.id, ids);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -494,11 +491,11 @@ export class AssetService {
|
||||
const childIds: string[] = [];
|
||||
const oldParent = await this.assetRepository.getById(oldParentId, {
|
||||
faces: {
|
||||
person: true,
|
||||
include: { person: true },
|
||||
},
|
||||
library: true,
|
||||
stack: {
|
||||
assets: true,
|
||||
include: { assets: true },
|
||||
},
|
||||
});
|
||||
if (!oldParent?.stackId) {
|
||||
@@ -513,7 +510,7 @@ export class AssetService {
|
||||
primaryAssetId: newParentId,
|
||||
});
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [
|
||||
this.communicationRepository.send(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, [
|
||||
...childIds,
|
||||
newParentId,
|
||||
oldParentId,
|
||||
|
||||
@@ -146,6 +146,7 @@ export class AuthService {
|
||||
|
||||
async adminSignUp(dto: SignUpDto): Promise<UserResponseDto> {
|
||||
const adminUser = await this.userRepository.getAdmin();
|
||||
|
||||
if (adminUser) {
|
||||
throw new BadRequestException('The server already has an admin');
|
||||
}
|
||||
@@ -426,7 +427,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
|
||||
const key = this.cryptoRepository.newPassword(32);
|
||||
const key = this.cryptoRepository.randomBytes(32).toString('base64').replaceAll(/\W/g, '');
|
||||
const token = this.cryptoRepository.hashSha256(key);
|
||||
|
||||
await this.userTokenRepository.create({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { FeatureFlag, SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { SystemConfig, SystemConfigKey } from 'src/entities/system-config.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import {
|
||||
IJobRepository,
|
||||
JobCommand,
|
||||
@@ -17,7 +17,7 @@ import { ISystemConfigRepository } from 'src/interfaces/system-config.interface'
|
||||
import { JobService } from 'src/services/job.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newCommunicationRepositoryMock } from 'test/repositories/communication.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newPersonRepositoryMock } from 'test/repositories/person.repository.mock';
|
||||
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
|
||||
@@ -34,17 +34,17 @@ describe(JobService.name, () => {
|
||||
let sut: JobService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let eventMock: jest.Mocked<IEventRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
sut = new JobService(assetMock, eventMock, jobMock, configMock, personMock);
|
||||
sut = new JobService(assetMock, communicationMock, jobMock, configMock, personMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AllJobStatusResponseDto, JobCommandDto, JobStatusDto } from 'src/dtos/job.dto';
|
||||
import { AssetType } from 'src/entities/asset.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import {
|
||||
ConcurrentQueueName,
|
||||
IJobRepository,
|
||||
@@ -27,7 +27,7 @@ export class JobService {
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
@@ -219,7 +219,7 @@ export class JobService {
|
||||
if (item.data.source === 'sidecar-write') {
|
||||
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
|
||||
if (asset) {
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset));
|
||||
this.communicationRepository.send(ClientEvent.ASSET_UPDATE, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.LINK_LIVE_PHOTOS, data: item.data });
|
||||
@@ -242,7 +242,7 @@ export class JobService {
|
||||
const { id } = item.data;
|
||||
const person = await this.personRepository.getById(id);
|
||||
if (person) {
|
||||
this.eventRepository.clientSend(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id);
|
||||
this.communicationRepository.send(ClientEvent.PERSON_THUMBNAIL, person.ownerId, person.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -279,13 +279,13 @@ export class JobService {
|
||||
|
||||
// Only live-photo motion part will be marked as not visible immediately on upload. Skip notifying clients
|
||||
if (asset && asset.isVisible) {
|
||||
this.eventRepository.clientSend(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||
this.communicationRepository.send(ClientEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case JobName.USER_DELETION: {
|
||||
this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id);
|
||||
this.communicationRepository.broadcast(ClientEvent.USER_DELETE, item.data.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,10 +127,10 @@ describe(LibraryService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onValidateConfig', () => {
|
||||
describe('validateConfig', () => {
|
||||
it('should allow a valid cron expression', () => {
|
||||
expect(() =>
|
||||
sut.onValidateConfig({
|
||||
sut.validateConfig({
|
||||
newConfig: { library: { scan: { cronExpression: '0 0 * * *' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
@@ -139,7 +139,7 @@ describe(LibraryService.name, () => {
|
||||
|
||||
it('should fail for an invalid cron expression', () => {
|
||||
expect(() =>
|
||||
sut.onValidateConfig({
|
||||
sut.validateConfig({
|
||||
newConfig: { library: { scan: { cronExpression: 'foo' } } } as SystemConfig,
|
||||
oldConfig: {} as SystemConfig,
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,7 @@ import path, { basename, parse } from 'node:path';
|
||||
import picomatch from 'picomatch';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { OnEventInternal } from 'src/decorators';
|
||||
import {
|
||||
CreateLibraryDto,
|
||||
LibraryResponseDto,
|
||||
@@ -23,9 +23,9 @@ import {
|
||||
import { AssetType } from 'src/entities/asset.entity';
|
||||
import { LibraryEntity, LibraryType } from 'src/entities/library.entity';
|
||||
import { IAssetRepository, WithProperty } from 'src/interfaces/asset.interface';
|
||||
import { InternalEvent, InternalEventMap } from 'src/interfaces/communication.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
@@ -105,8 +105,8 @@ export class LibraryService extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerAsyncEvent.CONFIG_VALIDATE)
|
||||
onValidateConfig({ newConfig }: ServerAsyncEventMap[ServerAsyncEvent.CONFIG_VALIDATE]) {
|
||||
@OnEventInternal(InternalEvent.VALIDATE_CONFIG)
|
||||
validateConfig({ newConfig }: InternalEventMap[InternalEvent.VALIDATE_CONFIG]) {
|
||||
const { scan } = newConfig.library;
|
||||
if (!validateCronExpression(scan.cronExpression)) {
|
||||
throw new Error(`Invalid cron expression ${scan.cronExpression}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Inject, Injectable, UnsupportedMediaTypeException } from '@nestjs/common';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config-ffmpeg.dto';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { AssetPathType } from 'src/entities/move.entity';
|
||||
import {
|
||||
|
||||
@@ -8,9 +8,9 @@ import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { SystemConfigKey } from 'src/entities/system-config.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IMediaRepository } from 'src/interfaces/media.interface';
|
||||
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
||||
@@ -24,9 +24,9 @@ import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newCommunicationRepositoryMock } from 'test/repositories/communication.repository.mock';
|
||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock';
|
||||
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
||||
@@ -46,7 +46,7 @@ describe(MetadataService.name, () => {
|
||||
let mediaMock: jest.Mocked<IMediaRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let eventMock: jest.Mocked<IEventRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let databaseMock: jest.Mocked<IDatabaseRepository>;
|
||||
let sut: MetadataService;
|
||||
|
||||
@@ -59,7 +59,7 @@ describe(MetadataService.name, () => {
|
||||
metadataMock = newMetadataRepositoryMock();
|
||||
moveMock = newMoveRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
mediaMock = newMediaRepositoryMock();
|
||||
databaseMock = newDatabaseRepositoryMock();
|
||||
@@ -67,7 +67,7 @@ describe(MetadataService.name, () => {
|
||||
sut = new MetadataService(
|
||||
albumMock,
|
||||
assetMock,
|
||||
eventMock,
|
||||
communicationMock,
|
||||
cryptoRepository,
|
||||
databaseMock,
|
||||
jobMock,
|
||||
@@ -195,7 +195,7 @@ describe(MetadataService.name, () => {
|
||||
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
expect(eventMock.clientSend).toHaveBeenCalledWith(
|
||||
expect(communicationMock.send).toHaveBeenCalledWith(
|
||||
ClientEvent.ASSET_HIDDEN,
|
||||
assetStub.livePhotoMotionAsset.ownerId,
|
||||
assetStub.livePhotoMotionAsset.id,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import {
|
||||
IBaseJob,
|
||||
IEntityJob,
|
||||
@@ -105,7 +105,7 @@ export class MetadataService {
|
||||
constructor(
|
||||
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@@ -185,7 +185,7 @@ export class MetadataService {
|
||||
await this.albumRepository.removeAsset(motionAsset.id);
|
||||
|
||||
// Notify clients to hide the linked live photo asset
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@@ -308,13 +308,7 @@ export class PersonService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const relations = {
|
||||
exifInfo: true,
|
||||
faces: {
|
||||
person: false,
|
||||
},
|
||||
};
|
||||
const [asset] = await this.assetRepository.getByIds([id], relations);
|
||||
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true, faces: true });
|
||||
if (!asset || !asset.resizePath || asset.faces?.length > 0) {
|
||||
return JobStatus.FAILED;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { ServerInfoService } from 'src/services/server-info.service';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newCommunicationRepositoryMock } from 'test/repositories/communication.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
|
||||
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
|
||||
@@ -16,7 +16,7 @@ import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
|
||||
describe(ServerInfoService.name, () => {
|
||||
let sut: ServerInfoService;
|
||||
let eventMock: jest.Mocked<IEventRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let serverInfoMock: jest.Mocked<IServerInfoRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
@@ -25,13 +25,20 @@ describe(ServerInfoService.name, () => {
|
||||
|
||||
beforeEach(() => {
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
serverInfoMock = newServerInfoRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
systemMetadataMock = newSystemMetadataRepositoryMock();
|
||||
|
||||
sut = new ServerInfoService(eventMock, configMock, userMock, serverInfoMock, storageMock, systemMetadataMock);
|
||||
sut = new ServerInfoService(
|
||||
communicationMock,
|
||||
configMock,
|
||||
userMock,
|
||||
serverInfoMock,
|
||||
storageMock,
|
||||
systemMetadataMock,
|
||||
);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { DateTime } from 'luxon';
|
||||
import { isDev, serverVersion } from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import {
|
||||
ServerConfigDto,
|
||||
ServerFeaturesDto,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
UsageByUserDto,
|
||||
} from 'src/dtos/server-info.dto';
|
||||
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
|
||||
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
@@ -33,7 +32,7 @@ export class ServerInfoService {
|
||||
private releaseVersionCheckedAt: DateTime | null = null;
|
||||
|
||||
constructor(
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
|
||||
@@ -41,10 +40,9 @@ export class ServerInfoService {
|
||||
@Inject(ISystemMetadataRepository) private readonly systemMetadataRepository: ISystemMetadataRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
this.communicationRepository.on('connect', (userId) => this.handleConnect(userId));
|
||||
}
|
||||
|
||||
onConnect() {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
await this.handleVersionCheck();
|
||||
|
||||
@@ -171,9 +169,8 @@ export class ServerInfoService {
|
||||
return true;
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
|
||||
onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
|
||||
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
|
||||
private handleConnect(userId: string) {
|
||||
this.communicationRepository.send(ClientEvent.SERVER_VERSION, userId, serverVersion);
|
||||
this.newReleaseNotification(userId);
|
||||
}
|
||||
|
||||
@@ -187,7 +184,7 @@ export class ServerInfoService {
|
||||
};
|
||||
|
||||
userId
|
||||
? this.eventRepository.clientSend(event, userId, payload)
|
||||
: this.eventRepository.clientBroadcast(event, payload);
|
||||
? this.communicationRepository.send(event, userId, payload)
|
||||
: this.communicationRepository.broadcast(event, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ describe(SmartInfoService.name, () => {
|
||||
});
|
||||
|
||||
it('should save the returned objects', async () => {
|
||||
searchMock.upsert.mockResolvedValue();
|
||||
machineMock.encodeImage.mockResolvedValue([0.01, 0.02, 0.03]);
|
||||
|
||||
await sut.handleEncodeClip({ id: asset.id });
|
||||
@@ -113,7 +114,12 @@ describe(SmartInfoService.name, () => {
|
||||
{ imagePath: 'path/to/resize.ext' },
|
||||
{ enabled: true, modelName: 'ViT-B-32__openai' },
|
||||
);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith('asset-1', [0.01, 0.02, 0.03]);
|
||||
expect(searchMock.upsert).toHaveBeenCalledWith(
|
||||
{
|
||||
assetId: 'asset-1',
|
||||
},
|
||||
[0.01, 0.02, 0.03],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -70,10 +70,10 @@ describe(StorageTemplateService.name, () => {
|
||||
SystemConfigCore.create(configMock).config$.next(defaults);
|
||||
});
|
||||
|
||||
describe('onValidateConfig', () => {
|
||||
describe('validate', () => {
|
||||
it('should allow valid templates', () => {
|
||||
expect(() =>
|
||||
sut.onValidateConfig({
|
||||
sut.validate({
|
||||
newConfig: {
|
||||
storageTemplate: {
|
||||
template:
|
||||
@@ -87,7 +87,7 @@ describe(StorageTemplateService.name, () => {
|
||||
|
||||
it('should fail for an invalid template', () => {
|
||||
expect(() =>
|
||||
sut.onValidateConfig({
|
||||
sut.validate({
|
||||
newConfig: {
|
||||
storageTemplate: {
|
||||
template: '{{foo}}',
|
||||
|
||||
@@ -14,15 +14,15 @@ import {
|
||||
} from 'src/constants';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { OnEventInternal } from 'src/decorators';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { AssetPathType } from 'src/entities/move.entity';
|
||||
import { SystemConfig } from 'src/entities/system-config.entity';
|
||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { InternalEvent, InternalEventMap } from 'src/interfaces/communication.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||
import { ServerAsyncEvent, ServerAsyncEventMap } from 'src/interfaces/event.interface';
|
||||
import { IEntityJob, JOBS_ASSET_PAGINATION_SIZE, JobStatus } from 'src/interfaces/job.interface';
|
||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||
@@ -86,8 +86,8 @@ export class StorageTemplateService {
|
||||
);
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerAsyncEvent.CONFIG_VALIDATE)
|
||||
onValidateConfig({ newConfig }: ServerAsyncEventMap[ServerAsyncEvent.CONFIG_VALIDATE]) {
|
||||
@OnEventInternal(InternalEvent.VALIDATE_CONFIG)
|
||||
validate({ newConfig }: InternalEventMap[InternalEvent.VALIDATE_CONFIG]) {
|
||||
try {
|
||||
const { compiled } = this.compile(newConfig.storageTemplate.template);
|
||||
this.render(compiled, {
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
} from 'src/entities/system-config.entity';
|
||||
import { IEventRepository, ServerEvent } from 'src/interfaces/event.interface';
|
||||
import { ICommunicationRepository, ServerEvent } from 'src/interfaces/communication.interface';
|
||||
import { QueueName } from 'src/interfaces/job.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { SystemConfigService } from 'src/services/system-config.service';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||
import { newCommunicationRepositoryMock } from 'test/repositories/communication.repository.mock';
|
||||
import { newSystemConfigRepositoryMock } from 'test/repositories/system-config.repository.mock';
|
||||
|
||||
const updates: SystemConfigEntity[] = [
|
||||
@@ -152,14 +152,14 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
describe(SystemConfigService.name, () => {
|
||||
let sut: SystemConfigService;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let eventMock: jest.Mocked<IEventRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
let smartInfoMock: jest.Mocked<ISearchRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.IMMICH_CONFIG_FILE;
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
sut = new SystemConfigService(configMock, eventMock, smartInfoMock);
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
sut = new SystemConfigService(configMock, communicationMock, smartInfoMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -330,8 +330,8 @@ describe(SystemConfigService.name, () => {
|
||||
|
||||
await expect(sut.updateConfig(updatedConfig)).resolves.toEqual(updatedConfig);
|
||||
|
||||
expect(eventMock.clientBroadcast).toHaveBeenCalled();
|
||||
expect(eventMock.serverSend).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE, null);
|
||||
expect(communicationMock.broadcast).toHaveBeenCalled();
|
||||
expect(communicationMock.sendServerEvent).toHaveBeenCalledWith(ServerEvent.CONFIG_UPDATE);
|
||||
expect(configMock.saveAll).toHaveBeenCalledWith(updates);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,16 +12,17 @@ import {
|
||||
supportedYearTokens,
|
||||
} from 'src/constants';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import { OnServerEvent } from 'src/decorators';
|
||||
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
|
||||
import { OnEventInternal } from 'src/decorators';
|
||||
import { SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config-storage-template.dto';
|
||||
import { SystemConfigDto, mapConfig } from 'src/dtos/system-config.dto';
|
||||
import { LogLevel, SystemConfig } from 'src/entities/system-config.entity';
|
||||
import {
|
||||
ClientEvent,
|
||||
IEventRepository,
|
||||
ServerAsyncEvent,
|
||||
ServerAsyncEventMap,
|
||||
ICommunicationRepository,
|
||||
InternalEvent,
|
||||
InternalEventMap,
|
||||
ServerEvent,
|
||||
} from 'src/interfaces/event.interface';
|
||||
} from 'src/interfaces/communication.interface';
|
||||
import { ISearchRepository } from 'src/interfaces/search.interface';
|
||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||
import { ImmichLogger } from 'src/utils/logger';
|
||||
@@ -33,10 +34,11 @@ export class SystemConfigService {
|
||||
|
||||
constructor(
|
||||
@Inject(ISystemConfigRepository) private repository: ISystemConfigRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
@Inject(ISearchRepository) private smartInfoRepository: ISearchRepository,
|
||||
) {
|
||||
this.core = SystemConfigCore.create(repository);
|
||||
this.communicationRepository.on(ServerEvent.CONFIG_UPDATE, () => this.handleConfigUpdate());
|
||||
this.core.config$.subscribe((config) => this.setLogLevel(config));
|
||||
}
|
||||
|
||||
@@ -59,8 +61,8 @@ export class SystemConfigService {
|
||||
return mapConfig(config);
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerAsyncEvent.CONFIG_VALIDATE)
|
||||
onValidateConfig({ newConfig, oldConfig }: ServerAsyncEventMap[ServerAsyncEvent.CONFIG_VALIDATE]) {
|
||||
@OnEventInternal(InternalEvent.VALIDATE_CONFIG)
|
||||
validateConfig({ newConfig, oldConfig }: InternalEventMap[InternalEvent.VALIDATE_CONFIG]) {
|
||||
if (!_.isEqual(instanceToPlain(newConfig.logging), oldConfig.logging) && this.getEnvLogLevel()) {
|
||||
throw new Error('Logging cannot be changed while the environment variable LOG_LEVEL is set.');
|
||||
}
|
||||
@@ -70,10 +72,7 @@ export class SystemConfigService {
|
||||
const oldConfig = await this.core.getConfig();
|
||||
|
||||
try {
|
||||
await this.eventRepository.serverSendAsync(ServerAsyncEvent.CONFIG_VALIDATE, {
|
||||
newConfig: dto,
|
||||
oldConfig,
|
||||
});
|
||||
await this.communicationRepository.emitAsync(InternalEvent.VALIDATE_CONFIG, { newConfig: dto, oldConfig });
|
||||
} catch (error) {
|
||||
this.logger.warn(`Unable to save system config due to a validation error: ${error}`);
|
||||
throw new BadRequestException(error instanceof Error ? error.message : error);
|
||||
@@ -81,8 +80,8 @@ export class SystemConfigService {
|
||||
|
||||
const newConfig = await this.core.updateConfig(dto);
|
||||
|
||||
this.eventRepository.clientBroadcast(ClientEvent.CONFIG_UPDATE, {});
|
||||
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
|
||||
this.communicationRepository.broadcast(ClientEvent.CONFIG_UPDATE, {});
|
||||
this.communicationRepository.sendServerEvent(ServerEvent.CONFIG_UPDATE);
|
||||
|
||||
if (oldConfig.machineLearning.clip.modelName !== newConfig.machineLearning.clip.modelName) {
|
||||
await this.smartInfoRepository.init(newConfig.machineLearning.clip.modelName);
|
||||
@@ -92,7 +91,7 @@ export class SystemConfigService {
|
||||
|
||||
// this is only used by the cli on config change, and it's not actually needed anymore
|
||||
async refreshConfig() {
|
||||
this.eventRepository.serverSend(ServerEvent.CONFIG_UPDATE, null);
|
||||
this.communicationRepository.sendServerEvent(ServerEvent.CONFIG_UPDATE);
|
||||
await this.core.refreshConfig();
|
||||
return true;
|
||||
}
|
||||
@@ -128,8 +127,7 @@ export class SystemConfigService {
|
||||
return theme.customCss;
|
||||
}
|
||||
|
||||
@OnServerEvent(ServerEvent.CONFIG_UPDATE)
|
||||
async onConfigUpdate() {
|
||||
private async handleConfigUpdate() {
|
||||
await this.core.refreshConfig();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.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 { newCommunicationRepositoryMock } from 'test/repositories/communication.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
|
||||
describe(TrashService.name, () => {
|
||||
@@ -15,7 +15,7 @@ describe(TrashService.name, () => {
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let jobMock: jest.Mocked<IJobRepository>;
|
||||
let eventMock: jest.Mocked<IEventRepository>;
|
||||
let communicationMock: jest.Mocked<ICommunicationRepository>;
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
@@ -24,10 +24,10 @@ describe(TrashService.name, () => {
|
||||
beforeEach(() => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
communicationMock = newCommunicationRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
|
||||
sut = new TrashService(accessMock, assetMock, jobMock, eventMock);
|
||||
sut = new TrashService(accessMock, assetMock, jobMock, communicationMock);
|
||||
});
|
||||
|
||||
describe('restoreAssets', () => {
|
||||
@@ -54,14 +54,14 @@ describe(TrashService.name, () => {
|
||||
assetMock.getByUserId.mockResolvedValue({ items: [], hasNextPage: false });
|
||||
await expect(sut.restore(authStub.user1)).resolves.toBeUndefined();
|
||||
expect(assetMock.restoreAll).not.toHaveBeenCalled();
|
||||
expect(eventMock.clientSend).not.toHaveBeenCalled();
|
||||
expect(communicationMock.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.clientSend).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [
|
||||
expect(communicationMock.send).toHaveBeenCalledWith(ClientEvent.ASSET_RESTORE, authStub.user1.user.id, [
|
||||
assetStub.image.id,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { ClientEvent, ICommunicationRepository } from 'src/interfaces/communication.interface';
|
||||
import { IJobRepository, JOBS_ASSET_PAGINATION_SIZE, JobName } from 'src/interfaces/job.interface';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@@ -16,7 +16,7 @@ export class TrashService {
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
@@ -60,6 +60,6 @@ export class TrashService {
|
||||
}
|
||||
|
||||
await this.assetRepository.restoreAll(ids);
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
|
||||
this.communicationRepository.send(ClientEvent.ASSET_RESTORE, auth.user.id, ids);
|
||||
}
|
||||
}
|
||||
|
||||