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/**
|
||||
|
||||
changelog:translation:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- web/src/lib/i18n/*.json
|
||||
- head-branch: ['^chore/translations$']
|
||||
|
@ -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 |
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 && \
|
||||
|
@ -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;
|
||||
|
@ -58,11 +58,11 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.113.1</string>
|
||||
<string>1.114.0</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>172</string>
|
||||
<string>173</string>
|
||||
<key>FLTEnableImpeller</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
|
@ -12,12 +12,14 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
|
||||
}
|
||||
|
||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||
export type SystemFlags = { mountFiles: boolean };
|
||||
|
||||
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.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
|
||||
[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<SystemConfig>;
|
||||
[SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags;
|
||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||
}
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ export enum VectorIndex {
|
||||
export enum DatabaseLock {
|
||||
GeodataImport = 100,
|
||||
Migrations = 200,
|
||||
SystemFileMounts = 300,
|
||||
StorageTemplateMigration = 420,
|
||||
CLIPDimSize = 512,
|
||||
LibraryWatch = 1337,
|
||||
|
@ -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<string, never>;
|
||||
[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<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
|
||||
*/
|
||||
|
@ -26,7 +26,7 @@ export interface MapMarker extends ReverseGeocodeResult {
|
||||
|
||||
export interface IMapRepository {
|
||||
init(): Promise<void>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
|
||||
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult>;
|
||||
getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||
fetchStyle(url: string): Promise<any>;
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ export interface ImmichTags extends Omit<Tags, TagsWithWrongTypes> {
|
||||
|
||||
export interface IMetadataRepository {
|
||||
teardown(): Promise<void>;
|
||||
readTags(path: string): Promise<ImmichTags | null>;
|
||||
readTags(path: string): Promise<ImmichTags>;
|
||||
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
|
||||
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
|
||||
getCountries(userIds: string[]): Promise<Array<string | null>>;
|
||||
|
@ -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}`);
|
||||
|
@ -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<E extends keyof ClientEventMap>(event: E, userId: string, data: ClientEventMap[E]) {
|
||||
this.server?.to(userId).emit(event, data);
|
||||
clientSend<E extends keyof ClientEventMap>(event: E, room: string, data: ClientEventMap[E]) {
|
||||
this.server?.to(room).emit(event, data);
|
||||
}
|
||||
|
||||
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}`);
|
||||
|
||||
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)}`);
|
||||
|
@ -36,11 +36,11 @@ export class MetadataRepository implements IMetadataRepository {
|
||||
await this.exiftool.end();
|
||||
}
|
||||
|
||||
readTags(path: string): Promise<ImmichTags | null> {
|
||||
readTags(path: string): Promise<ImmichTags> {
|
||||
return this.exiftool.read(path).catch((error) => {
|
||||
this.logger.warn(`Error reading exif data (${path}): ${error}`, error?.stack);
|
||||
return null;
|
||||
}) as Promise<ImmichTags | null>;
|
||||
return {};
|
||||
}) as Promise<ImmichTags>;
|
||||
}
|
||||
|
||||
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 { 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<ICryptoRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
@ -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 () => {
|
||||
|
@ -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<LogoutResponseDto> {
|
||||
if (auth.session) {
|
||||
await this.sessionRepository.delete(auth.session.id);
|
||||
await this.eventRepository.emit('session.delete', { sessionId: auth.session.id });
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -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,
|
||||
|
@ -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<keyof Tags> = [
|
||||
];
|
||||
|
||||
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<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 => {
|
||||
// handle lists of numbers
|
||||
if (Array.isArray(value)) {
|
||||
@ -218,36 +210,73 @@ export class MetadataService {
|
||||
}
|
||||
|
||||
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]);
|
||||
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<ImmichTags> {
|
||||
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<Tags>, 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<ImmichTags, 'Duration' | 'Orientation'> = {};
|
||||
|
||||
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<JobStatus> {
|
||||
|
@ -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<IAlbumRepository>;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let notificationMock: Mocked<INotificationRepository>;
|
||||
@ -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', () => {
|
||||
|
@ -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) {
|
||||
|
@ -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<IDatabaseRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -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 "<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;
|
||||
};
|
||||
|
||||
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>(IEventRepository);
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
|
@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
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 SkipLink from '$lib/components/elements/buttons/skip-link.svelte';
|
||||
import Icon from '$lib/components/elements/icon.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { resetSavedUser, user } from '$lib/stores/user.store';
|
||||
import { clickOutside } from '$lib/actions/click-outside';
|
||||
import { user } from '$lib/stores/user.store';
|
||||
import { handleLogout } from '$lib/utils/auth';
|
||||
import { logout } from '@immich/sdk';
|
||||
import { mdiCog, mdiMagnify, mdiTrayArrowUp } from '@mdi/js';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade, fly } from 'svelte/transition';
|
||||
import { AppRoute } from '../../../constants';
|
||||
import ImmichLogo from '../immich-logo.svelte';
|
||||
@ -17,9 +19,6 @@
|
||||
import ThemeButton from '../theme-button.svelte';
|
||||
import UserAvatar from '../user-avatar.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;
|
||||
|
||||
@ -30,16 +29,9 @@
|
||||
uploadClicked: void;
|
||||
}>();
|
||||
|
||||
const logOut = async () => {
|
||||
const onLogout = async () => {
|
||||
const { redirectUri } = await logout();
|
||||
|
||||
if (redirectUri.startsWith('/')) {
|
||||
await goto(redirectUri);
|
||||
} else {
|
||||
window.location.href = redirectUri;
|
||||
}
|
||||
resetSavedUser();
|
||||
foldersStore.clearCache();
|
||||
await handleLogout(redirectUri);
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -153,7 +145,7 @@
|
||||
{/if}
|
||||
|
||||
{#if shouldShowAccountInfoPanel}
|
||||
<AccountInfoPanel on:logout={logOut} />
|
||||
<AccountInfoPanel on:logout={onLogout} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
@ -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<Events> = 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 = () => {
|
||||
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user