This commit is contained in:
mario
2025-03-11 13:16:07 +07:00
commit 3c64adc353
215 changed files with 111738 additions and 0 deletions

91
src/app.ts Normal file
View 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
View File

@@ -0,0 +1 @@
declare module "@wearemothership/socket.io-stream";

38
src/dimse/compressFile.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}
}