init
This commit is contained in:
91
src/app.ts
Normal file
91
src/app.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import path from 'path';
|
||||
import fastify, { FastifyReply, FastifyRequest, FastifyInstance } from 'fastify';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import fastifyCors from '@fastify/cors';
|
||||
import fastifySensible from '@fastify/sensible';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
import fastifyAutoload from '@fastify/autoload';
|
||||
import { sendEcho } from './dimse/sendEcho';
|
||||
import { startScp } from './dimse/store';
|
||||
import { clearCache } from './utils/fileHelper';
|
||||
import { ConfParams, config } from './utils/config';
|
||||
import { shutdown } from './dimse/store';
|
||||
import { LoggerSingleton } from './utils/logger';
|
||||
import { socket } from './socket';
|
||||
import * as closeWithGrace from 'close-with-grace';
|
||||
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
const server: FastifyInstance = fastify();
|
||||
server.register(fastifyStatic, {
|
||||
root: path.join(__dirname, '../public'),
|
||||
});
|
||||
|
||||
server.setNotFoundHandler((req: FastifyRequest, res: FastifyReply) => {
|
||||
res.sendFile('index.html');
|
||||
});
|
||||
server.register(fastifyCors, {});
|
||||
server.register(fastifySensible);
|
||||
server.register(fastifyHelmet, {
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: { policy: 'require-corp' },
|
||||
crossOriginResourcePolicy: { policy: 'same-site' },
|
||||
crossOriginOpenerPolicy: { policy: 'same-origin' },
|
||||
});
|
||||
server.register(fastifyAutoload, {
|
||||
dir: path.join(__dirname, 'routes'),
|
||||
});
|
||||
server.register(fastifyAutoload, {
|
||||
dir: path.join(__dirname, 'routes'),
|
||||
options: { prefix: '/viewer' },
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
// log exceptions
|
||||
process.on('uncaughtException', async (err) => {
|
||||
await logger.error('uncaught exception received:');
|
||||
await logger.error(err.stack);
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
closeWithGrace.default({ delay: 500 }, async function ({ signal, err, manual }) {
|
||||
if (err) {
|
||||
console.error(err)
|
||||
}
|
||||
logger.info('shutting down web server...', signal, manual);
|
||||
try {
|
||||
await server.close();
|
||||
await socket.close();
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
if (!config.get(ConfParams.C_GET)) {
|
||||
logger.info('shutting down DICOM SCP server...');
|
||||
await shutdown();
|
||||
}
|
||||
})
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
const port = config.get(ConfParams.HTTP_PORT) as number;
|
||||
|
||||
// if not using c-get, start our scp
|
||||
if (!config.get(ConfParams.C_GET)) {
|
||||
startScp();
|
||||
}
|
||||
setTimeout(sendEcho, 3000);
|
||||
|
||||
const websocketUrl = config.get(ConfParams.WEBSOCKET_URL);
|
||||
if (websocketUrl) {
|
||||
logger.info(`connecting to dicomweb.websocket-bridge: ${websocketUrl}`);
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
// running clear cache and setup for 1h checks
|
||||
clearCache();
|
||||
setInterval(clearCache, 60000);
|
||||
|
||||
logger.info('starting webserver...', port);
|
||||
server.listen({ port, host: '0.0.0.0' });
|
||||
1
src/dicom-proxy.d.ts
vendored
Normal file
1
src/dicom-proxy.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "@wearemothership/socket.io-stream";
|
||||
38
src/dimse/compressFile.ts
Normal file
38
src/dimse/compressFile.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { recompress, recompressOptions } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
export async function compressFile(inputFile: string, outputDirectory: string, transferSyntax: string | undefined = undefined) {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const options: recompressOptions = {
|
||||
sourcePath: inputFile,
|
||||
storagePath: outputDirectory,
|
||||
writeTransfer: transferSyntax || config.get(ConfParams.XTRANSFER),
|
||||
lossyQuality: config.get(ConfParams.LOSSY_QUALITY),
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
// run find scu and return json response
|
||||
return new Promise((resolve, reject) => {
|
||||
recompress(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0) {
|
||||
resolve(true);
|
||||
} else {
|
||||
logger.error(`recompression failure (${inputFile}): ${json.message}`);
|
||||
reject();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(result);
|
||||
reject();
|
||||
}
|
||||
} else {
|
||||
logger.error('invalid result received');
|
||||
reject();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
52
src/dimse/fetchData.ts
Normal file
52
src/dimse/fetchData.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import throat from 'throat';
|
||||
import { fetchGet } from './fetchGet';
|
||||
import { fetchMove } from './fetchMove';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { getLockUid, QUERY_LEVEL } from './querLevel';
|
||||
import { Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
const maxAssociations = config.get(ConfParams.MAX_ASSOCIATIONS) as number;
|
||||
const throatLock = throat(maxAssociations);
|
||||
const lock = new Map();
|
||||
|
||||
export async function waitOrFetchData(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
|
||||
for (const peer of peers) {
|
||||
try {
|
||||
return await waitOrFetchDataOnAet(studyUid, seriesUid, imageUid, level, peer);
|
||||
} catch (error) {
|
||||
logger.warn(error);
|
||||
}
|
||||
}
|
||||
return Promise.reject('failed waitOrFetchData');
|
||||
}
|
||||
|
||||
export async function waitOrFetchDataOnAet(
|
||||
studyUid: string,
|
||||
seriesUid: string,
|
||||
imageUid: string,
|
||||
level: QUERY_LEVEL,
|
||||
target: DicomNode
|
||||
): Promise<unknown> {
|
||||
const scu = config.get(ConfParams.C_GET) ? fetchGet : fetchMove;
|
||||
const lockId = getLockUid(studyUid, seriesUid, imageUid, level);
|
||||
|
||||
return await throatLock(async () => {
|
||||
if (lock.has(lockId)) {
|
||||
return lock.get(lockId);
|
||||
}
|
||||
const promise = scu(studyUid, seriesUid, imageUid, level, target);
|
||||
promise
|
||||
.then(() => {
|
||||
lock.delete(lockId);
|
||||
})
|
||||
.catch(() => {
|
||||
lock.delete(lockId);
|
||||
});
|
||||
lock.set(lockId, promise);
|
||||
return promise;
|
||||
});
|
||||
}
|
||||
68
src/dimse/fetchGet.ts
Normal file
68
src/dimse/fetchGet.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { getScu, getScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { QUERY_LEVEL, queryLevelToPath, queryLevelToString } from './querLevel';
|
||||
|
||||
// request data from PACS via c-get or c-move
|
||||
export async function fetchGet(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL, target: DicomNode): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
// add query retrieve level and fetch whole study
|
||||
const ts = config.get(ConfParams.XTRANSFER) as string;
|
||||
|
||||
const getOptions: getScuOptions = {
|
||||
tags: [
|
||||
{
|
||||
key: '00080052',
|
||||
value: queryLevelToString(level),
|
||||
},
|
||||
{
|
||||
key: '0020000D',
|
||||
value: studyUid,
|
||||
},
|
||||
],
|
||||
netTransferPrefer: ts,
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE) as boolean,
|
||||
storagePath: config.get(ConfParams.STORAGE_PATH),
|
||||
};
|
||||
|
||||
if (level >= QUERY_LEVEL.SERIES) {
|
||||
getOptions.tags.push({
|
||||
key: '0020000E',
|
||||
value: seriesUid,
|
||||
});
|
||||
}
|
||||
|
||||
if (level >= QUERY_LEVEL.IMAGE) {
|
||||
getOptions.tags.push({
|
||||
key: '00080018',
|
||||
value: imageUid,
|
||||
});
|
||||
}
|
||||
const uidPath = queryLevelToPath(studyUid, seriesUid, imageUid, level);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
logger.info(`fetch start: ${uidPath}`);
|
||||
getScu(getOptions, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0 || json.code === 2) {
|
||||
logger.info(`fetch finished: ${uidPath}`);
|
||||
resolve(result);
|
||||
} else {
|
||||
logger.info(JSON.parse(result));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
54
src/dimse/fetchMeta.ts
Normal file
54
src/dimse/fetchMeta.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { sendCFindRequest, IQueryParams } from './findData';
|
||||
import { config, ConfParams } from '../utils/config';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { QUERY_LEVEL } from './querLevel';
|
||||
import { waitOrFetchDataOnAet } from './fetchData';
|
||||
import { parseMeta } from './parseMeta';
|
||||
import { Node as DicomNode } from 'dicom-dimse-native';
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export async function fetchMeta(query: IQueryParams, studyInstanceUID: string, seriesInstanceUID: string): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
const fullMeta = config.get(ConfParams.FULL_META) as boolean;
|
||||
|
||||
for (let i = 0; i < peers.length; i++) {
|
||||
const peer = peers[i];
|
||||
logger.info(`checking peer for data: ${peer.aet}`);
|
||||
const json = (await sendCFindRequest(QUERY_LEVEL.IMAGE, peer, query)) as object;
|
||||
|
||||
// make sure c-find worked
|
||||
if (Object.entries(json).length === 0) {
|
||||
logger.info(`no data found on peer: ${peer.aet}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fullMeta) {
|
||||
return json;
|
||||
}
|
||||
|
||||
// check if fetch is needed
|
||||
for (const [key] of Object.entries(json)) {
|
||||
const sopInstanceUid = json[key]['00080018'].Value[0];
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid);
|
||||
const exists = await fileExists(pathname);
|
||||
if (!exists) {
|
||||
logger.info(`fetching series ${seriesInstanceUID}`);
|
||||
await waitOrFetchDataOnAet(studyInstanceUID, seriesInstanceUID, '', QUERY_LEVEL.SERIES, peer);
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await parseMeta(json, studyInstanceUID, seriesInstanceUID);
|
||||
logger.info('parsing finished, resolving');
|
||||
return Promise.resolve(result);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return Promise.reject('failed fetching meta data');
|
||||
}
|
||||
67
src/dimse/fetchMove.ts
Normal file
67
src/dimse/fetchMove.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { moveScu, moveScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { QUERY_LEVEL, queryLevelToPath, queryLevelToString } from './querLevel';
|
||||
|
||||
// request data from PACS via c-get or c-move
|
||||
export async function fetchMove(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL, target: DicomNode): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
// add query retrieve level and fetch whole study
|
||||
const ts = config.get(ConfParams.XTRANSFER) as string;
|
||||
const moveOptions: moveScuOptions = {
|
||||
tags: [
|
||||
{
|
||||
key: '00080052',
|
||||
value: queryLevelToString(level),
|
||||
},
|
||||
{
|
||||
key: '0020000D',
|
||||
value: studyUid,
|
||||
},
|
||||
],
|
||||
destination: config.get(ConfParams.SOURCE),
|
||||
netTransferPrefer: ts,
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE) as boolean,
|
||||
};
|
||||
|
||||
if (level >= QUERY_LEVEL.SERIES) {
|
||||
moveOptions.tags.push({
|
||||
key: '0020000E',
|
||||
value: seriesUid,
|
||||
});
|
||||
}
|
||||
|
||||
if (level >= QUERY_LEVEL.IMAGE) {
|
||||
moveOptions.tags.push({
|
||||
key: '00080018',
|
||||
value: imageUid,
|
||||
});
|
||||
}
|
||||
const uidPath = queryLevelToPath(studyUid, seriesUid, imageUid, level);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
logger.info(`fetch start: ${uidPath}`);
|
||||
moveScu(moveOptions, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0 || json.code === 2) {
|
||||
logger.info(`fetch finished: ${uidPath}`);
|
||||
resolve(result);
|
||||
} else {
|
||||
logger.info(JSON.parse(result));
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
144
src/dimse/findData.ts
Normal file
144
src/dimse/findData.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { findScu, findScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { queryLevelToString, QUERY_LEVEL } from './querLevel';
|
||||
import { get_element } from '@iwharris/dicom-data-dictionary';
|
||||
import { tagsForLevel } from './tags';
|
||||
|
||||
const findDicomName = (name: string): string | undefined => {
|
||||
const dataElement = get_element(name);
|
||||
if (dataElement) {
|
||||
return dataElement.tag.replace('(', '').replace(',', '').replace(')', '');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const findVR = (name: string): string => {
|
||||
const dataElement = get_element(name);
|
||||
if (dataElement) {
|
||||
return dataElement.vr;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
export interface IQueryParams {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export async function doFind(level: QUERY_LEVEL, query: IQueryParams): Promise<Record<string, string>[][]> {
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
|
||||
const promises: Array<Promise<Record<string, string>[]>> = [];
|
||||
|
||||
peers.forEach((peer) => {
|
||||
promises.push(sendCFindRequest(level, peer, query));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
export async function sendCFindRequest(level: QUERY_LEVEL, target: DicomNode, query: IQueryParams): Promise<Record<string, string>[]> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
// add query retrieve level
|
||||
const options: findScuOptions = {
|
||||
tags: [
|
||||
{
|
||||
key: '00080052',
|
||||
value: queryLevelToString(level),
|
||||
},
|
||||
],
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
// parse all include fields
|
||||
const includes = query.includefield;
|
||||
|
||||
let tags = new Array<string>();
|
||||
if (includes) {
|
||||
tags = includes.split(',');
|
||||
}
|
||||
|
||||
const defaultTagsForLevel = tagsForLevel(level);
|
||||
tags.push(...defaultTagsForLevel);
|
||||
|
||||
// add parsed tags
|
||||
tags.forEach((element: string) => {
|
||||
const tagName = findDicomName(element) || element;
|
||||
|
||||
if (tagName == '00080030') {
|
||||
options.tags.push({ key: tagName, value: '0900-1000' });
|
||||
} else {
|
||||
if (tagName) {
|
||||
options.tags.push({ key: tagName, value: '' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// add search param
|
||||
let invalidInput = false;
|
||||
const minCharsQido = config.get(ConfParams.MIN_CHARS) as number;
|
||||
Object.keys(query).forEach((propName) => {
|
||||
const tag = findDicomName(propName);
|
||||
const vr = findVR(propName);
|
||||
if (tag) {
|
||||
let v = query[propName];
|
||||
// string vr types check
|
||||
if (['PN', 'LO', 'LT', 'SH', 'ST'].includes(vr)) {
|
||||
// just make sure to remove any wildcards from prefix and suffix
|
||||
v = v.replace(/^[*]/, '');
|
||||
v = v.replace(/[*]$/, '');
|
||||
|
||||
// check if minimum number of chars are reached from input
|
||||
if (minCharsQido > v.length) {
|
||||
invalidInput = true;
|
||||
}
|
||||
// auto append wildcard
|
||||
if (config.get(ConfParams.APPEND_WILDCARD)) {
|
||||
v += '*';
|
||||
}
|
||||
}
|
||||
options.tags.push({ key: tag, value: v });
|
||||
}
|
||||
});
|
||||
|
||||
const offset = query.offset ? parseInt(query.offset, 10) : 0;
|
||||
|
||||
// run find scu and return json response
|
||||
return new Promise((resolve) => {
|
||||
// return with empty results if invalid
|
||||
if (invalidInput) {
|
||||
resolve([]);
|
||||
}
|
||||
|
||||
findScu(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(result);
|
||||
if (json.code === 0) {
|
||||
const container = JSON.parse(json.container);
|
||||
if (container) {
|
||||
resolve(container.slice(offset));
|
||||
} else {
|
||||
resolve([]);
|
||||
}
|
||||
} else if (json.code === 1) {
|
||||
logger.info('query is pending...');
|
||||
} else {
|
||||
logger.error(`c-find failure: ${json.message}`);
|
||||
resolve([]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
logger.error(result);
|
||||
resolve([]);
|
||||
}
|
||||
} else {
|
||||
logger.error('invalid result received');
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
111
src/dimse/parseMeta.ts
Normal file
111
src/dimse/parseMeta.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import dicomParser from 'dicom-parser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
interface ValueType {
|
||||
Value: string[] | number[] | unknown[];
|
||||
vr: string;
|
||||
}
|
||||
type ElementType = Record<string, ValueType>;
|
||||
|
||||
function parseFile(filename: string): Promise<ElementType> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
return new Promise<ElementType>((resolve, reject) => {
|
||||
fileExists(filename).then((success: boolean) => {
|
||||
if (!success) {
|
||||
logger.error(`file does not exist: ${filename}`);
|
||||
return reject();
|
||||
}
|
||||
|
||||
fs.promises.readFile(filename).then((data: Uint8Array) => {
|
||||
const dataset = dicomParser.parseDicom(data);
|
||||
|
||||
// parse additional needed attributes
|
||||
const patientName = dataset.string('x00100010');
|
||||
const patentID = dataset.string('x00100020');
|
||||
const studyInstanceUID = dataset.string('x0020000d');
|
||||
const studyDate = dataset.string('x00080020');
|
||||
const studyTime = dataset.string('x00080030');
|
||||
const seriesInstanceUID = dataset.string('x0020000e');
|
||||
const seriesNumber = dataset.string('x00200011');
|
||||
const sopInstanceUID = dataset.string('x00080018');
|
||||
const sopClassUID = dataset.string('x00080016');
|
||||
const bitsAllocated = dataset.uint16('x00280100');
|
||||
const bitsStored = dataset.uint16('x00280101');
|
||||
const highBit = dataset.uint16('x00280102');
|
||||
const rows = dataset.uint16('x00280010');
|
||||
const cols = dataset.uint16('x00280011');
|
||||
const pixelSpacingString = dataset.string('x00280030');
|
||||
const pixelSpacing = pixelSpacingString ? pixelSpacingString.split('\\').map((e: string) => parseFloat(e)) : [1, 1];
|
||||
const modality = dataset.string('x00080060');
|
||||
const samplesPerPixel = dataset.uint16('x00280002');
|
||||
const photometricInterpretation = dataset.string('x00280004');
|
||||
const pixelRepresentation = dataset.uint16('x00280103');
|
||||
const windowCenter = dataset.string('x00281050');
|
||||
const wc = windowCenter ? parseFloat(windowCenter.split('\\')[0]) : 40;
|
||||
const windowWidth = dataset.string('x00281051');
|
||||
const ww = windowWidth ? parseFloat(windowWidth.split('\\')[0]) : 80;
|
||||
const rescaleIntercept = parseFloat(dataset.string('x00281052') || '1');
|
||||
const rescaleSlope = parseFloat(dataset.string('x00281053') || '1');
|
||||
const iopString = dataset.string('x00200037');
|
||||
const iop = iopString ? iopString.split('\\').map((e: string) => parseFloat(e)) : null;
|
||||
const ippString = dataset.string('x00200032');
|
||||
const ipp = ippString ? ippString.split('\\').map((e: string) => parseFloat(e)) : null;
|
||||
const instanceNumber = dataset.string('x00200013');
|
||||
const sliceThickness = dataset.string('x00180050');
|
||||
const sliceLocation = dataset.string('x00201041');
|
||||
|
||||
// append to all results
|
||||
const result: ElementType = {
|
||||
'00100010': { Value: [{ Alphabetic: patientName }], vr: 'PN' },
|
||||
'00100020': { Value: [patentID], vr: 'LO' },
|
||||
'0020000D': { Value: [studyInstanceUID], vr: 'UI' },
|
||||
'00080020': { Value: [studyDate], vr: 'DA' },
|
||||
'00080030': { Value: [studyTime], vr: 'TM' },
|
||||
'0020000E': { Value: [seriesInstanceUID], vr: 'UI' },
|
||||
'00200011': { Value: [seriesNumber], vr: 'IS' },
|
||||
'00080018': { Value: [sopInstanceUID], vr: 'UI' },
|
||||
'00080016': { Value: [sopClassUID], vr: 'UI' },
|
||||
'00080060': { Value: [modality], vr: 'CS' },
|
||||
'00280002': { Value: [samplesPerPixel], vr: 'US' },
|
||||
'00280004': { Value: [photometricInterpretation], vr: 'CS' },
|
||||
'00280010': { Value: [rows], vr: 'US' },
|
||||
'00280011': { Value: [cols], vr: 'US' },
|
||||
'00280030': { Value: pixelSpacing, vr: 'DS' },
|
||||
'00280100': { Value: [bitsAllocated], vr: 'US' },
|
||||
'00280101': { Value: [bitsStored], vr: 'US' },
|
||||
'00280102': { Value: [highBit], vr: 'US' },
|
||||
'00280103': { Value: [pixelRepresentation], vr: 'US' },
|
||||
'00281050': { Value: [wc], vr: 'DS' },
|
||||
'00281051': { Value: [ww], vr: 'DS' },
|
||||
'00281052': { Value: [rescaleIntercept], vr: 'DS' },
|
||||
'00281053': { Value: [rescaleSlope], vr: 'DS' },
|
||||
...(iop && { '00200037': { Value: iop, vr: 'DS' } }),
|
||||
...(ipp && { '00200032': { Value: ipp, vr: 'DS' } }),
|
||||
'00200013': { Value: [instanceNumber], vr: 'IS' },
|
||||
'00180050': { Value: [sliceThickness], vr: 'DS' },
|
||||
'00201041': { Value: [sliceLocation], vr: 'DS' },
|
||||
};
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function parseMeta(json: object, studyInstanceUID: string, seriesInstanceUID: string): Promise<unknown> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
logger.info(`parsing series ${seriesInstanceUID}`);
|
||||
|
||||
const parsing = new Array<Promise<ElementType>>();
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
for (const [key] of Object.entries(json)) {
|
||||
const sopInstanceUid = json[key]['00080018'].Value[0];
|
||||
const pathname = path.join(storagePath, studyInstanceUID, sopInstanceUid);
|
||||
parsing.push(parseFile(pathname));
|
||||
|
||||
}
|
||||
return Promise.all(parsing);
|
||||
}
|
||||
61
src/dimse/querLevel.ts
Normal file
61
src/dimse/querLevel.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
export enum QUERY_LEVEL {
|
||||
STUDY,
|
||||
SERIES,
|
||||
IMAGE,
|
||||
}
|
||||
|
||||
export const STUDY_LEVEL = 'STUDY';
|
||||
export const SERIES_LEVEL = 'SERIES';
|
||||
export const IMAGE_LEVEL = 'IMAGE';
|
||||
|
||||
export function queryLevelToPath(studyUid: string, seriesUid: string, imageUid: string, qlevel: QUERY_LEVEL): string {
|
||||
switch (qlevel) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return studyUid;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return `${studyUid}/${seriesUid}`;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return `${studyUid}/${seriesUid}/${imageUid}`;
|
||||
default:
|
||||
return `${studyUid}/${seriesUid}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function queryLevelToString(level: QUERY_LEVEL): string {
|
||||
switch (level) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return STUDY_LEVEL;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return SERIES_LEVEL;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return IMAGE_LEVEL;
|
||||
default:
|
||||
return STUDY_LEVEL;
|
||||
}
|
||||
}
|
||||
|
||||
export function stringToQueryLevel(level: string): QUERY_LEVEL {
|
||||
switch (level) {
|
||||
case STUDY_LEVEL:
|
||||
return QUERY_LEVEL.STUDY;
|
||||
case SERIES_LEVEL:
|
||||
return QUERY_LEVEL.SERIES;
|
||||
case IMAGE_LEVEL:
|
||||
return QUERY_LEVEL.IMAGE;
|
||||
default:
|
||||
return QUERY_LEVEL.STUDY;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLockUid(studyUid: string, seriesUid: string, imageUid: string, level: QUERY_LEVEL): string {
|
||||
switch (level) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return studyUid;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return seriesUid;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return imageUid;
|
||||
default:
|
||||
return studyUid;
|
||||
}
|
||||
}
|
||||
46
src/dimse/sendEcho.ts
Normal file
46
src/dimse/sendEcho.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { echoScu, echoScuOptions, Node as DicomNode } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
export async function sendEcho() {
|
||||
const peers = config.get(ConfParams.PEERS) as DicomNode[];
|
||||
|
||||
const promises: Array<Promise<unknown>> = [];
|
||||
|
||||
peers.forEach((peer) => {
|
||||
promises.push(sendCEchoRequest(peer));
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
export async function sendCEchoRequest(target: DicomNode) {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const options: echoScuOptions = {
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target,
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
logger.info(`sending C-ECHO to target: ${options.target?.aet}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
echoScu(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
const res = JSON.parse(result);
|
||||
if (res.code === 2) {
|
||||
logger.error(res.message);
|
||||
} else {
|
||||
logger.info(res.message);
|
||||
}
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
logger.error(result);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
51
src/dimse/store.ts
Normal file
51
src/dimse/store.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { startStoreScp, storeScpOptions, shutdownScu, shutdownScuOptions } from 'dicom-dimse-native';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
export async function startScp() {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const ts = config.get(ConfParams.XTRANSFER) as string;
|
||||
|
||||
const options: storeScpOptions = {
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
peers: config.get(ConfParams.PEERS),
|
||||
storagePath: config.get(ConfParams.STORAGE_PATH),
|
||||
netTransferPrefer: ts,
|
||||
netTransferPropose: ts,
|
||||
writeTransfer: ts,
|
||||
permissive: true,
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
logger.info(`pacs-server listening on port: ${options.source?.port}`);
|
||||
|
||||
startStoreScp(options, (result: string) => {
|
||||
// currently this will never log
|
||||
logger.info(JSON.parse(result));
|
||||
});
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const options: shutdownScuOptions = {
|
||||
source: config.get(ConfParams.SOURCE),
|
||||
target: config.get(ConfParams.SOURCE),
|
||||
verbose: config.get(ConfParams.VERBOSE),
|
||||
};
|
||||
|
||||
logger.info(`sending shutdown request to target: ${options.target?.aet}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
shutdownScu(options, (result: string) => {
|
||||
if (result && result.length > 0) {
|
||||
try {
|
||||
logger.info(JSON.parse(result));
|
||||
resolve(true);
|
||||
} catch (error) {
|
||||
logger.error(result);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
48
src/dimse/tags.ts
Normal file
48
src/dimse/tags.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { QUERY_LEVEL } from './querLevel';
|
||||
|
||||
export const studyLevelTags: Array<string> = [
|
||||
'00080005',
|
||||
'00080020',
|
||||
'00080030',
|
||||
'00080050',
|
||||
'00080054',
|
||||
'00080056',
|
||||
'00080061',
|
||||
'00080090',
|
||||
'00081190',
|
||||
'00100010',
|
||||
'00100020',
|
||||
'00100030',
|
||||
'00100040',
|
||||
'0020000D',
|
||||
'00200010',
|
||||
'00201206',
|
||||
'00201208',
|
||||
];
|
||||
|
||||
export const seriesLevelTags: Array<string> = [
|
||||
'00080005',
|
||||
'00080054',
|
||||
'00080056',
|
||||
'00080060',
|
||||
'0008103E',
|
||||
'00081190',
|
||||
'0020000E',
|
||||
'00200011',
|
||||
'00201209',
|
||||
];
|
||||
|
||||
export const imageLevelTags: Array<string> = ['00080016', '00080018'];
|
||||
|
||||
export function tagsForLevel(level: QUERY_LEVEL) {
|
||||
switch (level) {
|
||||
case QUERY_LEVEL.STUDY:
|
||||
return studyLevelTags;
|
||||
case QUERY_LEVEL.SERIES:
|
||||
return seriesLevelTags;
|
||||
case QUERY_LEVEL.IMAGE:
|
||||
return imageLevelTags;
|
||||
default:
|
||||
return studyLevelTags;
|
||||
}
|
||||
}
|
||||
282
src/dimse/wadoRs.ts
Normal file
282
src/dimse/wadoRs.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { waitOrFetchData } from './fetchData';
|
||||
import { compressFile } from './compressFile';
|
||||
import { doFind } from '../dimse/findData';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { QUERY_LEVEL } from './querLevel';
|
||||
import deepmerge from 'deepmerge';
|
||||
import dicomParser from 'dicom-parser';
|
||||
import combineMerge from '../utils/combineMerge';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import { execFile as exFile } from 'child_process';
|
||||
import util from 'util';
|
||||
|
||||
const execFile = util.promisify(exFile);
|
||||
|
||||
export type DataFormat = 'pixeldata' | 'bulkdata' | 'rendered' | 'thumbnail'
|
||||
|
||||
type WadoRsArgs = {
|
||||
studyInstanceUid: string;
|
||||
seriesInstanceUid?: string;
|
||||
sopInstanceUid?: string;
|
||||
dataFormat?: DataFormat;
|
||||
frame?: number | number[];
|
||||
};
|
||||
type WadoRsResponse = {
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
type QidoResponse = {
|
||||
[key: string]: {
|
||||
Value: string[]
|
||||
vr: string
|
||||
}
|
||||
}
|
||||
|
||||
const term = '\r\n';
|
||||
|
||||
/**
|
||||
* This function uses DCMTK to convert the given DICOM file to JPEG format
|
||||
* It will try twice, once without frames, the other with all frames.
|
||||
* If both fail it will throw the error returned by DCMTK.
|
||||
* If no resulting JPEG can be found, then it will return undefined.
|
||||
*
|
||||
* @param filepath Path to the file to convert
|
||||
* @param [asThumbnail=false] Return as thumbnail
|
||||
*/
|
||||
async function convertToJpeg(filepath: string, asThumbnail = false) {
|
||||
try {
|
||||
await execFile(
|
||||
'dcmj2pnm',
|
||||
['+oj', '+Jq', asThumbnail ? '10' : '100', filepath, `${filepath}.jpg`]
|
||||
);
|
||||
}
|
||||
catch (e) {
|
||||
// Try again but with all frames - if this fails don't catch the error (fail!)
|
||||
await execFile(
|
||||
'dcmj2pnm',
|
||||
['+oj', '+Jq', asThumbnail ? '10' : '100', '+Fa', filepath, `${filepath}`]
|
||||
);
|
||||
}
|
||||
let exists = await fileExists(`${filepath}.jpg`);
|
||||
let filePath;
|
||||
if (exists) {
|
||||
filePath = `${filepath}.jpg`;
|
||||
}
|
||||
else {
|
||||
exists = await fileExists(`${filepath}.0.jpg`);
|
||||
if (exists) {
|
||||
filePath = `${filepath}.0.jpg`;
|
||||
}
|
||||
}
|
||||
|
||||
if (exists && filePath) {
|
||||
return fs.readFile(filePath);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses (if needed) the DCM file and then adds the required data to the return buffer:
|
||||
* bulkdata and PixelData return the DCM pixeldata buffer
|
||||
* rendered returns a JPEG file buffer
|
||||
* otherwise returns a DICOM file buffer
|
||||
*
|
||||
* Attaches needed headers
|
||||
*/
|
||||
interface AddFileToBuffer {
|
||||
pathname: string,
|
||||
filename: string,
|
||||
instanceInfo: InstanceInfo,
|
||||
dataFormat?: DataFormat,
|
||||
}
|
||||
|
||||
async function addFileToBuffer({ pathname, filename, dataFormat, instanceInfo }: AddFileToBuffer): Promise<Buffer> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const filepath = path.join(pathname, filename);
|
||||
const buffArray: Buffer[] = [];
|
||||
let transferSyntax;
|
||||
// If there is a data format, use default compression
|
||||
if (dataFormat) {
|
||||
transferSyntax = '1.2.840.10008.1.2';
|
||||
}
|
||||
|
||||
let contentLocation = `/studies/${instanceInfo.study}`
|
||||
if (instanceInfo.series) {
|
||||
contentLocation += `/series/${instanceInfo.series}`
|
||||
}
|
||||
if (instanceInfo.instance) {
|
||||
contentLocation += `/instance/${instanceInfo.instance}`
|
||||
}
|
||||
|
||||
// Compress the file
|
||||
try {
|
||||
await compressFile(filepath, pathname, transferSyntax);
|
||||
}
|
||||
catch (e) {
|
||||
logger.error('Failed to compress', filepath);
|
||||
}
|
||||
|
||||
// This will throw out if the file doesn't OK (but that's what we want)
|
||||
const data = await fs.readFile(filepath);
|
||||
let returnData;
|
||||
switch (dataFormat) {
|
||||
case 'bulkdata':
|
||||
case 'pixeldata': {
|
||||
// Get the pixeldata from the DICOM and add it to the buffer.
|
||||
const dataset = dicomParser.parseDicom(data);
|
||||
const pixeldataElement = dataset.elements.x7fe00010;
|
||||
buffArray.push(Buffer.from(`Content-Type:application/octet-stream;${term}`));
|
||||
returnData = Buffer.from(dataset.byteArray.buffer, pixeldataElement.dataOffset, pixeldataElement.length);
|
||||
break;
|
||||
}
|
||||
case 'rendered': {
|
||||
// Convert the DCM file to a JPEG and return that
|
||||
buffArray.push(Buffer.from(`Content-Type:image/jpeg;${term}`));
|
||||
returnData = await convertToJpeg(filepath);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Just return the DCM file
|
||||
buffArray.push(Buffer.from(`Content-Type:${config.get(ConfParams.MIMETYPE)};transfer-syntax:${config.get(ConfParams.XTRANSFER)}${term}`));
|
||||
returnData = data;
|
||||
}
|
||||
}
|
||||
buffArray.push(Buffer.from(`Content-Location:${contentLocation};${term}`));
|
||||
buffArray.push(Buffer.from(term));
|
||||
buffArray.push(returnData);
|
||||
buffArray.push(Buffer.from(term));
|
||||
return Buffer.concat(buffArray);
|
||||
}
|
||||
|
||||
type InstanceInfo = {
|
||||
study: string,
|
||||
series?: string,
|
||||
instance?: string
|
||||
}
|
||||
|
||||
export async function doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat }: WadoRsArgs): Promise<WadoRsResponse> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
// Set up all the paths and query levels.
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
let queryLevel = QUERY_LEVEL.STUDY
|
||||
const studyPath = path.join(storagePath, studyInstanceUid);
|
||||
let pathname = studyPath;
|
||||
let filename = '';
|
||||
if (seriesInstanceUid) {
|
||||
queryLevel = QUERY_LEVEL.SERIES
|
||||
}
|
||||
if (sopInstanceUid) {
|
||||
filename = sopInstanceUid;
|
||||
pathname = path.join(pathname, sopInstanceUid);
|
||||
}
|
||||
|
||||
// Is the path that we have a directory or a file?
|
||||
let isDir = true;
|
||||
if (await fileExists(pathname)) {
|
||||
const stat = await fs.stat(pathname);
|
||||
isDir = await stat.isDirectory();
|
||||
}
|
||||
let useCache = false;
|
||||
const foundInstances: InstanceInfo[] = [];
|
||||
if (isDir) {
|
||||
// It's a directory, what things do we expect to find in this directory for this search?
|
||||
const json = deepmerge.all(await doFind(QUERY_LEVEL.IMAGE, { StudyInstanceUID: studyInstanceUid, SeriesInstanceUID: seriesInstanceUid ?? '', SOPInstanceUID: sopInstanceUid ?? '' }), { arrayMerge: combineMerge}) as QidoResponse[];
|
||||
const foundPromises = await Promise.all(json.map(async (instance) => {
|
||||
if (instance['00080018']) {
|
||||
const instanceUid = instance['00080018'].Value[0];
|
||||
const seriesUid = instance['0020000E'].Value[0];
|
||||
foundInstances.push({
|
||||
study: studyInstanceUid,
|
||||
series: seriesUid,
|
||||
instance: instanceUid
|
||||
});
|
||||
return fileExists(path.join(studyPath, instanceUid));
|
||||
}
|
||||
return true;
|
||||
}));
|
||||
|
||||
// If all of the files for this search exist, then we're gonna use the cache!
|
||||
useCache = foundPromises.reduce((prev, curr) => prev && curr, true);
|
||||
}
|
||||
else {
|
||||
// If the file exists, use the cache
|
||||
useCache = await fileExists(pathname);
|
||||
}
|
||||
|
||||
if (!useCache) {
|
||||
// We're not using the cache, so go and fetch the files. This will happen even if just one file is missing.
|
||||
// Could this be improved to just get the needed files?
|
||||
logger.info(`fetching ${pathname}`);
|
||||
await waitOrFetchData(studyInstanceUid, seriesInstanceUid ?? '', sopInstanceUid ?? '', queryLevel);
|
||||
}
|
||||
|
||||
// We only need a thumbnail - get it and bail.
|
||||
if (dataFormat === 'thumbnail') {
|
||||
// Just use the first of the foundInstances for this search
|
||||
const filePath = isDir ? path.join(pathname, foundInstances[0].instance as string) : pathname;
|
||||
const buff = await convertToJpeg(filePath, true);
|
||||
if (buff) {
|
||||
return {
|
||||
contentType: 'image/jpeg',
|
||||
buffer: buff
|
||||
};
|
||||
}
|
||||
else {
|
||||
throw new Error('Failed to create thumbnail');
|
||||
}
|
||||
}
|
||||
|
||||
let buffers: (Buffer | undefined)[] = [];
|
||||
try {
|
||||
if (isDir) {
|
||||
// We're in a directory, loop through the files we want and attach them to the return buffer
|
||||
const files = await fs.readdir(pathname)
|
||||
buffers = await Promise.all(files.map(async (file) => {
|
||||
const instanceInfo = foundInstances.find((i) => i.instance === file)
|
||||
if (instanceInfo) {
|
||||
return addFileToBuffer({ pathname, filename: file, dataFormat, instanceInfo });
|
||||
}
|
||||
}))
|
||||
}
|
||||
else {
|
||||
// Attach the one file that we need to the return buffer
|
||||
const instanceInfo = { study: studyInstanceUid, series: seriesInstanceUid, instance: sopInstanceUid }
|
||||
buffers = [await addFileToBuffer({ pathname: studyPath, filename, dataFormat, instanceInfo })];
|
||||
}
|
||||
|
||||
// Set up the boundaries and join together all of the file buffers to form
|
||||
// the final buffer to return to the client.
|
||||
const boundary = studyInstanceUid;
|
||||
const buffArray: Buffer[] = [];
|
||||
buffers = buffers.filter((b: Buffer | undefined) => !!b);
|
||||
buffers.forEach(async (buff) => {
|
||||
if (buff) {
|
||||
buffArray.push(Buffer.from(`--${boundary}${term}`));
|
||||
buffArray.push(buff);
|
||||
}
|
||||
});
|
||||
buffArray.push(Buffer.from(`--${boundary}--${term}`));
|
||||
|
||||
// We need to set the correct contentType depending on what was asked for.
|
||||
let type = 'application/dicom';
|
||||
if (dataFormat === 'rendered') {
|
||||
type = 'image/jpeg';
|
||||
}
|
||||
if (dataFormat?.match(/bulkdata|pixeldata/ig)) {
|
||||
type = 'application/octet-stream';
|
||||
}
|
||||
|
||||
const contentType = `multipart/related;type='${type}';boundary=${boundary}`;
|
||||
return Promise.resolve({
|
||||
contentType,
|
||||
buffer: Buffer.concat(buffArray),
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`failed to process ${pathname}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
66
src/dimse/wadoUri.ts
Normal file
66
src/dimse/wadoUri.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ConfParams, config } from '../utils/config';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
import { fileExists } from '../utils/fileHelper';
|
||||
import { compressFile } from './compressFile';
|
||||
import { waitOrFetchData } from './fetchData';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { stringToQueryLevel } from './querLevel';
|
||||
|
||||
type WadoUriArgs = {
|
||||
studyInstanceUid: string;
|
||||
seriesInstanceUid: string;
|
||||
sopInstanceUid: string;
|
||||
};
|
||||
type WadoUriResponse = {
|
||||
contentType: string;
|
||||
buffer: Buffer;
|
||||
};
|
||||
export async function doWadoUri({ studyInstanceUid, seriesInstanceUid, sopInstanceUid }: WadoUriArgs): Promise<WadoUriResponse> {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
const fetchLevel = config.get(ConfParams.FETCH_LEVEL) as string;
|
||||
const level = stringToQueryLevel(fetchLevel);
|
||||
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
const studyPath = path.join(storagePath, studyInstanceUid);
|
||||
const pathname = path.join(studyPath, sopInstanceUid);
|
||||
|
||||
// fetch if needed
|
||||
const exists = await fileExists(pathname);
|
||||
if (!exists) {
|
||||
try {
|
||||
await waitOrFetchData(studyInstanceUid, seriesInstanceUid, sopInstanceUid, level);
|
||||
} catch (err) {
|
||||
logger.error(`fetch failed for study: ${studyInstanceUid}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const postExists = await fileExists(pathname);
|
||||
if (!postExists) {
|
||||
const msg = `file not found ${pathname}`;
|
||||
logger.error(msg);
|
||||
throw msg;
|
||||
}
|
||||
|
||||
try {
|
||||
await compressFile(pathname, studyPath);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const msg = `failed to compress ${pathname}`;
|
||||
throw msg;
|
||||
}
|
||||
|
||||
// read file from file system
|
||||
const fsPromise = fs.promises;
|
||||
try {
|
||||
return {
|
||||
contentType: 'application/dicom',
|
||||
buffer: await fsPromise.readFile(pathname),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
const msg = `failed to read ${pathname}`;
|
||||
throw msg;
|
||||
}
|
||||
}
|
||||
383
src/routes/routes.ts
Normal file
383
src/routes/routes.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
import { fetchMeta } from '../dimse/fetchMeta';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { doFind } from '../dimse/findData';
|
||||
import { QUERY_LEVEL } from '../dimse/querLevel';
|
||||
import { doWadoRs } from '../dimse/wadoRs';
|
||||
import { doWadoUri } from '../dimse/wadoUri';
|
||||
import { LoggerSingleton } from '../utils/logger';
|
||||
|
||||
import deepmerge from 'deepmerge';
|
||||
import combineMerge from '../utils/combineMerge';
|
||||
|
||||
const options = { arrayMerge: combineMerge };
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
|
||||
interface IParamsStudy {
|
||||
studyInstanceUid: string;
|
||||
}
|
||||
|
||||
interface IParamsSeries extends IParamsStudy {
|
||||
seriesInstanceUid: string;
|
||||
}
|
||||
|
||||
interface IParamsImage extends IParamsSeries {
|
||||
sopInstanceUid: string;
|
||||
}
|
||||
|
||||
interface IQueryImage {
|
||||
studyUID: string;
|
||||
seriesUID: string;
|
||||
objectUID: string;
|
||||
}
|
||||
|
||||
interface QueryParams {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
module.exports = function (server: FastifyInstance, opts: unknown, done: () => void) {
|
||||
server.get<{
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies', async (req, reply) => {
|
||||
try {
|
||||
const { query } = req;
|
||||
const json = deepmerge.all(await doFind(QUERY_LEVEL.STUDY, query), options);
|
||||
return reply.send(json);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsStudy;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid', async (req, reply) => {
|
||||
const { params } = req;
|
||||
const { studyInstanceUid } = params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsStudy;
|
||||
}>('/rs/studies/:studyInstanceUid/pixeldata', async (req, reply) => {
|
||||
const { studyInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, dataFormat: 'pixeldata' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsStudy;
|
||||
}>('/rs/studies/:studyInstanceUid/rendered', async (req, reply) => {
|
||||
const { studyInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, dataFormat: 'rendered' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsStudy;
|
||||
}>('/rs/studies/:studyInstanceUid/thumbnail', async (req, reply) => {
|
||||
const { studyInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, dataFormat: 'thumbnail' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsStudy;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid/metadata', async (req, reply) => {
|
||||
const { query } = req;
|
||||
query.StudyInstanceUID = req.params.studyInstanceUid;
|
||||
|
||||
try {
|
||||
const json = deepmerge.all(await doFind(QUERY_LEVEL.SERIES, query), options);
|
||||
return reply.send(json);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsStudy;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid/series', async (req, reply) => {
|
||||
const { query } = req;
|
||||
query.StudyInstanceUID = req.params.studyInstanceUid;
|
||||
|
||||
try {
|
||||
const json = deepmerge.all(await doFind(QUERY_LEVEL.SERIES, query), options);
|
||||
return reply.send(json);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsSeries;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid', async (req, reply) => {
|
||||
const { params } = req;
|
||||
const { studyInstanceUid, seriesInstanceUid } = params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsSeries;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/pixeldata', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, dataFormat: 'pixeldata' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsSeries;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/rendered', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, dataFormat: 'rendered' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsSeries;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/thumbnail', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, dataFormat: 'thumbnail' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsSeries;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances', async (req, reply) => {
|
||||
const { query, params } = req;
|
||||
query.StudyInstanceUID = params.studyInstanceUid;
|
||||
query.SeriesInstanceUID = params.seriesInstanceUid;
|
||||
|
||||
try {
|
||||
const json = deepmerge.all(await doFind(QUERY_LEVEL.IMAGE, query), options);
|
||||
return reply.send(json);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsSeries;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/metadata', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid } = req.params;
|
||||
const { query } = req;
|
||||
query.StudyInstanceUID = studyInstanceUid;
|
||||
query.SeriesInstanceUID = seriesInstanceUid;
|
||||
|
||||
try {
|
||||
const rsp = await fetchMeta(query, studyInstanceUid, seriesInstanceUid);
|
||||
return reply.send(rsp);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsImage;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/pixeldata', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'pixeldata' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsImage;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/rendered', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'rendered' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsImage;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/thumbnail', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'thumbnail' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
}
|
||||
catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsImage;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid/frames/:frame', async (req, reply) => {
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = req.params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat: 'pixeldata' });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Params: IParamsImage;
|
||||
Querystring: QueryParams;
|
||||
}>('/rs/studies/:studyInstanceUid/series/:seriesInstanceUid/instances/:sopInstanceUid', async (req, reply) => {
|
||||
const { params } = req;
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid } = params;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
//------------------------------------------------------------------
|
||||
|
||||
server.get<{
|
||||
Querystring: IQueryImage;
|
||||
}>('/wadouri', async (req, reply) => {
|
||||
const { studyUID, seriesUID, objectUID } = req.query;
|
||||
|
||||
try {
|
||||
const rsp = await doWadoUri({ studyInstanceUid: studyUID, seriesInstanceUid: seriesUID, sopInstanceUid: objectUID });
|
||||
reply.header('Content-Type', rsp.contentType);
|
||||
return reply.send(rsp.buffer);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return reply.send(500);
|
||||
}
|
||||
});
|
||||
|
||||
done();
|
||||
};
|
||||
|
||||
//------------------------------------------------------------------
|
||||
98
src/socket.ts
Normal file
98
src/socket.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { ConfParams, config } from './utils/config';
|
||||
import { io } from 'socket.io-client';
|
||||
import { doFind } from './dimse/findData';
|
||||
import { stringToQueryLevel } from './dimse/querLevel';
|
||||
import { doWadoUri } from './dimse/wadoUri';
|
||||
import { LoggerSingleton } from './utils/logger';
|
||||
import { doWadoRs, DataFormat } from './dimse/wadoRs';
|
||||
import socketIOStream from '@wearemothership/socket.io-stream';
|
||||
import combineMerge from './utils/combineMerge';
|
||||
import deepmerge from 'deepmerge';
|
||||
|
||||
const options = { arrayMerge: combineMerge };
|
||||
const websocketUrl = config.get(ConfParams.WEBSOCKET_URL) as string;
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
export const socket = io(websocketUrl, {
|
||||
reconnection: true,
|
||||
reconnectionDelayMax: 10000,
|
||||
autoConnect: false,
|
||||
auth: {
|
||||
token: config.get(ConfParams.WEBSOCKET_TOKEN),
|
||||
},
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
logger.info('websocket connection established');
|
||||
});
|
||||
|
||||
socket.on('qido-request', async (data) => {
|
||||
const { level, query }: { level: string; query: Record<string, string> } = data;
|
||||
|
||||
if (data) {
|
||||
const lvl = stringToQueryLevel(level);
|
||||
logger.info('websocket QIDO request received, fetching metadata now...', level, data);
|
||||
const json = deepmerge.all(await doFind(lvl, query), options);
|
||||
logger.info('sending websocket response');
|
||||
socket.emit(data.uuid, json);
|
||||
}
|
||||
});
|
||||
|
||||
type WadoRequest = {
|
||||
studyInstanceUid: string;
|
||||
seriesInstanceUid?: string;
|
||||
sopInstanceUid?: string;
|
||||
dataFormat?: DataFormat;
|
||||
};
|
||||
|
||||
socket.on('wado-request', async (data) => {
|
||||
const { query }: { query: WadoRequest } = data;
|
||||
const { studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat } = query;
|
||||
|
||||
if (data) {
|
||||
logger.info('websocket WADO request received, fetching metadata now...');
|
||||
const { contentType, buffer } = await doWadoRs({ studyInstanceUid, seriesInstanceUid, sopInstanceUid, dataFormat });
|
||||
logger.info('sending websocket response stream');
|
||||
const stream = socketIOStream.createStream();
|
||||
socketIOStream(socket).emit(data.uuid, stream, { contentType: contentType });
|
||||
let offset = 0;
|
||||
const chunkSize = 512 * 1024; // 512kb
|
||||
const writeBuffer = () => {
|
||||
let ok = true;
|
||||
do {
|
||||
const b = Buffer.alloc(chunkSize);
|
||||
buffer.copy(b, 0, offset, offset + chunkSize);
|
||||
ok = stream.write(b);
|
||||
offset += chunkSize;
|
||||
} while (offset < buffer.length && ok);
|
||||
if (offset < buffer.length) {
|
||||
stream.once('drain', writeBuffer);
|
||||
} else {
|
||||
stream.end();
|
||||
}
|
||||
};
|
||||
writeBuffer();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('wadouri-request', async (data) => {
|
||||
if (data) {
|
||||
const { studyUID, seriesUID, objectUID, studyInstanceUid, seriesInstanceUid, sopInstanceUid } = data.query;
|
||||
try {
|
||||
logger.info('websocket wadouri request received, fetching metadata now...');
|
||||
const rsp = await doWadoUri({
|
||||
studyInstanceUid: studyInstanceUid ?? studyUID,
|
||||
seriesInstanceUid: seriesInstanceUid ?? seriesUID,
|
||||
sopInstanceUid: sopInstanceUid ?? objectUID,
|
||||
});
|
||||
socket.emit(data.uuid, rsp);
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
socket.emit(data.uuid, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
logger.info('websocket connection disconnected');
|
||||
});
|
||||
18
src/utils/combineMerge.ts
Normal file
18
src/utils/combineMerge.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
const combineMerge = (target, source, options) => {
|
||||
const destination = target.slice();
|
||||
|
||||
source.forEach((item: object, index: number) => {
|
||||
if (typeof destination[index] === 'undefined') {
|
||||
destination[index] = options.cloneUnlessOtherwiseSpecified(item, options);
|
||||
} else if (options.isMergeableObject(item)) {
|
||||
destination[index] = deepmerge(target[index], item, options);
|
||||
} else if (target.indexOf(item) === -1) {
|
||||
destination.push(item);
|
||||
}
|
||||
});
|
||||
return destination;
|
||||
};
|
||||
|
||||
export default combineMerge;
|
||||
66
src/utils/config.ts
Normal file
66
src/utils/config.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
|
||||
import conf from 'config';
|
||||
|
||||
export const enum ConfParams {
|
||||
LOG_DIR,
|
||||
STORAGE_PATH,
|
||||
XTRANSFER,
|
||||
MIMETYPE,
|
||||
SOURCE,
|
||||
PEERS,
|
||||
VERBOSE,
|
||||
MIN_CHARS,
|
||||
APPEND_WILDCARD,
|
||||
FETCH_LEVEL,
|
||||
MAX_ASSOCIATIONS,
|
||||
C_GET,
|
||||
HTTP_PORT,
|
||||
WEBSOCKET_URL,
|
||||
WEBSOCKET_TOKEN,
|
||||
CACHE_RETENTION,
|
||||
FULL_META,
|
||||
LOSSY_QUALITY,
|
||||
}
|
||||
|
||||
const ConfDef = new Map([
|
||||
[ConfParams.LOG_DIR, 'logDir'],
|
||||
[ConfParams.STORAGE_PATH, 'storagePath'],
|
||||
[ConfParams.XTRANSFER, 'transferSyntax'],
|
||||
[ConfParams.MIMETYPE, 'mimeType'],
|
||||
[ConfParams.SOURCE, 'source'],
|
||||
[ConfParams.PEERS, 'peers'],
|
||||
[ConfParams.VERBOSE, 'verboseLogging'],
|
||||
[ConfParams.MIN_CHARS, 'qidoMinChars'],
|
||||
[ConfParams.APPEND_WILDCARD, 'qidoAppendWildcard'],
|
||||
[ConfParams.FETCH_LEVEL, 'useFetchLevel'],
|
||||
[ConfParams.MAX_ASSOCIATIONS, 'maxAssociations'],
|
||||
[ConfParams.C_GET, 'useCget'],
|
||||
[ConfParams.HTTP_PORT, 'webserverPort'],
|
||||
[ConfParams.WEBSOCKET_URL, 'websocketUrl'],
|
||||
[ConfParams.WEBSOCKET_TOKEN, 'websocketToken'],
|
||||
[ConfParams.CACHE_RETENTION, 'cacheRetentionMinutes'],
|
||||
[ConfParams.FULL_META, 'fullMeta'],
|
||||
[ConfParams.LOSSY_QUALITY, 'lossyQuality'],
|
||||
]);
|
||||
|
||||
interface IConfig {
|
||||
get<T>(setting: ConfParams): T;
|
||||
has(setting: ConfParams): boolean;
|
||||
}
|
||||
|
||||
class Config implements IConfig {
|
||||
get<T>(setting: ConfParams): T {
|
||||
const s = ConfDef.get(setting);
|
||||
if (s) {
|
||||
return conf.get(s);
|
||||
}
|
||||
return conf.get('');
|
||||
}
|
||||
has(setting: ConfParams): boolean {
|
||||
const s = ConfDef.get(setting);
|
||||
return s ? conf.has(s) : false;
|
||||
}
|
||||
}
|
||||
|
||||
export const config = new Config();
|
||||
|
||||
52
src/utils/fileHelper.ts
Normal file
52
src/utils/fileHelper.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { promises } from 'fs';
|
||||
import path from 'path';
|
||||
import { ConfParams, config } from './config';
|
||||
import { LoggerSingleton } from './logger';
|
||||
|
||||
|
||||
const getDirectories = async (source: string) => {
|
||||
try {
|
||||
const dir = await promises.readdir(source, { withFileTypes: true })
|
||||
return dir.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name)
|
||||
}
|
||||
catch (e) {
|
||||
const logger = LoggerSingleton.Instance;
|
||||
logger.warn("Storage Folder doesn't exist: ", source);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fileExists(pathname: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await promises.stat(pathname);
|
||||
return !!stat;
|
||||
}
|
||||
catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearCache() {
|
||||
const storagePath = config.get(ConfParams.STORAGE_PATH) as string;
|
||||
const retention = config.get(ConfParams.CACHE_RETENTION) as number;
|
||||
const logger = LoggerSingleton.Instance;
|
||||
|
||||
if (retention < 0) {
|
||||
logger.warn('cache cleanup disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const dirs = await getDirectories(storagePath);
|
||||
const dateNow = new Date();
|
||||
|
||||
for (const dir of dirs) {
|
||||
const filepath = path.join(storagePath, dir);
|
||||
const stats = await promises.stat(filepath);
|
||||
const mtime = stats.mtime;
|
||||
const minutes = (dateNow.getTime() - mtime.getTime()) / 60000;
|
||||
if (minutes > retention) {
|
||||
logger.info(`removing: ${filepath}`);
|
||||
await promises.rm(filepath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/utils/logger.ts
Normal file
34
src/utils/logger.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import SimpleLogger from 'simple-node-logger';
|
||||
import shell from 'shelljs';
|
||||
import { ConfParams, config } from './config';
|
||||
|
||||
export class LoggerSingleton {
|
||||
private static _instance: LoggerSingleton;
|
||||
private logger: SimpleLogger.Logger;
|
||||
|
||||
private constructor() {
|
||||
// make sure default directories exist
|
||||
const logDir = config.get(ConfParams.LOG_DIR) as string;
|
||||
shell.mkdir('-p', logDir);
|
||||
shell.mkdir('-p', config.get(ConfParams.LOG_DIR));
|
||||
|
||||
// create a rolling file logger based on date/time that fires process events
|
||||
const opts = {
|
||||
errorEventName: 'error',
|
||||
logDirectory: logDir, // NOTE: folder must exist and be writable...
|
||||
fileNamePattern: 'roll-<DATE>.log',
|
||||
dateFormat: 'YYYY.MM.DD',
|
||||
};
|
||||
|
||||
const manager = SimpleLogger.createLogManager();
|
||||
// manager.createConsoleAppender();
|
||||
manager.createRollingFileAppender(opts);
|
||||
this.logger = manager.createLogger();
|
||||
}
|
||||
|
||||
public static get Instance() {
|
||||
// Do you need arguments? Make it a regular static method instead.
|
||||
const inst = this._instance || (this._instance = new this());
|
||||
return inst.logger;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user