mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:06:56 -04:00
Merge branch 'main' into chore/backup-wakelock
This commit is contained in:
commit
1b4990f98a
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@ -35,6 +35,4 @@ documentation:
|
|||||||
- machine-learning/app/**
|
- machine-learning/app/**
|
||||||
|
|
||||||
changelog:translation:
|
changelog:translation:
|
||||||
- changed-files:
|
- head-branch: ['^chore/translations$']
|
||||||
- any-glob-to-any-file:
|
|
||||||
- web/src/lib/i18n/*.json
|
|
||||||
|
@ -92,7 +92,7 @@ For the mobile app, you can use `https://demo.immich.app/api` for the `Server En
|
|||||||
| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
|
| LivePhoto/MotionPhoto backup and playback | Yes | Yes |
|
||||||
| Support 360 degree image display | No | Yes |
|
| Support 360 degree image display | No | Yes |
|
||||||
| User-defined storage structure | Yes | Yes |
|
| User-defined storage structure | Yes | Yes |
|
||||||
| Public Sharing | No | Yes |
|
| Public Sharing | Yes | Yes |
|
||||||
| Archive and Favorites | Yes | Yes |
|
| Archive and Favorites | Yes | Yes |
|
||||||
| Global Map | Yes | Yes |
|
| Global Map | Yes | Yes |
|
||||||
| Partner Sharing | Yes | Yes |
|
| Partner Sharing | Yes | Yes |
|
||||||
|
@ -98,7 +98,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
@ -48,7 +48,7 @@ services:
|
|||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
@ -33,7 +33,7 @@ services:
|
|||||||
- 2285:3001
|
- 2285:3001
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
|
image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
|
||||||
|
|
||||||
database:
|
database:
|
||||||
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
|
||||||
|
@ -40,11 +40,10 @@ FROM prod-cpu AS prod-openvino
|
|||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
apt-get install --no-install-recommends -yqq ocl-icd-libopencl1 wget && \
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-core_1.0.17193.4_amd64.deb && \
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-core_1.0.17384.11_amd64.deb && \
|
||||||
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17193.4/intel-igc-opencl_1.0.17193.4_amd64.deb && \
|
wget https://github.com/intel/intel-graphics-compiler/releases/download/igc-1.0.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd-dbgsym_24.26.30049.6_amd64.ddeb && \
|
wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/intel-opencl-icd_24.31.30508.7_amd64.deb && \
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \
|
wget https://github.com/intel/compute-runtime/releases/download/24.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
|
||||||
wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_amd64.deb && \
|
|
||||||
dpkg -i *.deb && \
|
dpkg -i *.deb && \
|
||||||
rm *.deb && \
|
rm *.deb && \
|
||||||
apt-get remove wget -yqq && \
|
apt-get remove wget -yqq && \
|
||||||
|
@ -401,7 +401,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 172;
|
CURRENT_PROJECT_VERSION = 173;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -543,7 +543,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 172;
|
CURRENT_PROJECT_VERSION = 173;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -571,7 +571,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 172;
|
CURRENT_PROJECT_VERSION = 173;
|
||||||
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
DEVELOPMENT_TEAM = 2F67MQ8R79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
|
@ -58,11 +58,11 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.113.1</string>
|
<string>1.114.0</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>172</string>
|
<string>173</string>
|
||||||
<key>FLTEnableImpeller</key>
|
<key>FLTEnableImpeller</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
|
@ -12,12 +12,14 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||||
|
export type SystemFlags = { mountFiles: boolean };
|
||||||
|
|
||||||
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
|
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
|
||||||
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
|
|
||||||
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
|
|
||||||
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
|
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
|
||||||
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
|
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
|
||||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
|
||||||
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
|
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
|
||||||
|
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
|
||||||
|
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
|
||||||
|
[SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags;
|
||||||
|
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||||
}
|
}
|
||||||
|
@ -153,6 +153,7 @@ export enum SystemMetadataKey {
|
|||||||
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
|
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
|
||||||
ADMIN_ONBOARDING = 'admin-onboarding',
|
ADMIN_ONBOARDING = 'admin-onboarding',
|
||||||
SYSTEM_CONFIG = 'system-config',
|
SYSTEM_CONFIG = 'system-config',
|
||||||
|
SYSTEM_FLAGS = 'system-flags',
|
||||||
VERSION_CHECK_STATE = 'version-check-state',
|
VERSION_CHECK_STATE = 'version-check-state',
|
||||||
LICENSE = 'license',
|
LICENSE = 'license',
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ export enum VectorIndex {
|
|||||||
export enum DatabaseLock {
|
export enum DatabaseLock {
|
||||||
GeodataImport = 100,
|
GeodataImport = 100,
|
||||||
Migrations = 200,
|
Migrations = 200,
|
||||||
|
SystemFileMounts = 300,
|
||||||
StorageTemplateMigration = 420,
|
StorageTemplateMigration = 420,
|
||||||
CLIPDimSize = 512,
|
CLIPDimSize = 512,
|
||||||
LibraryWatch = 1337,
|
LibraryWatch = 1337,
|
||||||
|
@ -21,6 +21,9 @@ type EmitEventMap = {
|
|||||||
'asset.tag': [{ assetId: string }];
|
'asset.tag': [{ assetId: string }];
|
||||||
'asset.untag': [{ assetId: string }];
|
'asset.untag': [{ assetId: string }];
|
||||||
|
|
||||||
|
// session events
|
||||||
|
'session.delete': [{ sessionId: string }];
|
||||||
|
|
||||||
// user events
|
// user events
|
||||||
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
|
||||||
};
|
};
|
||||||
@ -43,6 +46,7 @@ export enum ClientEvent {
|
|||||||
SERVER_VERSION = 'on_server_version',
|
SERVER_VERSION = 'on_server_version',
|
||||||
CONFIG_UPDATE = 'on_config_update',
|
CONFIG_UPDATE = 'on_config_update',
|
||||||
NEW_RELEASE = 'on_new_release',
|
NEW_RELEASE = 'on_new_release',
|
||||||
|
SESSION_DELETE = 'on_session_delete',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientEventMap {
|
export interface ClientEventMap {
|
||||||
@ -58,6 +62,7 @@ export interface ClientEventMap {
|
|||||||
[ClientEvent.SERVER_VERSION]: ServerVersionResponseDto;
|
[ClientEvent.SERVER_VERSION]: ServerVersionResponseDto;
|
||||||
[ClientEvent.CONFIG_UPDATE]: Record<string, never>;
|
[ClientEvent.CONFIG_UPDATE]: Record<string, never>;
|
||||||
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
|
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
|
||||||
|
[ClientEvent.SESSION_DELETE]: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ServerEvent {
|
export enum ServerEvent {
|
||||||
@ -77,7 +82,7 @@ export interface IEventRepository {
|
|||||||
/**
|
/**
|
||||||
* Send to connected clients for a specific user
|
* Send to connected clients for a specific user
|
||||||
*/
|
*/
|
||||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]): void;
|
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]): void;
|
||||||
/**
|
/**
|
||||||
* Send to all connected clients
|
* Send to all connected clients
|
||||||
*/
|
*/
|
||||||
|
@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult {
|
|||||||
|
|
||||||
export interface IMapRepository {
|
export interface IMapRepository {
|
||||||
init(): Promise<void>;
|
init(): Promise<void>;
|
||||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
|
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||||
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||||
fetchStyle(url: string): Promise<any>;
|
fetchStyle(url: string): Promise<any>;
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
|||||||
|
|
||||||
export interface IMetadataRepository {
|
export interface IMetadataRepository {
|
||||||
teardown(): Promise<void>;
|
teardown(): Promise<void>;
|
||||||
readTags(path: string): Promise<ImmichTags | null>;
|
readTags(path: string): Promise<ImmichTags>;
|
||||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||||
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
||||||
getCountries(userIds: string[]): Promise<Array<string | null>>;
|
getCountries(userIds: string[]): Promise<Array<string | null>>;
|
||||||
|
@ -17,7 +17,13 @@ async function bootstrapImmichAdmin() {
|
|||||||
|
|
||||||
function bootstrapWorker(name: string) {
|
function bootstrapWorker(name: string) {
|
||||||
console.log(`Starting ${name} worker`);
|
console.log(`Starting ${name} worker`);
|
||||||
|
|
||||||
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
|
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
|
||||||
|
|
||||||
|
worker.on('error', (error) => {
|
||||||
|
console.error(`${name} worker error: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
worker.on('exit', (exitCode) => {
|
worker.on('exit', (exitCode) => {
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
console.error(`${name} worker exited with code ${exitCode}`);
|
console.error(`${name} worker exited with code ${exitCode}`);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import {
|
import {
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
private server?: Server;
|
private server?: Server;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private authService: AuthService,
|
private moduleRef: ModuleRef,
|
||||||
private eventEmitter: EventEmitter2,
|
private eventEmitter: EventEmitter2,
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
) {
|
) {
|
||||||
@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
async handleConnection(client: Socket) {
|
async handleConnection(client: Socket) {
|
||||||
try {
|
try {
|
||||||
this.logger.log(`Websocket Connect: ${client.id}`);
|
this.logger.log(`Websocket Connect: ${client.id}`);
|
||||||
const auth = await this.authService.authenticate({
|
const auth = await this.moduleRef.get(AuthService).authenticate({
|
||||||
headers: client.request.headers,
|
headers: client.request.headers,
|
||||||
queryParams: {},
|
queryParams: {},
|
||||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
|
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
|
||||||
});
|
});
|
||||||
await client.join(auth.user.id);
|
await client.join(auth.user.id);
|
||||||
|
if (auth.session) {
|
||||||
|
await client.join(auth.session.id);
|
||||||
|
}
|
||||||
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
|
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
|
||||||
} catch (error: Error | any) {
|
} catch (error: Error | any) {
|
||||||
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
|
||||||
@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clientSend<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]) {
|
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]) {
|
||||||
this.server?.to(userId).emit(event, data);
|
this.server?.to(room).emit(event, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) {
|
clientBroadcast<E extends keyof ClientEventMap>(event: E, data: ClientEventMap[E]) {
|
||||||
|
@ -124,7 +124,7 @@ export class MapRepository implements IMapRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null> {
|
async reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult> {
|
||||||
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
|
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
|
||||||
|
|
||||||
const response = await this.geodataPlacesRepository
|
const response = await this.geodataPlacesRepository
|
||||||
@ -159,7 +159,7 @@ export class MapRepository implements IMapRepository {
|
|||||||
`Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
`Response from database for natural earth reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return null;
|
return { country: null, state: null, city: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
|
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
|
||||||
|
@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository {
|
|||||||
await this.exiftool.end();
|
await this.exiftool.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
readTags(path: string): Promise<ImmichTags | null> {
|
readTags(path: string): Promise<ImmichTags> {
|
||||||
return this.exiftool.read(path).catch((error) => {
|
return this.exiftool.read(path).catch((error) => {
|
||||||
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
||||||
return null;
|
return {};
|
||||||
}) as Promise<ImmichTags | null>;
|
}) as Promise<ImmichTags>;
|
||||||
}
|
}
|
||||||
|
|
||||||
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
|
||||||
|
@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
|
|||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||||
@ -20,6 +21,7 @@ import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
|||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
|
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
|
||||||
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
||||||
|
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||||
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
|
import { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
|
||||||
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
|
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
|
||||||
@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = {
|
|||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
let sut: AuthService;
|
let sut: AuthService;
|
||||||
let cryptoMock: Mocked<ICryptoRepository>;
|
let cryptoMock: Mocked<ICryptoRepository>;
|
||||||
|
let eventMock: Mocked<IEventRepository>;
|
||||||
let userMock: Mocked<IUserRepository>;
|
let userMock: Mocked<IUserRepository>;
|
||||||
let loggerMock: Mocked<ILoggerRepository>;
|
let loggerMock: Mocked<ILoggerRepository>;
|
||||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||||
@ -87,6 +90,7 @@ describe('AuthService', () => {
|
|||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
cryptoMock = newCryptoRepositoryMock();
|
cryptoMock = newCryptoRepositoryMock();
|
||||||
|
eventMock = newEventRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
loggerMock = newLoggerRepositoryMock();
|
loggerMock = newLoggerRepositoryMock();
|
||||||
systemMock = newSystemMetadataRepositoryMock();
|
systemMock = newSystemMetadataRepositoryMock();
|
||||||
@ -94,7 +98,7 @@ describe('AuthService', () => {
|
|||||||
shareMock = newSharedLinkRepositoryMock();
|
shareMock = newSharedLinkRepositoryMock();
|
||||||
keyMock = newKeyRepositoryMock();
|
keyMock = newKeyRepositoryMock();
|
||||||
|
|
||||||
sut = new AuthService(cryptoMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
|
sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@ -208,6 +212,7 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
|
expect(sessionMock.delete).toHaveBeenCalledWith('token123');
|
||||||
|
expect(eventMock.emit).toHaveBeenCalledWith('session.delete', { sessionId: 'token123' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
it('should return the default redirect if auth type is OAUTH but oauth is not enabled', async () => {
|
||||||
|
@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity';
|
|||||||
import { Permission } from 'src/enum';
|
import { Permission } from 'src/enum';
|
||||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { ISessionRepository } from 'src/interfaces/session.interface';
|
import { ISessionRepository } from 'src/interfaces/session.interface';
|
||||||
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
||||||
@ -75,6 +76,7 @@ export class AuthService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
|
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||||
@ -114,6 +116,7 @@ export class AuthService {
|
|||||||
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
async logout(auth: AuthDto, authType: AuthType): Promise<LogoutResponseDto> {
|
||||||
if (auth.session) {
|
if (auth.session) {
|
||||||
await this.sessionRepository.delete(auth.session.id);
|
await this.sessionRepository.delete(auth.session.id);
|
||||||
|
await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -522,13 +522,13 @@ describe(MetadataService.name, () => {
|
|||||||
it('should extract the correct video orientation', async () => {
|
it('should extract the correct video orientation', async () => {
|
||||||
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
assetMock.getByIds.mockResolvedValue([assetStub.video]);
|
||||||
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
|
||||||
metadataMock.readTags.mockResolvedValue(null);
|
metadataMock.readTags.mockResolvedValue({});
|
||||||
|
|
||||||
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
await sut.handleMetadataExtraction({ id: assetStub.video.id });
|
||||||
|
|
||||||
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
|
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
|
||||||
expect(assetMock.upsertExif).toHaveBeenCalledWith(
|
expect(assetMock.upsertExif).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ orientation: Orientation.Rotate270CW }),
|
expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -814,6 +814,9 @@ describe(MetadataService.name, () => {
|
|||||||
projectionType: 'EQUIRECTANGULAR',
|
projectionType: 'EQUIRECTANGULAR',
|
||||||
timeZone: tags.tz,
|
timeZone: tags.tz,
|
||||||
rating: tags.Rating,
|
rating: tags.Rating,
|
||||||
|
country: null,
|
||||||
|
state: null,
|
||||||
|
city: null,
|
||||||
});
|
});
|
||||||
expect(assetMock.update).toHaveBeenCalledWith({
|
expect(assetMock.update).toHaveBeenCalledWith({
|
||||||
id: assetStub.image.id,
|
id: assetStub.image.id,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { ContainerDirectoryItem, ExifDateTime, Tags } from 'exiftool-vendored';
|
import { ContainerDirectoryItem, ExifDateTime, Maybe, Tags } from 'exiftool-vendored';
|
||||||
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
import { firstDateTime } from 'exiftool-vendored/dist/FirstDateTime';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import { Duration } from 'luxon';
|
import { Duration } from 'luxon';
|
||||||
@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
|
|||||||
import { OnEmit } from 'src/decorators';
|
import { OnEmit } from 'src/decorators';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { ExifEntity } from 'src/entities/exif.entity';
|
|
||||||
import { PersonEntity } from 'src/entities/person.entity';
|
import { PersonEntity } from 'src/entities/person.entity';
|
||||||
import { AssetType, SourceType } from 'src/enum';
|
import { AssetType, SourceType } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
@ -30,7 +29,7 @@ import {
|
|||||||
QueueName,
|
QueueName,
|
||||||
} from 'src/interfaces/job.interface';
|
} from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IMapRepository } from 'src/interfaces/map.interface';
|
import { IMapRepository, ReverseGeocodeResult } from 'src/interfaces/map.interface';
|
||||||
import { IMediaRepository } from 'src/interfaces/media.interface';
|
import { IMediaRepository } from 'src/interfaces/media.interface';
|
||||||
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
|
||||||
import { IMoveRepository } from 'src/interfaces/move.interface';
|
import { IMoveRepository } from 'src/interfaces/move.interface';
|
||||||
@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array<keyof Tags> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export enum Orientation {
|
export enum Orientation {
|
||||||
Horizontal = '1',
|
Horizontal = 1,
|
||||||
MirrorHorizontal = '2',
|
MirrorHorizontal = 2,
|
||||||
Rotate180 = '3',
|
Rotate180 = 3,
|
||||||
MirrorVertical = '4',
|
MirrorVertical = 4,
|
||||||
MirrorHorizontalRotate270CW = '5',
|
MirrorHorizontalRotate270CW = 5,
|
||||||
Rotate90CW = '6',
|
Rotate90CW = 6,
|
||||||
MirrorHorizontalRotate90CW = '7',
|
MirrorHorizontalRotate90CW = 7,
|
||||||
Rotate270CW = '8',
|
Rotate270CW = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExifEntityWithoutGeocodeAndTypeOrm = Omit<ExifEntity, 'city' | 'state' | 'country' | 'description'> & {
|
|
||||||
dateTimeOriginal: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
|
|
||||||
const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null);
|
|
||||||
|
|
||||||
const validate = <T>(value: T): NonNullable<T> | null => {
|
const validate = <T>(value: T): NonNullable<T> | null => {
|
||||||
// handle lists of numbers
|
// handle lists of numbers
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
@ -218,36 +210,73 @@ export class MetadataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
|
async handleMetadataExtraction({ id }: IEntityJob): Promise<JobStatus> {
|
||||||
const { metadata } = await this.configCore.getConfig({ withCache: true });
|
const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
|
||||||
const [asset] = await this.assetRepository.getByIds([id]);
|
const [asset] = await this.assetRepository.getByIds([id]);
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.FAILED;
|
return JobStatus.FAILED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { exifData, exifTags } = await this.exifData(asset);
|
const stats = await this.storageRepository.stat(asset.originalPath);
|
||||||
|
|
||||||
if (asset.type === AssetType.VIDEO) {
|
const exifTags = await this.getExifTags(asset);
|
||||||
await this.applyVideoMetadata(asset, exifData);
|
|
||||||
}
|
this.logger.verbose('Exif Tags', exifTags);
|
||||||
|
|
||||||
|
const { dateTimeOriginal, localDateTime, timeZone, modifyDate } = this.getDates(asset, exifTags);
|
||||||
|
const { latitude, longitude, country, state, city } = await this.getGeo(exifTags, reverseGeocoding);
|
||||||
|
|
||||||
|
const exifData = {
|
||||||
|
assetId: asset.id,
|
||||||
|
|
||||||
|
// dates
|
||||||
|
dateTimeOriginal,
|
||||||
|
modifyDate,
|
||||||
|
timeZone,
|
||||||
|
|
||||||
|
// gps
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
country,
|
||||||
|
state,
|
||||||
|
city,
|
||||||
|
|
||||||
|
// image/file
|
||||||
|
fileSizeInByte: stats.size,
|
||||||
|
exifImageHeight: validate(exifTags.ImageHeight),
|
||||||
|
exifImageWidth: validate(exifTags.ImageWidth),
|
||||||
|
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
||||||
|
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
||||||
|
bitsPerSample: this.getBitsPerSample(exifTags),
|
||||||
|
colorspace: exifTags.ColorSpace ?? null,
|
||||||
|
|
||||||
|
// camera
|
||||||
|
make: exifTags.Make ?? null,
|
||||||
|
model: exifTags.Model ?? null,
|
||||||
|
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
||||||
|
iso: validate(exifTags.ISO),
|
||||||
|
exposureTime: exifTags.ExposureTime ?? null,
|
||||||
|
lensModel: exifTags.LensModel ?? null,
|
||||||
|
fNumber: validate(exifTags.FNumber),
|
||||||
|
focalLength: validate(exifTags.FocalLength),
|
||||||
|
|
||||||
|
// comments
|
||||||
|
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
||||||
|
profileDescription: exifTags.ProfileDescription || null,
|
||||||
|
rating: exifTags.Rating ?? null,
|
||||||
|
|
||||||
|
// grouping
|
||||||
|
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
||||||
|
autoStackId: this.getAutoStackId(exifTags),
|
||||||
|
};
|
||||||
|
|
||||||
await this.applyMotionPhotos(asset, exifTags);
|
|
||||||
await this.applyReverseGeocoding(asset, exifData);
|
|
||||||
await this.applyTagList(asset, exifTags);
|
await this.applyTagList(asset, exifTags);
|
||||||
|
await this.applyMotionPhotos(asset, exifTags);
|
||||||
|
|
||||||
await this.assetRepository.upsertExif(exifData);
|
await this.assetRepository.upsertExif(exifData);
|
||||||
|
|
||||||
const dateTimeOriginal = exifData.dateTimeOriginal;
|
|
||||||
let localDateTime = dateTimeOriginal ?? undefined;
|
|
||||||
|
|
||||||
const timeZoneOffset = tzOffset(firstDateTime(exifTags as Tags)) ?? 0;
|
|
||||||
|
|
||||||
if (dateTimeOriginal && timeZoneOffset) {
|
|
||||||
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.assetRepository.update({
|
await this.assetRepository.update({
|
||||||
id: asset.id,
|
id: asset.id,
|
||||||
duration: asset.duration,
|
duration: exifTags.Duration?.toString() ?? null,
|
||||||
localDateTime,
|
localDateTime,
|
||||||
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
|
||||||
});
|
});
|
||||||
@ -338,25 +367,20 @@ export class MetadataService {
|
|||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
private async getExifTags(asset: AssetEntity): Promise<ImmichTags> {
|
||||||
const { latitude, longitude } = exifData;
|
const mediaTags = await this.repository.readTags(asset.originalPath);
|
||||||
const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
|
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
|
||||||
if (!reverseGeocoding.enabled || !longitude || !latitude) {
|
const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
|
||||||
return;
|
|
||||||
|
// make sure dates comes from sidecar
|
||||||
|
const sidecarDate = firstDateTime(sidecarTags as Tags, EXIF_DATE_TAGS);
|
||||||
|
if (sidecarDate) {
|
||||||
|
for (const tag of EXIF_DATE_TAGS) {
|
||||||
|
delete mediaTags[tag];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return { ...mediaTags, ...videoTags, ...sidecarTags };
|
||||||
const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
|
||||||
if (!reverseGeocode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Object.assign(exifData, reverseGeocode);
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
this.logger.warn(
|
|
||||||
`Unable to run reverse geocoding due to ${error} for asset ${asset.id} at ${asset.originalPath}`,
|
|
||||||
error?.stack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
|
||||||
@ -576,66 +600,65 @@ export class MetadataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async exifData(
|
private getDates(asset: AssetEntity, exifTags: ImmichTags) {
|
||||||
asset: AssetEntity,
|
const dateTime = firstDateTime(exifTags as Maybe<Tags>, EXIF_DATE_TAGS);
|
||||||
): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
|
this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
|
||||||
const stats = await this.storageRepository.stat(asset.originalPath);
|
|
||||||
const mediaTags = await this.repository.readTags(asset.originalPath);
|
|
||||||
const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : null;
|
|
||||||
|
|
||||||
// ensure date from sidecar is used if present
|
// created
|
||||||
const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
|
let dateTimeOriginal = dateTime?.toDate();
|
||||||
if (mediaTags && hasDateOverride) {
|
if (!dateTimeOriginal) {
|
||||||
for (const tag of EXIF_DATE_TAGS) {
|
this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`);
|
||||||
delete mediaTags[tag];
|
dateTimeOriginal = asset.fileCreatedAt;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const exifTags = { ...mediaTags, ...sidecarTags };
|
// timezone
|
||||||
|
let timeZone = exifTags.tz ?? null;
|
||||||
|
if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) {
|
||||||
|
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
|
||||||
|
// https://github.com/photostructure/exiftool-vendored.js/issues/203
|
||||||
|
timeZone = 'UTC+0';
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.verbose('Exif Tags', exifTags);
|
if (timeZone) {
|
||||||
|
this.logger.debug(`Asset ${asset.id} timezone is ${timeZone} (via ${exifTags.tzSource})`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`Asset ${asset.id} has no time zone information`);
|
||||||
|
}
|
||||||
|
|
||||||
const dateTimeOriginalWithRawValue = this.getDateTimeOriginalWithRawValue(exifTags);
|
// offset minutes
|
||||||
const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt;
|
const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
|
||||||
const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue);
|
let localDateTime = dateTimeOriginal;
|
||||||
|
if (offsetMinutes) {
|
||||||
|
localDateTime = new Date(dateTimeOriginal.getTime() + offsetMinutes * 60_000);
|
||||||
|
this.logger.debug(`Asset ${asset.id} local time is offset by ${offsetMinutes} minutes`);
|
||||||
|
}
|
||||||
|
|
||||||
const exifData = {
|
return {
|
||||||
// altitude: tags.GPSAltitude ?? null,
|
|
||||||
assetId: asset.id,
|
|
||||||
bitsPerSample: this.getBitsPerSample(exifTags),
|
|
||||||
colorspace: exifTags.ColorSpace ?? null,
|
|
||||||
dateTimeOriginal,
|
dateTimeOriginal,
|
||||||
description: String(exifTags.ImageDescription || exifTags.Description || '').trim(),
|
|
||||||
exifImageHeight: validate(exifTags.ImageHeight),
|
|
||||||
exifImageWidth: validate(exifTags.ImageWidth),
|
|
||||||
exposureTime: exifTags.ExposureTime ?? null,
|
|
||||||
fileSizeInByte: stats.size,
|
|
||||||
fNumber: validate(exifTags.FNumber),
|
|
||||||
focalLength: validate(exifTags.FocalLength),
|
|
||||||
fps: validate(Number.parseFloat(exifTags.VideoFrameRate!)),
|
|
||||||
iso: validate(exifTags.ISO),
|
|
||||||
latitude: validate(exifTags.GPSLatitude),
|
|
||||||
lensModel: exifTags.LensModel ?? null,
|
|
||||||
livePhotoCID: (exifTags.ContentIdentifier || exifTags.MediaGroupUUID) ?? null,
|
|
||||||
autoStackId: this.getAutoStackId(exifTags),
|
|
||||||
longitude: validate(exifTags.GPSLongitude),
|
|
||||||
make: exifTags.Make ?? null,
|
|
||||||
model: exifTags.Model ?? null,
|
|
||||||
modifyDate: exifDate(exifTags.ModifyDate) ?? asset.fileModifiedAt,
|
|
||||||
orientation: validate(exifTags.Orientation)?.toString() ?? null,
|
|
||||||
profileDescription: exifTags.ProfileDescription || null,
|
|
||||||
projectionType: exifTags.ProjectionType ? String(exifTags.ProjectionType).toUpperCase() : null,
|
|
||||||
timeZone,
|
timeZone,
|
||||||
rating: exifTags.Rating ?? null,
|
localDateTime,
|
||||||
|
modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (exifData.latitude === 0 && exifData.longitude === 0) {
|
private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
|
||||||
this.logger.warn('Exif data has latitude and longitude of 0, setting to null');
|
let latitude = validate(tags.GPSLatitude);
|
||||||
exifData.latitude = null;
|
let longitude = validate(tags.GPSLongitude);
|
||||||
exifData.longitude = null;
|
|
||||||
|
// TODO take ref into account
|
||||||
|
|
||||||
|
if (latitude === 0 && longitude === 0) {
|
||||||
|
this.logger.warn('Latitude and longitude of 0, setting to null');
|
||||||
|
latitude = null;
|
||||||
|
longitude = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { exifData, exifTags };
|
let result: ReverseGeocodeResult = { country: null, state: null, city: null };
|
||||||
|
if (reverseGeocoding.enabled && longitude && latitude) {
|
||||||
|
result = await this.mapRepository.reverseGeocode({ latitude, longitude });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, latitude, longitude };
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAutoStackId(tags: ImmichTags | null): string | null {
|
private getAutoStackId(tags: ImmichTags | null): string | null {
|
||||||
@ -645,28 +668,6 @@ export class MetadataService {
|
|||||||
return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null;
|
return tags.BurstID ?? tags.BurstUUID ?? tags.CameraBurstID ?? tags.MediaUniqueID ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDateTimeOriginal(tags: ImmichTags | Tags | null) {
|
|
||||||
return this.getDateTimeOriginalWithRawValue(tags).exifDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDateTimeOriginalWithRawValue(tags: ImmichTags | Tags | null): { exifDate: Date | null; rawValue: string } {
|
|
||||||
if (!tags) {
|
|
||||||
return { exifDate: null, rawValue: '' };
|
|
||||||
}
|
|
||||||
const first = firstDateTime(tags as Tags, EXIF_DATE_TAGS);
|
|
||||||
return { exifDate: exifDate(first), rawValue: first?.rawValue ?? '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTimeZone(exifTags: ImmichTags, rawValue: string) {
|
|
||||||
const timeZone = exifTags.tz ?? null;
|
|
||||||
if (timeZone == null && rawValue.endsWith('+00:00')) {
|
|
||||||
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
|
|
||||||
// https://github.com/photostructure/exiftool-vendored.js/issues/203
|
|
||||||
return 'UTC+0';
|
|
||||||
}
|
|
||||||
return timeZone;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getBitsPerSample(tags: ImmichTags): number | null {
|
private getBitsPerSample(tags: ImmichTags): number | null {
|
||||||
const bitDepthTags = [
|
const bitDepthTags = [
|
||||||
tags.BitsPerSample,
|
tags.BitsPerSample,
|
||||||
@ -685,33 +686,37 @@ export class MetadataService {
|
|||||||
return bitsPerSample;
|
return bitsPerSample;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
|
private async getVideoTags(originalPath: string) {
|
||||||
const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath);
|
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
|
||||||
|
|
||||||
|
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
|
||||||
|
|
||||||
if (videoStreams[0]) {
|
if (videoStreams[0]) {
|
||||||
switch (videoStreams[0].rotation) {
|
switch (videoStreams[0].rotation) {
|
||||||
case -90: {
|
case -90: {
|
||||||
exifData.orientation = Orientation.Rotate90CW;
|
tags.Orientation = Orientation.Rotate90CW;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 0: {
|
case 0: {
|
||||||
exifData.orientation = Orientation.Horizontal;
|
tags.Orientation = Orientation.Horizontal;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 90: {
|
case 90: {
|
||||||
exifData.orientation = Orientation.Rotate270CW;
|
tags.Orientation = Orientation.Rotate270CW;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 180: {
|
case 180: {
|
||||||
exifData.orientation = Orientation.Rotate180;
|
tags.Orientation = Orientation.Rotate180;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (format.duration) {
|
if (format.duration) {
|
||||||
asset.duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS');
|
tags.Duration = Duration.fromObject({ seconds: format.duration }).toFormat('hh:mm:ss.SSS');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
private async processSidecar(id: string, isSync: boolean): Promise<JobStatus> {
|
||||||
|
@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
|
|||||||
import { AssetFileType, UserMetadataKey } from 'src/enum';
|
import { AssetFileType, UserMetadataKey } from 'src/enum';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
|
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
import { EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
|
||||||
@ -17,6 +18,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
|
|||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
|
||||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||||
|
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
|
||||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||||
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
|
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
|
||||||
@ -64,6 +66,7 @@ const configs = {
|
|||||||
describe(NotificationService.name, () => {
|
describe(NotificationService.name, () => {
|
||||||
let albumMock: Mocked<IAlbumRepository>;
|
let albumMock: Mocked<IAlbumRepository>;
|
||||||
let assetMock: Mocked<IAssetRepository>;
|
let assetMock: Mocked<IAssetRepository>;
|
||||||
|
let eventMock: Mocked<IEventRepository>;
|
||||||
let jobMock: Mocked<IJobRepository>;
|
let jobMock: Mocked<IJobRepository>;
|
||||||
let loggerMock: Mocked<ILoggerRepository>;
|
let loggerMock: Mocked<ILoggerRepository>;
|
||||||
let notificationMock: Mocked<INotificationRepository>;
|
let notificationMock: Mocked<INotificationRepository>;
|
||||||
@ -74,13 +77,23 @@ describe(NotificationService.name, () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
albumMock = newAlbumRepositoryMock();
|
albumMock = newAlbumRepositoryMock();
|
||||||
assetMock = newAssetRepositoryMock();
|
assetMock = newAssetRepositoryMock();
|
||||||
|
eventMock = newEventRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
loggerMock = newLoggerRepositoryMock();
|
loggerMock = newLoggerRepositoryMock();
|
||||||
notificationMock = newNotificationRepositoryMock();
|
notificationMock = newNotificationRepositoryMock();
|
||||||
systemMock = newSystemMetadataRepositoryMock();
|
systemMock = newSystemMetadataRepositoryMock();
|
||||||
userMock = newUserRepositoryMock();
|
userMock = newUserRepositoryMock();
|
||||||
|
|
||||||
sut = new NotificationService(systemMock, notificationMock, userMock, jobMock, loggerMock, assetMock, albumMock);
|
sut = new NotificationService(
|
||||||
|
eventMock,
|
||||||
|
systemMock,
|
||||||
|
notificationMock,
|
||||||
|
userMock,
|
||||||
|
jobMock,
|
||||||
|
loggerMock,
|
||||||
|
assetMock,
|
||||||
|
albumMock,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
|
@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
|
|||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
import { ArgOf } from 'src/interfaces/event.interface';
|
import { ArgOf, ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||||
import {
|
import {
|
||||||
IEmailJob,
|
IEmailJob,
|
||||||
IJobRepository,
|
IJobRepository,
|
||||||
@ -30,6 +30,7 @@ export class NotificationService {
|
|||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||||
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
|
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
|
||||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||||
@ -74,6 +75,12 @@ export class NotificationService {
|
|||||||
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
|
await this.jobRepository.queue({ name: JobName.NOTIFY_ALBUM_INVITE, data: { id, recipientId: userId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEmit({ event: 'session.delete' })
|
||||||
|
onSessionDelete({ sessionId }: ArgOf<'session.delete'>) {
|
||||||
|
// after the response is sent
|
||||||
|
setTimeout(() => this.eventRepository.clientSend(ClientEvent.SESSION_DELETE, sessionId, sessionId), 500);
|
||||||
|
}
|
||||||
|
|
||||||
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
|
async sendTestEmail(id: string, dto: SystemConfigSmtpDto) {
|
||||||
const user = await this.userRepository.get(id, { withDeleted: false });
|
const user = await this.userRepository.get(id, { withDeleted: false });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
@ -1,19 +1,29 @@
|
|||||||
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
|
import { IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
import { StorageService } from 'src/services/storage.service';
|
import { StorageService } from 'src/services/storage.service';
|
||||||
|
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
|
||||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||||
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||||
import { Mocked } from 'vitest';
|
import { Mocked } from 'vitest';
|
||||||
|
|
||||||
describe(StorageService.name, () => {
|
describe(StorageService.name, () => {
|
||||||
let sut: StorageService;
|
let sut: StorageService;
|
||||||
|
let databaseMock: Mocked<IDatabaseRepository>;
|
||||||
let storageMock: Mocked<IStorageRepository>;
|
let storageMock: Mocked<IStorageRepository>;
|
||||||
let loggerMock: Mocked<ILoggerRepository>;
|
let loggerMock: Mocked<ILoggerRepository>;
|
||||||
|
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
databaseMock = newDatabaseRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
loggerMock = newLoggerRepositoryMock();
|
loggerMock = newLoggerRepositoryMock();
|
||||||
sut = new StorageService(storageMock, loggerMock);
|
systemMock = newSystemMetadataRepositoryMock();
|
||||||
|
|
||||||
|
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work', () => {
|
it('should work', () => {
|
||||||
@ -21,9 +31,35 @@ describe(StorageService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('onBootstrap', () => {
|
describe('onBootstrap', () => {
|
||||||
it('should create the library folder on initialization', () => {
|
it('should enable mount folder checking', async () => {
|
||||||
sut.onBootstrap();
|
systemMock.get.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(sut.onBootstrap()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountFiles: true });
|
||||||
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video');
|
||||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library');
|
||||||
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile');
|
||||||
|
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if .immich is missing', async () => {
|
||||||
|
systemMock.get.mockResolvedValue({ mountFiles: true });
|
||||||
|
storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
|
||||||
|
|
||||||
|
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
|
||||||
|
|
||||||
|
expect(storageMock.writeFile).not.toHaveBeenCalled();
|
||||||
|
expect(systemMock.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error if .immich is present but read-only', async () => {
|
||||||
|
systemMock.get.mockResolvedValue({ mountFiles: true });
|
||||||
|
storageMock.writeFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
|
||||||
|
|
||||||
|
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
|
||||||
|
|
||||||
|
expect(systemMock.set).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -1,23 +1,52 @@
|
|||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||||
import { OnEmit } from 'src/decorators';
|
import { OnEmit } from 'src/decorators';
|
||||||
|
import { SystemMetadataKey } from 'src/enum';
|
||||||
|
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
|
||||||
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||||
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||||
|
import { ImmichStartupError } from 'src/utils/events';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StorageService {
|
export class StorageService {
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||||
|
@Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
|
||||||
) {
|
) {
|
||||||
this.logger.setContext(StorageService.name);
|
this.logger.setContext(StorageService.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEmit({ event: 'app.bootstrap' })
|
@OnEmit({ event: 'app.bootstrap' })
|
||||||
onBootstrap() {
|
async onBootstrap() {
|
||||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
|
||||||
this.storageRepository.mkdirSync(libraryBase);
|
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
|
||||||
|
|
||||||
|
this.logger.log('Verifying system mount folder checks');
|
||||||
|
|
||||||
|
// check each folder exists and is writable
|
||||||
|
for (const folder of Object.values(StorageFolder)) {
|
||||||
|
if (!flags.mountFiles) {
|
||||||
|
this.logger.log(`Writing initial mount file for the ${folder} folder`);
|
||||||
|
await this.verifyWriteAccess(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.verifyReadAccess(folder);
|
||||||
|
await this.verifyWriteAccess(folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!flags.mountFiles) {
|
||||||
|
flags.mountFiles = true;
|
||||||
|
await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
|
||||||
|
this.logger.log('Successfully enabled system mount folders checks');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Successfully verified system mount folder checks');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleDeleteFiles(job: IDeleteFilesJob) {
|
async handleDeleteFiles(job: IDeleteFilesJob) {
|
||||||
@ -38,4 +67,38 @@ export class StorageService {
|
|||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async verifyReadAccess(folder: StorageFolder) {
|
||||||
|
const { filePath } = this.getMountFilePaths(folder);
|
||||||
|
try {
|
||||||
|
await this.storageRepository.readFile(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to read ${filePath}: ${error}`);
|
||||||
|
this.logger.error(
|
||||||
|
`The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`,
|
||||||
|
);
|
||||||
|
throw new ImmichStartupError(`Failed to validate folder mount (read from "<MEDIA_LOCATION>/${folder}")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyWriteAccess(folder: StorageFolder) {
|
||||||
|
const { folderPath, filePath } = this.getMountFilePaths(folder);
|
||||||
|
try {
|
||||||
|
this.storageRepository.mkdirSync(folderPath);
|
||||||
|
await this.storageRepository.writeFile(filePath, Buffer.from(`${Date.now()}`));
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to write ${filePath}: ${error}`);
|
||||||
|
this.logger.error(
|
||||||
|
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
|
||||||
|
);
|
||||||
|
throw new ImmichStartupError(`Failed to validate folder mount (write to "<MEDIA_LOCATION>/${folder}")`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMountFilePaths(folder: StorageFolder) {
|
||||||
|
const folderPath = StorageCore.getBaseFolder(folder);
|
||||||
|
const filePath = join(folderPath, '.immich');
|
||||||
|
|
||||||
|
return { folderPath, filePath };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,9 @@ type Item<T extends EmitEvent> = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export class ImmichStartupError extends Error {}
|
||||||
|
export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
|
||||||
|
|
||||||
export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
export const setupEventHandlers = (moduleRef: ModuleRef) => {
|
||||||
const reflector = moduleRef.get(Reflector, { strict: false });
|
const reflector = moduleRef.get(Reflector, { strict: false });
|
||||||
const repository = moduleRef.get<IEventRepository>(IEventRepository);
|
const repository = moduleRef.get<IEventRepository>(IEventRepository);
|
||||||
|
@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/
|
|||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||||
import { ApiService } from 'src/services/api.service';
|
import { ApiService } from 'src/services/api.service';
|
||||||
|
import { isStartUpError } from 'src/utils/events';
|
||||||
import { otelStart } from 'src/utils/instrumentation';
|
import { otelStart } from 'src/utils/instrumentation';
|
||||||
import { useSwagger } from 'src/utils/misc';
|
import { useSwagger } from 'src/utils/misc';
|
||||||
|
|
||||||
@ -73,6 +74,9 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bootstrap().catch((error) => {
|
bootstrap().catch((error) => {
|
||||||
console.error(error);
|
if (!isStartUpError(error)) {
|
||||||
throw error;
|
console.error(error);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line unicorn/no-process-exit
|
||||||
|
process.exit(1);
|
||||||
});
|
});
|
||||||
|
@ -4,6 +4,7 @@ import { MicroservicesModule } from 'src/app.module';
|
|||||||
import { envName, serverVersion } from 'src/constants';
|
import { envName, serverVersion } from 'src/constants';
|
||||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||||
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
|
||||||
|
import { isStartUpError } from 'src/utils/events';
|
||||||
import { otelStart } from 'src/utils/instrumentation';
|
import { otelStart } from 'src/utils/instrumentation';
|
||||||
|
|
||||||
export async function bootstrap() {
|
export async function bootstrap() {
|
||||||
@ -25,7 +26,9 @@ export async function bootstrap() {
|
|||||||
|
|
||||||
if (!isMainThread) {
|
if (!isMainThread) {
|
||||||
bootstrap().catch((error) => {
|
bootstrap().catch((error) => {
|
||||||
console.error(error);
|
if (!isStartUpError(error)) {
|
||||||
process.exit(1);
|
console.error(error);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
|
import { clickOutside } from '$lib/actions/click-outside';
|
||||||
|
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
||||||
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
import LinkButton from '$lib/components/elements/buttons/link-button.svelte';
|
||||||
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
import SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
import { user } from '$lib/stores/user.store';
|
||||||
import { clickOutside } from '$lib/actions/click-outside';
|
import { handleLogout } from '$lib/utils/auth';
|
||||||
import { logout } from '@immich/sdk';
|
import { logout } from '@immich/sdk';
|
||||||
import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
|
import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
|
||||||
import { createEventDispatcher } from 'svelte';
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
import { AppRoute } from '../../../constants';
|
import { AppRoute } from '../../../constants';
|
||||||
import ImmichLogo from '../immich-logo.svelte';
|
import ImmichLogo from '../immich-logo.svelte';
|
||||||
@ -17,9 +19,6 @@
|
|||||||
import ThemeButton from '../theme-button.svelte';
|
import ThemeButton from '../theme-button.svelte';
|
||||||
import UserAvatar from '../user-avatar.svelte';
|
import UserAvatar from '../user-avatar.svelte';
|
||||||
import AccountInfoPanel from './account-info-panel.svelte';
|
import AccountInfoPanel from './account-info-panel.svelte';
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { foldersStore } from '$lib/stores/folders.store';
|
|
||||||
|
|
||||||
export let showUploadButton = true;
|
export let showUploadButton = true;
|
||||||
|
|
||||||
@ -30,16 +29,9 @@
|
|||||||
uploadClicked: void;
|
uploadClicked: void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const logOut = async () => {
|
const onLogout = async () => {
|
||||||
const { redirectUri } = await logout();
|
const { redirectUri } = await logout();
|
||||||
|
await handleLogout(redirectUri);
|
||||||
if (redirectUri.startsWith('/')) {
|
|
||||||
await goto(redirectUri);
|
|
||||||
} else {
|
|
||||||
window.location.href = redirectUri;
|
|
||||||
}
|
|
||||||
resetSavedUser();
|
|
||||||
foldersStore.clearCache();
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -153,7 +145,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if shouldShowAccountInfoPanel}
|
{#if shouldShowAccountInfoPanel}
|
||||||
<AccountInfoPanel on:logout={logOut} />
|
<AccountInfoPanel on:logout={onLogout} />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { handleLogout } from '$lib/utils/auth';
|
||||||
import { createEventEmitter } from '$lib/utils/eventemitter';
|
import { createEventEmitter } from '$lib/utils/eventemitter';
|
||||||
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
|
||||||
import { io, type Socket } from 'socket.io-client';
|
import { io, type Socket } from 'socket.io-client';
|
||||||
@ -24,6 +26,7 @@ export interface Events {
|
|||||||
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
|
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
|
||||||
on_config_update: () => void;
|
on_config_update: () => void;
|
||||||
on_new_release: (newRelase: ReleaseEvent) => void;
|
on_new_release: (newRelase: ReleaseEvent) => void;
|
||||||
|
on_session_delete: (sessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const websocket: Socket<Events> = io({
|
const websocket: Socket<Events> = io({
|
||||||
@ -47,6 +50,7 @@ websocket
|
|||||||
.on('disconnect', () => websocketStore.connected.set(false))
|
.on('disconnect', () => websocketStore.connected.set(false))
|
||||||
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
|
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
|
||||||
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
.on('on_new_release', (releaseVersion) => websocketStore.release.set(releaseVersion))
|
||||||
|
.on('on_session_delete', () => handleLogout(AppRoute.AUTH_LOGIN))
|
||||||
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
.on('connect_error', (e) => console.log('Websocket Connect Error', e));
|
||||||
|
|
||||||
export const openWebsocketConnection = () => {
|
export const openWebsocketConnection = () => {
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { foldersStore } from '$lib/stores/folders.store';
|
||||||
import { purchaseStore } from '$lib/stores/purchase.store';
|
import { purchaseStore } from '$lib/stores/purchase.store';
|
||||||
import { serverInfo } from '$lib/stores/server-info.store';
|
import { serverInfo } from '$lib/stores/server-info.store';
|
||||||
import { preferences as preferences$, user as user$ } from '$lib/stores/user.store';
|
import { preferences as preferences$, resetSavedUser, user as user$ } from '$lib/stores/user.store';
|
||||||
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
import { getAboutInfo, getMyPreferences, getMyUser, getStorage } from '@immich/sdk';
|
||||||
import { redirect } from '@sveltejs/kit';
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
@ -87,3 +89,16 @@ export const getAccountAge = (): number => {
|
|||||||
|
|
||||||
return Number(accountAge);
|
return Number(accountAge);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const handleLogout = async (redirectUri: string) => {
|
||||||
|
try {
|
||||||
|
if (redirectUri.startsWith('/')) {
|
||||||
|
await goto(redirectUri);
|
||||||
|
} else {
|
||||||
|
window.location.href = redirectUri;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
resetSavedUser();
|
||||||
|
foldersStore.clearCache();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user