diff --git a/.github/labeler.yml b/.github/labeler.yml
index 2a9abc7840381..c0c52f1d7e4b9 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -35,6 +35,4 @@ documentation:
- machine-learning/app/**
changelog:translation:
- - changed-files:
- - any-glob-to-any-file:
- - web/src/lib/i18n/*.json
+ - head-branch: ['^chore/translations$']
diff --git a/README.md b/README.md
index 85854577079f7..44c38e6d14813 100644
--- a/README.md
+++ b/README.md
@@ -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 |
| Support 360 degree image display | No | Yes |
| User-defined storage structure | Yes | Yes |
-| Public Sharing | No | Yes |
+| Public Sharing | Yes | Yes |
| Archive and Favorites | Yes | Yes |
| Global Map | Yes | Yes |
| Partner Sharing | Yes | Yes |
diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml
index 831b308a0c387..492f5b0c3d6e4 100644
--- a/docker/docker-compose.dev.yml
+++ b/docker/docker-compose.dev.yml
@@ -98,7 +98,7 @@ services:
redis:
container_name: immich_redis
- image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
+ image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck:
test: redis-cli ping || exit 1
diff --git a/docker/docker-compose.prod.yml b/docker/docker-compose.prod.yml
index 509674f328b35..20126457aa47e 100644
--- a/docker/docker-compose.prod.yml
+++ b/docker/docker-compose.prod.yml
@@ -47,7 +47,7 @@ services:
redis:
container_name: immich_redis
- image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
+ image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck:
test: redis-cli ping || exit 1
restart: always
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 927a95f5274c5..f0590385e4f90 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -48,7 +48,7 @@ services:
redis:
container_name: immich_redis
- image: docker.io/redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
+ image: docker.io/redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
healthcheck:
test: redis-cli ping || exit 1
restart: always
diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml
index cbeca0deca296..ce3d1d7ab1334 100644
--- a/e2e/docker-compose.yml
+++ b/e2e/docker-compose.yml
@@ -33,7 +33,7 @@ services:
- 2285:3001
redis:
- image: redis:6.2-alpine@sha256:e3b17ba9479deec4b7d1eeec1548a253acc5374d68d3b27937fcfe4df8d18c7e
+ image: redis:6.2-alpine@sha256:fd1b5400ca24adc2ff77abdf00acb72c3aae85b94e43557ab2606d29a74bfa01
database:
image: tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a3517cf6914295b5ab410db9ce23190a2d9d0b9dd6463e3fa298f0
diff --git a/machine-learning/Dockerfile b/machine-learning/Dockerfile
index f680aac826af3..12fb183c953d4 100644
--- a/machine-learning/Dockerfile
+++ b/machine-learning/Dockerfile
@@ -40,11 +40,10 @@ FROM prod-cpu AS prod-openvino
RUN apt-get update && \
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.17193.4/intel-igc-opencl_1.0.17193.4_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.26.30049.6/intel-opencl-icd_24.26.30049.6_amd64.deb && \
- wget https://github.com/intel/compute-runtime/releases/download/24.26.30049.6/libigdgmm12_22.3.20_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.17384.11/intel-igc-opencl_1.0.17384.11_amd64.deb && \
+ 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.31.30508.7/libigdgmm12_22.4.1_amd64.deb && \
dpkg -i *.deb && \
rm *.deb && \
apt-get remove wget -yqq && \
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index fb96609a06047..2d8439e36a4f3 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -401,7 +401,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 172;
+ CURRENT_PROJECT_VERSION = 173;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 172;
+ CURRENT_PROJECT_VERSION = 173;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -571,7 +571,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 172;
+ CURRENT_PROJECT_VERSION = 173;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 138b0e426d251..b33be9a370df7 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -58,11 +58,11 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 1.113.1
+ 1.114.0
CFBundleSignature
????
CFBundleVersion
- 172
+ 173
FLTEnableImpeller
ITSAppUsesNonExemptEncryption
diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts
index ae01c47b846d9..0a238e1da5f6b 100644
--- a/server/src/entities/system-metadata.entity.ts
+++ b/server/src/entities/system-metadata.entity.ts
@@ -12,12 +12,14 @@ export class SystemMetadataEntity> {
- [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
- [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
- [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial;
- [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
+ [SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
+ [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
+ [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial;
+ [SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags;
+ [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
}
diff --git a/server/src/enum.ts b/server/src/enum.ts
index 28973e0205831..32254854e4c5a 100644
--- a/server/src/enum.ts
+++ b/server/src/enum.ts
@@ -153,6 +153,7 @@ export enum SystemMetadataKey {
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
+ SYSTEM_FLAGS = 'system-flags',
VERSION_CHECK_STATE = 'version-check-state',
LICENSE = 'license',
}
diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts
index 373f1091429d7..51b39b95a8c08 100644
--- a/server/src/interfaces/database.interface.ts
+++ b/server/src/interfaces/database.interface.ts
@@ -15,6 +15,7 @@ export enum VectorIndex {
export enum DatabaseLock {
GeodataImport = 100,
Migrations = 200,
+ SystemFileMounts = 300,
StorageTemplateMigration = 420,
CLIPDimSize = 512,
LibraryWatch = 1337,
diff --git a/server/src/interfaces/event.interface.ts b/server/src/interfaces/event.interface.ts
index bb2b0d9ab4bc9..ec6e776f5992b 100644
--- a/server/src/interfaces/event.interface.ts
+++ b/server/src/interfaces/event.interface.ts
@@ -21,6 +21,9 @@ type EmitEventMap = {
'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }];
+ // session events
+ 'session.delete': [{ sessionId: string }];
+
// user events
'user.signup': [{ notify: boolean; id: string; tempPassword?: string }];
};
@@ -43,6 +46,7 @@ export enum ClientEvent {
SERVER_VERSION = 'on_server_version',
CONFIG_UPDATE = 'on_config_update',
NEW_RELEASE = 'on_new_release',
+ SESSION_DELETE = 'on_session_delete',
}
export interface ClientEventMap {
@@ -58,6 +62,7 @@ export interface ClientEventMap {
[ClientEvent.SERVER_VERSION]: ServerVersionResponseDto;
[ClientEvent.CONFIG_UPDATE]: Record;
[ClientEvent.NEW_RELEASE]: ReleaseNotification;
+ [ClientEvent.SESSION_DELETE]: string;
}
export enum ServerEvent {
@@ -77,7 +82,7 @@ export interface IEventRepository {
/**
* Send to connected clients for a specific user
*/
- clientSend(event: E, userId: string, data: ClientEventMap[E]): void;
+ clientSend(event: E, room: string, data: ClientEventMap[E]): void;
/**
* Send to all connected clients
*/
diff --git a/server/src/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts
index dce75ffd25b03..80b37c3a5f182 100644
--- a/server/src/interfaces/map.interface.ts
+++ b/server/src/interfaces/map.interface.ts
@@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult {
export interface IMapRepository {
init(): Promise;
- reverseGeocode(point: GeoPoint): Promise;
+ reverseGeocode(point: GeoPoint): Promise;
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise;
fetchStyle(url: string): Promise;
}
diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts
index 04e7b89d1e138..39ff6ab4af33d 100644
--- a/server/src/interfaces/metadata.interface.ts
+++ b/server/src/interfaces/metadata.interface.ts
@@ -50,7 +50,7 @@ export interface ImmichTags extends Omit {
export interface IMetadataRepository {
teardown(): Promise;
- readTags(path: string): Promise;
+ readTags(path: string): Promise;
writeTags(path: string, tags: Partial): Promise;
extractBinaryTag(tagName: string, path: string): Promise;
getCountries(userIds: string[]): Promise>;
diff --git a/server/src/main.ts b/server/src/main.ts
index 7839bafd2fa78..ee4de1a259d19 100644
--- a/server/src/main.ts
+++ b/server/src/main.ts
@@ -17,7 +17,13 @@ async function bootstrapImmichAdmin() {
function bootstrapWorker(name: string) {
console.log(`Starting ${name} worker`);
+
const worker = name === 'api' ? fork(`./dist/workers/${name}.js`) : new Worker(`./dist/workers/${name}.js`);
+
+ worker.on('error', (error) => {
+ console.error(`${name} worker error: ${error}`);
+ });
+
worker.on('exit', (exitCode) => {
if (exitCode !== 0) {
console.error(`${name} worker exited with code ${exitCode}`);
diff --git a/server/src/repositories/event.repository.ts b/server/src/repositories/event.repository.ts
index 668eac48d9de9..9aa12e15dd560 100644
--- a/server/src/repositories/event.repository.ts
+++ b/server/src/repositories/event.repository.ts
@@ -1,4 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
+import { ModuleRef } from '@nestjs/core';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
OnGatewayConnection,
@@ -37,7 +38,7 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
private server?: Server;
constructor(
- private authService: AuthService,
+ private moduleRef: ModuleRef,
private eventEmitter: EventEmitter2,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
@@ -62,12 +63,15 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
async handleConnection(client: Socket) {
try {
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,
queryParams: {},
metadata: { adminRoute: false, sharedLinkRoute: false, uri: '/api/socket.io' },
});
await client.join(auth.user.id);
+ if (auth.session) {
+ await client.join(auth.session.id);
+ }
this.serverSend(ServerEvent.WEBSOCKET_CONNECT, { userId: auth.user.id });
} catch (error: Error | any) {
this.logger.error(`Websocket connection error: ${error}`, error?.stack);
@@ -96,8 +100,8 @@ export class EventRepository implements OnGatewayConnection, OnGatewayDisconnect
}
}
- clientSend(event: E, userId: string, data: ClientEventMap[E]) {
- this.server?.to(userId).emit(event, data);
+ clientSend(event: E, room: string, data: ClientEventMap[E]) {
+ this.server?.to(room).emit(event, data);
}
clientBroadcast(event: E, data: ClientEventMap[E]) {
diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts
index da4e30d47cbf8..3508de720b2e1 100644
--- a/server/src/repositories/map.repository.ts
+++ b/server/src/repositories/map.repository.ts
@@ -124,7 +124,7 @@ export class MapRepository implements IMapRepository {
}
}
- async reverseGeocode(point: GeoPoint): Promise {
+ async reverseGeocode(point: GeoPoint): Promise {
this.logger.debug(`Request: ${point.latitude},${point.longitude}`);
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`,
);
- return null;
+ return { country: null, state: null, city: null };
}
this.logger.verbose(`Raw: ${JSON.stringify(ne_response, ['id', 'admin', 'admin_a3', 'type'], 2)}`);
diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts
index abffc1b78527a..9902f04d9bfcf 100644
--- a/server/src/repositories/metadata.repository.ts
+++ b/server/src/repositories/metadata.repository.ts
@@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository {
await this.exiftool.end();
}
- readTags(path: string): Promise {
+ readTags(path: string): Promise {
return this.exiftool.read(path).catch((error) => {
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
- return null;
- }) as Promise;
+ return {};
+ }) as Promise;
}
extractBinaryTag(path: string, tagName: string): Promise {
diff --git a/server/src/services/auth.service.spec.ts b/server/src/services/auth.service.spec.ts
index f2fa0c520a30f..acc2d3459ccd1 100644
--- a/server/src/services/auth.service.spec.ts
+++ b/server/src/services/auth.service.spec.ts
@@ -6,6 +6,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.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 { newKeyRepositoryMock } from 'test/repositories/api-key.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 { newSessionRepositoryMock } from 'test/repositories/session.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
@@ -56,6 +58,7 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => {
let sut: AuthService;
let cryptoMock: Mocked;
+ let eventMock: Mocked;
let userMock: Mocked;
let loggerMock: Mocked;
let systemMock: Mocked;
@@ -87,6 +90,7 @@ describe('AuthService', () => {
} as any);
cryptoMock = newCryptoRepositoryMock();
+ eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
@@ -94,7 +98,7 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock();
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', () => {
@@ -208,6 +212,7 @@ describe('AuthService', () => {
});
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 () => {
diff --git a/server/src/services/auth.service.ts b/server/src/services/auth.service.ts
index 2b25decc07035..6eaf755d0eb49 100644
--- a/server/src/services/auth.service.ts
+++ b/server/src/services/auth.service.ts
@@ -34,6 +34,7 @@ import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
+import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISessionRepository } from 'src/interfaces/session.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@@ -75,6 +76,7 @@ export class AuthService {
constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
+ @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@@ -114,6 +116,7 @@ export class AuthService {
async logout(auth: AuthDto, authType: AuthType): Promise {
if (auth.session) {
await this.sessionRepository.delete(auth.session.id);
+ await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
}
return {
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index 52f6609772b9d..5b447c235539e 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -522,13 +522,13 @@ describe(MetadataService.name, () => {
it('should extract the correct video orientation', async () => {
assetMock.getByIds.mockResolvedValue([assetStub.video]);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
- metadataMock.readTags.mockResolvedValue(null);
+ metadataMock.readTags.mockResolvedValue({});
await sut.handleMetadataExtraction({ id: assetStub.video.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.video.id]);
expect(assetMock.upsertExif).toHaveBeenCalledWith(
- expect.objectContaining({ orientation: Orientation.Rotate270CW }),
+ expect.objectContaining({ orientation: Orientation.Rotate270CW.toString() }),
);
});
@@ -814,6 +814,9 @@ describe(MetadataService.name, () => {
projectionType: 'EQUIRECTANGULAR',
timeZone: tags.tz,
rating: tags.Rating,
+ country: null,
+ state: null,
+ city: null,
});
expect(assetMock.update).toHaveBeenCalledWith({
id: assetStub.image.id,
diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts
index 58e7b994480ac..cf51a332f844c 100644
--- a/server/src/services/metadata.service.ts
+++ b/server/src/services/metadata.service.ts
@@ -1,5 +1,5 @@
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 _ from 'lodash';
import { Duration } from 'luxon';
@@ -11,7 +11,6 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnEmit } from 'src/decorators';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetEntity } from 'src/entities/asset.entity';
-import { ExifEntity } from 'src/entities/exif.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
@@ -30,7 +29,7 @@ import {
QueueName,
} from 'src/interfaces/job.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 { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
@@ -56,23 +55,16 @@ const EXIF_DATE_TAGS: Array = [
];
export enum Orientation {
- Horizontal = '1',
- MirrorHorizontal = '2',
- Rotate180 = '3',
- MirrorVertical = '4',
- MirrorHorizontalRotate270CW = '5',
- Rotate90CW = '6',
- MirrorHorizontalRotate90CW = '7',
- Rotate270CW = '8',
+ Horizontal = 1,
+ MirrorHorizontal = 2,
+ Rotate180 = 3,
+ MirrorVertical = 4,
+ MirrorHorizontalRotate270CW = 5,
+ Rotate90CW = 6,
+ MirrorHorizontalRotate90CW = 7,
+ Rotate270CW = 8,
}
-type ExifEntityWithoutGeocodeAndTypeOrm = Omit & {
- 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 = (value: T): NonNullable | null => {
// handle lists of numbers
if (Array.isArray(value)) {
@@ -218,36 +210,73 @@ export class MetadataService {
}
async handleMetadataExtraction({ id }: IEntityJob): Promise {
- const { metadata } = await this.configCore.getConfig({ withCache: true });
+ const { metadata, reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
return JobStatus.FAILED;
}
- const { exifData, exifTags } = await this.exifData(asset);
+ const stats = await this.storageRepository.stat(asset.originalPath);
- if (asset.type === AssetType.VIDEO) {
- await this.applyVideoMetadata(asset, exifData);
- }
+ const exifTags = await this.getExifTags(asset);
+
+ 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.applyMotionPhotos(asset, exifTags);
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({
id: asset.id,
- duration: asset.duration,
+ duration: exifTags.Duration?.toString() ?? null,
localDateTime,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
});
@@ -338,25 +367,20 @@ export class MetadataService {
return JobStatus.SUCCESS;
}
- private async applyReverseGeocoding(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
- const { latitude, longitude } = exifData;
- const { reverseGeocoding } = await this.configCore.getConfig({ withCache: true });
- if (!reverseGeocoding.enabled || !longitude || !latitude) {
- return;
+ private async getExifTags(asset: AssetEntity): Promise {
+ const mediaTags = await this.repository.readTags(asset.originalPath);
+ const sidecarTags = asset.sidecarPath ? await this.repository.readTags(asset.sidecarPath) : {};
+ const videoTags = asset.type === AssetType.VIDEO ? await this.getVideoTags(asset.originalPath) : {};
+
+ // 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 {
- 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,
- );
- }
+ return { ...mediaTags, ...videoTags, ...sidecarTags };
}
private async applyTagList(asset: AssetEntity, exifTags: ImmichTags) {
@@ -576,66 +600,65 @@ export class MetadataService {
);
}
- private async exifData(
- asset: AssetEntity,
- ): Promise<{ exifData: ExifEntityWithoutGeocodeAndTypeOrm; exifTags: ImmichTags }> {
- 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;
+ private getDates(asset: AssetEntity, exifTags: ImmichTags) {
+ const dateTime = firstDateTime(exifTags as Maybe, EXIF_DATE_TAGS);
+ this.logger.debug(`Asset ${asset.id} date time is ${dateTime}`);
- // ensure date from sidecar is used if present
- const hasDateOverride = !!this.getDateTimeOriginal(sidecarTags);
- if (mediaTags && hasDateOverride) {
- for (const tag of EXIF_DATE_TAGS) {
- delete mediaTags[tag];
- }
+ // created
+ let dateTimeOriginal = dateTime?.toDate();
+ if (!dateTimeOriginal) {
+ this.logger.warn(`Asset ${asset.id} has no valid date (${dateTime}), falling back to asset.fileCreatedAt`);
+ 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);
- const dateTimeOriginal = dateTimeOriginalWithRawValue.exifDate ?? asset.fileCreatedAt;
- const timeZone = this.getTimeZone(exifTags, dateTimeOriginalWithRawValue.rawValue);
+ // offset minutes
+ const offsetMinutes = dateTime?.tzoffsetMinutes || 0;
+ 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 = {
- // altitude: tags.GPSAltitude ?? null,
- assetId: asset.id,
- bitsPerSample: this.getBitsPerSample(exifTags),
- colorspace: exifTags.ColorSpace ?? null,
+ return {
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,
- rating: exifTags.Rating ?? null,
+ localDateTime,
+ modifyDate: (exifTags.ModifyDate as ExifDateTime)?.toDate() ?? asset.fileModifiedAt,
};
+ }
- if (exifData.latitude === 0 && exifData.longitude === 0) {
- this.logger.warn('Exif data has latitude and longitude of 0, setting to null');
- exifData.latitude = null;
- exifData.longitude = null;
+ private async getGeo(tags: ImmichTags, reverseGeocoding: SystemConfig['reverseGeocoding']) {
+ let latitude = validate(tags.GPSLatitude);
+ let longitude = validate(tags.GPSLongitude);
+
+ // 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 {
@@ -645,28 +668,6 @@ export class MetadataService {
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 {
const bitDepthTags = [
tags.BitsPerSample,
@@ -685,33 +686,37 @@ export class MetadataService {
return bitsPerSample;
}
- private async applyVideoMetadata(asset: AssetEntity, exifData: ExifEntityWithoutGeocodeAndTypeOrm) {
- const { videoStreams, format } = await this.mediaRepository.probe(asset.originalPath);
+ private async getVideoTags(originalPath: string) {
+ const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
+
+ const tags: Pick = {};
if (videoStreams[0]) {
switch (videoStreams[0].rotation) {
case -90: {
- exifData.orientation = Orientation.Rotate90CW;
+ tags.Orientation = Orientation.Rotate90CW;
break;
}
case 0: {
- exifData.orientation = Orientation.Horizontal;
+ tags.Orientation = Orientation.Horizontal;
break;
}
case 90: {
- exifData.orientation = Orientation.Rotate270CW;
+ tags.Orientation = Orientation.Rotate270CW;
break;
}
case 180: {
- exifData.orientation = Orientation.Rotate180;
+ tags.Orientation = Orientation.Rotate180;
break;
}
}
}
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 {
diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts
index 5bcead0ff31ae..9d9f8f5fcfe2f 100644
--- a/server/src/services/notification.service.spec.ts
+++ b/server/src/services/notification.service.spec.ts
@@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.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 { ILoggerRepository } from 'src/interfaces/logger.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 { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
+import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newNotificationRepositoryMock } from 'test/repositories/notification.repository.mock';
@@ -64,6 +66,7 @@ const configs = {
describe(NotificationService.name, () => {
let albumMock: Mocked;
let assetMock: Mocked;
+ let eventMock: Mocked;
let jobMock: Mocked;
let loggerMock: Mocked;
let notificationMock: Mocked;
@@ -74,13 +77,23 @@ describe(NotificationService.name, () => {
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
+ eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
notificationMock = newNotificationRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
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', () => {
diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts
index 274c91661ca2b..d450f8dc759a2 100644
--- a/server/src/services/notification.service.ts
+++ b/server/src/services/notification.service.ts
@@ -6,7 +6,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.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 {
IEmailJob,
IJobRepository,
@@ -30,6 +30,7 @@ export class NotificationService {
private configCore: SystemConfigCore;
constructor(
+ @Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@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 } });
}
+ @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) {
const user = await this.userRepository.get(id, { withDeleted: false });
if (!user) {
diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts
index d9b4c8eefb3f3..b0f38554cb032 100644
--- a/server/src/services/storage.service.spec.ts
+++ b/server/src/services/storage.service.spec.ts
@@ -1,19 +1,29 @@
+import { SystemMetadataKey } from 'src/enum';
+import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
+import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service';
+import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
+import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(StorageService.name, () => {
let sut: StorageService;
+ let databaseMock: Mocked;
let storageMock: Mocked;
let loggerMock: Mocked;
+ let systemMock: Mocked;
beforeEach(() => {
+ databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock();
- sut = new StorageService(storageMock, loggerMock);
+ systemMock = newSystemMetadataRepositoryMock();
+
+ sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
});
it('should work', () => {
@@ -21,9 +31,35 @@ describe(StorageService.name, () => {
});
describe('onBootstrap', () => {
- it('should create the library folder on initialization', () => {
- sut.onBootstrap();
+ it('should enable mount folder checking', async () => {
+ 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/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();
});
});
diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts
index c3f2c06438340..a8f6a76e747e1 100644
--- a/server/src/services/storage.service.ts
+++ b/server/src/services/storage.service.ts
@@ -1,23 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
+import { join } from 'node:path';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
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 { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
+import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
+import { ImmichStartupError } from 'src/utils/events';
@Injectable()
export class StorageService {
constructor(
+ @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
+ @Inject(ISystemMetadataRepository) private systemMetadata: ISystemMetadataRepository,
) {
this.logger.setContext(StorageService.name);
}
@OnEmit({ event: 'app.bootstrap' })
- onBootstrap() {
- const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
- this.storageRepository.mkdirSync(libraryBase);
+ async onBootstrap() {
+ await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
+ 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) {
@@ -38,4 +67,38 @@ export class StorageService {
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 "/${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 "/${folder}")`);
+ }
+ }
+
+ private getMountFilePaths(folder: StorageFolder) {
+ const folderPath = StorageCore.getBaseFolder(folder);
+ const filePath = join(folderPath, '.immich');
+
+ return { folderPath, filePath };
+ }
}
diff --git a/server/src/utils/events.ts b/server/src/utils/events.ts
index 2dd7e7fd5d208..064c9f75071ef 100644
--- a/server/src/utils/events.ts
+++ b/server/src/utils/events.ts
@@ -12,6 +12,9 @@ type Item = {
label: string;
};
+export class ImmichStartupError extends Error {}
+export const isStartUpError = (error: unknown): error is ImmichStartupError => error instanceof ImmichStartupError;
+
export const setupEventHandlers = (moduleRef: ModuleRef) => {
const reflector = moduleRef.get(Reflector, { strict: false });
const repository = moduleRef.get(IEventRepository);
diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts
index 5857f587a0d2e..629c50c653003 100644
--- a/server/src/workers/api.ts
+++ b/server/src/workers/api.ts
@@ -9,6 +9,7 @@ import { envName, excludePaths, isDev, resourcePaths, serverVersion } from 'src/
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
import { ApiService } from 'src/services/api.service';
+import { isStartUpError } from 'src/utils/events';
import { otelStart } from 'src/utils/instrumentation';
import { useSwagger } from 'src/utils/misc';
@@ -73,6 +74,9 @@ async function bootstrap() {
}
bootstrap().catch((error) => {
- console.error(error);
- throw error;
+ if (!isStartUpError(error)) {
+ console.error(error);
+ }
+ // eslint-disable-next-line unicorn/no-process-exit
+ process.exit(1);
});
diff --git a/server/src/workers/microservices.ts b/server/src/workers/microservices.ts
index f920e8c9476ea..789b6f5287bbd 100644
--- a/server/src/workers/microservices.ts
+++ b/server/src/workers/microservices.ts
@@ -4,6 +4,7 @@ import { MicroservicesModule } from 'src/app.module';
import { envName, serverVersion } from 'src/constants';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { WebSocketAdapter } from 'src/middleware/websocket.adapter';
+import { isStartUpError } from 'src/utils/events';
import { otelStart } from 'src/utils/instrumentation';
export async function bootstrap() {
@@ -25,7 +26,9 @@ export async function bootstrap() {
if (!isMainThread) {
bootstrap().catch((error) => {
- console.error(error);
- process.exit(1);
+ if (!isStartUpError(error)) {
+ console.error(error);
+ }
+ throw error;
});
}
diff --git a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
index 044a81b222d67..ad8801ff3f8bf 100644
--- a/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
+++ b/web/src/lib/components/shared-components/navigation-bar/navigation-bar.svelte
@@ -1,15 +1,17 @@
@@ -153,7 +145,7 @@
{/if}
{#if shouldShowAccountInfoPanel}
-
+
{/if}
diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts
index 6422983d94f8a..d398ca52a9d89 100644
--- a/web/src/lib/stores/websocket.ts
+++ b/web/src/lib/stores/websocket.ts
@@ -1,3 +1,5 @@
+import { AppRoute } from '$lib/constants';
+import { handleLogout } from '$lib/utils/auth';
import { createEventEmitter } from '$lib/utils/eventemitter';
import type { AssetResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import { io, type Socket } from 'socket.io-client';
@@ -24,6 +26,7 @@ export interface Events {
on_server_version: (serverVersion: ServerVersionResponseDto) => void;
on_config_update: () => void;
on_new_release: (newRelase: ReleaseEvent) => void;
+ on_session_delete: (sessionId: string) => void;
}
const websocket: Socket = io({
@@ -47,6 +50,7 @@ websocket
.on('disconnect', () => websocketStore.connected.set(false))
.on('on_server_version', (serverVersion) => websocketStore.serverVersion.set(serverVersion))
.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));
export const openWebsocketConnection = () => {
diff --git a/web/src/lib/utils/auth.ts b/web/src/lib/utils/auth.ts
index d37f1bb96074d..0ac1658948fb6 100644
--- a/web/src/lib/utils/auth.ts
+++ b/web/src/lib/utils/auth.ts
@@ -1,7 +1,9 @@
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 { 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 { redirect } from '@sveltejs/kit';
import { DateTime } from 'luxon';
@@ -87,3 +89,16 @@ export const getAccountAge = (): number => {
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();
+ }
+};