1
0
forked from Cutlery/immich

Merge branch 'main' of https://github.com/immich-app/immich into feat/offline-files-job

This commit is contained in:
Jonathan Jogenfors 2024-03-15 23:43:39 +01:00
commit 8bcee7ff64
144 changed files with 2423 additions and 5421 deletions

View File

@ -19,8 +19,9 @@ module.exports = {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-floating-promises': 'error',
'unicorn/prefer-module': 'off', 'unicorn/prefer-module': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/no-process-exit': 'off',
curly: 2, curly: 2,
'prettier/prettier': 0, 'prettier/prettier': 0,
'unicorn/prevent-abbreviations': 'error',
}, },
}; };

3913
cli/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -35,6 +35,7 @@
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"vite": "^5.0.12", "vite": "^5.0.12",
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.2.2", "vitest": "^1.2.2",
"yaml": "^2.3.1" "yaml": "^2.3.1"
}, },

View File

@ -1,4 +1,12 @@
import { AssetBulkUploadCheckResult } from '@immich/sdk'; import {
AssetBulkUploadCheckResult,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
defaults,
getAllAlbums,
getSupportedMediaTypes,
} from '@immich/sdk';
import byteSize from 'byte-size'; import byteSize from 'byte-size';
import cliProgress from 'cli-progress'; import cliProgress from 'cli-progress';
import { chunk, zip } from 'lodash-es'; import { chunk, zip } from 'lodash-es';
@ -7,9 +15,8 @@ import fs, { createReadStream } from 'node:fs';
import { access, constants, stat, unlink } from 'node:fs/promises'; import { access, constants, stat, unlink } from 'node:fs/promises';
import os from 'node:os'; import os from 'node:os';
import { basename } from 'node:path'; import { basename } from 'node:path';
import { ImmichApi } from 'src/services/api.service'; import { CrawlService } from 'src/services/crawl.service';
import { CrawlService } from '../services/crawl.service'; import { BaseOptions, authenticate } from 'src/utils';
import { BaseCommand } from './base-command';
const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][]; const zipDefined = zip as <T, U>(a: T[], b: U[]) => [T, U][];
@ -106,7 +113,7 @@ class Asset {
} }
} }
export class UploadOptionsDto { class UploadOptionsDto {
recursive? = false; recursive? = false;
exclusionPatterns?: string[] = []; exclusionPatterns?: string[] = [];
dryRun? = false; dryRun? = false;
@ -118,11 +125,13 @@ export class UploadOptionsDto {
concurrency? = 4; concurrency? = 4;
} }
export class UploadCommand extends BaseCommand { export const upload = (paths: string[], baseOptions: BaseOptions, uploadOptions: UploadOptionsDto) =>
api!: ImmichApi; new UploadCommand().run(paths, baseOptions, uploadOptions);
public async run(paths: string[], options: UploadOptionsDto): Promise<void> { // TODO refactor this
this.api = await this.connect(); class UploadCommand {
public async run(paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto): Promise<void> {
await authenticate(baseOptions);
console.log('Crawling for assets...'); console.log('Crawling for assets...');
const files = await this.getFiles(paths, options); const files = await this.getFiles(paths, options);
@ -264,7 +273,7 @@ export class UploadCommand extends BaseCommand {
} }
public async getAlbums(): Promise<Map<string, string>> { public async getAlbums(): Promise<Map<string, string>> {
const existingAlbums = await this.api.getAllAlbums(); const existingAlbums = await getAllAlbums({});
const albumMapping = new Map<string, string>(); const albumMapping = new Map<string, string>();
for (const album of existingAlbums) { for (const album of existingAlbums) {
@ -313,7 +322,7 @@ export class UploadCommand extends BaseCommand {
try { try {
for (const albumNames of chunk(newAlbums, options.concurrency)) { for (const albumNames of chunk(newAlbums, options.concurrency)) {
const newAlbumIds = await Promise.all( const newAlbumIds = await Promise.all(
albumNames.map((albumName: string) => this.api.createAlbum({ albumName }).then((r) => r.id)), albumNames.map((albumName: string) => createAlbum({ createAlbumDto: { albumName } }).then((r) => r.id)),
); );
for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) { for (const [albumName, albumId] of zipDefined(albumNames, newAlbumIds)) {
@ -348,7 +357,7 @@ export class UploadCommand extends BaseCommand {
try { try {
for (const [albumId, assets] of albumToAssets.entries()) { for (const [albumId, assets] of albumToAssets.entries()) {
for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) { for (const assetBatch of chunk(assets, Math.min(1000 * (options.concurrency ?? 4), 65_000))) {
await this.api.addAssetsToAlbum(albumId, { ids: assetBatch }); await addAssetsToAlbum({ id: albumId, bulkIdsDto: { ids: assetBatch } });
albumUpdateProgress.increment(assetBatch.length); albumUpdateProgress.increment(assetBatch.length);
} }
} }
@ -404,17 +413,18 @@ export class UploadCommand extends BaseCommand {
const assetBulkUploadCheckDto = { const assetBulkUploadCheckDto = {
assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })), assets: zipDefined(assetsToCheck, checksums).map(([asset, checksum]) => ({ id: asset.path, checksum })),
}; };
const checkResponse = await this.api.checkBulkUpload(assetBulkUploadCheckDto); const checkResponse = await checkBulkUpload({ assetBulkUploadCheckDto });
return checkResponse.results; return checkResponse.results;
} }
private async uploadAssets(assets: Asset[]): Promise<string[]> { private async uploadAssets(assets: Asset[]): Promise<string[]> {
const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData())); const fileRequests = await Promise.all(assets.map((asset) => asset.getUploadFormData()));
return Promise.all(fileRequests.map((request) => this.uploadAsset(request).then((response) => response.id))); 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[]> { private async crawl(paths: string[], options: UploadOptionsDto): Promise<string[]> {
const formatResponse = await this.api.getSupportedMediaTypes(); const formatResponse = await getSupportedMediaTypes();
const crawlService = new CrawlService(formatResponse.image, formatResponse.video); const crawlService = new CrawlService(formatResponse.image, formatResponse.video);
return crawlService.crawl({ return crawlService.crawl({
@ -426,14 +436,12 @@ export class UploadCommand extends BaseCommand {
} }
private async uploadAsset(data: FormData): Promise<{ id: string }> { private async uploadAsset(data: FormData): Promise<{ id: string }> {
const url = this.api.instanceUrl + '/asset/upload'; const { baseUrl, headers } = defaults;
const response = await fetch(url, { const response = await fetch(`${baseUrl}/asset/upload`, {
method: 'post', method: 'post',
redirect: 'error', redirect: 'error',
headers: { headers: headers as Record<string, string>,
'x-api-key': this.api.apiKey,
},
body: data, body: data,
}); });
if (response.status !== 200 && response.status !== 201) { if (response.status !== 200 && response.status !== 201) {

48
cli/src/commands/auth.ts Normal file
View File

@ -0,0 +1,48 @@
import { getMyUserInfo } from '@immich/sdk';
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 (instanceUrl: string, apiKey: string, options: BaseOptions) => {
console.log(`Logging in to ${instanceUrl}`);
const { configDirectory: configDir } = options;
await connect(instanceUrl, apiKey);
const [error, userInfo] = await withError(getMyUserInfo());
if (error) {
logError(error, 'Failed to load user info');
process.exit(1);
}
console.log(`Logged in as ${userInfo.email}`);
if (!existsSync(configDir)) {
// Create config folder if it doesn't exist
const created = await mkdir(configDir, { recursive: true });
if (!created) {
console.log(`Failed to create config folder: ${configDir}`);
return;
}
}
await writeAuthFile(configDir, { instanceUrl, apiKey });
console.log(`Wrote auth info to ${getAuthFilePath(configDir)}`);
};
export const logout = async (options: BaseOptions) => {
console.log('Logging out...');
const { configDirectory: configDir } = options;
const authFile = getAuthFilePath(configDir);
if (existsSync(authFile)) {
await unlink(authFile);
console.log(`Removed auth file: ${authFile}`);
}
console.log('Successfully logged out');
};

View File

@ -1,20 +0,0 @@
import { ServerVersionResponseDto, UserResponseDto } from '@immich/sdk';
import { ImmichApi } from 'src/services/api.service';
import { SessionService } from '../services/session.service';
export abstract class BaseCommand {
protected sessionService!: SessionService;
protected user!: UserResponseDto;
protected serverVersion!: ServerVersionResponseDto;
constructor(options: { configDirectory?: string }) {
if (!options.configDirectory) {
throw new Error('Config directory is required');
}
this.sessionService = new SessionService(options.configDirectory);
}
public async connect(): Promise<ImmichApi> {
return await this.sessionService.connect();
}
}

View File

@ -1,7 +0,0 @@
import { BaseCommand } from './base-command';
export class LoginCommand extends BaseCommand {
public async run(instanceUrl: string, apiKey: string): Promise<void> {
await this.sessionService.login(instanceUrl, apiKey);
}
}

View File

@ -1,8 +0,0 @@
import { BaseCommand } from './base-command';
export class LogoutCommand extends BaseCommand {
public static readonly description = 'Logout and remove persisted credentials';
public async run(): Promise<void> {
await this.sessionService.logout();
}
}

View File

@ -1,17 +0,0 @@
import { BaseCommand } from './base-command';
export class ServerInfoCommand extends BaseCommand {
public async run() {
const api = await this.connect();
const versionInfo = await api.getServerVersion();
const mediaTypes = await api.getSupportedMediaTypes();
const statistics = await api.getAssetStatistics();
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(
`Statistics:\n Images: ${statistics.images}\n Videos: ${statistics.videos}\n Total: ${statistics.total}`,
);
}
}

View File

@ -0,0 +1,15 @@
import { getAssetStatistics, getServerVersion, getSupportedMediaTypes } from '@immich/sdk';
import { BaseOptions, authenticate } from 'src/utils';
export const serverInfo = async (options: BaseOptions) => {
await authenticate(options);
const versionInfo = await getServerVersion();
const mediaTypes = await getSupportedMediaTypes();
const stats = await getAssetStatistics({});
console.log(`Server Version: ${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}`);
console.log(`Image Types: ${mediaTypes.image.map((extension) => extension.replace('.', ''))}`);
console.log(`Video Types: ${mediaTypes.video.map((extension) => extension.replace('.', ''))}`);
console.log(`Statistics:\n Images: ${stats.images}\n Videos: ${stats.videos}\n Total: ${stats.total}`);
};

View File

@ -2,11 +2,10 @@
import { Command, Option } from 'commander'; import { Command, Option } from 'commander';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { upload } from 'src/commands/asset';
import { login, logout } from 'src/commands/auth';
import { serverInfo } from 'src/commands/server-info';
import { version } from '../package.json'; import { version } from '../package.json';
import { LoginCommand } from './commands/login.command';
import { LogoutCommand } from './commands/logout.command';
import { ServerInfoCommand } from './commands/server-info.command';
import { UploadCommand } from './commands/upload.command';
const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/'); const defaultConfigDirectory = path.join(os.homedir(), '.config/immich/');
@ -18,14 +17,34 @@ const program = new Command()
new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored') new Option('-d, --config-directory <directory>', 'Configuration directory where auth.yml will be stored')
.env('IMMICH_CONFIG_DIR') .env('IMMICH_CONFIG_DIR')
.default(defaultConfigDirectory), .default(defaultConfigDirectory),
); )
.addOption(new Option('-u, --url [url]', 'Immich server URL').env('IMMICH_INSTANCE_URL'))
.addOption(new Option('-k, --key [apiKey]', 'Immich API key').env('IMMICH_API_KEY'));
program
.command('login')
.alias('login-key')
.description('Login using an API key')
.argument('url', 'Immich server URL')
.argument('key', 'Immich API key')
.action((url, key) => login(url, key, program.opts()));
program
.command('logout')
.description('Remove stored credentials')
.action(() => logout(program.opts()));
program
.command('server-info')
.description('Display server information')
.action(() => serverInfo(program.opts()));
program program
.command('upload') .command('upload')
.description('Upload assets') .description('Upload assets')
.usage('[options] [paths...]') .usage('[paths...] [options]')
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false)) .addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS')) .addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS').default([]))
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false)) .addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
.addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false)) .addOption(new Option('-H, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
.addOption( .addOption(
@ -50,32 +69,6 @@ program
) )
.addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS'))
.argument('[paths...]', 'One or more paths to assets to be uploaded') .argument('[paths...]', 'One or more paths to assets to be uploaded')
.action(async (paths, options) => { .action((paths, options) => upload(paths, program.opts(), options));
options.exclusionPatterns = options.ignore;
await new UploadCommand(program.opts()).run(paths, options);
});
program
.command('server-info')
.description('Display server information')
.action(async () => {
await new ServerInfoCommand(program.opts()).run();
});
program
.command('login-key')
.description('Login using an API key')
.argument('url')
.argument('key')
.action(async (url, key) => {
await new LoginCommand(program.opts()).run(url, key);
});
program
.command('logout')
.description('Remove stored credentials')
.action(async () => {
await new LogoutCommand(program.opts()).run();
});
program.parse(process.argv); program.parse(process.argv);

View File

@ -1,106 +0,0 @@
import {
ApiKeyCreateDto,
AssetBulkUploadCheckDto,
BulkIdsDto,
CreateAlbumDto,
CreateAssetDto,
LoginCredentialDto,
SignUpDto,
addAssetsToAlbum,
checkBulkUpload,
createAlbum,
createApiKey,
getAllAlbums,
getAllAssets,
getAssetStatistics,
getMyUserInfo,
getServerVersion,
getSupportedMediaTypes,
login,
pingServer,
signUpAdmin,
uploadFile,
} from '@immich/sdk';
/**
* Wraps the underlying API to abstract away the options and make API calls mockable for testing.
*/
export class ImmichApi {
private readonly options;
constructor(
public instanceUrl: string,
public apiKey: string,
) {
this.options = {
baseUrl: instanceUrl,
headers: {
'x-api-key': apiKey,
},
};
}
setApiKey(apiKey: string) {
this.apiKey = apiKey;
if (!this.options.headers) {
throw new Error('missing headers');
}
this.options.headers['x-api-key'] = apiKey;
}
addAssetsToAlbum(id: string, bulkIdsDto: BulkIdsDto) {
return addAssetsToAlbum({ id, bulkIdsDto }, this.options);
}
checkBulkUpload(assetBulkUploadCheckDto: AssetBulkUploadCheckDto) {
return checkBulkUpload({ assetBulkUploadCheckDto }, this.options);
}
createAlbum(createAlbumDto: CreateAlbumDto) {
return createAlbum({ createAlbumDto }, this.options);
}
createApiKey(apiKeyCreateDto: ApiKeyCreateDto, options: { headers: { Authorization: string } }) {
return createApiKey({ apiKeyCreateDto }, { ...this.options, ...options });
}
getAllAlbums() {
return getAllAlbums({}, this.options);
}
getAllAssets() {
return getAllAssets({}, this.options);
}
getAssetStatistics() {
return getAssetStatistics({}, this.options);
}
getMyUserInfo() {
return getMyUserInfo(this.options);
}
getServerVersion() {
return getServerVersion(this.options);
}
getSupportedMediaTypes() {
return getSupportedMediaTypes(this.options);
}
login(loginCredentialDto: LoginCredentialDto) {
return login({ loginCredentialDto }, this.options);
}
pingServer() {
return pingServer(this.options);
}
signUpAdmin(signUpDto: SignUpDto) {
return signUpAdmin({ signUpDto }, this.options);
}
uploadFile(createAssetDto: CreateAssetDto) {
return uploadFile({ createAssetDto }, this.options);
}
}

View File

@ -1,135 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import yaml from 'yaml';
import { SessionService } from './session.service';
const TEST_CONFIG_DIR = '/tmp/immich/';
const TEST_AUTH_FILE = path.join(TEST_CONFIG_DIR, 'auth.yml');
const TEST_IMMICH_INSTANCE_URL = 'https://test/api';
const TEST_IMMICH_API_KEY = 'pNussssKSYo5WasdgalvKJ1n9kdvaasdfbluPg';
const spyOnConsole = () => vi.spyOn(console, 'log').mockImplementation(() => {});
const createTestAuthFile = async (contents: string) => {
if (!fs.existsSync(TEST_CONFIG_DIR)) {
// Create config folder if it doesn't exist
const created = await fs.promises.mkdir(TEST_CONFIG_DIR, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${TEST_CONFIG_DIR}`);
}
}
fs.writeFileSync(TEST_AUTH_FILE, contents);
};
const readTestAuthFile = async (): Promise<string> => {
return await fs.promises.readFile(TEST_AUTH_FILE, 'utf8');
};
const deleteAuthFile = () => {
try {
fs.unlinkSync(TEST_AUTH_FILE);
} catch (error: any) {
if (error.code !== 'ENOENT') {
throw error;
}
}
};
const mocks = vi.hoisted(() => {
return {
getMyUserInfo: vi.fn(() => Promise.resolve({ email: 'admin@example.com' })),
pingServer: vi.fn(() => Promise.resolve({ res: 'pong' })),
};
});
vi.mock('./api.service', async (importOriginal) => {
const module = await importOriginal<typeof import('./api.service')>();
// @ts-expect-error this is only a partial implementation of the return value
module.ImmichApi.prototype.getMyUserInfo = mocks.getMyUserInfo;
module.ImmichApi.prototype.pingServer = mocks.pingServer;
return module;
});
describe('SessionService', () => {
let sessionService: SessionService;
beforeEach(() => {
deleteAuthFile();
sessionService = new SessionService(TEST_CONFIG_DIR);
});
afterEach(() => {
deleteAuthFile();
});
it('should connect to immich', async () => {
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.connect();
expect(mocks.pingServer).toHaveBeenCalledTimes(1);
});
it('should error if no auth file exists', async () => {
await sessionService.connect().catch((error) => {
expect(error.message).toEqual('No auth file exist. Please login first');
});
});
it('should error if auth file is missing instance URl', async () => {
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
}),
);
await sessionService.connect().catch((error) => {
expect(error.message).toEqual(`Instance URL missing in auth config file ${TEST_AUTH_FILE}`);
});
});
it('should error if auth file is missing api key', async () => {
await createTestAuthFile(
JSON.stringify({
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await expect(sessionService.connect()).rejects.toThrow(`API key missing in auth config file ${TEST_AUTH_FILE}`);
});
it('should create auth file when logged in', async () => {
await sessionService.login(TEST_IMMICH_INSTANCE_URL, TEST_IMMICH_API_KEY);
const data: string = await readTestAuthFile();
const authConfig = yaml.parse(data);
expect(authConfig.instanceUrl).toBe(TEST_IMMICH_INSTANCE_URL);
expect(authConfig.apiKey).toBe(TEST_IMMICH_API_KEY);
});
it('should delete auth file when logging out', async () => {
const consoleSpy = spyOnConsole();
await createTestAuthFile(
JSON.stringify({
apiKey: TEST_IMMICH_API_KEY,
instanceUrl: TEST_IMMICH_INSTANCE_URL,
}),
);
await sessionService.logout();
await fs.promises.access(TEST_AUTH_FILE, fs.constants.F_OK).catch((error) => {
expect(error.message).toContain('ENOENT');
});
expect(consoleSpy.mock.calls).toEqual([
['Logging out...'],
[`Removed auth file ${TEST_AUTH_FILE}`],
['Successfully logged out'],
]);
});
});

View File

@ -1,118 +0,0 @@
import { existsSync } from 'node:fs';
import { access, constants, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path';
import yaml from 'yaml';
import { ImmichApi } from './api.service';
class LoginError extends Error {
constructor(message: string) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
}
export class SessionService {
private get authPath() {
return path.join(this.configDirectory, '/auth.yml');
}
constructor(private configDirectory: string) {}
async connect(): Promise<ImmichApi> {
let instanceUrl = process.env.IMMICH_INSTANCE_URL;
let apiKey = process.env.IMMICH_API_KEY;
if (!instanceUrl || !apiKey) {
await access(this.authPath, constants.F_OK).catch((error) => {
if (error.code === 'ENOENT') {
throw new LoginError('No auth file exist. Please login first');
}
});
const data: string = await readFile(this.authPath, 'utf8');
const parsedConfig = yaml.parse(data);
instanceUrl = parsedConfig.instanceUrl;
apiKey = parsedConfig.apiKey;
if (!instanceUrl) {
throw new LoginError(`Instance URL missing in auth config file ${this.authPath}`);
}
if (!apiKey) {
throw new LoginError(`API key missing in auth config file ${this.authPath}`);
}
}
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
const api = new ImmichApi(instanceUrl, apiKey);
const pingResponse = await api.pingServer().catch((error) => {
throw new Error(`Failed to connect to server ${instanceUrl}: ${error.message}`, error);
});
if (pingResponse.res !== 'pong') {
throw new Error(`Could not parse response. Is Immich listening on ${instanceUrl}?`);
}
return api;
}
async login(instanceUrl: string, apiKey: string): Promise<ImmichApi> {
console.log(`Logging in to ${instanceUrl}`);
instanceUrl = await this.resolveApiEndpoint(instanceUrl);
const api = new ImmichApi(instanceUrl, apiKey);
// Check if server and api key are valid
const userInfo = await api.getMyUserInfo().catch((error) => {
throw new LoginError(`Failed to connect to server ${instanceUrl}: ${error.message}`);
});
console.log(`Logged in as ${userInfo.email}`);
if (!existsSync(this.configDirectory)) {
// Create config folder if it doesn't exist
const created = await mkdir(this.configDirectory, { recursive: true });
if (!created) {
throw new Error(`Failed to create config folder ${this.configDirectory}`);
}
}
await writeFile(this.authPath, yaml.stringify({ instanceUrl, apiKey }), { mode: 0o600 });
console.log(`Wrote auth info to ${this.authPath}`);
return api;
}
async logout(): Promise<void> {
console.log('Logging out...');
if (existsSync(this.authPath)) {
await unlink(this.authPath);
console.log('Removed auth file ' + this.authPath);
}
console.log('Successfully logged out');
}
private async resolveApiEndpoint(instanceUrl: string): Promise<string> {
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, instanceUrl).toString();
if (endpoint !== instanceUrl) {
console.debug(`Discovered API at ${endpoint}`);
}
return endpoint;
} catch {
return instanceUrl;
}
}
}

89
cli/src/utils.ts Normal file
View File

@ -0,0 +1,89 @@
import { defaults, getMyUserInfo, isHttpError } from '@immich/sdk';
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import yaml from 'yaml';
export interface BaseOptions {
configDirectory: string;
apiKey?: string;
instanceUrl?: string;
}
export interface AuthDto {
instanceUrl: string;
apiKey: string;
}
export const authenticate = async (options: BaseOptions): Promise<void> => {
const { configDirectory: configDir, instanceUrl, apiKey } = options;
// provided in command
if (instanceUrl && apiKey) {
await connect(instanceUrl, apiKey);
return;
}
// fallback to file
const config = await readAuthFile(configDir);
await connect(config.instanceUrl, config.apiKey);
};
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, instanceUrl).toString();
if (endpoint !== instanceUrl) {
console.debug(`Discovered API at ${endpoint}`);
}
instanceUrl = endpoint;
} catch {
// noop
}
defaults.baseUrl = instanceUrl;
defaults.headers = { 'x-api-key': apiKey };
const [error] = await withError(getMyUserInfo());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
process.exit(1);
}
};
export const logError = (error: unknown, message: string) => {
if (isHttpError(error)) {
console.error(`${message}: ${error.status}`);
console.error(JSON.stringify(error.data, undefined, 2));
} else {
console.error(`${message} - ${error}`);
}
};
export const getAuthFilePath = (dir: string) => join(dir, 'auth.yml');
export const readAuthFile = async (dir: string) => {
try {
const data = await readFile(getAuthFilePath(dir));
// TODO add class-transform/validation
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.');
process.exit(1);
}
throw error;
}
};
export const writeAuthFile = async (dir: string, auth: AuthDto) =>
writeFile(getAuthFilePath(dir), yaml.stringify(auth), { mode: 0o600 });
export const withError = async <T>(promise: Promise<T>): Promise<[Error, undefined] | [undefined, T]> => {
try {
const result = await promise;
return [undefined, result];
} catch (error: Error | any) {
return [error, undefined];
}
};

View File

@ -1,4 +1,5 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({ export default defineConfig({
build: { build: {
@ -14,4 +15,5 @@ export default defineConfig({
// bundle everything except for Node built-ins // bundle everything except for Node built-ins
noExternal: /^(?!node:).*$/, noExternal: /^(?!node:).*$/,
}, },
plugins: [tsconfigPaths()],
}); });

View File

@ -78,7 +78,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus image: prom/prometheus@sha256:bc1794e85c9e00293351b967efa267ce6af1c824ac875a9d0c7ac84700a8b53e
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 758 KiB

View File

@ -1,17 +0,0 @@
---
sidebar_position: 4
---
# Logo
Why the colorful flower, you ask?
I really like the Japanese culture, especially the books, history, and food. The current logo is a spin-off of [the Oda clan's symbol](https://en.wikipedia.org/wiki/Oda_clan).
![Oda_emblem](https://user-images.githubusercontent.com/27055614/182044504-a5ed33a8-5640-42de-b359-18fdbee9fb90.svg)
One of my favorite books is [Taikō](https://www.goodreads.com/book/show/336228.Taiko), it is a story about a prominent figure in the history of Japan, [Toyotomi Hideyoshi](https://www.britannica.com/biography/Toyotomi-Hideyoshi). He came from nothing, and through his resilience and wonderful mind, he has become one of the most powerful rulers in Japan's history. I enjoy his personality and the way he moved through life.
The color is an adaptation of **_App-Which-Must-Not-Be-Named_**'s color scheme, with an extra color (pink) to complete the flower's fifth petal. The petal layers are the same color scheme as the main layer rotating back and forth to "bring the flower to life."
![image](https://user-images.githubusercontent.com/27055614/182044984-2ee6d1ed-c4a7-4331-8a4b-64fcde77fe1f.png)

View File

@ -88,11 +88,10 @@ const config = {
}, },
}, },
navbar: { navbar: {
title: 'IMMICH',
logo: { logo: {
alt: 'Immich University Logo', alt: 'Immich Logo',
src: 'img/color-logo.png', src: 'img/immich-logo-inline-light.png',
srcDark: 'img/logo.png', srcDark: 'img/immich-logo-inline-dark.png',
}, },
items: [ items: [
{ {

View File

@ -1,23 +1,24 @@
import React from 'react'; import React from 'react';
import Link from '@docusaurus/Link'; import Link from '@docusaurus/Link';
import Layout from '@theme/Layout'; import Layout from '@theme/Layout';
import { useColorMode } from '@docusaurus/theme-common';
function HomepageHeader() { function HomepageHeader() {
const { isDarkTheme } = useColorMode();
return ( return (
<header> <header>
<section className="text-center m-6 p-12 border border-red-400 rounded-[50px] bg-gray-100 dark:bg-immich-dark-gray"> <section className="text-center m-6 p-12 border border-red-400 rounded-[50px] bg-slate-200 dark:bg-immich-dark-gray">
<img src="img/immich-logo.svg" className="md:h-24 h-12 mb-2" alt="Immich logo" /> <img
<h1 className="md:text-6xl font-immich-title mb-10 text-immich-primary dark:text-immich-dark-primary uppercase"> src={isDarkTheme ? 'img/immich-logo-stacked-dark.svg' : 'img/immich-logo-stacked-light.svg'}
Immich className="md:h-60 h-44 mb-2 antialiased"
</h1> alt="Immich logo"
<div className="font-thin sm:text-base md:text-2xl my-12 sm:leading-tight"> />
<p className="mb-1 uppercase"> <div className="sm:text-2xl text-lg md:text-4xl mb-12 sm:leading-tight">
Self-hosted backup solution <span className="block"></span> <p className="mb-1 font-medium text-immich-primary dark:text-immich-dark-primary">
for photos and videos <span className="block"></span> Self-hosted photo and <span className="block"></span>
on mobile device video management solution<span className="block"></span>
</p> </p>
</div> </div>
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 "> <div className="flex flex-col sm:flex-row place-items-center place-content-center mt-9 mb-16 gap-4 ">
<Link <Link
className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold uppercase" className="flex place-items-center place-content-center py-3 px-8 border bg-immich-primary dark:bg-immich-dark-primary rounded-full no-underline hover:no-underline text-white hover:text-gray-50 dark:text-immich-dark-bg font-bold uppercase"
@ -42,9 +43,7 @@ function HomepageHeader() {
Sponsor Sponsor
</a> </a>
</div> </div>
<img src="/img/immich-screenshots.png" alt="screenshots" width={'70%'} />
<img src="/img/immich-screenshots.png" alt="screenshots" width={'85%'} />
<div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1"> <div className="flex flex-col sm:flex-row place-items-center place-content-center mt-4 gap-1">
<div className="h-24"> <div className="h-24">
<a href="https://play.google.com/store/apps/details?id=app.alextran.immich"> <a href="https://play.google.com/store/apps/details?id=app.alextran.immich">

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_Black_00000037681990313894948460000012967653829507626171_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792"
style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#ACCBFA;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M110.16,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C95.71,543.8,102.32,537.4,110.16,537.4z M97.98,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M265.44,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.81,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.69-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.81,0,10.74,4.54,11.98,10.53
c6.19-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.73-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C271.43,685.04,265.44,679.26,265.44,671.82z"/>
<path class="st0" d="M431.45,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.82,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.68-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.82,0,10.74,4.54,11.98,10.53
c6.2-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.72-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C437.44,685.04,431.45,679.26,431.45,671.82z"/>
<path class="st0" d="M491.33,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C476.87,543.8,483.48,537.4,491.33,537.4z M479.15,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M522.09,634.04c0-29.11,18.17-52.65,48.32-52.65c15.9,0,30.56,7.23,37.17,17.97c2.48,3.92,2.89,6.19,2.89,8.05
c0,6.4-4.96,11.77-12.18,11.77c-4.75,0-9.08-2.68-10.94-7.43c-2.89-6.4-8.47-10.12-16.93-10.12c-15.9,0-24.78,14.25-24.78,32.21
c0,18.17,9.29,32.21,25.4,32.21c8.67,0,14.87-3.1,17.76-9.5c2.06-4.34,5.99-8.05,11.36-8.05c7.43,0,11.98,5.16,11.98,11.56
c0,3.1-1.24,6.81-3.92,10.32c-6.82,9.09-19.62,16.31-37.17,16.31C540.06,686.69,522.09,663.56,522.09,634.04z"/>
<path class="st0" d="M690.17,671.82c0-3.51,0.83-9.5,0.83-13.22v-35.3c0-12.6-7.02-21.27-19-21.27c-8.26,0-15.28,3.92-19.82,10.32
v46.25c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.78,13.22-13.01,13.22s-13.01-5.78-13.01-13.22c0-3.51,1.03-9.5,1.03-13.22v-99.94
c0-3.72-1.03-9.71-1.03-13.22c0-7.43,5.99-13.22,13.01-13.22c7.23,0,13.01,5.78,13.01,13.22c0,3.51-0.83,9.5-0.83,13.22v33.66
c6.2-6.81,15.07-10.94,26.43-10.94c21.27,0,36.55,15.9,36.55,38.61v38.61c0,3.72,1.03,9.71,1.03,13.22
c0,7.43-5.99,13.22-13.01,13.22C695.95,685.04,690.17,679.26,690.17,671.82z"/>
</g>
<g>
<path class="st1" d="M376.76,216.42c28.32,25.07,51.15,51.95,65.83,77.27c25.23-45.12,42.08-98.73,42.3-132.88
c0-0.24,0-0.46,0-0.66c0-50.53-50.41-70.2-93.82-70.2s-93.82,19.66-93.82,70.2c0,0.69,0,1.62,0,2.73
C321.44,173.62,350.14,192.84,376.76,216.42z"/>
<path class="st2" d="M222.27,354.21c17.7-19.69,44.85-41.04,75.5-59.08c32.6-19.19,65.21-32.59,93.83-38.73
c-35.11-37.94-80.89-70.53-113.31-81.29c-0.23-0.07-0.44-0.14-0.63-0.21c-48.06-15.61-82.34,26.25-95.75,67.54
c-13.42,41.29-10.29,95.31,37.77,110.92C220.33,353.58,221.21,353.86,222.27,354.21z"/>
<path class="st3" d="M600.73,241.74c-13.42-41.29-47.69-83.15-95.75-67.54c-0.66,0.21-1.54,0.5-2.6,0.84
c-2.75,26.34-12.16,59.57-26.36,92.17c-15.09,34.68-33.6,64.69-53.14,86.48c50.7,10.05,106.9,9.52,139.45-0.83
c0.23-0.07,0.44-0.14,0.63-0.21C611.02,337.05,614.15,283.03,600.73,241.74z"/>
<path class="st4" d="M348.22,394.58c-8.17-36.93-10.84-72.09-7.84-101.2c-46.93,21.67-92.08,55.14-112.33,82.64
c-0.14,0.19-0.27,0.37-0.39,0.54c-29.7,40.88-0.48,86.42,34.64,111.94s87.46,39.24,117.16-1.64c0.41-0.56,0.95-1.31,1.6-2.21
C367.81,461.72,355.9,429.3,348.22,394.58z"/>
<path class="st5" d="M554.19,373.91c-25.9,5.53-60.41,6.84-95.81,3.42c-37.65-3.64-71.91-11.96-98.67-23.82
c6.11,51.33,23.99,104.61,43.89,132.37c0.14,0.19,0.27,0.37,0.39,0.54c29.7,40.88,82.04,27.16,117.16,1.64S585.5,417,555.8,376.12
C555.39,375.56,554.85,374.81,554.19,373.91z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Router_Medium_x5F_White_00000062189486027058041470000012691761407447023025_"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 792 792"
style="enable-background:new 0 0 792 792;" xml:space="preserve">
<style type="text/css">
.st0{fill:#4251B0;}
.st1{fill:#FA2921;}
.st2{fill:#ED79B5;}
.st3{fill:#FFB400;}
.st4{fill:#1E83F7;}
.st5{fill:#18C249;}
</style>
<g>
<path class="st0" d="M110.16,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C95.71,543.8,102.32,537.4,110.16,537.4z M97.98,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M265.44,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.81,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.69-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.81,0,10.74,4.54,11.98,10.53
c6.19-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.73-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C271.43,685.04,265.44,679.26,265.44,671.82z"/>
<path class="st0" d="M431.45,671.82c0-3.51,1.03-9.5,1.03-13.22v-35.72c0-12.6-6.61-20.85-17.96-20.85
c-7.43,0-14.04,3.72-18.38,10.94c0.41,2.27,0.62,4.54,0.62,7.02v38.61c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.22,13.22
c-6.82,0-12.8-5.78-12.8-13.22c0-3.51,1.03-9.5,1.03-13.22v-36.34c0-3.92-0.62-7.43-2.06-10.53c-2.68-5.99-8.05-9.71-15.49-9.71
c-7.64,0-13.83,3.92-18.38,10.94v45.63c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.99,13.22-13.01,13.22c-7.23,0-13.01-5.78-13.01-13.22
c0-3.51,0.83-9.5,0.83-13.22v-47.7c0-3.72-1.86-10.32-1.86-13.42c0-7.43,5.37-13.22,12.6-13.22c6.82,0,10.74,4.54,11.98,10.53
c6.2-8.26,14.87-13.42,26.22-13.42c13.42,0,23.13,6.4,29.11,16.73c6.81-10.74,16.72-16.73,29.11-16.73
c20.86,0,36.75,15.07,36.75,39.23v37.99c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.57,13.22-13.01,13.22
C437.44,685.04,431.45,679.26,431.45,671.82z"/>
<path class="st0" d="M491.33,537.4c7.85,0,14.25,6.4,14.25,14.04c0,7.85-6.4,14.04-14.25,14.04s-14.45-6.19-14.45-14.04
C476.87,543.8,483.48,537.4,491.33,537.4z M479.15,610.7c0-3.72-0.83-9.71-0.83-13.22c0-7.43,5.78-13.22,13.01-13.22
s13.01,5.78,13.01,13.22c0,3.51-1.03,9.5-1.03,13.22v47.9c0,3.72,1.03,9.71,1.03,13.22c0,7.43-5.78,13.22-13.01,13.22
s-13.01-5.78-13.01-13.22c0-3.51,0.83-9.5,0.83-13.22V610.7z"/>
<path class="st0" d="M522.09,634.04c0-29.11,18.17-52.65,48.32-52.65c15.9,0,30.56,7.23,37.17,17.97c2.48,3.92,2.89,6.19,2.89,8.05
c0,6.4-4.96,11.77-12.18,11.77c-4.75,0-9.08-2.68-10.94-7.43c-2.89-6.4-8.47-10.12-16.93-10.12c-15.9,0-24.78,14.25-24.78,32.21
c0,18.17,9.29,32.21,25.4,32.21c8.67,0,14.87-3.1,17.76-9.5c2.06-4.34,5.99-8.05,11.36-8.05c7.43,0,11.98,5.16,11.98,11.56
c0,3.1-1.24,6.81-3.92,10.32c-6.82,9.09-19.62,16.31-37.17,16.31C540.06,686.69,522.09,663.56,522.09,634.04z"/>
<path class="st0" d="M690.17,671.82c0-3.51,0.83-9.5,0.83-13.22v-35.3c0-12.6-7.02-21.27-19-21.27c-8.26,0-15.28,3.92-19.82,10.32
v46.25c0,3.72,0.83,9.71,0.83,13.22c0,7.43-5.78,13.22-13.01,13.22s-13.01-5.78-13.01-13.22c0-3.51,1.03-9.5,1.03-13.22v-99.94
c0-3.72-1.03-9.71-1.03-13.22c0-7.43,5.99-13.22,13.01-13.22c7.23,0,13.01,5.78,13.01,13.22c0,3.51-0.83,9.5-0.83,13.22v33.66
c6.2-6.81,15.07-10.94,26.43-10.94c21.27,0,36.55,15.9,36.55,38.61v38.61c0,3.72,1.03,9.71,1.03,13.22
c0,7.43-5.99,13.22-13.01,13.22C695.95,685.04,690.17,679.26,690.17,671.82z"/>
</g>
<g>
<path class="st1" d="M376.76,216.42c28.32,25.07,51.15,51.95,65.83,77.27c25.23-45.12,42.08-98.73,42.3-132.88
c0-0.24,0-0.46,0-0.66c0-50.53-50.41-70.2-93.82-70.2s-93.82,19.66-93.82,70.2c0,0.69,0,1.62,0,2.73
C321.44,173.62,350.14,192.84,376.76,216.42z"/>
<path class="st2" d="M222.27,354.21c17.7-19.69,44.85-41.04,75.5-59.08c32.6-19.19,65.21-32.59,93.83-38.73
c-35.11-37.94-80.89-70.53-113.31-81.29c-0.23-0.07-0.44-0.14-0.63-0.21c-48.06-15.61-82.34,26.25-95.75,67.54
c-13.42,41.29-10.29,95.31,37.77,110.92C220.33,353.58,221.21,353.86,222.27,354.21z"/>
<path class="st3" d="M600.73,241.74c-13.42-41.29-47.69-83.15-95.75-67.54c-0.66,0.21-1.54,0.5-2.6,0.84
c-2.75,26.34-12.16,59.57-26.36,92.17c-15.09,34.68-33.6,64.69-53.14,86.48c50.7,10.05,106.9,9.52,139.45-0.83
c0.23-0.07,0.44-0.14,0.63-0.21C611.02,337.05,614.15,283.03,600.73,241.74z"/>
<path class="st4" d="M348.22,394.58c-8.17-36.93-10.84-72.09-7.84-101.2c-46.93,21.67-92.08,55.14-112.33,82.64
c-0.14,0.19-0.27,0.37-0.39,0.54c-29.7,40.88-0.48,86.42,34.64,111.94s87.46,39.24,117.16-1.64c0.41-0.56,0.95-1.31,1.6-2.21
C367.81,461.72,355.9,429.3,348.22,394.58z"/>
<path class="st5" d="M554.19,373.91c-25.9,5.53-60.41,6.84-95.81,3.42c-37.65-3.64-71.91-11.96-98.67-23.82
c6.11,51.33,23.99,104.61,43.89,132.37c0.14,0.19,0.27,0.37,0.39,0.54c29.7,40.88,82.04,27.16,117.16,1.64S585.5,417,555.8,376.12
C555.39,375.56,554.85,374.81,554.19,373.91z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

View File

@ -1,98 +1,29 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 26.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="svg2781" xmlns:svg="http://www.w3.org/2000/svg" <svg version="1.1" id="Flower" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 564.2 553.5" viewBox="0 0 792 792" style="enable-background:new 0 0 792 792;" xml:space="preserve">
style="enable-background:new 0 0 564.2 553.5;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:#4081EF;stroke:#512D8C;stroke-miterlimit:10;} .st0{fill:#FA2921;}
.st1{fill:#31A452;stroke:#512D8C;stroke-miterlimit:10;} .st1{fill:#ED79B5;}
.st2{fill:#DE7FB3;stroke:#512D8C;stroke-miterlimit:10;} .st2{fill:#FFB400;}
.st3{fill:#FFB800;stroke:#512D8C;stroke-miterlimit:10;} .st3{fill:#1E83F7;}
.st4{fill:#E64132;stroke:#512D8C;stroke-miterlimit:10;} .st4{fill:#18C249;}
.st5{fill:#F2F5FB;stroke:#512D8C;stroke-miterlimit:10;}
</style> </style>
<path class="st0" d="M210.5,549.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.5 <g id="Flower_00000077325900055813483940000000694823054982625702_">
c-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.5 <path class="st0" d="M375.48,267.63c38.64,34.21,69.78,70.87,89.82,105.42c34.42-61.56,57.42-134.71,57.71-181.3
l-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,1 c0-0.33,0-0.63,0-0.91c0-68.94-68.77-95.77-128.01-95.77s-128.01,26.83-128.01,95.77c0,0.94,0,2.2,0,3.72
c2.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.1 C300.01,209.24,339.15,235.47,375.48,267.63z"/>
c-4.6,1.5-8,2.2-13.1,2.7C216.6,550.1,215.3,550,210.5,549.6z"/> <path class="st1" d="M164.7,455.63c24.15-26.87,61.2-55.99,103.01-80.61c44.48-26.18,88.97-44.47,128.02-52.84
<path class="st1" d="M339.8,549.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-30 c-47.91-51.76-110.37-96.24-154.6-110.91c-0.31-0.1-0.6-0.19-0.86-0.28c-65.57-21.3-112.34,35.81-130.64,92.15
c-2.2-6.7-2.3-7.5-3.3-36.9c-0.5-14.9-0.9-27.9-0.9-28.9l0-1.9l2.3,1.8c2.6,2,6.6,3.4,8.5,3.1c0.6-0.1,3-0.5,5.3-0.8 c-18.3,56.34-14.04,130.04,51.53,151.34C162.05,454.77,163.25,455.16,164.7,455.63z"/>
c37.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.2 <path class="st2" d="M681.07,302.19c-18.3-56.34-65.07-113.45-130.64-92.15c-0.9,0.29-2.1,0.68-3.54,1.15
c0.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.7 c-3.75,35.93-16.6,81.27-35.96,125.76c-20.59,47.32-45.84,88.27-72.51,118c69.18,13.72,145.86,12.98,190.26-1.14
c-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.3C351.6,549.6,347.2,550.1,339.8,549.4z"/> c0.31-0.1,0.6-0.2,0.86-0.28C695.11,432.22,699.37,358.52,681.07,302.19z"/>
<path class="st2" d="M255.6,438c-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.4 <path class="st3" d="M336.54,510.71c-11.15-50.39-14.8-98.36-10.7-138.08c-64.03,29.57-125.63,75.23-153.26,112.76
c-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.9 c-0.19,0.26-0.37,0.51-0.53,0.73c-40.52,55.78-0.66,117.91,47.27,152.72c47.92,34.82,119.33,53.54,159.86-2.24
c-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.6 c0.56-0.76,1.3-1.78,2.19-3.01C363.28,602.32,347.02,558.08,336.54,510.71z"/>
c-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.3s-0.1,3.5-1,5.3c-2,4.3-6.8,7.9-10.3,7.8 <path class="st4" d="M617.57,482.52c-35.33,7.54-82.42,9.33-130.72,4.66c-51.37-4.96-98.11-16.32-134.63-32.5
C260.6,438.7,257.9,438.3,255.6,438z"/> c8.33,70.03,32.73,142.73,59.88,180.6c0.19,0.26,0.37,0.51,0.53,0.73c40.52,55.78,111.93,37.06,159.86,2.24
<path class="st0" d="M297.6,438.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.5 c47.92-34.82,87.79-96.95,47.27-152.72C619.2,484.77,618.46,483.75,617.57,482.52z"/>
c2.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.4 </g>
c33.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-1
c2.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.9
C300.2,438.8,299.4,438.9,297.6,438.2z"/>
<path class="st1" d="M211.1,398.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.4
c-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.7
c0.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.5
c-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.7
c0.9,0.4,5.5,1.9,6.6,2.1l1,0.2l0,35.3c0,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,1
c-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.5C218.2,398.6,213.2,398.9,211.1,398.5z"/>
<path class="st3" d="M342.9,398.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.3
c-7.1-2.1-12.7-7.4-15.2-14.3l-0.9-2.6v-37.1v-37.1l1.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.9
c7.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.7
c0.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.2
c-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.3
C356.4,397.6,349.5,399.5,342.9,398.5z"/>
<path class="st2" d="M53.9,341.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.9
c-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-10
c4.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.7
c-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.3
c0.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,8
c-16.4,4.7-27.7,7.8-29.8,8.1C64.1,342.1,56.1,342.3,53.9,341.9z"/>
<path class="st3" d="M494.7,341.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.8
c3.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.5
c0.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.2
c5.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.8
C547.8,328.6,521.7,345.2,494.7,341.7z"/>
<path class="st4" d="M133.9,318.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.3
c5.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.1
c-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.5
c-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.2
c0,3.3-0.1,3.8-1.1,5.7c-1.4,2.7-4.6,5.7-7.1,6.6C139.4,318.6,135.8,318.9,133.9,318.5z"/>
<path class="st1" d="M422.6,318.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.2
c0.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.9
c-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.4
c12.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.5
C425.9,318.9,425.1,318.9,422.6,318.5z"/>
<path class="st0" d="M178.2,307.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.4
c-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,0
c1.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.8
c0.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-15
c-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.1,4.3l-0.1,4.3L223,295.9
c-18,5.9-33.9,10.9-35.2,11.2C184.7,307.8,181.2,307.8,178.2,307.2z"/>
<path class="st4" d="M372.5,306.8c-1.8-0.5-17.5-5.6-35-11.3l-31.8-10.4l1-4.3v-4.3l22.6-7.7c15-4.9,24-8,24.6-8.5
c0.7-0.6,0.9-1.1,0.9-2.2c0-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.9
c-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.9
c9,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.2
c0.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.7
c0,5.8-2.1,10.7-6.4,15.1c-4,4.1-8.9,6.3-14.9,6.5C376.3,307.7,375.3,307.6,372.5,306.8z"/>
<path class="st5" d="M276.2,298.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.5
c2.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.3C288.9,298.6,282.2,300.5,276.2,298.9z"/>
<path class="st2" d="M248.3,229.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.4
c1.4,0.3,2.6,0.5,2.7,0.5s0.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.2
c0.4,1.5,0.7,3.7,0.7,4.9c0,1.2,0.1,2.1,0.3,2.1s1.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.6
c0,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.9
c-0.7,0-0.7-0.8-0.3-25.5v-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.2v25.8h-1.2c-0.5-0.2-2.4,0.3-4,0.9
s-3.1,1.1-3.2,1.1C269.2,258.5,259.8,245.6,248.3,229.8z"/>
<path class="st3" d="M210.9,164.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.2
c15.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.6
c-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.1
c-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.8
C221.6,163.3,215.9,165.9,210.9,164.8z"/>
<path class="st4" d="M174.7,123.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.5
c4.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,0
c25.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.9
c-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.1
c-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.2
C191,147,184.7,138,174.7,123.4z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

24
e2e/package-lock.json generated
View File

@ -2493,26 +2493,26 @@
} }
}, },
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "24.5.0", "version": "24.6.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.6.0.tgz",
"integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", "integrity": "sha512-jGjsoeYmR9VUrlZn0j1wcxMVi5y8C7A4FAa4vm3/l7ThT8d0f+jRcBqtdjaf+P5Ds/F4OgUq+ee/fRVhLy2DrA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@photostructure/tz-lookup": "^9.0.1", "@photostructure/tz-lookup": "^9.0.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0", "batch-cluster": "^13.0.0",
"he": "^1.2.0", "he": "^1.2.0",
"luxon": "^3.4.4" "luxon": "^3.4.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"exiftool-vendored.exe": "12.76.0", "exiftool-vendored.exe": "12.78.0",
"exiftool-vendored.pl": "12.76.0" "exiftool-vendored.pl": "12.78.0"
} }
}, },
"node_modules/exiftool-vendored.exe": { "node_modules/exiftool-vendored.exe": {
"version": "12.76.0", "version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.78.0.tgz",
"integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", "integrity": "sha512-eMN7L67sb89xi8sN7INPg19uwa1KibG2oOyGcfOvB47h+1hzmGgivVu/SZIMeOToVIbLRwUl+AFwLYSTNXsJEg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"os": [ "os": [
@ -2520,9 +2520,9 @@
] ]
}, },
"node_modules/exiftool-vendored.pl": { "node_modules/exiftool-vendored.pl": {
"version": "12.76.0", "version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
"integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", "integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==",
"dev": true, "dev": true,
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -1,20 +1,48 @@
import { LibraryResponseDto, LibraryType, LoginResponseDto, getAllLibraries } from '@immich/sdk'; import {
LibraryResponseDto,
LibraryType,
LoginResponseDto,
ScanLibraryDto,
getAllLibraries,
scanLibrary,
} from '@immich/sdk';
import { existsSync, rmdirSync } from 'node:fs';
import { Socket } from 'socket.io-client';
import { userDto, uuidDto } from 'src/fixtures'; import { userDto, uuidDto } from 'src/fixtures';
import { errorDto } from 'src/responses'; import { errorDto } from 'src/responses';
import { app, asBearerAuth, testAssetDirInternal, utils } from 'src/utils'; import { app, asBearerAuth, testAssetDir, testAssetDirInternal, utils } from 'src/utils';
import request from 'supertest'; import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
const scan = async (accessToken: string, id: string, dto: ScanLibraryDto = {}) =>
scanLibrary({ id, scanLibraryDto: dto }, { headers: asBearerAuth(accessToken) });
describe('/library', () => { describe('/library', () => {
let admin: LoginResponseDto; let admin: LoginResponseDto;
let user: LoginResponseDto; let user: LoginResponseDto;
let library: LibraryResponseDto; let library: LibraryResponseDto;
let websocket: Socket;
beforeAll(async () => { beforeAll(async () => {
await utils.resetDatabase(); await utils.resetDatabase();
admin = await utils.adminSetup(); admin = await utils.adminSetup();
user = await utils.userSetup(admin.accessToken, userDto.user1); user = await utils.userSetup(admin.accessToken, userDto.user1);
library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External }); library = await utils.createLibrary(admin.accessToken, { type: LibraryType.External });
websocket = await utils.connectWebsocket(admin.accessToken);
});
afterAll(() => {
utils.disconnectWebsocket(websocket);
});
beforeEach(() => {
utils.resetEvents();
const tempDir = `${testAssetDir}/temp`;
if (existsSync(tempDir)) {
rmdirSync(tempDir, { recursive: true });
}
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetA.png`);
utils.createImageFile(`${testAssetDir}/temp/directoryB/assetB.png`);
}); });
describe('GET /library', () => { describe('GET /library', () => {
@ -376,6 +404,36 @@ describe('/library', () => {
]), ]),
); );
}); });
it('should delete an external library with assets', async () => {
const library = await utils.createLibrary(admin.accessToken, {
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
const { status, body } = await request(app)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
expect(body).toEqual({});
const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) });
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
// ensure no files get deleted
expect(existsSync(`${testAssetDir}/temp/directoryA/assetA.png`)).toBe(true);
expect(existsSync(`${testAssetDir}/temp/directoryB/assetB.png`)).toBe(true);
});
}); });
describe('GET /library/:id/statistics', () => { describe('GET /library/:id/statistics', () => {
@ -394,6 +452,89 @@ describe('/library', () => {
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should not scan an upload library', async () => {
const library = await utils.createLibrary(admin.accessToken, {
type: LibraryType.Upload,
});
const { status, body } = await request(app)
.post(`/library/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest('Can only refresh external libraries'));
});
it('should scan external library', async () => {
const library = await utils.createLibrary(admin.accessToken, {
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
const { assets } = await utils.metadataSearch(admin.accessToken, {
originalPath: `${testAssetDirInternal}/temp/directoryA/assetA.png`,
});
expect(assets.count).toBe(1);
});
it('should scan external library with exclusion pattern', async () => {
const library = await utils.createLibrary(admin.accessToken, {
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
exclusionPatterns: ['**/directoryA'],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 1 });
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(1);
expect(assets.items[0].originalPath.includes('directoryB'));
});
it('should scan multiple import paths', async () => {
const library = await utils.createLibrary(admin.accessToken, {
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp/directoryA`, `${testAssetDirInternal}/temp/directoryB`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(2);
expect(assets.items.find((asset) => asset.originalPath.includes('directoryA'))).toBeDefined();
expect(assets.items.find((asset) => asset.originalPath.includes('directoryB'))).toBeDefined();
});
it('should pick up new files', async () => {
const library = await utils.createLibrary(admin.accessToken, {
type: LibraryType.External,
importPaths: [`${testAssetDirInternal}/temp`],
});
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 2 });
const { assets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(assets.count).toBe(2);
utils.createImageFile(`${testAssetDir}/temp/directoryA/assetB.png`);
await scan(admin.accessToken, library.id);
await utils.waitForWebsocketEvent({ event: 'assetUpload', total: 3 });
const { assets: newAssets } = await utils.metadataSearch(admin.accessToken, { libraryId: library.id });
expect(newAssets.count).toBe(3);
});
}); });
describe('POST /library/:id/removeOffline', () => { describe('POST /library/:id/removeOffline', () => {

View File

@ -21,7 +21,9 @@ describe(`immich login-key`, () => {
it('should require a valid key', async () => { it('should require a valid key', async () => {
const { stderr, exitCode } = await immichCli(['login-key', 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 http://127.0.0.1:2283/api: Error: 401'); expect(stderr).toContain('Failed to connect to server');
expect(stderr).toContain('Invalid API key');
expect(stderr).toContain('401');
expect(exitCode).toBe(1); expect(exitCode).toBe(1);
}); });

View File

@ -5,6 +5,7 @@ import {
CreateAssetDto, CreateAssetDto,
CreateLibraryDto, CreateLibraryDto,
CreateUserDto, CreateUserDto,
MetadataSearchDto,
PersonCreateDto, PersonCreateDto,
SharedLinkCreateDto, SharedLinkCreateDto,
ValidateLibraryDto, ValidateLibraryDto,
@ -16,8 +17,10 @@ import {
createUser, createUser,
defaults, defaults,
deleteAssets, deleteAssets,
getAllAssets,
getAssetInfo, getAssetInfo,
login, login,
searchMetadata,
setAdminOnboarding, setAdminOnboarding,
signUpAdmin, signUpAdmin,
validate, validate,
@ -25,9 +28,9 @@ import {
import { BrowserContext } from '@playwright/test'; import { BrowserContext } from '@playwright/test';
import { exec, spawn } from 'node:child_process'; import { exec, spawn } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs'; import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import path from 'node:path'; import path, { dirname } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import pg from 'pg'; import pg from 'pg';
import { io, type Socket } from 'socket.io-client'; import { io, type Socket } from 'socket.io-client';
@ -37,7 +40,7 @@ import request from 'supertest';
type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; type CliResponse = { stdout: string; stderr: string; exitCode: number | null };
type EventType = 'assetUpload' | 'assetDelete' | 'userDelete'; type EventType = 'assetUpload' | 'assetDelete' | 'userDelete';
type WaitOptions = { event: EventType; id: string; timeout?: number }; type WaitOptions = { event: EventType; id?: string; total?: number; timeout?: number };
type AdminSetupOptions = { onboarding?: boolean }; type AdminSetupOptions = { onboarding?: boolean };
type AssetData = { bytes?: Buffer; filename: string }; type AssetData = { bytes?: Buffer; filename: string };
@ -83,16 +86,30 @@ const events: Record<EventType, Set<string>> = {
userDelete: new Set<string>(), userDelete: new Set<string>(),
}; };
const callbacks: Record<string, () => void> = {}; const idCallbacks: Record<string, () => void> = {};
const countCallbacks: Record<string, { count: number; callback: () => void }> = {};
const execPromise = promisify(exec); const execPromise = promisify(exec);
const onEvent = ({ event, id }: { event: EventType; id: string }) => { const onEvent = ({ event, id }: { event: EventType; id: string }) => {
events[event].add(id); // console.log(`Received event: ${event} [id=${id}]`);
const callback = callbacks[id]; const set = events[event];
if (callback) { set.add(id);
callback();
delete callbacks[id]; const idCallback = idCallbacks[id];
if (idCallback) {
idCallback();
delete idCallbacks[id];
}
const item = countCallbacks[event];
if (item) {
const { count, callback: countCallback } = item;
if (set.size >= count) {
countCallback();
delete countCallbacks[event];
}
} }
}; };
@ -184,20 +201,43 @@ export const utils = {
} }
}, },
waitForWebsocketEvent: async ({ event, id, timeout: ms }: WaitOptions): Promise<void> => { resetEvents: () => {
console.log(`Waiting for ${event} [${id}]`); for (const set of Object.values(events)) {
set.clear();
}
},
waitForWebsocketEvent: async ({ event, id, total: count, timeout: ms }: WaitOptions): Promise<void> => {
if (!id && !count) {
throw new Error('id or count must be provided for waitForWebsocketEvent');
}
const type = id ? `id=${id}` : `count=${count}`;
console.log(`Waiting for ${event} [${type}]`);
const set = events[event]; const set = events[event];
if (set.has(id)) { if ((id && set.has(id)) || (count && set.size >= count)) {
return; return;
} }
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000); const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000);
callbacks[id] = () => { if (id) {
clearTimeout(timeout); idCallbacks[id] = () => {
resolve(); clearTimeout(timeout);
}; resolve();
};
}
if (count) {
countCallbacks[event] = {
count,
callback: () => {
clearTimeout(timeout);
resolve();
},
};
}
}); });
}, },
@ -263,8 +303,31 @@ export const utils = {
return body as AssetFileUploadResponseDto; return body as AssetFileUploadResponseDto;
}, },
createImageFile: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });
}
if (!existsSync(path)) {
writeFileSync(path, makeRandomImage());
}
},
removeImageFile: (path: string) => {
if (!existsSync(path)) {
return;
}
rmSync(path);
},
getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }), getAssetInfo: (accessToken: string, id: string) => getAssetInfo({ id }, { headers: asBearerAuth(accessToken) }),
getAllAssets: (accessToken: string) => getAllAssets({}, { headers: asBearerAuth(accessToken) }),
metadataSearch: async (accessToken: string, dto: MetadataSearchDto) => {
return searchMetadata({ metadataSearchDto: dto }, { headers: asBearerAuth(accessToken) });
},
deleteAssets: (accessToken: string, ids: string[]) => deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }), deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),

