1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2024-12-23 01:27:14 +02:00

Adds main events to extension #753

This commit is contained in:
Patrik J. Braun 2023-10-31 20:38:08 +01:00
parent 538593e780
commit f7dba927b8
12 changed files with 239 additions and 129 deletions

1
.gitignore vendored
View File

@ -28,3 +28,4 @@ locale.source.xlf
test.*
/db/
/test/cypress/screenshots/
/extensions/

View File

@ -7,10 +7,42 @@ const forcedDebug = process.env['NODE_ENV'] === 'debug';
if (forcedDebug === true) {
console.log(
'NODE_ENV environmental variable is set to debug, forcing all logs to print'
'NODE_ENV environmental variable is set to debug, forcing all logs to print'
);
}
export type LoggerFunction = (...args: (string | number)[]) => void;
export interface ILogger {
silly: LoggerFunction;
debug: LoggerFunction;
verbose: LoggerFunction;
info: LoggerFunction;
warn: LoggerFunction;
error: LoggerFunction;
}
export const createLoggerWrapper = (TAG: string): ILogger => ({
silly: (...args: (string | number)[]) => {
Logger.silly(TAG, ...args);
},
debug: (...args: (string | number)[]) => {
Logger.debug(TAG, ...args);
},
verbose: (...args: (string | number)[]) => {
Logger.verbose(TAG, ...args);
},
info: (...args: (string | number)[]) => {
Logger.info(TAG, ...args);
},
warn: (...args: (string | number)[]) => {
Logger.warn(TAG, ...args);
},
error: (...args: (string | number)[]) => {
Logger.error(TAG, ...args);
}
});
export class Logger {
public static silly(...args: (string | number)[]): void {
if (!forcedDebug && Config.Server.Log.level < LogLevel.silly) {
@ -55,10 +87,10 @@ export class Logger {
const date = new Date().toLocaleString();
let LOG_TAG = '';
if (
args.length > 0 &&
typeof args[0] === 'string' &&
args[0].startsWith('[') &&
args[0].endsWith(']')
args.length > 0 &&
typeof args[0] === 'string' &&
args[0].startsWith('[') &&
args[0].endsWith(']')
) {
LOG_TAG = args[0];
args.shift();

View File

@ -16,15 +16,15 @@ export class ProjectPathClass {
this.reset();
}
normalizeRelative(pathStr: string): string {
public normalizeRelative(pathStr: string): string {
return path.join(pathStr, path.sep);
}
getAbsolutePath(pathStr: string): string {
public getAbsolutePath(pathStr: string): string {
return path.isAbsolute(pathStr) ? pathStr : path.join(this.Root, pathStr);
}
getRelativePathToImages(pathStr: string): string {
public getRelativePathToImages(pathStr: string): string {
return path.relative(this.ImageFolder, pathStr);
}
@ -36,7 +36,7 @@ export class ProjectPathClass {
this.TranscodedFolder = path.join(this.TempFolder, 'tc');
this.FacesFolder = path.join(this.TempFolder, 'f');
this.DBFolder = this.getAbsolutePath(Config.Database.dbFolder);
this.ExtensionFolder = path.join(this.Root, 'extension');
this.ExtensionFolder = path.join(this.Root, 'extensions');
// create thumbnail folder if not exist
if (!fs.existsSync(this.TempFolder)) {

View File

@ -19,7 +19,7 @@ export class AlbumManager implements IObjectManager {
private static async updateAlbum(album: SavedSearchEntity): Promise<void> {
const connection = await SQLConnection.getConnection();
const cover =
await ObjectManagers.getInstance().CoverManager.getAlbumCover(album);
await ObjectManagers.getInstance().CoverManager.getCoverForAlbum(album);
const count = await
ObjectManagers.getInstance().SearchManager.getCount((album as SavedSearchDTO).searchQuery);

View File

@ -14,6 +14,7 @@ import {CoverPhotoDTO} from '../../../common/entities/PhotoDTO';
import {IObjectManager} from './IObjectManager';
import {Logger} from '../../Logger';
import {SearchManager} from './SearchManager';
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
const LOG_TAG = '[CoverManager]';
@ -29,21 +30,22 @@ export class CoverManager implements IObjectManager {
public async resetCovers(): Promise<void> {
const connection = await SQLConnection.getConnection();
await connection
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false})
.execute();
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false})
.execute();
}
public async onNewDataVersion(changedDir: ParentDirectoryDTO): Promise<void> {
@ExtensionDecorator(e => e.gallery.CoverManager.invalidateDirectoryCovers)
protected async invalidateDirectoryCovers(dir: ParentDirectoryDTO) {
// Invalidating Album cover
let fullPath = DiskManager.normalizeDirPath(
path.join(changedDir.path, changedDir.name)
path.join(dir.path, dir.name)
);
const query = (await SQLConnection.getConnection())
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false});
.createQueryBuilder()
.update(DirectoryEntity)
.set({validCover: false});
let i = 0;
const root = DiskManager.pathFromRelativeDirName('.');
@ -53,62 +55,67 @@ export class CoverManager implements IObjectManager {
fullPath = parentPath;
++i;
query.orWhere(
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = name;
param['path' + i] = parentPath;
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = name;
param['path' + i] = parentPath;
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
);
}
++i;
query.orWhere(
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = DiskManager.dirName('.');
param['path' + i] = DiskManager.pathFromRelativeDirName('.');
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
new Brackets((q: WhereExpression) => {
const param: { [key: string]: string } = {};
param['name' + i] = DiskManager.dirName('.');
param['path' + i] = DiskManager.pathFromRelativeDirName('.');
q.where(`path = :path${i}`, param);
q.andWhere(`name = :name${i}`, param);
})
);
await query.execute();
}
public async getAlbumCover(album: {
public async onNewDataVersion(changedDir: ParentDirectoryDTO): Promise<void> {
await this.invalidateDirectoryCovers(changedDir);
}
@ExtensionDecorator(e => e.gallery.CoverManager.getCoverForAlbum)
public async getCoverForAlbum(album: {
searchQuery: SearchQueryDTO;
}): Promise<CoverPhotoDTOWithID> {
const albumQuery: Brackets = await
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(album.searchQuery);
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(album.searchQuery);
const connection = await SQLConnection.getConnection();
const coverQuery = (): SelectQueryBuilder<MediaEntity> => {
const query = connection
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(albumQuery);
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(albumQuery);
SearchManager.setSorting(query, Config.AlbumCover.Sorting);
return query;
};
let coverMedia = null;
if (
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
) {
try {
const coverFilterQuery = await
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery);
ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery);
coverMedia = await coverQuery()
.andWhere(coverFilterQuery)
.limit(1)
.getOne();
.andWhere(coverFilterQuery)
.limit(1)
.getOne();
} catch (e) {
Logger.error(LOG_TAG, 'Cant get album cover using:', JSON.stringify(album.searchQuery), JSON.stringify(Config.AlbumCover.SearchQuery));
throw e;
@ -127,52 +134,53 @@ export class CoverManager implements IObjectManager {
}
public async getPartialDirsWithoutCovers(): Promise<
{ id: number; name: string; path: string }[]
{ id: number; name: string; path: string }[]
> {
const connection = await SQLConnection.getConnection();
return await connection
.getRepository(DirectoryEntity)
.createQueryBuilder('directory')
.where('directory.validCover = :validCover', {validCover: 0}) // 0 === false
.select(['name', 'id', 'path'])
.getRawMany();
.getRepository(DirectoryEntity)
.createQueryBuilder('directory')
.where('directory.validCover = :validCover', {validCover: 0}) // 0 === false
.select(['name', 'id', 'path'])
.getRawMany();
}
public async setAndGetCoverForDirectory(dir: {
@ExtensionDecorator(e => e.gallery.CoverManager.getCoverForDirectory)
protected async getCoverForDirectory(dir: {
id: number;
name: string;
path: string;
}): Promise<CoverPhotoDTOWithID> {
}) {
const connection = await SQLConnection.getConnection();
const coverQuery = (): SelectQueryBuilder<MediaEntity> => {
const query = connection
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(
new Brackets((q: WhereExpression) => {
q.where('media.directory = :dir', {
dir: dir.id,
});
if (Config.Database.type === DatabaseType.mysql) {
q.orWhere('directory.path like :path || \'%\'', {
path: DiskManager.pathFromParent(dir),
});
} else {
q.orWhere('directory.path GLOB :path', {
path: DiskManager.pathFromParent(dir)
// glob escaping. see https://github.com/bpatrik/pigallery2/issues/621
.replaceAll('[', '[[]') + '*',
});
}
})
);
.getRepository(MediaEntity)
.createQueryBuilder('media')
.innerJoin('media.directory', 'directory')
.select(['media.name', 'media.id', ...CoverManager.DIRECTORY_SELECT])
.where(
new Brackets((q: WhereExpression) => {
q.where('media.directory = :dir', {
dir: dir.id,
});
if (Config.Database.type === DatabaseType.mysql) {
q.orWhere('directory.path like :path || \'%\'', {
path: DiskManager.pathFromParent(dir),
});
} else {
q.orWhere('directory.path GLOB :path', {
path: DiskManager.pathFromParent(dir)
// glob escaping. see https://github.com/bpatrik/pigallery2/issues/621
.replaceAll('[', '[[]') + '*',
});
}
})
);
// Select from the directory if any otherwise from any subdirectories.
// (There is no priority between subdirectories)
query.orderBy(
`CASE WHEN directory.id = ${dir.id} THEN 0 ELSE 1 END`,
'ASC'
`CASE WHEN directory.id = ${dir.id} THEN 0 ELSE 1 END`,
'ASC'
);
SearchManager.setSorting(query, Config.AlbumCover.Sorting);
@ -181,33 +189,43 @@ export class CoverManager implements IObjectManager {
let coverMedia: CoverPhotoDTOWithID = null;
if (
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
Config.AlbumCover.SearchQuery &&
!Utils.equalsFilter(Config.AlbumCover.SearchQuery, {
type: SearchQueryTypes.any_text,
text: '',
} as TextSearch)
) {
coverMedia = await coverQuery()
.andWhere(
await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery)
)
.limit(1)
.getOne();
.andWhere(
await ObjectManagers.getInstance().SearchManager.prepareAndBuildWhereQuery(Config.AlbumCover.SearchQuery)
)
.limit(1)
.getOne();
}
if (!coverMedia) {
coverMedia = await coverQuery().limit(1).getOne();
}
return coverMedia;
}
public async setAndGetCoverForDirectory(dir: {
id: number;
name: string;
path: string;
}): Promise<CoverPhotoDTOWithID> {
const connection = await SQLConnection.getConnection();
const coverMedia = await this.getCoverForDirectory(dir);
// set validCover bit to true even if there is no cover (to prevent future updates)
await connection
.createQueryBuilder()
.update(DirectoryEntity)
.set({cover: coverMedia, validCover: true})
.where('id = :dir', {
dir: dir.id,
})
.execute();
.createQueryBuilder()
.update(DirectoryEntity)
.set({cover: coverMedia, validCover: true})
.where('id = :dir', {
dir: dir.id,
})
.execute();
return coverMedia || null;
}

View File

@ -3,7 +3,7 @@ import {Config} from '../../../common/config/private/Config';
import * as fs from 'fs';
import * as path from 'path';
import {IObjectManager} from '../database/IObjectManager';
import {Logger} from '../../Logger';
import {createLoggerWrapper, Logger} from '../../Logger';
import {IExtensionEvents, IExtensionObject, IServerExtension} from './IExtension';
import {ObjectManagers} from '../ObjectManagers';
import {Server} from '../../server';
@ -16,7 +16,19 @@ export class ExtensionManager implements IObjectManager {
events: IExtensionEvents = {
gallery: {
MetadataLoader: {
loadPhotoMetadata: new ExtensionEvent()
loadPhotoMetadata: new ExtensionEvent(),
loadVideoMetadata: new ExtensionEvent()
},
CoverManager: {
getCoverForDirectory: new ExtensionEvent(),
getCoverForAlbum: new ExtensionEvent(),
invalidateDirectoryCovers: new ExtensionEvent(),
},
DiskManager: {
scanDirectory: new ExtensionEvent()
},
ImageRenderer: {
render: new ExtensionEvent()
}
}
};
@ -27,15 +39,18 @@ export class ExtensionManager implements IObjectManager {
}
public loadExtensionsList() {
Logger.debug(LOG_TAG, 'Loading extension list from ' + ProjectPath.ExtensionFolder);
if (!fs.existsSync(ProjectPath.ExtensionFolder)) {
return;
}
Config.Extensions.list = fs
.readdirSync(ProjectPath.ExtensionFolder)
.filter((f): boolean =>
fs.statSync(path.join(ProjectPath.ExtensionFolder, f)).isDirectory()
);
Config.Extensions.list.sort();
Logger.debug(LOG_TAG, 'Extensions found ', JSON.stringify(Config.Extensions.list));
}
private async callServerFN(fn: (ext: IServerExtension, extName: string) => Promise<void>) {
@ -44,6 +59,7 @@ export class ExtensionManager implements IObjectManager {
const extPath = path.join(ProjectPath.ExtensionFolder, extName);
const serverExt = path.join(extPath, 'server.js');
if (!fs.existsSync(serverExt)) {
Logger.silly(LOG_TAG, `Skipping ${extName} server initiation. server.js does not exists`);
continue;
}
// eslint-disable-next-line @typescript-eslint/no-var-requires
@ -52,23 +68,24 @@ export class ExtensionManager implements IObjectManager {
}
}
private createExtensionObject(): IExtensionObject {
private createExtensionObject(name: string): IExtensionObject {
return {
app: {
_app: {
objectManagers: ObjectManagers.getInstance(),
config: Config,
paths: ProjectPath,
expressApp: Server.getInstance().app
expressApp: Server.getInstance().app,
config: Config
},
events: null
paths: ProjectPath,
Logger: createLoggerWrapper(`[Extension: ${name}]`),
events: this.events
};
}
private async initExtensions() {
await this.callServerFN(async (ext, extName) => {
if (typeof ext?.init === 'function') {
Logger.debug(LOG_TAG, 'Running Init on extension:' + extName);
await ext?.init(this.createExtensionObject());
Logger.debug(LOG_TAG, 'Running init on extension: ' + extName);
await ext?.init(this.createExtensionObject(extName));
}
});
}
@ -77,7 +94,7 @@ export class ExtensionManager implements IObjectManager {
await this.callServerFN(async (ext, extName) => {
if (typeof ext?.cleanUp === 'function') {
Logger.debug(LOG_TAG, 'Running Init on extension:' + extName);
await ext?.cleanUp();
await ext?.cleanUp(this.createExtensionObject(extName));
}
});
}

View File

@ -2,7 +2,7 @@ import * as express from 'express';
import {PrivateConfigClass} from '../../../common/config/private/Config';
import {ObjectManagers} from '../ObjectManagers';
import {ProjectPathClass} from '../../ProjectPath';
import {ExtensionEvent} from './ExtensionEvent';
import {ILogger} from '../../Logger';
export type IExtensionBeforeEventHandler<I, O> = (input: { inputs: I }, event: { stopPropagation: boolean }) => Promise<{ inputs: I } | O>;
@ -11,36 +11,66 @@ export type IExtensionAfterEventHandler<O> = (output: O) => Promise<O>;
export interface IExtensionEvent<I, O> {
before: (handler: IExtensionBeforeEventHandler<I, O>) => void;
after: (handler: IExtensionAfterEventHandler<O>) => void
after: (handler: IExtensionAfterEventHandler<O>) => void;
}
/**
* All main event callbacks in the app
*/
export interface IExtensionEvents {
gallery: {
// indexing: IExtensionEvent<any, any>;
// scanningDirectory: IExtensionEvent<any, any>;
/**
* Events for Directory and Album covers
*/
CoverManager: {
getCoverForAlbum: IExtensionEvent<any, any>;
getCoverForDirectory: IExtensionEvent<any, any>
/**
* Invalidates directory covers for a given directory and every parent
*/
invalidateDirectoryCovers: IExtensionEvent<any, any>;
},
ImageRenderer: {
/**
* Renders a thumbnail or photo
*/
render: IExtensionEvent<any, any>
},
/**
* Reads exif, iptc, etc.. metadata for photos/videos
*/
MetadataLoader: {
loadVideoMetadata: IExtensionEvent<any, any>,
loadPhotoMetadata: IExtensionEvent<any, any>
},
/**
* Scans the storage for a given directory and returns the list of child directories,
* photos, videos and metafiles
*/
DiskManager: {
scanDirectory: IExtensionEvent<any, any>
}
//listingDirectory: IExtensionEvent<any, any>;
//searching: IExtensionEvent<any, any>;
};
}
export interface IExtensionApp {
expressApp: express.Express;
config: PrivateConfigClass;
objectManagers: ObjectManagers;
paths: ProjectPathClass;
config: PrivateConfigClass;
}
export interface IExtensionObject {
app: IExtensionApp;
_app: IExtensionApp;
paths: ProjectPathClass;
Logger: ILogger;
events: IExtensionEvents;
}
export interface IServerExtension {
init(app: IExtensionObject): Promise<void>;
cleanUp?: () => Promise<void>;
/**
* Extension interface. All extension is expected to implement and export these methods
*/
export interface IServerExtension {
init(extension: IExtensionObject): Promise<void>;
cleanUp?: (extension: IExtensionObject) => Promise<void>;
}

View File

@ -14,6 +14,7 @@ import {GPXProcessing} from './fileprocessing/GPXProcessing';
import {MDFileDTO} from '../../../common/entities/MDFileDTO';
import {MetadataLoader} from './MetadataLoader';
import {NotificationManager} from '../NotifocationManager';
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
const LOG_TAG = '[DiskManager]';
@ -101,6 +102,7 @@ export class DiskManager {
)) as ParentDirectoryDTO<FileDTO>;
}
@ExtensionDecorator(e => e.gallery.DiskManager.scanDirectory)
public static async scanDirectory(
relativeDirectoryName: string,
settings: DirectoryScanSettings = {}

View File

@ -18,6 +18,8 @@ const LOG_TAG = '[MetadataLoader]';
const ffmpeg = FFmpegFactory.get();
export class MetadataLoader {
@ExtensionDecorator(e=>e.gallery.MetadataLoader.loadVideoMetadata)
public static loadVideoMetadata(fullPath: string): Promise<VideoMetadata> {
return new Promise<VideoMetadata>((resolve) => {
const metadata: VideoMetadata = {

View File

@ -5,6 +5,7 @@ import {Logger} from '../../Logger';
import {FfmpegCommand, FfprobeData} from 'fluent-ffmpeg';
import {FFmpegFactory} from '../FFmpegFactory';
import * as path from 'path';
import {ExtensionDecorator} from '../extension/ExtensionDecorator';
sharp.cache(false);
@ -129,6 +130,7 @@ export class VideoRendererFactory {
export class ImageRendererFactory {
@ExtensionDecorator(e=>e.gallery.ImageRenderer.render)
public static async render(input: MediaRendererInput | SvgRendererInput): Promise<void> {
let image: Sharp;

View File

@ -35,7 +35,7 @@ export class Server {
public app: express.Express;
private server: HttpServer;
static instance: Server;
static instance: Server = null;
public static getInstance(): Server {
if (this.instance === null) {
@ -70,7 +70,13 @@ export class Server {
).configPath +
':'
);
Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), null, '\t'));
Logger.verbose(LOG_TAG, JSON.stringify(Config.toJSON({attachDescription: false}), (k, v) => {
const MAX_LENGTH = 80;
if (typeof v === 'string' && v.length > MAX_LENGTH) {
v = v.slice(0, MAX_LENGTH - 3) + '...';
}
return v;
}, 2));
this.app = express();

View File

@ -212,21 +212,21 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
it('should get cover for saved search', async () => {
const pm = new CoverManager();
Config.AlbumCover.SearchQuery = null;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'sw'
} as TextSearch
}))).to.deep.equalInAnyOrder(previewifyMedia(p4));
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Boba'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'sw'
} as TextSearch
}))).to.deep.equalInAnyOrder(previewifyMedia(p));
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Derem'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'sw'
@ -234,7 +234,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
}))).to.deep.equalInAnyOrder(previewifyMedia(p2));
// Having a preview search query that does not return valid result
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'wont find it'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'Derem'
@ -242,7 +242,7 @@ describe('CoverManager', (sqlHelper: DBTestHelper) => {
}))).to.deep.equalInAnyOrder(previewifyMedia(p2));
// having a saved search that does not have any image
Config.AlbumCover.SearchQuery = {type: SearchQueryTypes.any_text, text: 'Derem'} as TextSearch;
expect(Utils.clone(await pm.getAlbumCover({
expect(Utils.clone(await pm.getCoverForAlbum({
searchQuery: {
type: SearchQueryTypes.any_text,
text: 'wont find it'