mirror of
https://github.com/immich-app/immich.git
synced 2026-02-02 02:04:47 -08:00
Compare commits
1 Commits
sqlite_thu
...
renovate/t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e319c77a |
@@ -16,7 +16,7 @@ config_roots = [
|
||||
[tools]
|
||||
node = "24.13.0"
|
||||
flutter = "3.35.7"
|
||||
pnpm = "10.28.0"
|
||||
pnpm = "10.28.2"
|
||||
terragrunt = "0.98.0"
|
||||
opentofu = "1.11.4"
|
||||
java = "21.0.2"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "2.5.2",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.28.0+sha512.05df71d1421f21399e053fde567cea34d446fa02c76571441bfc1c7956e98e363088982d940465fd34480d4d90a0668bc12362f8aa88000a64e83d0b0e47be48",
|
||||
"packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264",
|
||||
"engines": {
|
||||
"pnpm": ">=10.0.0"
|
||||
}
|
||||
|
||||
3132
pnpm-lock.yaml
generated
3132
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,6 @@ onlyBuiltDependencies:
|
||||
- sharp
|
||||
- '@tailwindcss/oxide'
|
||||
- bcrypt
|
||||
- better-sqlite3
|
||||
overrides:
|
||||
canvas: 2.11.2
|
||||
sharp: ^0.34.5
|
||||
@@ -60,10 +59,6 @@ packageExtensions:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
better-sqlite3:
|
||||
dependencies:
|
||||
node-addon-api: '*'
|
||||
node-gyp: '*'
|
||||
dedupePeerDependents: false
|
||||
preferWorkspacePackages: true
|
||||
injectWorkspacePackages: true
|
||||
|
||||
@@ -6,4 +6,4 @@ if [[ "$IMMICH_ENV" == "production" ]]; then
|
||||
fi
|
||||
|
||||
cd /usr/src/app || exit
|
||||
exec pnpm --filter immich exec nest start --no-shell --debug "0.0.0.0:9230" --watch -- "$@"
|
||||
pnpm --filter immich exec nest start --debug "0.0.0.0:9230" --watch -- "$@"
|
||||
|
||||
@@ -45,14 +45,14 @@
|
||||
"@nestjs/websockets": "^11.0.4",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "^2.0.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.210.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.210.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.58.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.56.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.62.0",
|
||||
"@opentelemetry/exporter-prometheus": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-http": "^0.211.0",
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.59.0",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.57.0",
|
||||
"@opentelemetry/instrumentation-pg": "^0.63.0",
|
||||
"@opentelemetry/resources": "^2.0.1",
|
||||
"@opentelemetry/sdk-metrics": "^2.0.1",
|
||||
"@opentelemetry/sdk-node": "^0.210.0",
|
||||
"@opentelemetry/sdk-node": "^0.211.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.34.0",
|
||||
"@react-email/components": "^0.5.0",
|
||||
"@react-email/render": "^1.1.2",
|
||||
@@ -61,7 +61,6 @@
|
||||
"archiver": "^7.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"body-parser": "^2.2.0",
|
||||
"bullmq": "^5.51.0",
|
||||
"chokidar": "^4.0.3",
|
||||
@@ -70,7 +69,7 @@
|
||||
"compression": "^1.8.0",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cron": "4.3.5",
|
||||
"cron": "4.4.0",
|
||||
"exiftool-vendored": "^34.3.0",
|
||||
"express": "^5.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
@@ -82,7 +81,7 @@
|
||||
"jose": "^5.10.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"kysely": "0.28.2",
|
||||
"kysely": "0.28.10",
|
||||
"kysely-postgres-js": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^3.4.2",
|
||||
@@ -125,7 +124,6 @@
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/body-parser": "^1.19.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
|
||||
@@ -89,10 +89,8 @@ export class BaseModule implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
console.log(`[${this.worker}] onModuleDestroy called - emitting AppShutdown`);
|
||||
await this.eventRepository.emit('AppShutdown');
|
||||
await teardownTelemetry();
|
||||
console.log(`[${this.worker}] onModuleDestroy complete`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,8 +148,6 @@ export class ImmichAdminModule implements OnModuleDestroy {
|
||||
constructor(private service: CliService) {}
|
||||
|
||||
async onModuleDestroy() {
|
||||
console.log('[ImmichAdmin] onModuleDestroy called');
|
||||
await this.service.cleanup();
|
||||
console.log('[ImmichAdmin] onModuleDestroy complete');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import {
|
||||
PromptConfirmMoveQuestions,
|
||||
PromptMediaLocationQuestions,
|
||||
} from 'src/commands/media-location.command';
|
||||
import {
|
||||
MigrateThumbnailsCommand,
|
||||
PromptConfirmMigrationQuestion,
|
||||
} from 'src/commands/migrate-thumbnails.command';
|
||||
import { DisableOAuthLogin, EnableOAuthLogin } from 'src/commands/oauth-login';
|
||||
import { DisablePasswordLoginCommand, EnablePasswordLoginCommand } from 'src/commands/password-login';
|
||||
import { PromptPasswordQuestions, ResetAdminPasswordCommand } from 'src/commands/reset-admin-password.command';
|
||||
@@ -32,6 +28,4 @@ export const commandsAndQuestions = [
|
||||
ChangeMediaLocationCommand,
|
||||
PromptMediaLocationQuestions,
|
||||
PromptConfirmMoveQuestions,
|
||||
MigrateThumbnailsCommand,
|
||||
PromptConfirmMigrationQuestion,
|
||||
];
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
import { Command, CommandRunner, InquirerService, Option, Question, QuestionSet } from 'nest-commander';
|
||||
import { CliService } from 'src/services/cli.service';
|
||||
|
||||
@Command({
|
||||
name: 'migrate-thumbnails-to-sqlite',
|
||||
description: 'Migrate thumbnails from filesystem to SQLite storage',
|
||||
})
|
||||
export class MigrateThumbnailsCommand extends CommandRunner {
|
||||
constructor(
|
||||
private service: CliService,
|
||||
private inquirer: InquirerService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-p, --path <path>',
|
||||
description: 'Absolute path to the SQLite database file',
|
||||
})
|
||||
parsePath(value: string) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Option({
|
||||
flags: '-y, --yes',
|
||||
description: 'Skip confirmation prompt',
|
||||
})
|
||||
parseYes() {
|
||||
return true;
|
||||
}
|
||||
|
||||
async run(passedParams: string[], options: { path?: string; yes?: boolean }): Promise<void> {
|
||||
try {
|
||||
const sqlitePath = options.path ?? this.service.getDefaultThumbnailStoragePath();
|
||||
|
||||
console.log(`\nMigration settings:`);
|
||||
console.log(` SQLite path: ${sqlitePath}`);
|
||||
console.log(`\nThis will read all thumbnail files from the filesystem and store them in SQLite.`);
|
||||
console.log(`Existing entries in SQLite will be skipped.\n`);
|
||||
|
||||
if (!options.yes) {
|
||||
const { confirmed } = await this.inquirer.ask<{ confirmed: boolean }>('prompt-confirm-migration', {});
|
||||
if (!confirmed) {
|
||||
console.log('Migration cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nStarting migration...\n');
|
||||
|
||||
let lastProgressUpdate = 0;
|
||||
|
||||
const result = await this.service.migrateThumbnailsToSqlite({
|
||||
sqlitePath,
|
||||
onProgress: ({ current, migrated, skipped, errors }) => {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressUpdate > 500 || current === 1) {
|
||||
lastProgressUpdate = now;
|
||||
process.stdout.write(
|
||||
`\rProcessed: ${current} | Migrated: ${migrated} | Skipped: ${skipped} | Errors: ${errors}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\n\nMigration complete!`);
|
||||
console.log(` Total processed: ${result.total}`);
|
||||
console.log(` Migrated: ${result.migrated}`);
|
||||
console.log(` Skipped: ${result.skipped}`);
|
||||
console.log(` Errors: ${result.errors}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error('Migration failed.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@QuestionSet({ name: 'prompt-confirm-migration' })
|
||||
export class PromptConfirmMigrationQuestion {
|
||||
@Question({
|
||||
message: 'Do you want to proceed with the migration? [Y/n]',
|
||||
name: 'confirmed',
|
||||
})
|
||||
parseConfirmed(value: string): boolean {
|
||||
return ['yes', 'y', ''].includes((value || 'y').toLowerCase());
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.inte
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadFiles } from 'src/types';
|
||||
import { ImmichBufferResponse, ImmichFileResponse, sendBuffer, sendFile } from 'src/utils/file';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@@ -163,29 +163,26 @@ export class AssetMediaController {
|
||||
|
||||
if (viewThumbnailRes instanceof ImmichFileResponse) {
|
||||
await sendFile(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
||||
return;
|
||||
}
|
||||
|
||||
if (viewThumbnailRes instanceof ImmichBufferResponse) {
|
||||
await sendBuffer(res, next, () => Promise.resolve(viewThumbnailRes), this.logger);
|
||||
return;
|
||||
}
|
||||
|
||||
const { targetSize } = viewThumbnailRes;
|
||||
const [reqPath, reqSearch] = req.url.split('?');
|
||||
let redirPath: string;
|
||||
const redirSearchParams = new URLSearchParams(reqSearch);
|
||||
if (targetSize === 'original') {
|
||||
redirPath = 'original';
|
||||
redirSearchParams.delete('size');
|
||||
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
|
||||
redirPath = reqPath;
|
||||
redirSearchParams.set('size', targetSize);
|
||||
} else {
|
||||
throw new Error('Invalid targetSize: ' + targetSize);
|
||||
// viewThumbnailRes is a AssetMediaRedirectResponse
|
||||
// which redirects to the original asset or a specific size to make better use of caching
|
||||
const { targetSize } = viewThumbnailRes;
|
||||
const [reqPath, reqSearch] = req.url.split('?');
|
||||
let redirPath: string;
|
||||
const redirSearchParams = new URLSearchParams(reqSearch);
|
||||
if (targetSize === 'original') {
|
||||
// relative path to this.downloadAsset
|
||||
redirPath = 'original';
|
||||
redirSearchParams.delete('size');
|
||||
} else if (Object.values(AssetMediaSize).includes(targetSize)) {
|
||||
redirPath = reqPath;
|
||||
redirSearchParams.set('size', targetSize);
|
||||
} else {
|
||||
throw new Error('Invalid targetSize: ' + targetSize);
|
||||
}
|
||||
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
|
||||
return res.redirect(finalRedirPath);
|
||||
}
|
||||
const finalRedirPath = redirPath + '?' + redirSearchParams.toString();
|
||||
return res.redirect(finalRedirPath);
|
||||
}
|
||||
|
||||
@Get(':id/video/playback')
|
||||
|
||||
@@ -120,10 +120,6 @@ export class StorageCore {
|
||||
);
|
||||
}
|
||||
|
||||
static getThumbnailStoragePath(): string {
|
||||
return join(StorageCore.getMediaLocation(), 'thumbnails.sqlite3');
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
@@ -140,10 +140,6 @@ export class EnvDto {
|
||||
@Optional()
|
||||
IMMICH_WORKERS_EXCLUDE?: string;
|
||||
|
||||
@Optional()
|
||||
@Matches(/^\//, { message: 'IMMICH_THUMBNAIL_SQLITE_PATH must be an absolute path' })
|
||||
IMMICH_THUMBNAIL_SQLITE_PATH?: string;
|
||||
|
||||
@IsString()
|
||||
@Optional()
|
||||
DB_DATABASE_NAME?: string;
|
||||
|
||||
@@ -25,16 +25,10 @@ class Workers {
|
||||
*/
|
||||
restarting = false;
|
||||
|
||||
/**
|
||||
* Whether we're in graceful shutdown
|
||||
*/
|
||||
shuttingDown = false;
|
||||
|
||||
/**
|
||||
* Boot all enabled workers
|
||||
*/
|
||||
async bootstrap() {
|
||||
this.setupSignalHandlers();
|
||||
const isMaintenanceMode = await this.isMaintenanceMode();
|
||||
const { workers } = new ConfigRepository().getEnv();
|
||||
|
||||
@@ -120,12 +114,7 @@ class Workers {
|
||||
} else {
|
||||
const worker = new Worker(workerFile);
|
||||
|
||||
kill = () => {
|
||||
// Post a shutdown message to allow graceful cleanup
|
||||
worker.postMessage({ type: 'shutdown' });
|
||||
// Force terminate after timeout if worker doesn't exit
|
||||
setTimeout(() => void worker.terminate(), 5000);
|
||||
};
|
||||
kill = async () => void (await worker.terminate());
|
||||
anyWorker = worker;
|
||||
}
|
||||
|
||||
@@ -135,53 +124,17 @@ class Workers {
|
||||
this.workers[name] = { kill };
|
||||
}
|
||||
|
||||
private setupSignalHandlers() {
|
||||
const shutdown = async (signal: NodeJS.Signals) => {
|
||||
if (this.shuttingDown) {
|
||||
return;
|
||||
}
|
||||
this.shuttingDown = true;
|
||||
console.log(`Received ${signal}, initiating graceful shutdown...`);
|
||||
|
||||
const workerNames = Object.keys(this.workers) as ImmichWorker[];
|
||||
for (const name of workerNames) {
|
||||
console.log(`Sending ${signal} to ${name} worker`);
|
||||
await this.workers[name]?.kill(signal);
|
||||
}
|
||||
|
||||
// Give workers time to shutdown gracefully
|
||||
setTimeout(() => {
|
||||
console.log('Shutdown timeout reached, forcing exit');
|
||||
process.exit(0);
|
||||
}, 10_000);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
onError(name: ImmichWorker, error: Error) {
|
||||
console.error(`${name} worker error: ${error}, stack: ${error.stack}`);
|
||||
}
|
||||
|
||||
onExit(name: ImmichWorker, exitCode: number | null) {
|
||||
console.log(`${name} worker exited with code ${exitCode}`);
|
||||
delete this.workers[name];
|
||||
|
||||
// graceful shutdown in progress
|
||||
if (this.shuttingDown) {
|
||||
if (Object.keys(this.workers).length === 0) {
|
||||
console.log('All workers have exited, shutting down main process');
|
||||
process.exit(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// restart immich server
|
||||
if (exitCode === ExitCode.AppRestart || this.restarting) {
|
||||
this.restarting = true;
|
||||
|
||||
console.info(`${name} worker shutdown for restart`);
|
||||
delete this.workers[name];
|
||||
|
||||
// once all workers shut down, bootstrap again
|
||||
if (Object.keys(this.workers).length === 0) {
|
||||
@@ -192,9 +145,11 @@ class Workers {
|
||||
return;
|
||||
}
|
||||
|
||||
// unexpected exit - shutdown the entire process
|
||||
// shutdown the entire process
|
||||
delete this.workers[name];
|
||||
|
||||
if (exitCode !== 0) {
|
||||
console.error(`${name} worker exited unexpectedly`);
|
||||
console.error(`${name} worker exited with code ${exitCode}`);
|
||||
|
||||
if (this.workers[ImmichWorker.Api] && name !== ImmichWorker.Api) {
|
||||
console.error('Killing api process');
|
||||
|
||||
@@ -423,13 +423,4 @@ export class AssetJobRepository {
|
||||
streamForMigrationJob() {
|
||||
return this.db.selectFrom('asset').select(['id']).where('asset.deletedAt', 'is', null).stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamAllThumbnailFiles() {
|
||||
return this.db
|
||||
.selectFrom('asset_file')
|
||||
.select(['asset_file.assetId', 'asset_file.type', 'asset_file.path', 'asset_file.isEdited'])
|
||||
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview])
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,10 +107,6 @@ export interface EnvData {
|
||||
mediaLocation?: string;
|
||||
};
|
||||
|
||||
thumbnailStorage: {
|
||||
sqlitePath?: string;
|
||||
};
|
||||
|
||||
workers: ImmichWorker[];
|
||||
|
||||
plugins: {
|
||||
@@ -335,10 +331,6 @@ const getEnv = (): EnvData => {
|
||||
mediaLocation: dto.IMMICH_MEDIA_LOCATION,
|
||||
},
|
||||
|
||||
thumbnailStorage: {
|
||||
sqlitePath: dto.IMMICH_THUMBNAIL_SQLITE_PATH,
|
||||
},
|
||||
|
||||
telemetry: {
|
||||
apiPort: dto.IMMICH_API_METRICS_PORT || 8081,
|
||||
microservicesPort: dto.IMMICH_MICROSERVICES_METRICS_PORT || 8082,
|
||||
|
||||
@@ -44,7 +44,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -99,7 +98,6 @@ export const repositories = [
|
||||
SystemMetadataRepository,
|
||||
TagRepository,
|
||||
TelemetryRepository,
|
||||
ThumbnailStorageRepository,
|
||||
TrashRepository,
|
||||
UserRepository,
|
||||
ViewRepository,
|
||||
|
||||
@@ -2,7 +2,7 @@ import sharp from 'sharp';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ImageFormat, SourceType } from 'src/enum';
|
||||
import { SourceType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||
import { MediaRepository } from 'src/repositories/media.repository';
|
||||
@@ -664,51 +664,4 @@ describe(MediaRepository.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateThumbnailToBuffer', () => {
|
||||
it('should return a buffer instead of writing to file', async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
|
||||
format: ImageFormat.Webp,
|
||||
quality: 80,
|
||||
size: 50,
|
||||
colorspace: 'srgb',
|
||||
processInvalidImages: false,
|
||||
});
|
||||
|
||||
expect(result).toBeInstanceOf(Buffer);
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
const metadata = await sharp(result).metadata();
|
||||
expect(metadata.format).toBe('webp');
|
||||
expect(metadata.width).toBeLessThanOrEqual(50);
|
||||
});
|
||||
|
||||
it('should apply same options as generateThumbnail', async () => {
|
||||
const inputBuffer = await sharp({
|
||||
create: { width: 200, height: 200, channels: 3, background: { r: 0, g: 255, b: 0 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const result = await sut.generateThumbnailToBuffer(inputBuffer, {
|
||||
format: ImageFormat.Jpeg,
|
||||
quality: 90,
|
||||
size: 75,
|
||||
colorspace: 'srgb',
|
||||
processInvalidImages: false,
|
||||
progressive: true,
|
||||
});
|
||||
|
||||
const metadata = await sharp(result).metadata();
|
||||
expect(metadata.format).toBe('jpeg');
|
||||
expect(metadata.width).toBe(75);
|
||||
expect(metadata.height).toBe(75);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -182,17 +182,6 @@ export class MediaRepository {
|
||||
await decoded.toFile(output);
|
||||
}
|
||||
|
||||
async generateThumbnailToBuffer(input: string | Buffer, options: GenerateThumbnailOptions): Promise<Buffer> {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
return pipeline
|
||||
.toFormat(options.format, {
|
||||
quality: options.quality,
|
||||
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
|
||||
progressive: options.progressive,
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
let pipeline = sharp(input, {
|
||||
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { automock } from 'test/utils';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe(ThumbnailStorageRepository.name, () => {
|
||||
let sut: ThumbnailStorageRepository;
|
||||
let testDbPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
testDbPath = join(tmpdir(), `immich-test-thumbnails-${randomUUID()}.db`);
|
||||
const logger = automock(LoggingRepository, { args: [undefined, { getEnv: () => ({}) }], strict: false });
|
||||
sut = new ThumbnailStorageRepository(logger);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sut.close();
|
||||
if (existsSync(testDbPath)) {
|
||||
rmSync(testDbPath);
|
||||
}
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should create database and schema', () => {
|
||||
sut.initialize(testDbPath);
|
||||
|
||||
expect(sut.isEnabled()).toBe(true);
|
||||
|
||||
const db = new Database(testDbPath, { readonly: true });
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='thumbnails'").all();
|
||||
db.close();
|
||||
|
||||
expect(tables).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should enable WAL mode', () => {
|
||||
sut.initialize(testDbPath);
|
||||
|
||||
const db = new Database(testDbPath, { readonly: true });
|
||||
const result = db.prepare('PRAGMA journal_mode').get() as { journal_mode: string };
|
||||
db.close();
|
||||
|
||||
expect(result.journal_mode).toBe('wal');
|
||||
});
|
||||
});
|
||||
|
||||
describe('store', () => {
|
||||
const assetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(() => {
|
||||
sut.initialize(testDbPath);
|
||||
});
|
||||
|
||||
it('should store thumbnail data', async () => {
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toEqual(testData);
|
||||
expect(result!.mimeType).toBe(mimeType);
|
||||
expect(result!.size).toBe(testData.length);
|
||||
});
|
||||
|
||||
it('should replace existing thumbnail on conflict', async () => {
|
||||
const newData = Buffer.from('updated thumbnail data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: newData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
expect(result!.data).toEqual(newData);
|
||||
expect(result!.size).toBe(newData.length);
|
||||
});
|
||||
|
||||
it('should store with correct mime type and size', async () => {
|
||||
const jpegData = Buffer.from('jpeg thumbnail data with different size');
|
||||
const jpegMimeType = 'image/jpeg';
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: jpegData,
|
||||
mimeType: jpegMimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Preview, false);
|
||||
expect(result!.mimeType).toBe(jpegMimeType);
|
||||
expect(result!.size).toBe(jpegData.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
const assetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(async () => {
|
||||
sut.initialize(testDbPath);
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stored thumbnail data', async () => {
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toEqual(testData);
|
||||
expect(result!.mimeType).toBe(mimeType);
|
||||
});
|
||||
|
||||
it('should return null for non-existent thumbnail', async () => {
|
||||
const result = await sut.get(randomUUID(), AssetFileType.Thumbnail, false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should distinguish between edited and non-edited', async () => {
|
||||
const editedData = Buffer.from('edited thumbnail data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: true,
|
||||
data: editedData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const nonEditedResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
const editedResult = await sut.get(assetId, AssetFileType.Thumbnail, true);
|
||||
|
||||
expect(nonEditedResult!.data).toEqual(testData);
|
||||
expect(editedResult!.data).toEqual(editedData);
|
||||
});
|
||||
|
||||
it('should distinguish between different thumbnail types', async () => {
|
||||
const previewData = Buffer.from('preview data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: previewData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const thumbnailResult = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
|
||||
|
||||
expect(thumbnailResult!.data).toEqual(testData);
|
||||
expect(previewResult!.data).toEqual(previewData);
|
||||
});
|
||||
|
||||
it('should fall back to non-edited when edited is requested but not found', async () => {
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.data).toEqual(testData);
|
||||
});
|
||||
|
||||
it('should return edited when both exist and edited is requested', async () => {
|
||||
const editedData = Buffer.from('edited thumbnail data');
|
||||
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: true,
|
||||
data: editedData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, true);
|
||||
|
||||
expect(result!.data).toEqual(editedData);
|
||||
});
|
||||
|
||||
it('should not fall back to edited when non-edited is requested but not found', async () => {
|
||||
const newAssetId = randomUUID();
|
||||
const editedData = Buffer.from('edited only data');
|
||||
|
||||
await sut.store({
|
||||
assetId: newAssetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: true,
|
||||
data: editedData,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const result = await sut.get(newAssetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
const assetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(async () => {
|
||||
sut.initialize(testDbPath);
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
await sut.store({
|
||||
assetId,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: testData,
|
||||
mimeType,
|
||||
});
|
||||
});
|
||||
|
||||
it('should delete specific thumbnail', async () => {
|
||||
await sut.delete(assetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
const result = await sut.get(assetId, AssetFileType.Thumbnail, false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should not affect other thumbnails for same asset', async () => {
|
||||
await sut.delete(assetId, AssetFileType.Thumbnail, false);
|
||||
|
||||
const previewResult = await sut.get(assetId, AssetFileType.Preview, false);
|
||||
expect(previewResult).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteByAsset', () => {
|
||||
const assetId = randomUUID();
|
||||
const otherAssetId = randomUUID();
|
||||
const testData = Buffer.from('test thumbnail data');
|
||||
const mimeType = 'image/webp';
|
||||
|
||||
beforeEach(async () => {
|
||||
sut.initialize(testDbPath);
|
||||
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
|
||||
await sut.store({ assetId, type: AssetFileType.Preview, isEdited: false, data: testData, mimeType });
|
||||
await sut.store({ assetId, type: AssetFileType.Thumbnail, isEdited: true, data: testData, mimeType });
|
||||
await sut.store({ assetId: otherAssetId, type: AssetFileType.Thumbnail, isEdited: false, data: testData, mimeType });
|
||||
});
|
||||
|
||||
it('should delete all thumbnails for an asset', async () => {
|
||||
await sut.deleteByAsset(assetId);
|
||||
|
||||
expect(await sut.get(assetId, AssetFileType.Thumbnail, false)).toBeNull();
|
||||
expect(await sut.get(assetId, AssetFileType.Preview, false)).toBeNull();
|
||||
expect(await sut.get(assetId, AssetFileType.Thumbnail, true)).toBeNull();
|
||||
});
|
||||
|
||||
it('should not affect other assets', async () => {
|
||||
await sut.deleteByAsset(assetId);
|
||||
|
||||
const otherAssetResult = await sut.get(otherAssetId, AssetFileType.Thumbnail, false);
|
||||
expect(otherAssetResult).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled', () => {
|
||||
it('should return false when database is not initialized', () => {
|
||||
expect(sut.isEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when database is initialized', () => {
|
||||
sut.initialize(testDbPath);
|
||||
expect(sut.isEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { Injectable, OnModuleDestroy } from '@nestjs/common';
|
||||
import Database, { Database as DatabaseType, Statement } from 'better-sqlite3';
|
||||
import { AssetFileType } from 'src/enum';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
|
||||
export interface ThumbnailData {
|
||||
assetId: string;
|
||||
type: AssetFileType;
|
||||
isEdited: boolean;
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
export interface ThumbnailResult {
|
||||
data: Buffer;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
interface ThumbnailRow {
|
||||
data: Buffer;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ThumbnailStorageRepository implements OnModuleDestroy {
|
||||
private database: DatabaseType | null = null;
|
||||
private insertStatement: Statement | null = null;
|
||||
private selectStatement: Statement | null = null;
|
||||
private deleteStatement: Statement | null = null;
|
||||
private deleteByAssetStatement: Statement | null = null;
|
||||
|
||||
constructor(private logger: LoggingRepository) {
|
||||
this.logger.setContext(ThumbnailStorageRepository.name);
|
||||
}
|
||||
|
||||
initialize(databasePath: string): void {
|
||||
this.database = new Database(databasePath);
|
||||
|
||||
this.database.pragma('page_size = 32768');
|
||||
this.database.pragma('journal_mode = WAL');
|
||||
this.database.pragma('synchronous = NORMAL');
|
||||
this.database.pragma('cache_size = -131072');
|
||||
this.database.pragma('mmap_size = 2147483648');
|
||||
this.database.pragma('temp_store = MEMORY');
|
||||
this.database.pragma('wal_autocheckpoint = 10000');
|
||||
|
||||
this.database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
asset_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
is_edited INTEGER NOT NULL DEFAULT 0,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
PRIMARY KEY (asset_id, type, is_edited)
|
||||
)
|
||||
`);
|
||||
|
||||
this.insertStatement = this.database.prepare(`
|
||||
INSERT OR REPLACE INTO thumbnails (asset_id, type, is_edited, data, mime_type, size)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
this.selectStatement = this.database.prepare(`
|
||||
SELECT data, mime_type, size FROM thumbnails
|
||||
WHERE asset_id = ? AND type = ? AND is_edited = ?
|
||||
`);
|
||||
|
||||
this.deleteStatement = this.database.prepare(`
|
||||
DELETE FROM thumbnails WHERE asset_id = ? AND type = ? AND is_edited = ?
|
||||
`);
|
||||
|
||||
this.deleteByAssetStatement = this.database.prepare(`
|
||||
DELETE FROM thumbnails WHERE asset_id = ?
|
||||
`);
|
||||
|
||||
this.logger.log(`SQLite thumbnail storage initialized at ${databasePath}`);
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.database !== null;
|
||||
}
|
||||
|
||||
store(thumbnail: ThumbnailData): void {
|
||||
if (!this.insertStatement) {
|
||||
throw new Error('SQLite thumbnail storage not initialized');
|
||||
}
|
||||
|
||||
const isEditedInt = thumbnail.isEdited ? 1 : 0;
|
||||
this.insertStatement.run(
|
||||
thumbnail.assetId,
|
||||
thumbnail.type,
|
||||
isEditedInt,
|
||||
thumbnail.data,
|
||||
thumbnail.mimeType,
|
||||
thumbnail.data.length,
|
||||
);
|
||||
}
|
||||
|
||||
get(assetId: string, type: AssetFileType, isEdited: boolean): ThumbnailResult | null {
|
||||
if (!this.selectStatement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEditedInt = isEdited ? 1 : 0;
|
||||
let result = this.selectStatement.get(assetId, type, isEditedInt) as ThumbnailRow | undefined;
|
||||
|
||||
if (!result && isEdited) {
|
||||
result = this.selectStatement.get(assetId, type, 0) as ThumbnailRow | undefined;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
data: result.data,
|
||||
mimeType: result.mime_type,
|
||||
size: result.size,
|
||||
};
|
||||
}
|
||||
|
||||
delete(assetId: string, type: AssetFileType, isEdited: boolean): void {
|
||||
if (!this.deleteStatement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isEditedInt = isEdited ? 1 : 0;
|
||||
this.deleteStatement.run(assetId, type, isEditedInt);
|
||||
}
|
||||
|
||||
deleteByAsset(assetId: string): void {
|
||||
if (!this.deleteByAssetStatement) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.deleteByAssetStatement.run(assetId);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.database) {
|
||||
this.logger.log('Closing SQLite thumbnail storage database');
|
||||
this.database.pragma('wal_checkpoint(TRUNCATE)');
|
||||
this.database.close();
|
||||
this.database = null;
|
||||
this.insertStatement = null;
|
||||
this.selectStatement = null;
|
||||
this.deleteStatement = null;
|
||||
this.deleteByAssetStatement = null;
|
||||
this.logger.log('SQLite thumbnail storage database closed');
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
this.logger.log('onModuleDestroy called - closing SQLite thumbnail storage');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadBody } from 'src/types';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||
import { ImmichBufferResponse, ImmichFileResponse } from 'src/utils/file';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
@@ -762,128 +762,6 @@ describe(AssetMediaService.name, () => {
|
||||
);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
|
||||
describe('with SQLite storage', () => {
|
||||
it('should return ImmichBufferResponse when thumbnail exists in SQLite', async () => {
|
||||
const thumbnailData = Buffer.from('thumbnail-data');
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue({
|
||||
data: thumbnailData,
|
||||
mimeType: 'image/webp',
|
||||
size: thumbnailData.length,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichBufferResponse({
|
||||
data: thumbnailData,
|
||||
contentType: 'image/webp',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName: 'asset-id_thumbnail.webp',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
expect(mocks.asset.getForThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to filesystem when thumbnail not in SQLite', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue(null);
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, false);
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle edited thumbnails from SQLite', async () => {
|
||||
const thumbnailData = Buffer.from('edited-thumbnail-data');
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue({
|
||||
data: thumbnailData,
|
||||
mimeType: 'image/webp',
|
||||
size: thumbnailData.length,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL, edited: true }),
|
||||
).resolves.toEqual(
|
||||
new ImmichBufferResponse({
|
||||
data: thumbnailData,
|
||||
contentType: 'image/webp',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName: 'asset-id_thumbnail.webp',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Thumbnail, true);
|
||||
});
|
||||
|
||||
it('should handle preview size from SQLite', async () => {
|
||||
const previewData = Buffer.from('preview-data');
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
mocks.thumbnailStorage.get.mockReturnValue({
|
||||
data: previewData,
|
||||
mimeType: 'image/jpeg',
|
||||
size: previewData.length,
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
|
||||
).resolves.toEqual(
|
||||
new ImmichBufferResponse({
|
||||
data: previewData,
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName: 'asset-id_preview.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).toHaveBeenCalledWith(assetStub.image.id, AssetFileType.Preview, false);
|
||||
});
|
||||
|
||||
it('should skip SQLite lookup when storage is disabled', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
|
||||
mocks.asset.getForThumbnail.mockResolvedValue({
|
||||
...assetStub.image,
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
|
||||
).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/uploads/user-id/thumbs/path.jpg',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
fileName: 'asset-id_thumbnail.jpg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mocks.thumbnailStorage.get).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.getForThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
|
||||
@@ -37,12 +37,7 @@ import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import {
|
||||
getFilenameExtension,
|
||||
getFileNameWithoutExtension,
|
||||
ImmichBufferResponse,
|
||||
ImmichFileResponse,
|
||||
} from 'src/utils/file';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
@@ -224,7 +219,7 @@ export class AssetMediaService extends BaseService {
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaOptionsDto,
|
||||
): Promise<ImmichFileResponse | ImmichBufferResponse | AssetMediaRedirectResponse> {
|
||||
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
if (dto.size === AssetMediaSize.Original) {
|
||||
@@ -236,30 +231,20 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
const size = (dto.size ?? AssetMediaSize.THUMBNAIL) as unknown as AssetFileType;
|
||||
const isEdited = dto.edited ?? false;
|
||||
|
||||
if (this.thumbnailStorageRepository.isEnabled()) {
|
||||
const thumbnail = this.thumbnailStorageRepository.get(id, size, isEdited);
|
||||
if (thumbnail) {
|
||||
// this.logger.log(`Thumbnail served from SQLite: assetId=${id}, type=${size}`);
|
||||
const extension = mimeTypes.toExtension(thumbnail.mimeType) || '';
|
||||
const fileName = `${id}_${size}${extension}`;
|
||||
return new ImmichBufferResponse({
|
||||
data: thumbnail.data,
|
||||
contentType: thumbnail.mimeType,
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
fileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(id, size, isEdited);
|
||||
const { originalPath, originalFileName, path } = await this.assetRepository.getForThumbnail(
|
||||
id,
|
||||
size,
|
||||
dto.edited ?? false,
|
||||
);
|
||||
|
||||
if (size === AssetFileType.FullSize && mimeTypes.isWebSupportedImage(originalPath) && !dto.edited) {
|
||||
// use original file for web supported images
|
||||
return { targetSize: 'original' };
|
||||
}
|
||||
|
||||
if (dto.size === AssetMediaSize.FULLSIZE && !path) {
|
||||
// downgrade to preview if fullsize is not available.
|
||||
// e.g. disabled or not yet (re)generated
|
||||
return { targetSize: AssetMediaSize.PREVIEW };
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -109,7 +108,6 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
SystemMetadataRepository,
|
||||
TagRepository,
|
||||
TelemetryRepository,
|
||||
ThumbnailStorageRepository,
|
||||
TrashRepository,
|
||||
UserRepository,
|
||||
VersionHistoryRepository,
|
||||
@@ -169,7 +167,6 @@ export class BaseService {
|
||||
protected systemMetadataRepository: SystemMetadataRepository,
|
||||
protected tagRepository: TagRepository,
|
||||
protected telemetryRepository: TelemetryRepository,
|
||||
protected thumbnailStorageRepository: ThumbnailStorageRepository,
|
||||
protected trashRepository: TrashRepository,
|
||||
protected userRepository: UserRepository,
|
||||
protected versionRepository: VersionHistoryRepository,
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isAbsolute } from 'node:path';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { MaintenanceAuthDto } from 'src/dtos/maintenance.dto';
|
||||
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { AssetFileType, MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { MaintenanceAction, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { createMaintenanceLoginUrl, generateMaintenanceSecret } from 'src/utils/maintenance';
|
||||
import { getExternalDomain } from 'src/utils/misc';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
|
||||
@Injectable()
|
||||
export class CliService extends BaseService {
|
||||
@@ -188,88 +186,4 @@ export class CliService extends BaseService {
|
||||
cleanup() {
|
||||
return this.databaseRepository.shutdown();
|
||||
}
|
||||
|
||||
getDefaultThumbnailStoragePath(): string {
|
||||
const envData = this.configRepository.getEnv();
|
||||
if (envData.thumbnailStorage.sqlitePath) {
|
||||
return envData.thumbnailStorage.sqlitePath;
|
||||
}
|
||||
|
||||
const mediaLocation = envData.storage.mediaLocation ?? this.detectMediaLocation();
|
||||
StorageCore.setMediaLocation(mediaLocation);
|
||||
return StorageCore.getThumbnailStoragePath();
|
||||
}
|
||||
|
||||
private detectMediaLocation(): string {
|
||||
const candidates = ['/data', '/usr/src/app/upload'];
|
||||
for (const candidate of candidates) {
|
||||
if (this.storageRepository.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return '/usr/src/app/upload';
|
||||
}
|
||||
|
||||
async migrateThumbnailsToSqlite(options: {
|
||||
sqlitePath: string;
|
||||
onProgress: (progress: { current: number; migrated: number; skipped: number; errors: number }) => void;
|
||||
}): Promise<{ total: number; migrated: number; skipped: number; errors: number }> {
|
||||
const { sqlitePath, onProgress } = options;
|
||||
|
||||
if (!isAbsolute(sqlitePath)) {
|
||||
throw new Error('SQLite path must be an absolute path');
|
||||
}
|
||||
|
||||
this.thumbnailStorageRepository.initialize(sqlitePath);
|
||||
|
||||
let current = 0;
|
||||
let migrated = 0;
|
||||
let skipped = 0;
|
||||
let errors = 0;
|
||||
|
||||
for await (const file of this.assetJobRepository.streamAllThumbnailFiles()) {
|
||||
current++;
|
||||
|
||||
try {
|
||||
const existingData = this.thumbnailStorageRepository.get(
|
||||
file.assetId,
|
||||
file.type as AssetFileType,
|
||||
file.isEdited,
|
||||
);
|
||||
|
||||
if (existingData) {
|
||||
skipped++;
|
||||
onProgress({ current, migrated, skipped, errors });
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileExists = await this.storageRepository.checkFileExists(file.path);
|
||||
if (!fileExists) {
|
||||
skipped++;
|
||||
onProgress({ current, migrated, skipped, errors });
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await this.storageRepository.readFile(file.path);
|
||||
const mimeType = mimeTypes.lookup(file.path) || 'image/jpeg';
|
||||
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: file.assetId,
|
||||
type: file.type as AssetFileType,
|
||||
isEdited: file.isEdited,
|
||||
data,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
migrated++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
this.logger.error(`Failed to migrate thumbnail for asset ${file.assetId}: ${error}`);
|
||||
}
|
||||
|
||||
onProgress({ current, migrated, skipped, errors });
|
||||
}
|
||||
|
||||
return { total: current, migrated, skipped, errors };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,82 +1128,6 @@ describe(MediaService.name, () => {
|
||||
expect.stringContaining('fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('with SQLite storage enabled', () => {
|
||||
beforeEach(() => {
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should store thumbnail in SQLite when enabled', async () => {
|
||||
const thumbnailBuffer = Buffer.from('thumbnail-data');
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnailToBuffer).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: false,
|
||||
data: thumbnailBuffer,
|
||||
mimeType: 'image/webp',
|
||||
});
|
||||
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith({
|
||||
assetId: assetStub.image.id,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: false,
|
||||
data: thumbnailBuffer,
|
||||
mimeType: 'image/jpeg',
|
||||
});
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not write to filesystem when SQLite is enabled', async () => {
|
||||
const thumbnailBuffer = Buffer.from('thumbnail-data');
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should store with correct mime type for JPEG preview', async () => {
|
||||
const thumbnailBuffer = Buffer.from('preview-data');
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
image: { preview: { format: ImageFormat.Jpeg } },
|
||||
});
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
mocks.media.generateThumbnailToBuffer.mockResolvedValue(thumbnailBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.thumbnailStorage.store).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: AssetFileType.Preview,
|
||||
mimeType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with SQLite storage disabled', () => {
|
||||
beforeEach(() => {
|
||||
mocks.thumbnailStorage.isEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('should continue using filesystem when SQLite is disabled', async () => {
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.image);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: assetStub.image.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||
expect(mocks.media.generateThumbnailToBuffer).not.toHaveBeenCalled();
|
||||
expect(mocks.thumbnailStorage.store).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetEditThumbnailGeneration', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile, Exif } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -35,7 +35,6 @@ import {
|
||||
ImageDimensions,
|
||||
JobItem,
|
||||
JobOf,
|
||||
RawImageInfo,
|
||||
VideoFormat,
|
||||
VideoInterfaces,
|
||||
VideoStreamInfo,
|
||||
@@ -57,9 +56,6 @@ interface UpsertFileOptions {
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
|
||||
// Feature flag: Enable fullsize thumbnail storage in SQLite
|
||||
const SQLITE_STORE_FULLSIZE = true;
|
||||
|
||||
@Injectable()
|
||||
export class MediaService extends BaseService {
|
||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
||||
@@ -324,27 +320,8 @@ export class MediaService extends BaseService {
|
||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||
const { info, data, colorspace, generateFullsize, convertFullsize, extracted } = extractedImage;
|
||||
|
||||
// generate final images
|
||||
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
|
||||
if (this.thumbnailStorageRepository.isEnabled()) {
|
||||
return this.generateImageThumbnailsToSqlite(
|
||||
asset,
|
||||
data,
|
||||
image,
|
||||
thumbnailOptions,
|
||||
useEdits,
|
||||
fullsizeDimensions,
|
||||
extracted,
|
||||
generateFullsize,
|
||||
convertFullsize,
|
||||
);
|
||||
} else {
|
||||
console.log('not enabled');
|
||||
}
|
||||
|
||||
// generate final images to filesystem
|
||||
const promises = [
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
|
||||
@@ -399,6 +376,9 @@ export class MediaService extends BaseService {
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
|
||||
return {
|
||||
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||
thumbhash: outputs[0] as Buffer,
|
||||
@@ -406,82 +386,6 @@ export class MediaService extends BaseService {
|
||||
};
|
||||
}
|
||||
|
||||
private async generateImageThumbnailsToSqlite(
|
||||
asset: ThumbnailAsset,
|
||||
data: Buffer,
|
||||
image: SystemConfig['image'],
|
||||
thumbnailOptions: {
|
||||
colorspace: Colorspace;
|
||||
processInvalidImages: boolean;
|
||||
raw: RawImageInfo;
|
||||
edits: AssetEditActionItem[];
|
||||
},
|
||||
useEdits: boolean,
|
||||
fullsizeDimensions: ImageDimensions,
|
||||
extracted: { buffer: Buffer; format: RawExtractedFormat } | null,
|
||||
generateFullsize: boolean,
|
||||
convertFullsize: boolean,
|
||||
) {
|
||||
const [thumbnailBuffer, previewBuffer, thumbhash] = await Promise.all([
|
||||
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.thumbnail, ...thumbnailOptions }),
|
||||
this.mediaRepository.generateThumbnailToBuffer(data, { ...image.preview, ...thumbnailOptions }),
|
||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||
]);
|
||||
|
||||
// Check if fullsize should be stored in SQLite
|
||||
let fullsizeBuffer: Buffer | null = null;
|
||||
let fullsizeMimeType: string | null = null;
|
||||
|
||||
if (SQLITE_STORE_FULLSIZE && generateFullsize) {
|
||||
if (convertFullsize) {
|
||||
// Convert scenario: generate fullsize from data buffer
|
||||
fullsizeBuffer = await this.mediaRepository.generateThumbnailToBuffer(data, {
|
||||
format: image.fullsize.format,
|
||||
quality: image.fullsize.quality,
|
||||
progressive: image.fullsize.progressive,
|
||||
...thumbnailOptions,
|
||||
});
|
||||
fullsizeMimeType = `image/${image.fullsize.format}`;
|
||||
} else if (extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||
// Extract scenario: use extracted buffer directly
|
||||
fullsizeBuffer = extracted.buffer;
|
||||
fullsizeMimeType = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Thumbnail,
|
||||
isEdited: useEdits,
|
||||
data: thumbnailBuffer,
|
||||
mimeType: `image/${image.thumbnail.format}`,
|
||||
});
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Preview,
|
||||
isEdited: useEdits,
|
||||
data: previewBuffer,
|
||||
mimeType: `image/${image.preview.format}`,
|
||||
});
|
||||
|
||||
// Store fullsize if generated
|
||||
if (fullsizeBuffer && fullsizeMimeType) {
|
||||
this.thumbnailStorageRepository.store({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.FullSize,
|
||||
isEdited: useEdits,
|
||||
data: fullsizeBuffer,
|
||||
mimeType: fullsizeMimeType,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
files: [] as UpsertFileOptions[],
|
||||
thumbhash,
|
||||
fullsizeDimensions,
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||
async handleGeneratePersonThumbnail({ id }: JobOf<JobName.PersonGenerateThumbnail>): Promise<JobStatus> {
|
||||
const { machineLearning, metadata, image } = await this.getConfig({ withCache: true });
|
||||
|
||||
@@ -45,7 +45,6 @@ export class StorageService extends BaseService {
|
||||
@OnEvent({ name: 'AppBootstrap', priority: BootstrapEventPriority.StorageService })
|
||||
async onBootstrap() {
|
||||
StorageCore.setMediaLocation(this.detectMediaLocation());
|
||||
this.initializeThumbnailStorage();
|
||||
|
||||
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||
const flags =
|
||||
@@ -134,12 +133,6 @@ export class StorageService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
private initializeThumbnailStorage(): void {
|
||||
const { thumbnailStorage } = this.configRepository.getEnv();
|
||||
const path = thumbnailStorage.sqlitePath ?? StorageCore.getThumbnailStoragePath();
|
||||
this.thumbnailStorageRepository.initialize(path);
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.FileDelete, queue: QueueName.BackgroundTask })
|
||||
async handleDeleteFiles(job: JobOf<JobName.FileDelete>): Promise<JobStatus> {
|
||||
const { files } = job;
|
||||
|
||||
@@ -30,17 +30,6 @@ export class ImmichFileResponse {
|
||||
Object.assign(this, response);
|
||||
}
|
||||
}
|
||||
|
||||
export class ImmichBufferResponse {
|
||||
public readonly data!: Buffer;
|
||||
public readonly contentType!: string;
|
||||
public readonly cacheControl!: CacheControl;
|
||||
public readonly fileName?: string;
|
||||
|
||||
constructor(response: ImmichBufferResponse) {
|
||||
Object.assign(this, response);
|
||||
}
|
||||
}
|
||||
type SendFile = Parameters<Response['sendFile']>;
|
||||
type SendFileOptions = SendFile[1];
|
||||
|
||||
@@ -93,40 +82,6 @@ export const sendFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const sendBuffer = async (
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
handler: () => Promise<ImmichBufferResponse> | ImmichBufferResponse,
|
||||
logger: LoggingRepository,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const file = await handler();
|
||||
const cacheControlHeader = cacheControlHeaders[file.cacheControl];
|
||||
if (cacheControlHeader) {
|
||||
res.set('Cache-Control', cacheControlHeader);
|
||||
}
|
||||
|
||||
res.header('Content-Type', file.contentType);
|
||||
res.header('Content-Length', file.data.length.toString());
|
||||
if (file.fileName) {
|
||||
res.header('Content-Disposition', `inline; filename*=UTF-8''${encodeURIComponent(file.fileName)}`);
|
||||
}
|
||||
|
||||
res.send(file.data);
|
||||
} catch (error: Error | any) {
|
||||
if (isConnectionAborted(error) || res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof HttpException === false) {
|
||||
logger.error(`Unable to send buffer: ${error}`, error.stack);
|
||||
}
|
||||
|
||||
res.header('Cache-Control', 'none');
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
|
||||
return new StreamableFile(stream, { type, length });
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ async function bootstrap() {
|
||||
configureTelemetry();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(ApiModule, { bufferLogs: true });
|
||||
app.enableShutdownHooks();
|
||||
app.get(AppRepository).setCloseFn(() => app.close());
|
||||
|
||||
void configureExpress(app, {
|
||||
|
||||
@@ -11,7 +11,6 @@ async function bootstrap() {
|
||||
configureTelemetry();
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(MaintenanceModule, { bufferLogs: true });
|
||||
app.enableShutdownHooks();
|
||||
app.get(AppRepository).setCloseFn(() => app.close());
|
||||
|
||||
void configureExpress(app, {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { isMainThread, parentPort } from 'node:worker_threads';
|
||||
import { isMainThread } from 'node:worker_threads';
|
||||
import { MicroservicesModule } from 'src/app.module';
|
||||
import { serverVersion } from 'src/constants';
|
||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||
@@ -16,7 +16,6 @@ export async function bootstrap() {
|
||||
}
|
||||
|
||||
const app = await NestFactory.create(MicroservicesModule, { bufferLogs: true });
|
||||
app.enableShutdownHooks();
|
||||
const logger = await app.resolve(LoggingRepository);
|
||||
const configRepository = app.get(ConfigRepository);
|
||||
app.get(AppRepository).setCloseFn(() => app.close());
|
||||
@@ -30,24 +29,13 @@ export async function bootstrap() {
|
||||
await (host ? app.listen(0, host) : app.listen(0));
|
||||
|
||||
logger.log(`Immich Microservices is running [v${serverVersion}] [${environment}] `);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
if (!isMainThread) {
|
||||
bootstrap()
|
||||
.then((app) => {
|
||||
parentPort?.on('message', (message) => {
|
||||
if (message?.type === 'shutdown') {
|
||||
console.log('Microservices worker received shutdown message');
|
||||
void app.close();
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
bootstrap().catch((error) => {
|
||||
if (!isStartUpError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,10 +84,6 @@ const envData: EnvData = {
|
||||
ignoreMountCheckErrors: false,
|
||||
},
|
||||
|
||||
thumbnailStorage: {
|
||||
sqlitePath: undefined,
|
||||
},
|
||||
|
||||
telemetry: {
|
||||
apiPort: 8081,
|
||||
microservicesPort: 8082,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Mocked, vitest } from 'vitest';
|
||||
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
||||
return {
|
||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
generateThumbnailToBuffer: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { RepositoryInterface } from 'src/types';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
export const newThumbnailStorageRepositoryMock = (): Mocked<RepositoryInterface<ThumbnailStorageRepository>> => {
|
||||
return {
|
||||
initialize: vitest.fn(),
|
||||
isEnabled: vitest.fn().mockReturnValue(false),
|
||||
store: vitest.fn(),
|
||||
get: vitest.fn(),
|
||||
delete: vitest.fn(),
|
||||
deleteByAsset: vitest.fn(),
|
||||
close: vitest.fn(),
|
||||
};
|
||||
};
|
||||
@@ -60,7 +60,6 @@ import { SyncRepository } from 'src/repositories/sync.repository';
|
||||
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
|
||||
import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { ThumbnailStorageRepository } from 'src/repositories/thumbnail-storage.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
@@ -82,7 +81,6 @@ import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'
|
||||
import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { newThumbnailStorageRepositoryMock } from 'test/repositories/thumbnail-storage.repository.mock';
|
||||
import { ITelemetryRepositoryMock, newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||
import { assert, Mock, Mocked, vitest } from 'vitest';
|
||||
|
||||
@@ -257,7 +255,6 @@ export type ServiceOverrides = {
|
||||
systemMetadata: SystemMetadataRepository;
|
||||
tag: TagRepository;
|
||||
telemetry: TelemetryRepository;
|
||||
thumbnailStorage: ThumbnailStorageRepository;
|
||||
trash: TrashRepository;
|
||||
user: UserRepository;
|
||||
versionHistory: VersionHistoryRepository;
|
||||
@@ -335,7 +332,6 @@ export const getMocks = () => {
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
tag: automock(TagRepository, { args: [, loggerMock], strict: false }),
|
||||
telemetry: newTelemetryRepositoryMock(),
|
||||
thumbnailStorage: newThumbnailStorageRepositoryMock(),
|
||||
trash: automock(TrashRepository),
|
||||
user: automock(UserRepository, { strict: false }),
|
||||
versionHistory: automock(VersionHistoryRepository),
|
||||
@@ -401,7 +397,6 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.systemMetadata || (mocks.systemMetadata as As<SystemMetadataRepository>),
|
||||
overrides.tag || (mocks.tag as As<TagRepository>),
|
||||
overrides.telemetry || (mocks.telemetry as unknown as TelemetryRepository),
|
||||
overrides.thumbnailStorage || (mocks.thumbnailStorage as As<ThumbnailStorageRepository>),
|
||||
overrides.trash || (mocks.trash as As<TrashRepository>),
|
||||
overrides.user || (mocks.user as As<UserRepository>),
|
||||
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"@photo-sphere-viewer/settings-plugin": "^5.14.0",
|
||||
"@photo-sphere-viewer/video-plugin": "^5.14.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@zoom-image/core": "^0.41.0",
|
||||
"@zoom-image/core": "^0.42.0",
|
||||
"@zoom-image/svelte": "^0.3.0",
|
||||
"dom-to-image": "^2.6.0",
|
||||
"fabric": "^6.5.4",
|
||||
@@ -98,7 +98,7 @@
|
||||
"prettier-plugin-sort-json": "^4.1.1",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rollup-plugin-visualizer": "^6.0.0",
|
||||
"svelte": "5.48.0",
|
||||
"svelte": "5.48.5",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-eslint-parser": "^1.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
|
||||
Reference in New Issue
Block a user