View File

@ -1,6 +1,6 @@
ARG DEVICE=cpu ARG DEVICE=cpu
FROM python:3.11-bookworm@sha256:8e697181d24bd77cc4251fdd37e4cdd6d725c5de2ed63b9bc8db77357400c5e2 as builder-cpu FROM python:3.11-bookworm@sha256:991e20a11120277e977cadbc104e7a9b196a68a346597879821b19034285a403 as builder-cpu
FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as builder-openvino FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as builder-openvino
USER root USER root
@ -34,7 +34,7 @@ RUN python3 -m venv /opt/venv
COPY poetry.lock pyproject.toml ./ COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev RUN poetry install --sync --no-interaction --no-ansi --no-root --with ${DEVICE} --without dev
FROM python:3.11-slim-bookworm@sha256:ce81dc539f0aedc9114cae640f8352fad83d37461c24a3615b01f081d0c0583a as prod-cpu FROM python:3.11-slim-bookworm@sha256:a2eb07f336e4f194358382611b4fea136c632b40baa6314cb27a366deeaf0144 as prod-cpu
FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as prod-openvino FROM openvino/ubuntu22_runtime:2023.1.0@sha256:002842a9005ba01543b7169ff6f14ecbec82287f09c4d1dd37717f0a8e8754a7 as prod-openvino
USER root USER root

View File

@ -1828,38 +1828,38 @@ files = [
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "1.8.0" version = "1.9.0"
description = "Optional static typing for Python" description = "Optional static typing for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"},
{file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"},
{file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"},
{file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"},
{file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"},
{file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"},
{file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"},
{file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"},
{file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"},
{file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"},
{file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"},
{file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"},
{file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"},
{file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"},
{file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"},
{file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"},
{file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"},
{file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"},
{file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"},
{file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"},
{file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"},
{file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"},
{file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"},
{file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"},
{file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"},
{file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"},
{file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"},
] ]
[package.dependencies] [package.dependencies]
@ -2289,13 +2289,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-co
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.3.0" version = "1.4.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"},
{file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"},
] ]
[package.extras] [package.extras]
@ -2472,13 +2472,13 @@ files = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "8.0.2" version = "8.1.1"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-8.0.2-py3-none-any.whl", hash = "sha256:edfaaef32ce5172d5466b5127b42e0d6d35ebbe4453f0e3505d96afd93f6b096"}, {file = "pytest-8.1.1-py3-none-any.whl", hash = "sha256:2a8386cfc11fa9d2c50ee7b2a57e7d898ef90470a7a34c4b949ff59662bb78b7"},
{file = "pytest-8.0.2.tar.gz", hash = "sha256:d4051d623a2e0b7e51960ba963193b09ce6daeb9759a451844a21e4ddedfc1bd"}, {file = "pytest-8.1.1.tar.gz", hash = "sha256:ac978141a75948948817d360297b7aae0fcb9d6ff6bc9ec6d514b85d5a65c044"},
] ]
[package.dependencies] [package.dependencies]
@ -2486,21 +2486,21 @@ colorama = {version = "*", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""}
iniconfig = "*" iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=1.3.0,<2.0" pluggy = ">=1.4,<2.0"
tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] testing = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]] [[package]]
name = "pytest-asyncio" name = "pytest-asyncio"
version = "0.23.5" version = "0.23.5.post1"
description = "Pytest support for asyncio" description = "Pytest support for asyncio"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-asyncio-0.23.5.tar.gz", hash = "sha256:3a048872a9c4ba14c3e90cc1aa20cbc2def7d01c7c8db3777ec281ba9c057675"}, {file = "pytest-asyncio-0.23.5.post1.tar.gz", hash = "sha256:b9a8806bea78c21276bc34321bbf234ba1b2ea5b30d9f0ce0f2dea45e4685813"},
{file = "pytest_asyncio-0.23.5-py3-none-any.whl", hash = "sha256:4e7093259ba018d58ede7d5315131d21923a60f8a6e9ee266ce1589685c89eac"}, {file = "pytest_asyncio-0.23.5.post1-py3-none-any.whl", hash = "sha256:30f54d27774e79ac409778889880242b0403d09cabd65b727ce90fe92dd5d80e"},
] ]
[package.dependencies] [package.dependencies]
@ -3288,13 +3288,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.27.1" version = "0.28.0"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "uvicorn-0.27.1-py3-none-any.whl", hash = "sha256:5c89da2f3895767472a35556e539fd59f7edbe9b1e9c0e1c99eebeadc61838e4"}, {file = "uvicorn-0.28.0-py3-none-any.whl", hash = "sha256:6623abbbe6176204a4226e67607b4d52cc60ff62cda0ff177613645cefa2ece1"},
{file = "uvicorn-0.27.1.tar.gz", hash = "sha256:3d9a267296243532db80c83a959a3400502165ade2c1338dea4e67915fd4745a"}, {file = "uvicorn-0.28.0.tar.gz", hash = "sha256:cab4473b5d1eaeb5a0f6375ac4bc85007ffc75c3cc1768816d9e5d589857b067"},
] ]
[package.dependencies] [package.dependencies]

View File

@ -58,6 +58,7 @@
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
<uses-permission android:name="android.permission.MANAGE_MEDIA" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" /> <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" /> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" /> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
@ -75,4 +76,4 @@
<data android:scheme="geo" /> <data android:scheme="geo" />
</intent> </intent>
</queries> </queries>
</manifest> </manifest>

View File

@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/datetime_extensions.dart'; import 'package:immich_mobile/extensions/datetime_extensions.dart';
import 'package:immich_mobile/modules/activities/models/activity.model.dart'; import 'package:immich_mobile/modules/activities/models/activity.model.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart'; import 'package:immich_mobile/modules/asset_viewer/providers/current_asset.provider.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart'; import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
@ -106,9 +106,8 @@ class _ActivityAssetThumbnail extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)), borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage( image: DecorationImage(
image: ImmichRemoteImageProvider( image: ImmichRemoteThumbnailProvider(
assetId: assetId, assetId: assetId,
isThumbnail: true,
), ),
fit: BoxFit.cover, fit: BoxFit.cover,
), ),

View File

@ -0,0 +1,58 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/exceptions/image_loading_exception.dart';
import 'package:immich_mobile/shared/models/store.dart';
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
///
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
/// for this wonderful implementation of their image loader
class ImageLoader {
static Future<ui.Codec> loadImageFromCache(
String uri, {
required ImageCacheManager cache,
required ImageDecoderCallback decode,
StreamController<ImageChunkEvent>? chunkEvents,
int? height,
int? width,
}) async {
final headers = {
'x-immich-user-token': Store.get(StoreKey.accessToken),
};
final stream = cache.getImageFile(
uri,
withProgress: true,
headers: headers,
maxHeight: height,
maxWidth: width,
);
await for (final result in stream) {
if (result is DownloadProgress) {
// We are downloading the file, so update the [chunkEvents]
chunkEvents?.add(
ImageChunkEvent(
cumulativeBytesLoaded: result.downloaded,
expectedTotalBytes: result.totalSize,
),
);
}
if (result is FileInfo) {
// We have the file
final file = result.file;
final bytes = await file.readAsBytes();
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final decoded = await decode(buffer);
return decoded;
}
}
// If we get here, the image failed to load from the cache stream
throw ImageLoadingException('Could not load image from stream');
}
}

View File

@ -0,0 +1,20 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// The cache manager for full size images [ImmichRemoteImageProvider]
class RemoteImageCacheManager extends CacheManager with ImageCacheManager {
static const key = 'remoteImageCacheKey';
static final RemoteImageCacheManager _instance = RemoteImageCacheManager._();
factory RemoteImageCacheManager() {
return _instance;
}
RemoteImageCacheManager._()
: super(
Config(
key,
maxNrOfCacheObjects: 500,
stalePeriod: const Duration(days: 30),
),
);
}

View File

@ -0,0 +1,21 @@
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// The cache manager for thumbnail images [ImmichRemoteThumbnailProvider]
class ThumbnailImageCacheManager extends CacheManager with ImageCacheManager {
static const key = 'thumbnailImageCacheKey';
static final ThumbnailImageCacheManager _instance =
ThumbnailImageCacheManager._();
factory ThumbnailImageCacheManager() {
return _instance;
}
ThumbnailImageCacheManager._()
: super(
Config(
key,
maxNrOfCacheObjects: 5000,
stalePeriod: const Duration(days: 30),
),
);
}

View File

@ -0,0 +1,5 @@
/// An exception for the [ImageLoader] and the Immich image providers
class ImageLoadingException implements Exception {
final String message;
ImageLoadingException(this.message);
}

View File

@ -1,8 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/remote_image_cache_manager.dart';
import 'package:openapi/api.dart' as api; import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -12,24 +14,18 @@ import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our Image Provider HTTP client to make the request /// The remote image provider for full size remote images
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 10;
/// The remote image provider
class ImmichRemoteImageProvider class ImmichRemoteImageProvider
extends ImageProvider<ImmichRemoteImageProvider> { extends ImageProvider<ImmichRemoteImageProvider> {
/// The [Asset.remoteId] of the asset to fetch /// The [Asset.remoteId] of the asset to fetch
final String assetId; final String assetId;
// If this is a thumbnail, we stop at loading the /// The image cache manager
// smallest version of the remote image final ImageCacheManager? cacheManager;
final bool isThumbnail;
ImmichRemoteImageProvider({ ImmichRemoteImageProvider({
required this.assetId, required this.assetId,
this.isThumbnail = false, this.cacheManager,
}); });
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -46,9 +42,10 @@ class ImmichRemoteImageProvider
ImmichRemoteImageProvider key, ImmichRemoteImageProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
) { ) {
final cache = cacheManager ?? RemoteImageCacheManager();
final chunkEvents = StreamController<ImageChunkEvent>(); final chunkEvents = StreamController<ImageChunkEvent>();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents), codec: _codec(key, cache, decode, chunkEvents),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream, chunkEvents: chunkEvents.stream,
); );
@ -69,82 +66,61 @@ class ImmichRemoteImageProvider
// Streams in each stage of the image as we ask for it // Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec( Stream<ui.Codec> _codec(
ImmichRemoteImageProvider key, ImmichRemoteImageProvider key,
ImageCacheManager cache,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents, StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
// Load a preview to the chunk events // Load a preview to the chunk events
if (_loadPreview || key.isThumbnail) { if (_loadPreview) {
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
); );
yield await _loadFromUri( yield await ImageLoader.loadImageFromCache(
Uri.parse(preview), preview,
decode, cache: cache,
chunkEvents, decode: decode,
chunkEvents: chunkEvents,
); );
} }
// Guard thumnbail rendering
if (key.isThumbnail) {
await chunkEvents.close();
return;
}
// Load the higher resolution version of the image // Load the higher resolution version of the image
final url = getThumbnailUrlForRemoteId( final url = getThumbnailUrlForRemoteId(
key.assetId, key.assetId,
type: api.ThumbnailFormat.JPEG, type: api.ThumbnailFormat.JPEG,
); );
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); final codec = await ImageLoader.loadImageFromCache(
url,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
yield codec; yield codec;
// Load the final remote image // Load the final remote image
if (_useOriginal) { if (_useOriginal) {
// Load the original image // Load the original image
final url = getImageUrlFromId(key.assetId); final url = getImageUrlFromId(key.assetId);
final codec = await _loadFromUri(Uri.parse(url), decode, chunkEvents); final codec = await ImageLoader.loadImageFromCache(
url,
cache: cache,
decode: decode,
chunkEvents: chunkEvents,
);
yield codec; yield codec;
} }
await chunkEvents.close(); await chunkEvents.close();
} }
// Loads the codec from the URI and sends the events to the [chunkEvents] stream
Future<ui.Codec> _loadFromUri(
Uri uri,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
final request = await _httpClient.getUrl(uri);
request.headers.add(
'x-immich-user-token',
Store.get(StoreKey.accessToken),
);
final response = await request.close();
// Chunks of the completed image can be shown
final data = await consolidateHttpClientResponseBytes(
response,
onBytesReceived: (cumulative, total) {
chunkEvents.add(
ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
),
);
},
);
// Decode the response
final buffer = await ui.ImmutableBuffer.fromUint8List(data);
return decode(buffer);
}
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return assetId == other.assetId && isThumbnail == other.isThumbnail; if (other is ImmichRemoteImageProvider) {
return assetId == other.assetId;
}
return false;
} }
@override @override

View File

@ -1,30 +1,34 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/image_loader.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/cache/thumbnail_image_cache_manager.dart';
import 'package:openapi/api.dart' as api; import 'package:openapi/api.dart' as api;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart'; import 'package:flutter/painting.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/utils/image_url_builder.dart';
/// Our HTTP client to make the request
final _httpClient = HttpClient()
..autoUncompress = false
..maxConnectionsPerHost = 100;
/// The remote image provider /// The remote image provider
class ImmichRemoteThumbnailProvider class ImmichRemoteThumbnailProvider
extends ImageProvider<ImmichRemoteThumbnailProvider> { extends ImageProvider<ImmichRemoteThumbnailProvider> {
/// The [Asset.remoteId] of the asset to fetch /// The [Asset.remoteId] of the asset to fetch
final String assetId; final String assetId;
final int? height;
final int? width;
/// The image cache manager
final ImageCacheManager? cacheManager;
ImmichRemoteThumbnailProvider({ ImmichRemoteThumbnailProvider({
required this.assetId, required this.assetId,
this.height,
this.width,
this.cacheManager,
}); });
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key /// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
@ -41,19 +45,18 @@ class ImmichRemoteThumbnailProvider
ImmichRemoteThumbnailProvider key, ImmichRemoteThumbnailProvider key,
ImageDecoderCallback decode, ImageDecoderCallback decode,
) { ) {
final chunkEvents = StreamController<ImageChunkEvent>(); final cache = cacheManager ?? ThumbnailImageCacheManager();
return MultiImageStreamCompleter( return MultiImageStreamCompleter(
codec: _codec(key, decode, chunkEvents), codec: _codec(key, cache, decode),
scale: 1.0, scale: 1.0,
chunkEvents: chunkEvents.stream,
); );
} }
// Streams in each stage of the image as we ask for it // Streams in each stage of the image as we ask for it
Stream<ui.Codec> _codec( Stream<ui.Codec> _codec(
ImmichRemoteThumbnailProvider key, ImmichRemoteThumbnailProvider key,
ImageCacheManager cache,
ImageDecoderCallback decode, ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async* { ) async* {
// Load a preview to the chunk events // Load a preview to the chunk events
final preview = getThumbnailUrlForRemoteId( final preview = getThumbnailUrlForRemoteId(
@ -61,50 +64,21 @@ class ImmichRemoteThumbnailProvider
type: api.ThumbnailFormat.WEBP, type: api.ThumbnailFormat.WEBP,
); );
yield await _loadFromUri( yield await ImageLoader.loadImageFromCache(
Uri.parse(preview), preview,
decode, cache: cache,
chunkEvents, decode: decode,
); );
await chunkEvents.close();
}
// Loads the codec from the URI and sends the events to the [chunkEvents] stream
Future<ui.Codec> _loadFromUri(
Uri uri,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
final request = await _httpClient.getUrl(uri);
request.headers.add(
'x-immich-user-token',
Store.get(StoreKey.accessToken),
);
final response = await request.close();
// Chunks of the completed image can be shown
final data = await consolidateHttpClientResponseBytes(
response,
onBytesReceived: (cumulative, total) {
chunkEvents.add(
ImageChunkEvent(
cumulativeBytesLoaded: cumulative,
expectedTotalBytes: total,
),
);
},
);
// Decode the response
final buffer = await ui.ImmutableBuffer.fromUint8List(data);
return decode(buffer);
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! ImmichRemoteImageProvider) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return assetId == other.assetId; if (other is ImmichRemoteThumbnailProvider) {
return assetId == other.assetId;
}
return false;
} }
@override @override

View File

@ -21,6 +21,11 @@ class BackUpState {
final BackUpProgressEnum backupProgress; final BackUpProgressEnum backupProgress;
final List<String> allAssetsInDatabase; final List<String> allAssetsInDatabase;
final double progressInPercentage; final double progressInPercentage;
final String progressInFileSize;
final double progressInFileSpeed;
final List<double> progressInFileSpeeds;
final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes;
final double iCloudDownloadProgress; final double iCloudDownloadProgress;
final CancellationToken cancelToken; final CancellationToken cancelToken;
final ServerDiskInfo serverInfo; final ServerDiskInfo serverInfo;
@ -48,6 +53,11 @@ class BackUpState {
required this.backupProgress, required this.backupProgress,
required this.allAssetsInDatabase, required this.allAssetsInDatabase,
required this.progressInPercentage, required this.progressInPercentage,
required this.progressInFileSize,
required this.progressInFileSpeed,
required this.progressInFileSpeeds,
required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes,
required this.iCloudDownloadProgress, required this.iCloudDownloadProgress,
required this.cancelToken, required this.cancelToken,
required this.serverInfo, required this.serverInfo,
@ -68,6 +78,11 @@ class BackUpState {
BackUpProgressEnum? backupProgress, BackUpProgressEnum? backupProgress,
List<String>? allAssetsInDatabase, List<String>? allAssetsInDatabase,
double? progressInPercentage, double? progressInPercentage,
String? progressInFileSize,
double? progressInFileSpeed,
List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
double? iCloudDownloadProgress, double? iCloudDownloadProgress,
CancellationToken? cancelToken, CancellationToken? cancelToken,
ServerDiskInfo? serverInfo, ServerDiskInfo? serverInfo,
@ -87,6 +102,13 @@ class BackUpState {
backupProgress: backupProgress ?? this.backupProgress, backupProgress: backupProgress ?? this.backupProgress,
allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase, allAssetsInDatabase: allAssetsInDatabase ?? this.allAssetsInDatabase,
progressInPercentage: progressInPercentage ?? this.progressInPercentage, progressInPercentage: progressInPercentage ?? this.progressInPercentage,
progressInFileSize: progressInFileSize ?? this.progressInFileSize,
progressInFileSpeed: progressInFileSpeed ?? this.progressInFileSpeed,
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
progressInFileSpeedUpdateTime:
progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ??
this.progressInFileSpeedUpdateSentBytes,
iCloudDownloadProgress: iCloudDownloadProgress:
iCloudDownloadProgress ?? this.iCloudDownloadProgress, iCloudDownloadProgress ?? this.iCloudDownloadProgress,
cancelToken: cancelToken ?? this.cancelToken, cancelToken: cancelToken ?? this.cancelToken,
@ -109,7 +131,7 @@ class BackUpState {
@override @override
String toString() { String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)'; return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, iCloudDownloadProgress: $iCloudDownloadProgress, cancelToken: $cancelToken, serverInfo: $serverInfo, autoBackup: $autoBackup, backgroundBackup: $backgroundBackup, backupRequireWifi: $backupRequireWifi, backupRequireCharging: $backupRequireCharging, backupTriggerDelay: $backupTriggerDelay, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
} }
@override @override
@ -120,6 +142,12 @@ class BackUpState {
return other.backupProgress == backupProgress && return other.backupProgress == backupProgress &&
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) && collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
other.progressInPercentage == progressInPercentage && other.progressInPercentage == progressInPercentage &&
other.progressInFileSize == progressInFileSize &&
other.progressInFileSpeed == progressInFileSpeed &&
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes ==
progressInFileSpeedUpdateSentBytes &&
other.iCloudDownloadProgress == iCloudDownloadProgress && other.iCloudDownloadProgress == iCloudDownloadProgress &&
other.cancelToken == cancelToken && other.cancelToken == cancelToken &&
other.serverInfo == serverInfo && other.serverInfo == serverInfo &&
@ -144,6 +172,11 @@ class BackUpState {
return backupProgress.hashCode ^ return backupProgress.hashCode ^
allAssetsInDatabase.hashCode ^ allAssetsInDatabase.hashCode ^
progressInPercentage.hashCode ^ progressInPercentage.hashCode ^
progressInFileSize.hashCode ^
progressInFileSpeed.hashCode ^
progressInFileSpeeds.hashCode ^
progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^
iCloudDownloadProgress.hashCode ^ iCloudDownloadProgress.hashCode ^
cancelToken.hashCode ^ cancelToken.hashCode ^
serverInfo.hashCode ^ serverInfo.hashCode ^

View File

@ -6,6 +6,7 @@ class CurrentUploadAsset {
final DateTime fileCreatedAt; final DateTime fileCreatedAt;
final String fileName; final String fileName;
final String fileType; final String fileType;
final int? fileSize;
final bool? iCloudAsset; final bool? iCloudAsset;
CurrentUploadAsset({ CurrentUploadAsset({
@ -13,6 +14,7 @@ class CurrentUploadAsset {
required this.fileCreatedAt, required this.fileCreatedAt,
required this.fileName, required this.fileName,
required this.fileType, required this.fileType,
this.fileSize,
this.iCloudAsset, this.iCloudAsset,
}); });
@ -21,6 +23,7 @@ class CurrentUploadAsset {
DateTime? fileCreatedAt, DateTime? fileCreatedAt,
String? fileName, String? fileName,
String? fileType, String? fileType,
int? fileSize,
bool? iCloudAsset, bool? iCloudAsset,
}) { }) {
return CurrentUploadAsset( return CurrentUploadAsset(
@ -28,6 +31,7 @@ class CurrentUploadAsset {
fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt, fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt,
fileName: fileName ?? this.fileName, fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType, fileType: fileType ?? this.fileType,
fileSize: fileSize ?? this.fileSize,
iCloudAsset: iCloudAsset ?? this.iCloudAsset, iCloudAsset: iCloudAsset ?? this.iCloudAsset,
); );
} }
@ -38,6 +42,7 @@ class CurrentUploadAsset {
'fileCreatedAt': fileCreatedAt.millisecondsSinceEpoch, 'fileCreatedAt': fileCreatedAt.millisecondsSinceEpoch,
'fileName': fileName, 'fileName': fileName,
'fileType': fileType, 'fileType': fileType,
'fileSize': fileSize,
'iCloudAsset': iCloudAsset, 'iCloudAsset': iCloudAsset,
}; };
} }
@ -49,6 +54,7 @@ class CurrentUploadAsset {
DateTime.fromMillisecondsSinceEpoch(map['fileCreatedAt'] as int), DateTime.fromMillisecondsSinceEpoch(map['fileCreatedAt'] as int),
fileName: map['fileName'] as String, fileName: map['fileName'] as String,
fileType: map['fileType'] as String, fileType: map['fileType'] as String,
fileSize: map['fileSize'] as int,
iCloudAsset: iCloudAsset:
map['iCloudAsset'] != null ? map['iCloudAsset'] as bool : null, map['iCloudAsset'] != null ? map['iCloudAsset'] as bool : null,
); );
@ -61,7 +67,7 @@ class CurrentUploadAsset {
@override @override
String toString() { String toString() {
return 'CurrentUploadAsset(id: $id, fileCreatedAt: $fileCreatedAt, fileName: $fileName, fileType: $fileType, iCloudAsset: $iCloudAsset)'; return 'CurrentUploadAsset(id: $id, fileCreatedAt: $fileCreatedAt, fileName: $fileName, fileType: $fileType, fileSize: $fileSize, iCloudAsset: $iCloudAsset)';
} }
@override @override
@ -72,6 +78,7 @@ class CurrentUploadAsset {
other.fileCreatedAt == fileCreatedAt && other.fileCreatedAt == fileCreatedAt &&
other.fileName == fileName && other.fileName == fileName &&
other.fileType == fileType && other.fileType == fileType &&
other.fileSize == fileSize &&
other.iCloudAsset == iCloudAsset; other.iCloudAsset == iCloudAsset;
} }
@ -81,6 +88,7 @@ class CurrentUploadAsset {
fileCreatedAt.hashCode ^ fileCreatedAt.hashCode ^
fileName.hashCode ^ fileName.hashCode ^
fileType.hashCode ^ fileType.hashCode ^
fileSize.hashCode ^
iCloudAsset.hashCode; iCloudAsset.hashCode;
} }
} }

View File

@ -1,4 +1,6 @@
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart'; import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
class ManualUploadState { class ManualUploadState {
@ -14,9 +16,19 @@ class ManualUploadState {
final int totalAssetsToUpload; final int totalAssetsToUpload;
final int successfulUploads; final int successfulUploads;
final double progressInPercentage; final double progressInPercentage;
final String progressInFileSize;
final double progressInFileSpeed;
final List<double> progressInFileSpeeds;
final DateTime progressInFileSpeedUpdateTime;
final int progressInFileSpeedUpdateSentBytes;
const ManualUploadState({ const ManualUploadState({
required this.progressInPercentage, required this.progressInPercentage,
required this.progressInFileSize,
required this.progressInFileSpeed,
required this.progressInFileSpeeds,
required this.progressInFileSpeedUpdateTime,
required this.progressInFileSpeedUpdateSentBytes,
required this.cancelToken, required this.cancelToken,
required this.currentUploadAsset, required this.currentUploadAsset,
required this.totalAssetsToUpload, required this.totalAssetsToUpload,
@ -27,6 +39,11 @@ class ManualUploadState {
ManualUploadState copyWith({ ManualUploadState copyWith({
double? progressInPercentage, double? progressInPercentage,
String? progressInFileSize,
double? progressInFileSpeed,
List<double>? progressInFileSpeeds,
DateTime? progressInFileSpeedUpdateTime,
int? progressInFileSpeedUpdateSentBytes,
CancellationToken? cancelToken, CancellationToken? cancelToken,
CurrentUploadAsset? currentUploadAsset, CurrentUploadAsset? currentUploadAsset,
int? totalAssetsToUpload, int? totalAssetsToUpload,
@ -36,6 +53,13 @@ class ManualUploadState {
}) { }) {
return ManualUploadState( return ManualUploadState(
progressInPercentage: progressInPercentage ?? this.progressInPercentage, progressInPercentage: progressInPercentage ?? this.progressInPercentage,
progressInFileSize: progressInFileSize ?? this.progressInFileSize,
progressInFileSpeed: progressInFileSpeed ?? this.progressInFileSpeed,
progressInFileSpeeds: progressInFileSpeeds ?? this.progressInFileSpeeds,
progressInFileSpeedUpdateTime:
progressInFileSpeedUpdateTime ?? this.progressInFileSpeedUpdateTime,
progressInFileSpeedUpdateSentBytes: progressInFileSpeedUpdateSentBytes ??
this.progressInFileSpeedUpdateSentBytes,
cancelToken: cancelToken ?? this.cancelToken, cancelToken: cancelToken ?? this.cancelToken,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset, currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload, totalAssetsToUpload: totalAssetsToUpload ?? this.totalAssetsToUpload,
@ -48,15 +72,22 @@ class ManualUploadState {
@override @override
String toString() { String toString() {
return 'ManualUploadState(progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)'; return 'ManualUploadState(progressInPercentage: $progressInPercentage, progressInFileSize: $progressInFileSize, progressInFileSpeed: $progressInFileSpeed, progressInFileSpeeds: $progressInFileSpeeds, progressInFileSpeedUpdateTime: $progressInFileSpeedUpdateTime, progressInFileSpeedUpdateSentBytes: $progressInFileSpeedUpdateSentBytes, cancelToken: $cancelToken, currentUploadAsset: $currentUploadAsset, totalAssetsToUpload: $totalAssetsToUpload, successfulUploads: $successfulUploads, currentAssetIndex: $currentAssetIndex, showDetailedNotification: $showDetailedNotification)';
} }
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
final collectionEquals = const DeepCollectionEquality().equals;
return other is ManualUploadState && return other is ManualUploadState &&
other.progressInPercentage == progressInPercentage && other.progressInPercentage == progressInPercentage &&
other.progressInFileSize == progressInFileSize &&
other.progressInFileSpeed == progressInFileSpeed &&
collectionEquals(other.progressInFileSpeeds, progressInFileSpeeds) &&
other.progressInFileSpeedUpdateTime == progressInFileSpeedUpdateTime &&
other.progressInFileSpeedUpdateSentBytes ==
progressInFileSpeedUpdateSentBytes &&
other.cancelToken == cancelToken && other.cancelToken == cancelToken &&
other.currentUploadAsset == currentUploadAsset && other.currentUploadAsset == currentUploadAsset &&
other.totalAssetsToUpload == totalAssetsToUpload && other.totalAssetsToUpload == totalAssetsToUpload &&
@ -68,6 +99,11 @@ class ManualUploadState {
@override @override
int get hashCode { int get hashCode {
return progressInPercentage.hashCode ^ return progressInPercentage.hashCode ^
progressInFileSize.hashCode ^
progressInFileSpeed.hashCode ^
progressInFileSpeeds.hashCode ^
progressInFileSpeedUpdateTime.hashCode ^
progressInFileSpeedUpdateSentBytes.hashCode ^
cancelToken.hashCode ^ cancelToken.hashCode ^
currentUploadAsset.hashCode ^ currentUploadAsset.hashCode ^
totalAssetsToUpload.hashCode ^ totalAssetsToUpload.hashCode ^

View File

@ -20,6 +20,7 @@ import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/server_info.service.dart'; import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:immich_mobile/utils/backup_progress.dart';
import 'package:immich_mobile/utils/diff.dart'; import 'package:immich_mobile/utils/diff.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
@ -40,6 +41,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: const [], allAssetsInDatabase: const [],
progressInPercentage: 0, progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(), cancelToken: CancellationToken(),
autoBackup: Store.get(StoreKey.autoBackup, false), autoBackup: Store.get(StoreKey.autoBackup, false),
backgroundBackup: Store.get(StoreKey.backgroundBackup, false), backgroundBackup: Store.get(StoreKey.backgroundBackup, false),
@ -63,6 +69,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
fileCreatedAt: DateTime.parse('2020-10-04'), fileCreatedAt: DateTime.parse('2020-10-04'),
fileName: '...', fileName: '...',
fileType: '...', fileType: '...',
fileSize: 0,
iCloudAsset: false, iCloudAsset: false,
), ),
iCloudDownloadProgress: 0.0, iCloudDownloadProgress: 0.0,
@ -495,6 +502,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
state = state.copyWith( state = state.copyWith(
backupProgress: BackUpProgressEnum.idle, backupProgress: BackUpProgressEnum.idle,
progressInPercentage: 0.0, progressInPercentage: 0.0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
); );
} }
@ -535,6 +546,10 @@ class BackupNotifier extends StateNotifier<BackUpState> {
.toSet(), .toSet(),
backupProgress: BackUpProgressEnum.done, backupProgress: BackUpProgressEnum.done,
progressInPercentage: 0.0, progressInPercentage: 0.0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
); );
_updatePersistentAlbumsSelection(); _updatePersistentAlbumsSelection();
} }
@ -543,8 +558,36 @@ class BackupNotifier extends StateNotifier<BackUpState> {
} }
void _onUploadProgress(int sent, int total) { void _onUploadProgress(int sent, int total) {
double lastUploadSpeed = state.progressInFileSpeed;
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
final now = DateTime.now();
final duration = now.difference(lastUpdateTime);
// Keep the upload speed average span limited, to keep it somewhat relevant
if (lastUploadSpeeds.length > 10) {
lastUploadSpeeds.removeAt(0);
}
if (duration.inSeconds > 0) {
lastUploadSpeeds.add(
((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(),
);
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
lastUpdateTime = now;
lastSentBytes = sent;
}
state = state.copyWith( state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100), progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
progressInFileSize: humanReadableFileBytesProgress(sent, total),
progressInFileSpeed: lastUploadSpeed,
progressInFileSpeeds: lastUploadSpeeds,
progressInFileSpeedUpdateTime: lastUpdateTime,
progressInFileSpeedUpdateSentBytes: lastSentBytes,
); );
} }

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:cancellation_token_http/http.dart'; import 'package:cancellation_token_http/http.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
@ -47,6 +48,11 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
) : super( ) : super(
ManualUploadState( ManualUploadState(
progressInPercentage: 0, progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeeds: const [],
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
cancelToken: CancellationToken(), cancelToken: CancellationToken(),
currentUploadAsset: CurrentUploadAsset( currentUploadAsset: CurrentUploadAsset(
id: '...', id: '...',
@ -123,9 +129,38 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
} }
void _onProgress(int sent, int total) { void _onProgress(int sent, int total) {
double lastUploadSpeed = state.progressInFileSpeed;
List<double> lastUploadSpeeds = state.progressInFileSpeeds.toList();
DateTime lastUpdateTime = state.progressInFileSpeedUpdateTime;
int lastSentBytes = state.progressInFileSpeedUpdateSentBytes;
final now = DateTime.now();
final duration = now.difference(lastUpdateTime);
// Keep the upload speed average span limited, to keep it somewhat relevant
if (lastUploadSpeeds.length > 10) {
lastUploadSpeeds.removeAt(0);
}
if (duration.inSeconds > 0) {
lastUploadSpeeds.add(
((sent - lastSentBytes) / duration.inSeconds).abs().roundToDouble(),
);
lastUploadSpeed = lastUploadSpeeds.average.abs().roundToDouble();
lastUpdateTime = now;
lastSentBytes = sent;
}
state = state.copyWith( state = state.copyWith(
progressInPercentage: (sent.toDouble() / total.toDouble() * 100), progressInPercentage: (sent.toDouble() / total.toDouble() * 100),
progressInFileSize: humanReadableFileBytesProgress(sent, total),
progressInFileSpeed: lastUploadSpeed,
progressInFileSpeeds: lastUploadSpeeds,
progressInFileSpeedUpdateTime: lastUpdateTime,
progressInFileSpeedUpdateSentBytes: lastSentBytes,
); );
if (state.showDetailedNotification) { if (state.showDetailedNotification) {
final title = "backup_background_service_current_upload_notification" final title = "backup_background_service_current_upload_notification"
.tr(args: [state.currentUploadAsset.fileName]); .tr(args: [state.currentUploadAsset.fileName]);
@ -184,6 +219,8 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
state = state.copyWith( state = state.copyWith(
progressInPercentage: 0, progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
totalAssetsToUpload: allUploadAssets.length, totalAssetsToUpload: allUploadAssets.length,
successfulUploads: 0, successfulUploads: 0,
currentAssetIndex: 0, currentAssetIndex: 0,
@ -291,7 +328,13 @@ class ManualUploadNotifier extends StateNotifier<ManualUploadState> {
if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) { if (_backupProvider.backupProgress != BackUpProgressEnum.manualInProgress) {
_backupProvider.updateBackupProgress(BackUpProgressEnum.idle); _backupProvider.updateBackupProgress(BackUpProgressEnum.idle);
} }
state = state.copyWith(progressInPercentage: 0); state = state.copyWith(
progressInPercentage: 0,
progressInFileSize: "0 B / 0 B",
progressInFileSpeed: 0,
progressInFileSpeedUpdateTime: DateTime.now(),
progressInFileSpeedUpdateSentBytes: 0,
);
} }
Future<bool> uploadAssets( Future<bool> uploadAssets(

View File

@ -316,6 +316,8 @@ class BackupService {
req.files.add(assetRawUploadData); req.files.add(assetRawUploadData);
var fileSize = file.lengthSync();
if (entity.isLivePhoto) { if (entity.isLivePhoto) {
if (livePhotoFile != null) { if (livePhotoFile != null) {
final livePhotoTitle = p.setExtension( final livePhotoTitle = p.setExtension(
@ -330,6 +332,7 @@ class BackupService {
filename: livePhotoTitle, filename: livePhotoTitle,
); );
req.files.add(livePhotoRawUploadData); req.files.add(livePhotoRawUploadData);
fileSize += livePhotoFile.lengthSync();
} else { } else {
_log.warning( _log.warning(
"Failed to obtain motion part of the livePhoto - $originalFileName", "Failed to obtain motion part of the livePhoto - $originalFileName",
@ -345,6 +348,7 @@ class BackupService {
: entity.createDateTime, : entity.createDateTime,
fileName: originalFileName, fileName: originalFileName,
fileType: _getAssetType(entity.type), fileType: _getAssetType(entity.type),
fileSize: fileSize,
iCloudAsset: false, iCloudAsset: false,
), ),
); );

View File

@ -26,10 +26,28 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
var uploadProgress = !isManualUpload var uploadProgress = !isManualUpload
? ref.watch(backupProvider).progressInPercentage ? ref.watch(backupProvider).progressInPercentage
: ref.watch(manualUploadProvider).progressInPercentage; : ref.watch(manualUploadProvider).progressInPercentage;
var uploadFileProgress = !isManualUpload
? ref.watch(backupProvider).progressInFileSize
: ref.watch(manualUploadProvider).progressInFileSize;
var uploadFileSpeed = !isManualUpload
? ref.watch(backupProvider).progressInFileSpeed
: ref.watch(manualUploadProvider).progressInFileSpeed;
var iCloudDownloadProgress = var iCloudDownloadProgress =
ref.watch(backupProvider).iCloudDownloadProgress; ref.watch(backupProvider).iCloudDownloadProgress;
final isShowThumbnail = useState(false); final isShowThumbnail = useState(false);
String formatUploadFileSpeed(double uploadFileSpeed) {
if (uploadFileSpeed < 1024) {
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
} else if (uploadFileSpeed < 1024 * 1024) {
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} else {
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
}
}
String getAssetCreationDate() { String getAssetCreationDate() {
return DateFormat.yMMMMd().format( return DateFormat.yMMMMd().format(
DateTime.parse( DateTime.parse(
@ -202,7 +220,26 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
), ),
Text( Text(
" ${uploadProgress.toStringAsFixed(0)}%", " ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
),
],
),
);
}
buildUploadStats() {
return Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
uploadFileProgress,
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
),
Text(
formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
), ),
], ],
), ),
@ -265,6 +302,7 @@ class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
children: [ children: [
if (Platform.isIOS) buildiCloudDownloadProgerssBar(), if (Platform.isIOS) buildiCloudDownloadProgerssBar(),
buildUploadProgressBar(), buildUploadProgressBar(),
buildUploadStats(),
Padding( Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: buildAssetInfoTable(), child: buildAssetInfoTable(),

View File

@ -42,7 +42,6 @@ class ImmichImage extends StatelessWidget {
if (asset == null) { if (asset == null) {
return ImmichRemoteImageProvider( return ImmichRemoteImageProvider(
assetId: assetId!, assetId: assetId!,
isThumbnail: false,
); );
} }
@ -53,7 +52,6 @@ class ImmichImage extends StatelessWidget {
} else { } else {
return ImmichRemoteImageProvider( return ImmichRemoteImageProvider(
assetId: asset.remoteId!, assetId: asset.remoteId!,
isThumbnail: false,
); );
} }
} }

View File

@ -3,7 +3,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_local_thumbnail_provider.dart';
import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_image_provider.dart'; import 'package:immich_mobile/modules/asset_viewer/image_providers/immich_remote_thumbnail_provider.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart'; import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
@ -38,9 +38,8 @@ class ImmichThumbnail extends HookWidget {
} }
if (asset == null) { if (asset == null) {
return ImmichRemoteImageProvider( return ImmichRemoteThumbnailProvider(
assetId: assetId!, assetId: assetId!,
isThumbnail: true,
); );
} }
@ -51,9 +50,10 @@ class ImmichThumbnail extends HookWidget {
width: thumbnailSize, width: thumbnailSize,
); );
} else { } else {
return ImmichRemoteImageProvider( return ImmichRemoteThumbnailProvider(
assetId: asset.remoteId!, assetId: asset.remoteId!,
isThumbnail: true, height: thumbnailSize,
width: thumbnailSize,
); );
} }
} }

View File

@ -10,6 +10,25 @@ String formatAssetBackupProgress(int uploadedAssets, int assetsToUpload) {
return "$percent% ($uploadedAssets/$assetsToUpload)"; return "$percent% ($uploadedAssets/$assetsToUpload)";
} }
/// prints progress in useful (kilo/mega/giga)bytes
String humanReadableFileBytesProgress(int bytes, int bytesTotal) {
String unit = "KB";
if (bytesTotal >= 0x40000000) {
unit = "GB";
bytes >>= 20;
bytesTotal >>= 20;
} else if (bytesTotal >= 0x100000) {
unit = "MB";
bytes >>= 10;
bytesTotal >>= 10;
} else if (bytesTotal < 0x400) {
return "${(bytes).toStringAsFixed(2)} B / ${(bytesTotal).toStringAsFixed(2)} B";
}
return "${(bytes / 1024.0).toStringAsFixed(2)} $unit / ${(bytesTotal / 1024.0).toStringAsFixed(2)} $unit";
}
/// prints percentage and absolute progress in useful (kilo/mega/giga)bytes /// prints percentage and absolute progress in useful (kilo/mega/giga)bytes
String humanReadableBytesProgress(int bytes, int bytesTotal) { String humanReadableBytesProgress(int bytes, int bytesTotal) {
String unit = "KB"; // Kilobyte String unit = "KB"; // Kilobyte

View File

@ -34,117 +34,7 @@ describe(`${LibraryController.name} (e2e)`, () => {
await restoreTempFolder(); await restoreTempFolder();
}); });
describe('DELETE /library/:id', () => {
it('should delete an external library with assets', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets.length).toBeGreaterThan(2);
const { status } = await request(server)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
expect(libraries).toHaveLength(1);
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
});
});
describe('POST /library/:id/scan', () => { describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should scan external library with import paths', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'el_torcal_rocks',
libraryId: library.id,
resized: true,
thumbhash: expect.any(String),
exifInfo: expect.objectContaining({
exifImageWidth: 512,
exifImageHeight: 341,
latitude: null,
longitude: null,
}),
}),
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'silver_fir',
libraryId: library.id,
resized: true,
thumbhash: expect.any(String),
exifInfo: expect.objectContaining({
exifImageWidth: 511,
exifImageHeight: 323,
latitude: null,
longitude: null,
}),
}),
]),
);
});
it('should scan external library with exclusion pattern', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.EXTERNAL,
importPaths: [`${IMMICH_TEST_ASSET_PATH}/albums/nature`],
exclusionPatterns: ['**/el_corcal*'],
});
await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
expect(assets).toEqual(
expect.arrayContaining([
expect.not.objectContaining({
// Excluded by exclusion pattern
originalFileName: 'el_torcal_rocks',
}),
expect.objectContaining({
type: AssetType.IMAGE,
originalFileName: 'silver_fir',
libraryId: library.id,
resized: true,
exifInfo: expect.objectContaining({
exifImageWidth: 511,
exifImageHeight: 323,
latitude: null,
longitude: null,
}),
}),
]),
);
});
it('should offline a missing file when called with checkForOffline', async () => { it('should offline a missing file when called with checkForOffline', async () => {
await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, { await fs.promises.cp(`${IMMICH_TEST_ASSET_PATH}/albums/nature`, `${IMMICH_TEST_ASSET_TEMP_PATH}/albums/nature`, {
recursive: true, recursive: true,
@ -421,19 +311,6 @@ describe(`${LibraryController.name} (e2e)`, () => {
); );
}); });
}); });
it('should not scan an upload library', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, {
type: LibraryType.UPLOAD,
});
const { status, body } = await request(server)
.post(`/library/${library.id}/scan`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Can only refresh external libraries'));
});
}); });
describe('POST /library/:id/removeOffline', () => { describe('POST /library/:id/removeOffline', () => {

View File

@ -34,8 +34,8 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.5.0", "exiftool-vendored": "~24.6.0",
"exiftool-vendored.pl": "12.76", "exiftool-vendored.pl": "12.78",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0", "geo-tz": "^8.0.0",
@ -50,7 +50,6 @@
"mnemonist": "^0.39.8", "mnemonist": "^0.39.8",
"nest-commander": "^3.11.1", "nest-commander": "^3.11.1",
"nestjs-otel": "^5.1.5", "nestjs-otel": "^5.1.5",
"node-addon-api": "^7.0.0",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"picomatch": "^4.0.0", "picomatch": "^4.0.0",
@ -4017,9 +4016,9 @@
} }
}, },
"node_modules/@photostructure/tz-lookup": { "node_modules/@photostructure/tz-lookup": {
"version": "9.0.1", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz",
"integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog==" "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw=="
}, },
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
@ -7676,34 +7675,34 @@
"dev": true "dev": true
}, },
"node_modules/exiftool-vendored": { "node_modules/exiftool-vendored": {
"version": "24.5.0", "version": "24.6.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.6.0.tgz",
"integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", "integrity": "sha512-jGjsoeYmR9VUrlZn0j1wcxMVi5y8C7A4FAa4vm3/l7ThT8d0f+jRcBqtdjaf+P5Ds/F4OgUq+ee/fRVhLy2DrA==",
"dependencies": { "dependencies": {
"@photostructure/tz-lookup": "^9.0.1", "@photostructure/tz-lookup": "^9.0.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0", "batch-cluster": "^13.0.0",
"he": "^1.2.0", "he": "^1.2.0",
"luxon": "^3.4.4" "luxon": "^3.4.4"
}, },
"optionalDependencies": { "optionalDependencies": {
"exiftool-vendored.exe": "12.76.0", "exiftool-vendored.exe": "12.78.0",
"exiftool-vendored.pl": "12.76.0" "exiftool-vendored.pl": "12.78.0"
} }
}, },
"node_modules/exiftool-vendored.exe": { "node_modules/exiftool-vendored.exe": {
"version": "12.76.0", "version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.78.0.tgz",
"integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", "integrity": "sha512-eMN7L67sb89xi8sN7INPg19uwa1KibG2oOyGcfOvB47h+1hzmGgivVu/SZIMeOToVIbLRwUl+AFwLYSTNXsJEg==",
"optional": true, "optional": true,
"os": [ "os": [
"win32" "win32"
] ]
}, },
"node_modules/exiftool-vendored.pl": { "node_modules/exiftool-vendored.pl": {
"version": "12.76.0", "version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
"integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==", "integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg==",
"os": [ "os": [
"!win32" "!win32"
] ]
@ -10679,14 +10678,6 @@
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
}, },
"node_modules/node-addon-api": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz",
"integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==",
"engines": {
"node": "^16 || ^18 || >= 20"
}
},
"node_modules/node-emoji": { "node_modules/node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",
@ -17074,9 +17065,9 @@
} }
}, },
"@photostructure/tz-lookup": { "@photostructure/tz-lookup": {
"version": "9.0.1", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.1.tgz", "resolved": "https://registry.npmjs.org/@photostructure/tz-lookup/-/tz-lookup-9.0.2.tgz",
"integrity": "sha512-inMfhc1QVKheq/PHF0v2vRPnPzTljxscuOKK95o3VlZA4T4w4DeYIpu7dC4W1EyjUhfZJlCBUudpmnFGNCqTog==" "integrity": "sha512-H8+tTt7ilJNkFyb+QgPnLEGUjQzGwiMb9n7lwRZNBgSKL3VZs9AkjI1E//FcwPjNafwAH932U92+xTqJiF3Bbw=="
}, },
"@pkgjs/parseargs": { "@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
@ -19950,15 +19941,15 @@
} }
}, },
"exiftool-vendored": { "exiftool-vendored": {
"version": "24.5.0", "version": "24.6.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.5.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-24.6.0.tgz",
"integrity": "sha512-uLGYfeshak3mYn2ucCsebXfNFdOpeAULlMb84wiJv+4B236n+ypgK/vr8bJgAcsIPSRJXFSz9WonvjjQYYqR3w==", "integrity": "sha512-jGjsoeYmR9VUrlZn0j1wcxMVi5y8C7A4FAa4vm3/l7ThT8d0f+jRcBqtdjaf+P5Ds/F4OgUq+ee/fRVhLy2DrA==",
"requires": { "requires": {
"@photostructure/tz-lookup": "^9.0.1", "@photostructure/tz-lookup": "^9.0.2",
"@types/luxon": "^3.4.2", "@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0", "batch-cluster": "^13.0.0",
"exiftool-vendored.exe": "12.76.0", "exiftool-vendored.exe": "12.78.0",
"exiftool-vendored.pl": "12.76.0", "exiftool-vendored.pl": "12.78.0",
"he": "^1.2.0", "he": "^1.2.0",
"luxon": "^3.4.4" "luxon": "^3.4.4"
}, },
@ -19971,15 +19962,15 @@
} }
}, },
"exiftool-vendored.exe": { "exiftool-vendored.exe": {
"version": "12.76.0", "version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.76.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.78.0.tgz",
"integrity": "sha512-lbKPPs31qpjnhFiMRaVxJX+iNcJ+p0NrRSFLHHaX6KTsfMba6e5i6NykSvU3wMiafzUTef1Fen3XQ+8n1tjjNw==", "integrity": "sha512-eMN7L67sb89xi8sN7INPg19uwa1KibG2oOyGcfOvB47h+1hzmGgivVu/SZIMeOToVIbLRwUl+AFwLYSTNXsJEg==",
"optional": true "optional": true
}, },
"exiftool-vendored.pl": { "exiftool-vendored.pl": {
"version": "12.76.0", "version": "12.78.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.76.0.tgz", "resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.78.0.tgz",
"integrity": "sha512-4DxqgnvL71YziVoY27ZMgVfLAWDH3pQLljuV5+ffTnTPvz/BWeV+/bVFwRvDqCD3lkCWds0YfVcsycfJgbQ5fA==" "integrity": "sha512-K8j9NgxRpTFskFuXEl0AGsc692yYyThe4i3SXgx7xc0fu/vwD2c7tRGljkEtvaweYnMmfrF4DhCpuTu0aux6sg=="
}, },
"exit": { "exit": {
"version": "0.1.2", "version": "0.1.2",
@ -22263,11 +22254,6 @@
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==" "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="
}, },
"node-addon-api": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz",
"integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g=="
},
"node-emoji": { "node-emoji": {
"version": "1.11.0", "version": "1.11.0",
"resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz",

View File

@ -58,8 +58,8 @@
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"cookie-parser": "^1.4.6", "cookie-parser": "^1.4.6",
"exiftool-vendored": "~24.5.0", "exiftool-vendored": "~24.6.0",
"exiftool-vendored.pl": "12.76", "exiftool-vendored.pl": "12.78",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0", "geo-tz": "^8.0.0",
@ -74,7 +74,6 @@
"mnemonist": "^0.39.8", "mnemonist": "^0.39.8",
"nest-commander": "^3.11.1", "nest-commander": "^3.11.1",
"nestjs-otel": "^5.1.5", "nestjs-otel": "^5.1.5",
"node-addon-api": "^7.0.0",
"openid-client": "^5.4.3", "openid-client": "^5.4.3",
"pg": "^8.11.3", "pg": "^8.11.3",
"picomatch": "^4.0.0", "picomatch": "^4.0.0",

View File

@ -113,6 +113,7 @@ const validImages = [
'.sr2', '.sr2',
'.srf', '.srf',
'.srw', '.srw',
'.svg',
'.tiff', '.tiff',
'.webp', '.webp',
'.x3f', '.x3f',

View File

@ -22,6 +22,7 @@ import {
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository, IUserRepository,
JobItem, JobItem,
JobStatus,
TimeBucketOptions, TimeBucketOptions,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
@ -384,7 +385,7 @@ export class AssetService {
this.communicationRepository.send(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids); this.communicationRepository.send(ClientEvent.ASSET_STACK_UPDATE, auth.user.id, ids);
} }
async handleAssetDeletionCheck() { async handleAssetDeletionCheck(): Promise<JobStatus> {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
const trashedDays = config.trash.enabled ? config.trash.days : 0; const trashedDays = config.trash.enabled ? config.trash.days : 0;
const trashedBefore = DateTime.now() const trashedBefore = DateTime.now()
@ -400,10 +401,10 @@ export class AssetService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleAssetDeletion(job: IAssetDeletionJob) { async handleAssetDeletion(job: IAssetDeletionJob): Promise<JobStatus> {
const { id, fromExternal } = job; const { id, fromExternal } = job;
const asset = await this.assetRepository.getById(id, { const asset = await this.assetRepository.getById(id, {
@ -416,12 +417,12 @@ export class AssetService {
}); });
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
// Ignore requests that are not from external library job but is for an external asset // Ignore requests that are not from external library job but is for an external asset
if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) { if (!fromExternal && (!asset.library || asset.library.type === LibraryType.EXTERNAL)) {
return false; return JobStatus.SKIPPED;
} }
// Replace the parent of the stack children with a new asset // Replace the parent of the stack children with a new asset
@ -456,7 +457,7 @@ export class AssetService {
await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } }); await this.jobRepository.queue({ name: JobName.DELETE_FILES, data: { files } });
} }
return true; return JobStatus.SUCCESS;
} }
async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> { async deleteAll(auth: AuthDto, dto: AssetBulkDeleteDto): Promise<void> {

View File

@ -18,6 +18,7 @@ import {
IPersonRepository, IPersonRepository,
IStorageRepository, IStorageRepository,
IUserRepository, IUserRepository,
JobStatus,
} from '../repositories'; } from '../repositories';
import { AuditService } from './audit.service'; import { AuditService } from './audit.service';
@ -48,8 +49,8 @@ describe(AuditService.name, () => {
describe('handleCleanup', () => { describe('handleCleanup', () => {
it('should delete old audit entries', async () => { it('should delete old audit entries', async () => {
await expect(sut.handleCleanup()).resolves.toBe(true); await expect(sut.handleCleanup()).resolves.toBe(JobStatus.SUCCESS);
expect(auditMock.removeBefore).toBeCalledWith(expect.any(Date)); expect(auditMock.removeBefore).toHaveBeenCalledWith(expect.any(Date));
}); });
}); });

View File

@ -16,6 +16,7 @@ import {
IPersonRepository, IPersonRepository,
IStorageRepository, IStorageRepository,
IUserRepository, IUserRepository,
JobStatus,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
import { import {
@ -44,9 +45,9 @@ export class AuditService {
this.access = AccessCore.create(accessRepository); this.access = AccessCore.create(accessRepository);
} }
async handleCleanup(): Promise<boolean> { async handleCleanup(): Promise<JobStatus> {
await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); await this.repository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return true; return JobStatus.SUCCESS;
} }
async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> { async getDeletes(auth: AuthDto, dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {

View File

@ -41,6 +41,7 @@ describe('mimeTypes', () => {
{ mimetype: 'image/sr2', extension: '.sr2' }, { mimetype: 'image/sr2', extension: '.sr2' },
{ mimetype: 'image/srf', extension: '.srf' }, { mimetype: 'image/srf', extension: '.srf' },
{ mimetype: 'image/srw', extension: '.srw' }, { mimetype: 'image/srw', extension: '.srw' },
{ mimetype: 'image/svg', extension: '.svg' },
{ mimetype: 'image/tiff', extension: '.tif' }, { mimetype: 'image/tiff', extension: '.tif' },
{ mimetype: 'image/tiff', extension: '.tiff' }, { mimetype: 'image/tiff', extension: '.tiff' },
{ mimetype: 'image/webp', extension: '.webp' }, { mimetype: 'image/webp', extension: '.webp' },

View File

@ -133,13 +133,14 @@ const image: Record<string, string[]> = {
'.sr2': ['image/sr2', 'image/x-sony-sr2'], '.sr2': ['image/sr2', 'image/x-sony-sr2'],
'.srf': ['image/srf', 'image/x-sony-srf'], '.srf': ['image/srf', 'image/x-sony-srf'],
'.srw': ['image/srw', 'image/x-samsung-srw'], '.srw': ['image/srw', 'image/x-samsung-srw'],
'.svg': ['image/svg'],
'.tif': ['image/tiff'], '.tif': ['image/tiff'],
'.tiff': ['image/tiff'], '.tiff': ['image/tiff'],
'.webp': ['image/webp'], '.webp': ['image/webp'],
'.x3f': ['image/x3f', 'image/x-sigma-x3f'], '.x3f': ['image/x3f', 'image/x-sigma-x3f'],
}; };
const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp']); const profileExtensions = new Set(['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp', '.svg']);
const profile: Record<string, string[]> = Object.fromEntries( const profile: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => profileExtensions.has(key)), Object.entries(image).filter(([key]) => profileExtensions.has(key)),
); );

View File

@ -16,13 +16,14 @@ import {
ISystemConfigRepository, ISystemConfigRepository,
JobHandler, JobHandler,
JobItem, JobItem,
JobStatus,
} from '../repositories'; } from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core'; import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core';
import { JobCommand, JobName, QueueName } from './job.constants'; import { JobCommand, JobName, QueueName } from './job.constants';
import { JobService } from './job.service'; import { JobService } from './job.service';
const makeMockHandlers = (success: boolean) => { const makeMockHandlers = (status: JobStatus) => {
const mock = jest.fn().mockResolvedValue(success); const mock = jest.fn().mockResolvedValue(status);
return Object.fromEntries(Object.values(JobName).map((jobName) => [jobName, mock])) as unknown as Record< return Object.fromEntries(Object.values(JobName).map((jobName) => [jobName, mock])) as unknown as Record<
JobName, JobName,
JobHandler JobHandler
@ -221,13 +222,13 @@ describe(JobService.name, () => {
describe('init', () => { describe('init', () => {
it('should register a handler for each queue', async () => { it('should register a handler for each queue', async () => {
await sut.init(makeMockHandlers(true)); await sut.init(makeMockHandlers(JobStatus.SUCCESS));
expect(configMock.load).toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled();
expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length); expect(jobMock.addHandler).toHaveBeenCalledTimes(Object.keys(QueueName).length);
}); });
it('should subscribe to config changes', async () => { it('should subscribe to config changes', async () => {
await sut.init(makeMockHandlers(false)); await sut.init(makeMockHandlers(JobStatus.FAILED));
SystemConfigCore.create(newSystemConfigRepositoryMock(false)).config$.next({ SystemConfigCore.create(newSystemConfigRepositoryMock(false)).config$.next({
job: { job: {
@ -332,7 +333,7 @@ describe(JobService.name, () => {
} }
} }
await sut.init(makeMockHandlers(true)); await sut.init(makeMockHandlers(JobStatus.SUCCESS));
await jobMock.addHandler.mock.calls[0][2](item); await jobMock.addHandler.mock.calls[0][2](item);
if (jobs.length > 1) { if (jobs.length > 1) {
@ -348,7 +349,7 @@ describe(JobService.name, () => {
}); });
it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => { it(`should not queue any jobs when ${item.name} finishes with 'false'`, async () => {
await sut.init(makeMockHandlers(false)); await sut.init(makeMockHandlers(JobStatus.FAILED));
await jobMock.addHandler.mock.calls[0][2](item); await jobMock.addHandler.mock.calls[0][2](item);
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();

View File

@ -11,6 +11,7 @@ import {
ISystemConfigRepository, ISystemConfigRepository,
JobHandler, JobHandler,
JobItem, JobItem,
JobStatus,
QueueCleanType, QueueCleanType,
} from '../repositories'; } from '../repositories';
import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core'; import { FeatureFlag, SystemConfigCore } from '../system-config/system-config.core';
@ -155,8 +156,8 @@ export class JobService {
try { try {
const handler = jobHandlers[name]; const handler = jobHandlers[name];
const success = await handler(data); const status = await handler(data);
if (success) { if (status === JobStatus.SUCCESS || status == JobStatus.SKIPPED) {
await this.onDone(item); await this.onDone(item);
} }
} catch (error: Error | any) { } catch (error: Error | any) {

View File

@ -89,7 +89,7 @@ export class ValidateLibraryResponseDto {
export class ValidateLibraryImportPathResponseDto { export class ValidateLibraryImportPathResponseDto {
importPath!: string; importPath!: string;
isValid?: boolean = false; isValid: boolean = false;
message?: string; message?: string;
} }

View File

@ -18,6 +18,7 @@ import {
userStub, userStub,
} from '@test'; } from '@test';
import { when } from 'jest-when'; import { when } from 'jest-when';
import { R_OK } from 'node:constants';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job'; import { ILibraryFileJob, ILibraryRefreshJob, JobName } from '../job';
import { import {
@ -28,6 +29,7 @@ import {
ILibraryRepository, ILibraryRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobStatus,
StorageEventType, StorageEventType,
} from '../repositories'; } from '../repositories';
import { SystemConfigCore } from '../system-config/system-config.core'; import { SystemConfigCore } from '../system-config/system-config.core';
@ -214,7 +216,7 @@ describe(LibraryService.name, () => {
libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1); libraryMock.get.mockResolvedValue(libraryStub.uploadLibrary1);
await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(false); await expect(sut.handleQueueAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
}); });
it('should ignore import paths that do not exist', async () => { it('should ignore import paths that do not exist', async () => {
@ -320,7 +322,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create.mock.calls).toEqual([ expect(assetMock.create.mock.calls).toEqual([
[ [
@ -368,7 +370,7 @@ describe(LibraryService.name, () => {
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create.mock.calls).toEqual([ expect(assetMock.create.mock.calls).toEqual([
[ [
@ -415,7 +417,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.video); assetMock.create.mockResolvedValue(assetStub.video);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create.mock.calls).toEqual([ expect(assetMock.create.mock.calls).toEqual([
[ [
@ -471,7 +473,7 @@ describe(LibraryService.name, () => {
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() }); libraryMock.get.mockResolvedValue({ ...libraryStub.externalLibrary1, deletedAt: new Date() });
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(false); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.FAILED);
expect(assetMock.create.mock.calls).toEqual([]); expect(assetMock.create.mock.calls).toEqual([]);
}); });
@ -492,7 +494,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
@ -509,7 +511,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.METADATA_EXTRACTION, name: JobName.METADATA_EXTRACTION,
@ -540,7 +542,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true }); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.image.id, isOffline: true });
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
@ -558,7 +560,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.offline);
assetMock.create.mockResolvedValue(assetStub.offline); assetMock.create.mockResolvedValue(assetStub.offline);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false }); expect(assetMock.save).toHaveBeenCalledWith({ id: assetStub.offline.id, isOffline: false });
@ -591,7 +593,7 @@ describe(LibraryService.name, () => {
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
}); });
it('should refresh an existing asset if forced', async () => { it('should refresh an existing asset if forced', async () => {
@ -605,7 +607,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.image);
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], {
fileCreatedAt: new Date('2023-01-01'), fileCreatedAt: new Date('2023-01-01'),
@ -633,7 +635,7 @@ describe(LibraryService.name, () => {
assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null); assetMock.getByLibraryIdAndOriginalPath.mockResolvedValue(null);
assetMock.create.mockResolvedValue(assetStub.image); assetMock.create.mockResolvedValue(assetStub.image);
await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(true); await expect(sut.handleAssetRefresh(mockLibraryJob)).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.create).toHaveBeenCalled(); expect(assetMock.create).toHaveBeenCalled();
const createdAsset = assetMock.create.mock.calls[0][0]; const createdAsset = assetMock.create.mock.calls[0][0];
@ -1056,7 +1058,7 @@ describe(LibraryService.name, () => {
describe('handleQueueCleanup', () => { describe('handleQueueCleanup', () => {
it('should queue cleanup jobs', async () => { it('should queue cleanup jobs', async () => {
libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]); libraryMock.getAllDeleted.mockResolvedValue([libraryStub.uploadLibrary1, libraryStub.externalLibrary1]);
await expect(sut.handleQueueCleanup()).resolves.toBe(true); await expect(sut.handleQueueCleanup()).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } }, { name: JobName.LIBRARY_DELETE, data: { id: libraryStub.uploadLibrary1.id } },
@ -1343,7 +1345,7 @@ describe(LibraryService.name, () => {
libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.getAssetIds.mockResolvedValue([]);
libraryMock.delete.mockImplementation(async () => {}); libraryMock.delete.mockImplementation(async () => {});
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(false); await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.FAILED);
}); });
it('should delete an empty library', async () => { it('should delete an empty library', async () => {
@ -1351,7 +1353,7 @@ describe(LibraryService.name, () => {
libraryMock.getAssetIds.mockResolvedValue([]); libraryMock.getAssetIds.mockResolvedValue([]);
libraryMock.delete.mockImplementation(async () => {}); libraryMock.delete.mockImplementation(async () => {});
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
}); });
it('should delete a library with assets', async () => { it('should delete a library with assets', async () => {
@ -1361,7 +1363,7 @@ describe(LibraryService.name, () => {
assetMock.getById.mockResolvedValue(assetStub.image1); assetMock.getById.mockResolvedValue(assetStub.image1);
await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(true); await expect(sut.handleDeleteLibrary({ id: libraryStub.uploadLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
}); });
}); });
@ -1494,7 +1496,7 @@ describe(LibraryService.name, () => {
it('should queue the refresh job', async () => { it('should queue the refresh job', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueAllScan({})).resolves.toBe(true); await expect(sut.handleQueueAllScan({})).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queue.mock.calls).toEqual([ expect(jobMock.queue.mock.calls).toEqual([
[ [
@ -1519,7 +1521,7 @@ describe(LibraryService.name, () => {
it('should queue the force refresh job', async () => { it('should queue the force refresh job', async () => {
libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]); libraryMock.getAll.mockResolvedValue([libraryStub.externalLibrary1]);
await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(true); await expect(sut.handleQueueAllScan({ force: true })).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queue).toHaveBeenCalledWith({ expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.LIBRARY_QUEUE_CLEANUP, name: JobName.LIBRARY_QUEUE_CLEANUP,
@ -1544,7 +1546,7 @@ describe(LibraryService.name, () => {
assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false }); assetMock.getWith.mockResolvedValue({ items: [assetStub.image1], hasNextPage: false });
assetMock.getById.mockResolvedValue(assetStub.image1); assetMock.getById.mockResolvedValue(assetStub.image1);
await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(true); await expect(sut.handleOfflineRemoval({ id: libraryStub.externalLibrary1.id })).resolves.toBe(JobStatus.SUCCESS);
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
@ -1650,5 +1652,32 @@ describe(LibraryService.name, () => {
}, },
]); ]);
}); });
it('should detect when import path is in immich media folder', async () => {
storageMock.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
const validImport = libraryStub.hasImmichPaths.importPaths[1];
when(storageMock.checkFileExists).calledWith(validImport, R_OK).mockResolvedValue(true);
const result = await sut.validate(authStub.external1, libraryStub.hasImmichPaths.id, {
importPaths: libraryStub.hasImmichPaths.importPaths,
});
expect(result.importPaths).toEqual([
{
importPath: libraryStub.hasImmichPaths.importPaths[0],
isValid: false,
message: 'Cannot use media upload folder for external libraries',
},
{
importPath: validImport,
isValid: true,
},
{
importPath: libraryStub.hasImmichPaths.importPaths[2],
isValid: false,
message: 'Cannot use media upload folder for external libraries',
},
]);
});
}); });
}); });

View File

@ -21,9 +21,11 @@ import {
ILibraryRepository, ILibraryRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobStatus,
StorageEventType, StorageEventType,
WithProperty, WithProperty,
} from '../repositories'; } from '../repositories';
import { StorageCore } from '../storage';
import { SystemConfigCore } from '../system-config'; import { SystemConfigCore } from '../system-config';
import { import {
CreateLibraryDto, CreateLibraryDto,
@ -240,13 +242,13 @@ export class LibraryService extends EventEmitter {
return libraries.map((library) => mapLibrary(library)); return libraries.map((library) => mapLibrary(library));
} }
async handleQueueCleanup(): Promise<boolean> { async handleQueueCleanup(): Promise<JobStatus> {
this.logger.debug('Cleaning up any pending library deletions'); this.logger.debug('Cleaning up any pending library deletions');
const pendingDeletion = await this.repository.getAllDeleted(); const pendingDeletion = await this.repository.getAllDeleted();
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })), pendingDeletion.map((libraryToDelete) => ({ name: JobName.LIBRARY_DELETE, data: { id: libraryToDelete.id } })),
); );
return true; return JobStatus.SUCCESS;
} }
async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> { async create(auth: AuthDto, dto: CreateLibraryDto): Promise<LibraryResponseDto> {
@ -325,9 +327,13 @@ export class LibraryService extends EventEmitter {
const validation = new ValidateLibraryImportPathResponseDto(); const validation = new ValidateLibraryImportPathResponseDto();
validation.importPath = importPath; validation.importPath = importPath;
if (StorageCore.isImmichPath(importPath)) {
validation.message = 'Cannot use media upload folder for external libraries';
return validation;
}
try { try {
const stat = await this.storageRepository.stat(importPath); const stat = await this.storageRepository.stat(importPath);
if (!stat.isDirectory()) { if (!stat.isDirectory()) {
validation.message = 'Not a directory'; validation.message = 'Not a directory';
return validation; return validation;
@ -409,10 +415,10 @@ export class LibraryService extends EventEmitter {
await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } }); await this.jobRepository.queue({ name: JobName.LIBRARY_DELETE, data: { id } });
} }
async handleDeleteLibrary(job: IEntityJob): Promise<boolean> { async handleDeleteLibrary(job: IEntityJob): Promise<JobStatus> {
const library = await this.repository.get(job.id, true); const library = await this.repository.get(job.id, true);
if (!library) { if (!library) {
return false; return JobStatus.FAILED;
} }
// TODO use pagination // TODO use pagination
@ -426,10 +432,10 @@ export class LibraryService extends EventEmitter {
this.logger.log(`Deleting library ${job.id}`); this.logger.log(`Deleting library ${job.id}`);
await this.repository.delete(job.id); await this.repository.delete(job.id);
} }
return true; return JobStatus.SUCCESS;
} }
async handleAssetRefresh(job: ILibraryFileJob) { async handleAssetRefresh(job: ILibraryFileJob): Promise<JobStatus> {
const assetPath = path.normalize(job.assetPath); const assetPath = path.normalize(job.assetPath);
const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath); const existingAssetEntity = await this.assetRepository.getByLibraryIdAndOriginalPath(job.id, assetPath);
@ -444,7 +450,7 @@ export class LibraryService extends EventEmitter {
this.logger.debug(`Marking asset as offline: ${assetPath}`); this.logger.debug(`Marking asset as offline: ${assetPath}`);
await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true }); await this.assetRepository.save({ id: existingAssetEntity.id, isOffline: true });
return true; return JobStatus.SUCCESS;
} else { } else {
// File can't be accessed and does not already exist in db // File can't be accessed and does not already exist in db
throw new BadRequestException('Cannot access file', { cause: error }); throw new BadRequestException('Cannot access file', { cause: error });
@ -482,7 +488,7 @@ export class LibraryService extends EventEmitter {
if (!doImport && !doRefresh) { if (!doImport && !doRefresh) {
// If we don't import, exit here // If we don't import, exit here
return true; return JobStatus.SKIPPED;
} }
let assetType: AssetType; let assetType: AssetType;
@ -508,7 +514,7 @@ export class LibraryService extends EventEmitter {
const library = await this.repository.get(job.id, true); const library = await this.repository.get(job.id, true);
if (library?.deletedAt) { if (library?.deletedAt) {
this.logger.error('Cannot import asset into deleted library'); this.logger.error('Cannot import asset into deleted library');
return false; return JobStatus.FAILED;
} }
const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`); const pathHash = this.cryptoRepository.hashSha1(`path:${assetPath}`);
@ -539,7 +545,7 @@ export class LibraryService extends EventEmitter {
}); });
} else { } else {
// Not importing and not refreshing, do nothing // Not importing and not refreshing, do nothing
return true; return JobStatus.SKIPPED;
} }
this.logger.debug(`Queuing metadata extraction for: ${assetPath}`); this.logger.debug(`Queuing metadata extraction for: ${assetPath}`);
@ -550,7 +556,7 @@ export class LibraryService extends EventEmitter {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } }); await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: assetId } });
} }
return true; return JobStatus.SUCCESS;
} }
async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) { async queueScan(auth: AuthDto, id: string, dto: ScanLibraryDto) {
@ -590,7 +596,7 @@ export class LibraryService extends EventEmitter {
}); });
} }
async handleQueueAllScan(job: IBaseJob): Promise<boolean> { async handleQueueAllScan(job: IBaseJob): Promise<JobStatus> {
this.logger.debug(`Refreshing all external libraries: force=${job.force}`); this.logger.debug(`Refreshing all external libraries: force=${job.force}`);
// Queue cleanup // Queue cleanup
@ -608,7 +614,7 @@ export class LibraryService extends EventEmitter {
}, },
})), })),
); );
return true; return JobStatus.SUCCESS;
} }
async handleQueueOnlineStatusCheck(job: IEntityJob): Promise<boolean> { async handleQueueOnlineStatusCheck(job: IEntityJob): Promise<boolean> {
@ -651,7 +657,7 @@ export class LibraryService extends EventEmitter {
return true; return true;
} }
async handleOfflineRemoval(job: IEntityJob): Promise<boolean> { async handleOfflineRemoval(job: IEntityJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id), this.assetRepository.getWith(pagination, WithProperty.IS_OFFLINE, job.id),
); );
@ -663,14 +669,14 @@ export class LibraryService extends EventEmitter {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise<boolean> { async handleQueueAssetRefresh(job: ILibraryRefreshJob): Promise<JobStatus> {
const library = await this.repository.get(job.id); const library = await this.repository.get(job.id);
if (!library || library.type !== LibraryType.EXTERNAL) { if (!library || library.type !== LibraryType.EXTERNAL) {
this.logger.warn('Can only refresh external libraries'); this.logger.warn('Can only refresh external libraries');
return false; return JobStatus.FAILED;
} }
this.logger.log(`Refreshing library: ${job.id}`); this.logger.log(`Refreshing library: ${job.id}`);
@ -728,7 +734,7 @@ export class LibraryService extends EventEmitter {
await this.repository.update({ id: job.id, refreshedAt: new Date() }); await this.repository.update({ id: job.id, refreshedAt: new Date() });
return true; return JobStatus.SUCCESS;
} }
private async findOrFail(id: string) { private async findOrFail(id: string) {

View File

@ -34,6 +34,7 @@ import {
IPersonRepository, IPersonRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobStatus,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { MediaService } from './media.service'; import { MediaService } from './media.service';
@ -1214,22 +1215,22 @@ describe(MediaService.name, () => {
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
it('should return false if hwaccel is enabled for an unsupported codec', async () => { it('should fail if hwaccel is enabled for an unsupported codec', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([ configMock.load.mockResolvedValue([
{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC }, { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.NVENC },
{ key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 }, { key: SystemConfigKey.FFMPEG_TARGET_VIDEO_CODEC, value: VideoCodec.VP9 },
]); ]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
it('should return false if hwaccel option is invalid', async () => { it('should fail if hwaccel option is invalid', async () => {
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: 'invalid' }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
@ -1548,12 +1549,12 @@ describe(MediaService.name, () => {
); );
}); });
it('should return false for qsv if no hw devices', async () => { it('should fail for qsv if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]); storageMock.readdir.mockResolvedValue([]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.QSV }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });
@ -1777,12 +1778,12 @@ describe(MediaService.name, () => {
); );
}); });
it('should return false for vaapi if no hw devices', async () => { it('should fail for vaapi if no hw devices', async () => {
storageMock.readdir.mockResolvedValue([]); storageMock.readdir.mockResolvedValue([]);
mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer); mediaMock.probe.mockResolvedValue(probeStub.matroskaContainer);
configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.VAAPI }]);
assetMock.getByIds.mockResolvedValue([assetStub.video]); assetMock.getByIds.mockResolvedValue([assetStub.video]);
await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toEqual(false); await expect(sut.handleVideoConversion({ id: assetStub.video.id })).resolves.toBe(JobStatus.FAILED);
expect(mediaMock.transcode).not.toHaveBeenCalled(); expect(mediaMock.transcode).not.toHaveBeenCalled();
}); });

View File

@ -24,6 +24,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobItem, JobItem,
JobStatus,
VideoCodecHWConfig, VideoCodecHWConfig,
VideoStreamInfo, VideoStreamInfo,
WithoutProperty, WithoutProperty,
@ -70,7 +71,7 @@ export class MediaService {
); );
} }
async handleQueueGenerateThumbnails({ force }: IBaseJob) { async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
? this.assetRepository.getAll(pagination) ? this.assetRepository.getAll(pagination)
@ -118,10 +119,10 @@ export class MediaService {
await this.jobRepository.queueAll(jobs); await this.jobRepository.queueAll(jobs);
return true; return JobStatus.SUCCESS;
} }
async handleQueueMigration() { async handleQueueMigration(): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination), this.assetRepository.getAll(pagination),
); );
@ -148,31 +149,31 @@ export class MediaService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleAssetMigration({ id }: IEntityJob) { async handleAssetMigration({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
await this.storageCore.moveAssetFile(asset, AssetPathType.JPEG_THUMBNAIL); await this.storageCore.moveAssetFile(asset, AssetPathType.JPEG_THUMBNAIL);
await this.storageCore.moveAssetFile(asset, AssetPathType.WEBP_THUMBNAIL); await this.storageCore.moveAssetFile(asset, AssetPathType.WEBP_THUMBNAIL);
await this.storageCore.moveAssetFile(asset, AssetPathType.ENCODED_VIDEO); await this.storageCore.moveAssetFile(asset, AssetPathType.ENCODED_VIDEO);
return true; return JobStatus.SUCCESS;
} }
async handleGenerateJpegThumbnail({ id }: IEntityJob) { async handleGenerateJpegThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
const resizePath = await this.generateThumbnail(asset, 'jpeg'); const resizePath = await this.generateThumbnail(asset, 'jpeg');
await this.assetRepository.save({ id: asset.id, resizePath }); await this.assetRepository.save({ id: asset.id, resizePath });
return true; return JobStatus.SUCCESS;
} }
private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') { private async generateThumbnail(asset: AssetEntity, format: 'jpeg' | 'webp') {
@ -214,30 +215,30 @@ export class MediaService {
return path; return path;
} }
async handleGenerateWebpThumbnail({ id }: IEntityJob) { async handleGenerateWebpThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
const webpPath = await this.generateThumbnail(asset, 'webp'); const webpPath = await this.generateThumbnail(asset, 'webp');
await this.assetRepository.save({ id: asset.id, webpPath }); await this.assetRepository.save({ id: asset.id, webpPath });
return true; return JobStatus.SUCCESS;
} }
async handleGenerateThumbhashThumbnail({ id }: IEntityJob): Promise<boolean> { async handleGenerateThumbhashThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset?.resizePath) { if (!asset?.resizePath) {
return false; return JobStatus.FAILED;
} }
const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath); const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath);
await this.assetRepository.save({ id: asset.id, thumbhash }); await this.assetRepository.save({ id: asset.id, thumbhash });
return true; return JobStatus.SUCCESS;
} }
async handleQueueVideoConversion(job: IBaseJob) { async handleQueueVideoConversion(job: IBaseJob): Promise<JobStatus> {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
@ -252,13 +253,13 @@ export class MediaService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleVideoConversion({ id }: IEntityJob) { async handleVideoConversion({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset || asset.type !== AssetType.VIDEO) { if (!asset || asset.type !== AssetType.VIDEO) {
return false; return JobStatus.FAILED;
} }
const input = asset.originalPath; const input = asset.originalPath;
@ -270,12 +271,12 @@ export class MediaService {
const mainAudioStream = this.getMainStream(audioStreams); const mainAudioStream = this.getMainStream(audioStreams);
const containerExtension = format.formatName; const containerExtension = format.formatName;
if (!mainVideoStream || !containerExtension) { if (!mainVideoStream || !containerExtension) {
return false; return JobStatus.FAILED;
} }
if (!mainVideoStream.height || !mainVideoStream.width) { if (!mainVideoStream.height || !mainVideoStream.width) {
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`); this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
return false; return JobStatus.FAILED;
} }
const { ffmpeg: config } = await this.configCore.getConfig(); const { ffmpeg: config } = await this.configCore.getConfig();
@ -288,7 +289,7 @@ export class MediaService {
await this.assetRepository.save({ id: asset.id, encodedVideoPath: null }); await this.assetRepository.save({ id: asset.id, encodedVideoPath: null });
} }
return true; return JobStatus.SKIPPED;
} }
let transcodeOptions; let transcodeOptions;
@ -298,7 +299,7 @@ export class MediaService {
); );
} catch (error) { } catch (error) {
this.logger.error(`An error occurred while configuring transcoding options: ${error}`); this.logger.error(`An error occurred while configuring transcoding options: ${error}`);
return false; return JobStatus.FAILED;
} }
this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`); this.logger.log(`Started encoding video ${asset.id} ${JSON.stringify(transcodeOptions)}`);
@ -322,7 +323,7 @@ export class MediaService {
await this.assetRepository.save({ id: asset.id, encodedVideoPath: output }); await this.assetRepository.save({ id: asset.id, encodedVideoPath: output });
return true; return JobStatus.SUCCESS;
} }
private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T { private getMainStream<T extends VideoStreamInfo | AudioStreamInfo>(streams: T[]): T {

View File

@ -37,6 +37,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
ImmichTags, ImmichTags,
JobStatus,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { MetadataService, Orientation } from './metadata.service'; import { MetadataService, Orientation } from './metadata.service';
@ -113,7 +114,7 @@ describe(MetadataService.name, () => {
describe('handleLivePhotoLinking', () => { describe('handleLivePhotoLinking', () => {
it('should handle an asset that could not be found', async () => { it('should handle an asset that could not be found', async () => {
await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
@ -123,7 +124,7 @@ describe(MetadataService.name, () => {
it('should handle an asset without exif info', async () => { it('should handle an asset without exif info', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]); assetMock.getByIds.mockResolvedValue([{ ...assetStub.image, exifInfo: undefined }]);
await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(false); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
@ -133,7 +134,7 @@ describe(MetadataService.name, () => {
it('should handle livePhotoCID not set', async () => { it('should handle livePhotoCID not set', async () => {
assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]); assetMock.getByIds.mockResolvedValue([{ ...assetStub.image }]);
await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(true); await expect(sut.handleLivePhotoLinking({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled(); expect(assetMock.findLivePhotoMatch).not.toHaveBeenCalled();
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
@ -148,7 +149,9 @@ describe(MetadataService.name, () => {
}, },
]); ]);
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(true); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
JobStatus.SKIPPED,
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true });
expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({
livePhotoCID: assetStub.livePhotoStillAsset.id, livePhotoCID: assetStub.livePhotoStillAsset.id,
@ -169,7 +172,9 @@ describe(MetadataService.name, () => {
]); ]);
assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true });
expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({ expect(assetMock.findLivePhotoMatch).toHaveBeenCalledWith({
livePhotoCID: assetStub.livePhotoMotionAsset.id, livePhotoCID: assetStub.livePhotoMotionAsset.id,
@ -194,7 +199,9 @@ describe(MetadataService.name, () => {
]); ]);
assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); assetMock.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); await expect(sut.handleLivePhotoLinking({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(communicationMock.send).toHaveBeenCalledWith( expect(communicationMock.send).toHaveBeenCalledWith(
ClientEvent.ASSET_HIDDEN, ClientEvent.ASSET_HIDDEN,
assetStub.livePhotoMotionAsset.ownerId, assetStub.livePhotoMotionAsset.ownerId,
@ -207,7 +214,7 @@ describe(MetadataService.name, () => {
it('should queue metadata extraction for all assets without exif values', async () => { it('should queue metadata extraction for all assets without exif values', async () => {
assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); assetMock.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(true); await expect(sut.handleQueueMetadataExtraction({ force: false })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getWithout).toHaveBeenCalled(); expect(assetMock.getWithout).toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
@ -220,7 +227,7 @@ describe(MetadataService.name, () => {
it('should queue metadata extraction for all assets', async () => { it('should queue metadata extraction for all assets', async () => {
assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); assetMock.getAll.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(true); await expect(sut.handleQueueMetadataExtraction({ force: true })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getAll).toHaveBeenCalled(); expect(assetMock.getAll).toHaveBeenCalled();
expect(jobMock.queueAll).toHaveBeenCalledWith([ expect(jobMock.queueAll).toHaveBeenCalledWith([
{ {
@ -237,7 +244,7 @@ describe(MetadataService.name, () => {
}); });
it('should handle an asset that could not be found', async () => { it('should handle an asset that could not be found', async () => {
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(false); await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id]);
expect(assetMock.upsertExif).not.toHaveBeenCalled(); expect(assetMock.upsertExif).not.toHaveBeenCalled();
@ -630,19 +637,13 @@ describe(MetadataService.name, () => {
describe('handleSidecarSync', () => { describe('handleSidecarSync', () => {
it('should do nothing if asset could not be found', async () => { it('should do nothing if asset could not be found', async () => {
assetMock.getByIds.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([]);
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(false); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
}); });
it('should do nothing if asset has no sidecar path', async () => { it('should do nothing if asset has no sidecar path', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]); assetMock.getByIds.mockResolvedValue([assetStub.image]);
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(false); await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
expect(assetMock.save).not.toHaveBeenCalled();
});
it('should do nothing if asset has no sidecar path', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await expect(sut.handleSidecarSync({ id: assetStub.image.id })).resolves.toBe(false);
expect(assetMock.save).not.toHaveBeenCalled(); expect(assetMock.save).not.toHaveBeenCalled();
}); });
@ -650,7 +651,7 @@ describe(MetadataService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
storageMock.checkFileExists.mockResolvedValue(true); storageMock.checkFileExists.mockResolvedValue(true);
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.sidecar.id, id: assetStub.sidecar.id,
@ -663,7 +664,7 @@ describe(MetadataService.name, () => {
storageMock.checkFileExists.mockResolvedValueOnce(false); storageMock.checkFileExists.mockResolvedValueOnce(false);
storageMock.checkFileExists.mockResolvedValueOnce(true); storageMock.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecarWithoutExt.id })).resolves.toBe(JobStatus.SUCCESS);
expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(
2, 2,
assetStub.sidecarWithoutExt.sidecarPath, assetStub.sidecarWithoutExt.sidecarPath,
@ -680,7 +681,7 @@ describe(MetadataService.name, () => {
storageMock.checkFileExists.mockResolvedValueOnce(true); storageMock.checkFileExists.mockResolvedValueOnce(true);
storageMock.checkFileExists.mockResolvedValueOnce(true); storageMock.checkFileExists.mockResolvedValueOnce(true);
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK); expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(1, assetStub.sidecar.sidecarPath, constants.R_OK);
expect(storageMock.checkFileExists).toHaveBeenNthCalledWith( expect(storageMock.checkFileExists).toHaveBeenNthCalledWith(
2, 2,
@ -697,7 +698,7 @@ describe(MetadataService.name, () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
storageMock.checkFileExists.mockResolvedValue(false); storageMock.checkFileExists.mockResolvedValue(false);
await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(true); await expect(sut.handleSidecarSync({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SUCCESS);
expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK); expect(storageMock.checkFileExists).toHaveBeenCalledWith(`${assetStub.sidecar.originalPath}.xmp`, constants.R_OK);
expect(assetMock.save).toHaveBeenCalledWith({ expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.sidecar.id, id: assetStub.sidecar.id,
@ -754,13 +755,13 @@ describe(MetadataService.name, () => {
describe('handleSidecarWrite', () => { describe('handleSidecarWrite', () => {
it('should skip assets that do not exist anymore', async () => { it('should skip assets that do not exist anymore', async () => {
assetMock.getByIds.mockResolvedValue([]); assetMock.getByIds.mockResolvedValue([]);
await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(false); await expect(sut.handleSidecarWrite({ id: 'asset-123' })).resolves.toBe(JobStatus.FAILED);
expect(metadataMock.writeTags).not.toHaveBeenCalled(); expect(metadataMock.writeTags).not.toHaveBeenCalled();
}); });
it('should skip jobs with not metadata', async () => { it('should skip jobs with not metadata', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.sidecar]); assetMock.getByIds.mockResolvedValue([assetStub.sidecar]);
await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(true); await expect(sut.handleSidecarWrite({ id: assetStub.sidecar.id })).resolves.toBe(JobStatus.SKIPPED);
expect(metadataMock.writeTags).not.toHaveBeenCalled(); expect(metadataMock.writeTags).not.toHaveBeenCalled();
}); });
@ -778,7 +779,7 @@ describe(MetadataService.name, () => {
longitude: gps, longitude: gps,
dateTimeOriginal: date, dateTimeOriginal: date,
}), }),
).resolves.toBe(true); ).resolves.toBe(JobStatus.SUCCESS);
expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, { expect(metadataMock.writeTags).toHaveBeenCalledWith(assetStub.sidecar.sidecarPath, {
ImageDescription: description, ImageDescription: description,
CreationDate: date, CreationDate: date,

View File

@ -26,6 +26,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
ImmichTags, ImmichTags,
JobStatus,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { StorageCore } from '../storage'; import { StorageCore } from '../storage';
@ -151,15 +152,15 @@ export class MetadataService {
await this.repository.teardown(); await this.repository.teardown();
} }
async handleLivePhotoLinking(job: IEntityJob) { async handleLivePhotoLinking(job: IEntityJob): Promise<JobStatus> {
const { id } = job; const { id } = job;
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
if (!asset?.exifInfo) { if (!asset?.exifInfo) {
return false; return JobStatus.FAILED;
} }
if (!asset.exifInfo.livePhotoCID) { if (!asset.exifInfo.livePhotoCID) {
return true; return JobStatus.SKIPPED;
} }
const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO; const otherType = asset.type === AssetType.VIDEO ? AssetType.IMAGE : AssetType.VIDEO;
@ -171,7 +172,7 @@ export class MetadataService {
}); });
if (!match) { if (!match) {
return true; return JobStatus.SKIPPED;
} }
const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset]; const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
@ -183,10 +184,10 @@ export class MetadataService {
// Notify clients to hide the linked live photo asset // Notify clients to hide the linked live photo asset
this.communicationRepository.send(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id); this.communicationRepository.send(ClientEvent.ASSET_HIDDEN, motionAsset.ownerId, motionAsset.id);
return true; return JobStatus.SUCCESS;
} }
async handleQueueMetadataExtraction(job: IBaseJob) { async handleQueueMetadataExtraction(job: IBaseJob): Promise<JobStatus> {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
@ -200,13 +201,13 @@ export class MetadataService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleMetadataExtraction({ id }: IEntityJob) { async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
const { exifData, tags } = await this.exifData(asset); const { exifData, tags } = await this.exifData(asset);
@ -260,10 +261,10 @@ export class MetadataService {
metadataExtractedAt: new Date(), metadataExtractedAt: new Date(),
}); });
return true; return JobStatus.SUCCESS;
} }
async handleQueueSidecar(job: IBaseJob) { async handleQueueSidecar(job: IBaseJob): Promise<JobStatus> {
const { force } = job; const { force } = job;
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force return force
@ -280,22 +281,22 @@ export class MetadataService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
handleSidecarSync({ id }: IEntityJob) { handleSidecarSync({ id }: IEntityJob): Promise<JobStatus> {
return this.processSidecar(id, true); return this.processSidecar(id, true);
} }
handleSidecarDiscovery({ id }: IEntityJob) { handleSidecarDiscovery({ id }: IEntityJob): Promise<JobStatus> {
return this.processSidecar(id, false); return this.processSidecar(id, false);
} }
async handleSidecarWrite(job: ISidecarWriteJob) { async handleSidecarWrite(job: ISidecarWriteJob): Promise<JobStatus> {
const { id, description, dateTimeOriginal, latitude, longitude } = job; const { id, description, dateTimeOriginal, latitude, longitude } = job;
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`; const sidecarPath = asset.sidecarPath || `${asset.originalPath}.xmp`;
@ -310,7 +311,7 @@ export class MetadataService {
); );
if (Object.keys(exif).length === 0) { if (Object.keys(exif).length === 0) {
return true; return JobStatus.SKIPPED;
} }
await this.repository.writeTags(sidecarPath, exif); await this.repository.writeTags(sidecarPath, exif);
@ -319,7 +320,7 @@ export class MetadataService {
await this.assetRepository.save({ id, sidecarPath }); await this.assetRepository.save({ id, sidecarPath });
} }
return true; return JobStatus.SUCCESS;
} }
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) { private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
@ -552,19 +553,19 @@ export class MetadataService {
return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS'); return Duration.fromObject({ seconds: _seconds }).toFormat('hh:mm:ss.SSS');
} }
private async processSidecar(id: string, isSync: boolean) { private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
if (isSync && !asset.sidecarPath) { if (isSync && !asset.sidecarPath) {
return false; return JobStatus.FAILED;
} }
if (!isSync && (!asset.isVisible || asset.sidecarPath)) { if (!isSync && (!asset.isVisible || asset.sidecarPath)) {
return false; return JobStatus.FAILED;
} }
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp // XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
@ -587,11 +588,11 @@ export class MetadataService {
if (sidecarPath) { if (sidecarPath) {
await this.assetRepository.save({ id: asset.id, sidecarPath }); await this.assetRepository.save({ id: asset.id, sidecarPath });
return true; return JobStatus.SUCCESS;
} }
if (!isSync) { if (!isSync) {
return false; return JobStatus.FAILED;
} }
this.logger.debug( this.logger.debug(
@ -599,6 +600,6 @@ export class MetadataService {
); );
await this.assetRepository.save({ id: asset.id, sidecarPath: null }); await this.assetRepository.save({ id: asset.id, sidecarPath: null });
return true; return JobStatus.SUCCESS;
} }
} }

View File

@ -34,6 +34,7 @@ import {
ISearchRepository, ISearchRepository,
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobStatus,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { PersonResponseDto, mapFaces, mapPerson } from './person.dto'; import { PersonResponseDto, mapFaces, mapPerson } from './person.dto';
@ -357,7 +358,7 @@ describe(PersonService.name, () => {
describe('handlePersonMigration', () => { describe('handlePersonMigration', () => {
it('should not move person files', async () => { it('should not move person files', async () => {
personMock.getById.mockResolvedValue(null); personMock.getById.mockResolvedValue(null);
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toStrictEqual(false); await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.FAILED);
}); });
}); });
@ -454,10 +455,10 @@ describe(PersonService.name, () => {
}); });
describe('handleQueueDetectFaces', () => { describe('handleQueueDetectFaces', () => {
it('should return if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
await expect(sut.handleQueueDetectFaces({})).resolves.toBe(true); await expect(sut.handleQueueDetectFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queue).not.toHaveBeenCalled(); expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled();
@ -530,19 +531,19 @@ describe(PersonService.name, () => {
}); });
describe('handleQueueRecognizeFaces', () => { describe('handleQueueRecognizeFaces', () => {
it('should return if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 0, paused: 0, completed: 0, failed: 0, delayed: 0 });
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled();
}); });
it('should return if recognition jobs are already queued', async () => { it('should skip if recognition jobs are already queued', async () => {
jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 1, paused: 0, completed: 0, failed: 0, delayed: 0 }); jobMock.getJobCounts.mockResolvedValue({ active: 1, waiting: 1, paused: 0, completed: 0, failed: 0, delayed: 0 });
await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(true); await expect(sut.handleQueueRecognizeFaces({})).resolves.toBe(JobStatus.SKIPPED);
expect(jobMock.queueAll).not.toHaveBeenCalled(); expect(jobMock.queueAll).not.toHaveBeenCalled();
}); });
@ -612,10 +613,10 @@ describe(PersonService.name, () => {
}); });
describe('handleDetectFaces', () => { describe('handleDetectFaces', () => {
it('should return if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(true); await expect(sut.handleDetectFaces({ id: 'foo' })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled();
}); });
@ -701,31 +702,31 @@ describe(PersonService.name, () => {
}); });
describe('handleRecognizeFaces', () => { describe('handleRecognizeFaces', () => {
it('should return false if face does not exist', async () => { it('should fail if face does not exist', async () => {
personMock.getFaceByIdWithAssets.mockResolvedValue(null); personMock.getFaceByIdWithAssets.mockResolvedValue(null);
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(false); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED);
expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled();
expect(personMock.createFaces).not.toHaveBeenCalled(); expect(personMock.createFaces).not.toHaveBeenCalled();
}); });
it('should return false if face does not have asset', async () => { it('should fail if face does not have asset', async () => {
const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null }; const face = { ...faceStub.face1, asset: null } as AssetFaceEntity & { asset: null };
personMock.getFaceByIdWithAssets.mockResolvedValue(face); personMock.getFaceByIdWithAssets.mockResolvedValue(face);
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(false); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.FAILED);
expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled();
expect(personMock.createFaces).not.toHaveBeenCalled(); expect(personMock.createFaces).not.toHaveBeenCalled();
}); });
it('should return true if face already has an assigned person', async () => { it('should skip if face already has an assigned person', async () => {
personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1); personMock.getFaceByIdWithAssets.mockResolvedValue(faceStub.face1);
expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(true); expect(await sut.handleRecognizeFaces({ id: faceStub.face1.id })).toBe(JobStatus.SKIPPED);
expect(personMock.reassignFaces).not.toHaveBeenCalled(); expect(personMock.reassignFaces).not.toHaveBeenCalled();
expect(personMock.create).not.toHaveBeenCalled(); expect(personMock.create).not.toHaveBeenCalled();
@ -852,10 +853,10 @@ describe(PersonService.name, () => {
}); });
describe('handleGeneratePersonThumbnail', () => { describe('handleGeneratePersonThumbnail', () => {
it('should return if machine learning is disabled', async () => { it('should skip if machine learning is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(true); await expect(sut.handleGeneratePersonThumbnail({ id: 'person-1' })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(configMock.load).toHaveBeenCalled(); expect(configMock.load).toHaveBeenCalled();
}); });

View File

@ -24,6 +24,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobItem, JobItem,
JobStatus,
UpdateFacesData, UpdateFacesData,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
@ -265,16 +266,16 @@ export class PersonService {
} }
} }
async handlePersonCleanup() { async handlePersonCleanup(): Promise<JobStatus> {
const people = await this.repository.getAllWithoutFaces(); const people = await this.repository.getAllWithoutFaces();
await this.delete(people); await this.delete(people);
return true; return JobStatus.SUCCESS;
} }
async handleQueueDetectFaces({ force }: IBaseJob) { async handleQueueDetectFaces({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
return true; return JobStatus.SKIPPED;
} }
if (force) { if (force) {
@ -294,13 +295,13 @@ export class PersonService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleDetectFaces({ id }: IEntityJob) { async handleDetectFaces({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
return true; return JobStatus.SKIPPED;
} }
const relations = { const relations = {
@ -311,7 +312,7 @@ export class PersonService {
}; };
const [asset] = await this.assetRepository.getByIds([id], relations); const [asset] = await this.assetRepository.getByIds([id], relations);
if (!asset || !asset.resizePath || asset.faces?.length > 0) { if (!asset || !asset.resizePath || asset.faces?.length > 0) {
return false; return JobStatus.FAILED;
} }
const faces = await this.machineLearningRepository.detectFaces( const faces = await this.machineLearningRepository.detectFaces(
@ -346,13 +347,13 @@ export class PersonService {
facesRecognizedAt: new Date(), facesRecognizedAt: new Date(),
}); });
return true; return JobStatus.SUCCESS;
} }
async handleQueueRecognizeFaces({ force }: IBaseJob) { async handleQueueRecognizeFaces({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
return true; return JobStatus.SKIPPED;
} }
await this.jobRepository.waitForQueueCompletion(QueueName.THUMBNAIL_GENERATION, QueueName.FACE_DETECTION); await this.jobRepository.waitForQueueCompletion(QueueName.THUMBNAIL_GENERATION, QueueName.FACE_DETECTION);
@ -364,7 +365,7 @@ export class PersonService {
this.logger.debug( this.logger.debug(
`Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`, `Skipping facial recognition queueing because ${waiting} job${waiting > 1 ? 's are' : ' is'} already queued`,
); );
return true; return JobStatus.SKIPPED;
} }
const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const facePagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
@ -377,13 +378,13 @@ export class PersonService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleRecognizeFaces({ id, deferred }: IDeferrableJob) { async handleRecognizeFaces({ id, deferred }: IDeferrableJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
return true; return JobStatus.SKIPPED;
} }
const face = await this.repository.getFaceByIdWithAssets( const face = await this.repository.getFaceByIdWithAssets(
@ -393,12 +394,12 @@ export class PersonService {
); );
if (!face || !face.asset) { if (!face || !face.asset) {
this.logger.warn(`Face ${id} not found`); this.logger.warn(`Face ${id} not found`);
return false; return JobStatus.FAILED;
} }
if (face.personId) { if (face.personId) {
this.logger.debug(`Face ${id} already has a person assigned`); this.logger.debug(`Face ${id} already has a person assigned`);
return true; return JobStatus.SKIPPED;
} }
const matches = await this.smartInfoRepository.searchFaces({ const matches = await this.smartInfoRepository.searchFaces({
@ -411,7 +412,7 @@ export class PersonService {
// `matches` also includes the face itself // `matches` also includes the face itself
if (machineLearning.facialRecognition.minFaces > 1 && matches.length <= 1) { if (machineLearning.facialRecognition.minFaces > 1 && matches.length <= 1) {
this.logger.debug(`Face ${id} only matched the face itself, skipping`); this.logger.debug(`Face ${id} only matched the face itself, skipping`);
return true; return JobStatus.SKIPPED;
} }
this.logger.debug(`Face ${id} has ${matches.length} matches`); this.logger.debug(`Face ${id} has ${matches.length} matches`);
@ -420,7 +421,7 @@ export class PersonService {
if (!isCore && !deferred) { if (!isCore && !deferred) {
this.logger.debug(`Deferring non-core face ${id} for later processing`); this.logger.debug(`Deferring non-core face ${id} for later processing`);
await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } }); await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } });
return true; return JobStatus.SKIPPED;
} }
let personId = matches.find((match) => match.face.personId)?.face.personId; let personId = matches.find((match) => match.face.personId)?.face.personId;
@ -450,34 +451,34 @@ export class PersonService {
await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId }); await this.repository.reassignFaces({ faceIds: [id], newPersonId: personId });
} }
return true; return JobStatus.SUCCESS;
} }
async handlePersonMigration({ id }: IEntityJob) { async handlePersonMigration({ id }: IEntityJob): Promise<JobStatus> {
const person = await this.repository.getById(id); const person = await this.repository.getById(id);
if (!person) { if (!person) {
return false; return JobStatus.FAILED;
} }
await this.storageCore.movePersonFile(person, PersonPathType.FACE); await this.storageCore.movePersonFile(person, PersonPathType.FACE);
return true; return JobStatus.SUCCESS;
} }
async handleGeneratePersonThumbnail(data: IEntityJob) { async handleGeneratePersonThumbnail(data: IEntityJob): Promise<JobStatus> {
const { machineLearning, thumbnail } = await this.configCore.getConfig(); const { machineLearning, thumbnail } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) { if (!machineLearning.enabled || !machineLearning.facialRecognition.enabled) {
return true; return JobStatus.SKIPPED;
} }
const person = await this.repository.getById(data.id); const person = await this.repository.getById(data.id);
if (!person?.faceAssetId) { if (!person?.faceAssetId) {
return false; return JobStatus.FAILED;
} }
const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId); const face = await this.repository.getFaceByIdWithAssets(person.faceAssetId);
if (face === null) { if (face === null) {
return false; return JobStatus.FAILED;
} }
const { const {
@ -492,7 +493,7 @@ export class PersonService {
const [asset] = await this.assetRepository.getByIds([assetId]); const [asset] = await this.assetRepository.getByIds([assetId]);
if (!asset?.resizePath) { if (!asset?.resizePath) {
return false; return JobStatus.FAILED;
} }
this.logger.verbose(`Cropping face for person: ${person.id}`); this.logger.verbose(`Cropping face for person: ${person.id}`);
const thumbnailPath = StorageCore.getPersonThumbnailPath(person); const thumbnailPath = StorageCore.getPersonThumbnailPath(person);
@ -533,7 +534,7 @@ export class PersonService {
await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions); await this.mediaRepository.resize(croppedOutput, thumbnailPath, thumbnailOptions);
await this.repository.update({ id: person.id, thumbnailPath }); await this.repository.update({ id: person.id, thumbnailPath });
return true; return JobStatus.SUCCESS;
} }
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> { async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {

View File

@ -96,7 +96,13 @@ export type JobItem =
| { name: JobName.LIBRARY_CHECK_IF_ASSET_ONLINE; data: IEntityJob } | { name: JobName.LIBRARY_CHECK_IF_ASSET_ONLINE; data: IEntityJob }
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }; | { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>; export enum JobStatus {
SUCCESS = 'success',
FAILED = 'failed',
SKIPPED = 'skipped',
}
export type JobHandler<T = any> = (data: T) => Promise<JobStatus>;
export type JobItemHandler = (item: JobItem) => Promise<void>; export type JobItemHandler = (item: JobItem) => Promise<void>;
export const IJobRepository = 'IJobRepository'; export const IJobRepository = 'IJobRepository';

View File

@ -10,6 +10,7 @@ import {
IMachineLearningRepository, IMachineLearningRepository,
ISearchRepository, ISearchRepository,
ISystemConfigRepository, ISystemConfigRepository,
JobStatus,
WithoutProperty, WithoutProperty,
} from '../repositories'; } from '../repositories';
import { SystemConfigCore } from '../system-config'; import { SystemConfigCore } from '../system-config';
@ -44,10 +45,10 @@ export class SmartInfoService {
await this.jobRepository.resume(QueueName.SMART_SEARCH); await this.jobRepository.resume(QueueName.SMART_SEARCH);
} }
async handleQueueEncodeClip({ force }: IBaseJob) { async handleQueueEncodeClip({ force }: IBaseJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.clip.enabled) { if (!machineLearning.enabled || !machineLearning.clip.enabled) {
return true; return JobStatus.SKIPPED;
} }
if (force) { if (force) {
@ -66,22 +67,22 @@ export class SmartInfoService {
); );
} }
return true; return JobStatus.SUCCESS;
} }
async handleEncodeClip({ id }: IEntityJob) { async handleEncodeClip({ id }: IEntityJob): Promise<JobStatus> {
const { machineLearning } = await this.configCore.getConfig(); const { machineLearning } = await this.configCore.getConfig();
if (!machineLearning.enabled || !machineLearning.clip.enabled) { if (!machineLearning.enabled || !machineLearning.clip.enabled) {
return true; return JobStatus.SKIPPED;
} }
const [asset] = await this.assetRepository.getByIds([id]); const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
if (!asset.resizePath) { if (!asset.resizePath) {
return false; return JobStatus.FAILED;
} }
const clipEmbedding = await this.machineLearning.encodeImage( const clipEmbedding = await this.machineLearning.encodeImage(
@ -97,6 +98,6 @@ export class SmartInfoService {
await this.repository.upsert({ assetId: asset.id }, clipEmbedding); await this.repository.upsert({ assetId: asset.id }, clipEmbedding);
return true; return JobStatus.SUCCESS;
} }
} }

View File

@ -8,6 +8,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository, IUserRepository,
JobStatus,
StorageTemplateService, StorageTemplateService,
defaults, defaults,
} from '@app/domain'; } from '@app/domain';
@ -76,7 +77,7 @@ describe(StorageTemplateService.name, () => {
describe('handleMigrationSingle', () => { describe('handleMigrationSingle', () => {
it('should skip when storage template is disabled', async () => { it('should skip when storage template is disabled', async () => {
configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]); configMock.load.mockResolvedValue([{ key: SystemConfigKey.STORAGE_TEMPLATE_ENABLED, value: false }]);
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SKIPPED);
expect(assetMock.getByIds).not.toHaveBeenCalled(); expect(assetMock.getByIds).not.toHaveBeenCalled();
expect(storageMock.checkFileExists).not.toHaveBeenCalled(); expect(storageMock.checkFileExists).not.toHaveBeenCalled();
expect(storageMock.rename).not.toHaveBeenCalled(); expect(storageMock.rename).not.toHaveBeenCalled();
@ -138,7 +139,9 @@ describe(StorageTemplateService.name, () => {
newPath: newMotionPicturePath, newPath: newMotionPicturePath,
}); });
await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(true); await expect(sut.handleMigrationSingle({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
JobStatus.SUCCESS,
);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id], { exifInfo: true });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id], { exifInfo: true });
@ -190,7 +193,7 @@ describe(StorageTemplateService.name, () => {
newPath, newPath,
}); });
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
@ -247,7 +250,7 @@ describe(StorageTemplateService.name, () => {
newPath, newPath,
}); });
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);
@ -298,7 +301,7 @@ describe(StorageTemplateService.name, () => {
newPath, newPath,
}); });
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(1);
@ -364,7 +367,7 @@ describe(StorageTemplateService.name, () => {
newPath, newPath,
}); });
await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(true); await expect(sut.handleMigrationSingle({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true }); expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.image.id], { exifInfo: true });
expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3); expect(storageMock.checkFileExists).toHaveBeenCalledTimes(3);

View File

@ -18,6 +18,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository, IUserRepository,
JobStatus,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
import { import {
@ -85,16 +86,16 @@ export class StorageTemplateService {
); );
} }
async handleMigrationSingle({ id }: IEntityJob) { async handleMigrationSingle({ id }: IEntityJob): Promise<JobStatus> {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
const storageTemplateEnabled = config.storageTemplate.enabled; const storageTemplateEnabled = config.storageTemplate.enabled;
if (!storageTemplateEnabled) { if (!storageTemplateEnabled) {
return true; return JobStatus.SKIPPED;
} }
const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true }); const [asset] = await this.assetRepository.getByIds([id], { exifInfo: true });
if (!asset) { if (!asset) {
return false; return JobStatus.FAILED;
} }
const user = await this.userRepository.get(asset.ownerId, {}); const user = await this.userRepository.get(asset.ownerId, {});
@ -106,21 +107,21 @@ export class StorageTemplateService {
if (asset.livePhotoVideoId) { if (asset.livePhotoVideoId) {
const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId], { exifInfo: true }); const [livePhotoVideo] = await this.assetRepository.getByIds([asset.livePhotoVideoId], { exifInfo: true });
if (!livePhotoVideo) { if (!livePhotoVideo) {
return false; return JobStatus.FAILED;
} }
const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath); const motionFilename = getLivePhotoMotionFilename(filename, livePhotoVideo.originalPath);
await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename }); await this.moveAsset(livePhotoVideo, { storageLabel, filename: motionFilename });
} }
return true; return JobStatus.SUCCESS;
} }
async handleMigration() { async handleMigration(): Promise<JobStatus> {
this.logger.log('Starting storage template migration'); this.logger.log('Starting storage template migration');
const { storageTemplate } = await this.configCore.getConfig(); const { storageTemplate } = await this.configCore.getConfig();
const { enabled } = storageTemplate; const { enabled } = storageTemplate;
if (!enabled) { if (!enabled) {
this.logger.log('Storage template migration disabled, skipping'); this.logger.log('Storage template migration disabled, skipping');
return true; return JobStatus.SKIPPED;
} }
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination, { withExif: true }), this.assetRepository.getAll(pagination, { withExif: true }),
@ -142,7 +143,7 @@ export class StorageTemplateService {
this.logger.log('Finished storage template migration'); this.logger.log('Finished storage template migration');
return true; return JobStatus.SUCCESS;
} }
async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {

View File

@ -20,6 +20,9 @@ export enum StorageFolder {
THUMBNAILS = 'thumbs', THUMBNAILS = 'thumbs',
} }
export const THUMBNAIL_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.THUMBNAILS));
export const ENCODED_VIDEO_DIR = resolve(join(APP_MEDIA_LOCATION, StorageFolder.ENCODED_VIDEO));
export interface MoveRequest { export interface MoveRequest {
entityId: string; entityId: string;
pathType: PathType; pathType: PathType;
@ -115,6 +118,10 @@ export class StorageCore {
return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION)); return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION));
} }
static isGeneratedAsset(path: string) {
return path.startsWith(THUMBNAIL_DIR) || path.startsWith(ENCODED_VIDEO_DIR);
}
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) { async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset; const { id: entityId, resizePath, webpPath, encodedVideoPath } = asset;
switch (pathType) { switch (pathType) {

View File

@ -1,7 +1,7 @@
import { ImmichLogger } from '@app/infra/logger'; import { ImmichLogger } from '@app/infra/logger';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { IDeleteFilesJob } from '../job'; import { IDeleteFilesJob } from '../job';
import { IStorageRepository } from '../repositories'; import { IStorageRepository, JobStatus } from '../repositories';
import { StorageCore, StorageFolder } from './storage.core'; import { StorageCore, StorageFolder } from './storage.core';
@Injectable() @Injectable()
@ -31,6 +31,6 @@ export class StorageService {
} }
} }
return true; return JobStatus.SUCCESS;
} }
} }

View File

@ -4,7 +4,7 @@ export const supportedWeekTokens = ['W', 'WW'];
export const supportedDayTokens = ['d', 'dd']; export const supportedDayTokens = ['d', 'dd'];
export const supportedHourTokens = ['h', 'hh', 'H', 'HH']; export const supportedHourTokens = ['h', 'hh', 'H', 'HH'];
export const supportedMinuteTokens = ['m', 'mm']; export const supportedMinuteTokens = ['m', 'mm'];
export const supportedSecondTokens = ['s', 'ss']; export const supportedSecondTokens = ['s', 'ss', 'SSS'];
export const supportedPresetTokens = [ export const supportedPresetTokens = [
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}', '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}-{{dd}}/{{filename}}', '{{y}}/{{MM}}-{{dd}}/{{filename}}',

View File

@ -323,7 +323,7 @@ describe(SystemConfigService.name, () => {
'{{y}}/{{y}}-{{WW}}/{{assetId}}', '{{y}}/{{y}}-{{WW}}/{{assetId}}',
'{{album}}/{{filename}}', '{{album}}/{{filename}}',
], ],
secondOptions: ['s', 'ss'], secondOptions: ['s', 'ss', 'SSS'],
weekOptions: ['W', 'WW'], weekOptions: ['W', 'WW'],
yearOptions: ['y', 'yy'], yearOptions: ['y', 'yy'],
}); });

View File

@ -14,6 +14,7 @@ import {
IStorageRepository, IStorageRepository,
ISystemConfigRepository, ISystemConfigRepository,
IUserRepository, IUserRepository,
JobStatus,
UserFindOptions, UserFindOptions,
} from '../repositories'; } from '../repositories';
import { StorageCore, StorageFolder } from '../storage'; import { StorageCore, StorageFolder } from '../storage';
@ -143,12 +144,12 @@ export class UserService {
return { admin, password, provided: !!providedPassword }; return { admin, password, provided: !!providedPassword };
} }
async handleUserSyncUsage() { async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage(); await this.userRepository.syncUsage();
return true; return JobStatus.SUCCESS;
} }
async handleUserDeleteCheck() { async handleUserDeleteCheck(): Promise<JobStatus> {
const users = await this.userRepository.getDeletedUsers(); const users = await this.userRepository.getDeletedUsers();
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
await this.jobRepository.queueAll( await this.jobRepository.queueAll(
@ -158,20 +159,20 @@ export class UserService {
: [], : [],
), ),
); );
return true; return JobStatus.SUCCESS;
} }
async handleUserDelete({ id, force }: IEntityJob) { async handleUserDelete({ id, force }: IEntityJob): Promise<JobStatus> {
const config = await this.configCore.getConfig(); const config = await this.configCore.getConfig();
const user = await this.userRepository.get(id, { withDeleted: true }); const user = await this.userRepository.get(id, { withDeleted: true });
if (!user) { if (!user) {
return false; return JobStatus.FAILED;
} }
// just for extra protection here // just for extra protection here
if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) { if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) {
this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`);
return false; return JobStatus.SKIPPED;
} }
this.logger.log(`Deleting user: ${user.id}`); this.logger.log(`Deleting user: ${user.id}`);
@ -193,7 +194,7 @@ export class UserService {
await this.albumRepository.deleteAll(user.id); await this.albumRepository.deleteAll(user.id);
await this.userRepository.delete(user, true); await this.userRepository.delete(user, true);
return true; return JobStatus.SUCCESS;
} }
private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean { private isReadyForDeletion(user: UserEntity, deleteDelay: number): boolean {

View File

@ -13,7 +13,6 @@ import {
Res, Res,
UploadedFiles, UploadedFiles,
UseInterceptors, UseInterceptors,
ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
@ -58,7 +57,7 @@ export class AssetController {
async uploadFile( async uploadFile(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles, @UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Body(new ValidationPipe({ transform: true })) dto: CreateAssetDto, @Body() dto: CreateAssetDto,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<AssetFileUploadResponseDto> { ): Promise<AssetFileUploadResponseDto> {
const file = mapToUploadFile(files.assetData[0]); const file = mapToUploadFile(files.assetData[0]);
@ -90,7 +89,7 @@ export class AssetController {
@Next() next: NextFunction, @Next() next: NextFunction,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: ServeFileDto, @Query() dto: ServeFileDto,
) { ) {
await sendFile(res, next, () => this.serviceV1.serveFile(auth, id, dto)); await sendFile(res, next, () => this.serviceV1.serveFile(auth, id, dto));
} }
@ -103,7 +102,7 @@ export class AssetController {
@Next() next: NextFunction, @Next() next: NextFunction,
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Query(new ValidationPipe({ transform: true })) dto: GetAssetThumbnailDto, @Query() dto: GetAssetThumbnailDto,
) { ) {
await sendFile(res, next, () => this.serviceV1.serveThumbnail(auth, id, dto)); await sendFile(res, next, () => this.serviceV1.serveThumbnail(auth, id, dto));
} }
@ -133,10 +132,7 @@ export class AssetController {
required: false, required: false,
schema: { type: 'string' }, schema: { type: 'string' },
}) })
getAllAssets( getAllAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise<AssetResponseDto[]> {
@Auth() auth: AuthDto,
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
): Promise<AssetResponseDto[]> {
return this.serviceV1.getAllAssets(auth, dto); return this.serviceV1.getAllAssets(auth, dto);
} }
@ -147,7 +143,7 @@ export class AssetController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
checkExistingAssets( checkExistingAssets(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Body(ValidationPipe) dto: CheckExistingAssetsDto, @Body() dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> { ): Promise<CheckExistingAssetsResponseDto> {
return this.serviceV1.checkExistingAssets(auth, dto); return this.serviceV1.checkExistingAssets(auth, dto);
} }
@ -159,7 +155,7 @@ export class AssetController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
checkBulkUpload( checkBulkUpload(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Body(ValidationPipe) dto: AssetBulkUploadCheckDto, @Body() dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> { ): Promise<AssetBulkUploadCheckResponseDto> {
return this.serviceV1.bulkUploadCheck(auth, dto); return this.serviceV1.bulkUploadCheck(auth, dto);
} }

View File

@ -1,8 +1,8 @@
import { DomainModule } from '@app/domain'; import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra'; import { InfraModule } from '@app/infra';
import { AssetEntity, ExifEntity } from '@app/infra/entities'; import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { Module, OnModuleInit } from '@nestjs/common'; import { Module, OnModuleInit, ValidationPipe } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core'; import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AssetRepositoryV1, IAssetRepositoryV1 } from './api-v1/asset/asset-repository'; import { AssetRepositoryV1, IAssetRepositoryV1 } from './api-v1/asset/asset-repository';
@ -70,6 +70,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
PersonController, PersonController,
], ],
providers: [ providers: [
{ provide: APP_PIPE, useValue: new ValidationPipe({ transform: true, whitelist: true }) },
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
{ provide: APP_GUARD, useClass: AppGuard }, { provide: APP_GUARD, useClass: AppGuard },
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 }, { provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },

View File

@ -17,28 +17,15 @@ import {
SwaggerDocumentOptions, SwaggerDocumentOptions,
SwaggerModule, SwaggerModule,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import _ from 'lodash'; import _ from 'lodash';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { access, constants } from 'node:fs/promises'; import { access, constants } from 'node:fs/promises';
import path, { isAbsolute } from 'node:path'; import path, { isAbsolute } from 'node:path';
import { promisify } from 'node:util'; import { promisify } from 'node:util';
import { applyDecorators, UsePipes, ValidationPipe } from '@nestjs/common';
import { SchemaObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import { Metadata } from './app.guard'; import { Metadata } from './app.guard';
export function UseValidation() {
return applyDecorators(
UsePipes(
new ValidationPipe({
transform: true,
whitelist: true,
}),
),
);
}
type SendFile = Parameters<Response['sendFile']>; type SendFile = Parameters<Response['sendFile']>;
type SendFileOptions = SendFile[1]; type SendFileOptions = SendFile[1];

View File

@ -11,13 +11,11 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Query
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { Auth, Authenticated } from '../app.guard'; import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Activity') @ApiTags('Activity')
@Controller('activity') @Controller('activity')
@Authenticated() @Authenticated()
@UseValidation()
export class ActivityController { export class ActivityController {
constructor(private service: ActivityService) {} constructor(private service: ActivityService) {}

View File

@ -15,13 +15,11 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe'; import { ParseMeUUIDPipe } from '../api-v1/validation/parse-me-uuid-pipe';
import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; import { Auth, Authenticated, SharedLinkRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Album') @ApiTags('Album')
@Controller('album') @Controller('album')
@Authenticated() @Authenticated()
@UseValidation()
export class AlbumController { export class AlbumController {
constructor(private service: AlbumService) {} constructor(private service: AlbumService) {}

View File

@ -9,13 +9,11 @@ import {
import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard'; import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('API Key') @ApiTags('API Key')
@Controller('api-key') @Controller('api-key')
@Authenticated() @Authenticated()
@UseValidation()
export class APIKeyController { export class APIKeyController {
constructor(private service: APIKeyService) {} constructor(private service: APIKeyService) {}

View File

@ -8,7 +8,6 @@ import {
AssetStatsResponseDto, AssetStatsResponseDto,
AuthDto, AuthDto,
DeviceIdDto, DeviceIdDto,
DownloadService,
MapMarkerDto, MapMarkerDto,
MapMarkerResponseDto, MapMarkerResponseDto,
MemoryLaneDto, MemoryLaneDto,
@ -19,21 +18,18 @@ import {
TimeBucketAssetDto, TimeBucketAssetDto,
TimeBucketDto, TimeBucketDto,
TimeBucketResponseDto, TimeBucketResponseDto,
TrashService,
UpdateAssetDto as UpdateDto, UpdateAssetDto as UpdateDto,
UpdateStackParentDto, UpdateStackParentDto,
} from '@app/domain'; } from '@app/domain';
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated, SharedLinkRoute } from '../app.guard'; import { Auth, Authenticated, SharedLinkRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
import { Route } from '../interceptors'; import { Route } from '../interceptors';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Asset') @ApiTags('Asset')
@Controller('assets') @Controller('assets')
@Authenticated() @Authenticated()
@UseValidation()
export class AssetsController { export class AssetsController {
constructor(private searchService: SearchService) {} constructor(private searchService: SearchService) {}
@ -50,13 +46,8 @@ export class AssetsController {
@ApiTags('Asset') @ApiTags('Asset')
@Controller(Route.ASSET) @Controller(Route.ASSET)
@Authenticated() @Authenticated()
@UseValidation()
export class AssetController { export class AssetController {
constructor( constructor(private service: AssetService) {}
private service: AssetService,
private downloadService: DownloadService,
private trashService: TrashService,
) {}
@Get('map-marker') @Get('map-marker')
getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> { getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {

View File

@ -11,12 +11,10 @@ import {
import { Body, Controller, Get, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AdminRoute, Auth, Authenticated } from '../app.guard'; import { AdminRoute, Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('Audit') @ApiTags('Audit')
@Controller('audit') @Controller('audit')
@Authenticated() @Authenticated()
@UseValidation()
export class AuditController { export class AuditController {
constructor(private service: AuditService) {} constructor(private service: AuditService) {}

View File

@ -19,13 +19,11 @@ import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Req,
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from '../app.guard'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Authentication') @ApiTags('Authentication')
@Controller('auth') @Controller('auth')
@Authenticated() @Authenticated()
@UseValidation()
export class AuthController { export class AuthController {
constructor(private service: AuthService) {} constructor(private service: AuthService) {}

View File

@ -3,13 +3,12 @@ import { Body, Controller, HttpCode, HttpStatus, Next, Param, Post, Res, Streama
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard'; import { Auth, Authenticated, FileResponse, SharedLinkRoute } from '../app.guard';
import { UseValidation, asStreamableFile, sendFile } from '../app.utils'; import { asStreamableFile, sendFile } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Download') @ApiTags('Download')
@Controller('download') @Controller('download')
@Authenticated() @Authenticated()
@UseValidation()
export class DownloadController { export class DownloadController {
constructor(private service: DownloadService) {} constructor(private service: DownloadService) {}

View File

@ -2,13 +2,11 @@ import { AssetFaceResponseDto, AuthDto, FaceDto, PersonResponseDto, PersonServic
import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard'; import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Face') @ApiTags('Face')
@Controller('face') @Controller('face')
@Authenticated() @Authenticated()
@UseValidation()
export class FaceController { export class FaceController {
constructor(private service: PersonService) {} constructor(private service: PersonService) {}

View File

@ -2,12 +2,10 @@ import { AllJobStatusResponseDto, JobCommandDto, JobIdParamDto, JobService, JobS
import { Body, Controller, Get, Param, Put } from '@nestjs/common'; import { Body, Controller, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../app.guard'; import { Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('Job') @ApiTags('Job')
@Controller('jobs') @Controller('jobs')
@Authenticated({ admin: true }) @Authenticated({ admin: true })
@UseValidation()
export class JobController { export class JobController {
constructor(private service: JobService) {} constructor(private service: JobService) {}

View File

@ -13,13 +13,11 @@ import {
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { AdminRoute, Auth, Authenticated } from '../app.guard'; import { AdminRoute, Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Library') @ApiTags('Library')
@Controller('library') @Controller('library')
@Authenticated() @Authenticated()
@UseValidation()
@AdminRoute() @AdminRoute()
export class LibraryController { export class LibraryController {
constructor(private service: LibraryService) {} constructor(private service: LibraryService) {}

View File

@ -12,12 +12,10 @@ import { Body, Controller, Get, HttpStatus, Post, Redirect, Req, Res } from '@ne
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { Auth, Authenticated, GetLoginDetails, PublicRoute } from '../app.guard'; import { Auth, Authenticated, GetLoginDetails, PublicRoute } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('OAuth') @ApiTags('OAuth')
@Controller('oauth') @Controller('oauth')
@Authenticated() @Authenticated()
@UseValidation()
export class OAuthController { export class OAuthController {
constructor(private service: AuthService) {} constructor(private service: AuthService) {}

View File

@ -3,13 +3,11 @@ import { PartnerResponseDto, UpdatePartnerDto } from '@app/domain/partner/partne
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard'; import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Partner') @ApiTags('Partner')
@Controller('partner') @Controller('partner')
@Authenticated() @Authenticated()
@UseValidation()
export class PartnerController { export class PartnerController {
constructor(private service: PartnerService) {} constructor(private service: PartnerService) {}

View File

@ -17,13 +17,12 @@ import { Body, Controller, Get, Next, Param, Post, Put, Query, Res } from '@nest
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
import { Auth, Authenticated, FileResponse } from '../app.guard'; import { Auth, Authenticated, FileResponse } from '../app.guard';
import { UseValidation, sendFile } from '../app.utils'; import { sendFile } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto'; import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Person') @ApiTags('Person')
@Controller('person') @Controller('person')
@Authenticated() @Authenticated()
@UseValidation()
export class PersonController { export class PersonController {
constructor(private service: PersonService) {} constructor(private service: PersonService) {}

View File

@ -15,12 +15,10 @@ import { SearchSuggestionRequestDto } from '@app/domain/search/dto/search-sugges
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common'; import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Auth, Authenticated } from '../app.guard'; import { Auth, Authenticated } from '../app.guard';
import { UseValidation } from '../app.utils';
@ApiTags('Search') @ApiTags('Search')
@Controller('search') @Controller('search')
@Authenticated() @Authenticated()
@UseValidation()
export class SearchController { export class SearchController {
constructor(private service: SearchService) {} constructor(private service: SearchService) {}

Some files were not shown because too many files have changed in this diff Show More