227 lines
5.3 KiB
Go
227 lines
5.3 KiB
Go
package dicom
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// Global port allocator for concurrent safety.
|
|
var (
|
|
portMu sync.Mutex
|
|
portInUse = make(map[int]bool)
|
|
)
|
|
|
|
// allocatePort picks a free port from the range [base, base+range).
|
|
func allocatePort(base, size int) (int, func()) {
|
|
portMu.Lock()
|
|
defer portMu.Unlock()
|
|
|
|
for i := 0; i < size; i++ {
|
|
port := base + i
|
|
if !portInUse[port] {
|
|
portInUse[port] = true
|
|
return port, func() {
|
|
portMu.Lock()
|
|
delete(portInUse, port)
|
|
portMu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// StorescpResult holds the outcome of a storescp process.
|
|
type StorescpResult struct {
|
|
PID int
|
|
Port int
|
|
Stderr string
|
|
Err error
|
|
}
|
|
|
|
// StartStoresCP launches storescp in the background.
|
|
// Returns a result channel that will receive the outcome when the process exits,
|
|
// plus a stop function to kill the process, and an error if startup failed.
|
|
func StartStoresCP(ctx context.Context, bin, aeTitle string, port int, outputDir string) (resultCh <-chan StorescpResult, stop func(), err error) {
|
|
args := []string{
|
|
strconv.Itoa(port),
|
|
"-aet", aeTitle,
|
|
"-od", outputDir,
|
|
"+xa", // accept all transfer syntaxes
|
|
}
|
|
|
|
slog.Info("starting storescp",
|
|
"bin", bin,
|
|
"port", port,
|
|
"ae", aeTitle,
|
|
"dir", outputDir,
|
|
)
|
|
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
|
|
// Redirect stderr to a buffer (storescp logs association info to stderr)
|
|
var stderrBuf bytes.Buffer
|
|
cmd.Stderr = &stderrBuf
|
|
cmd.Stdout = nil // storescp doesn't use stdout
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, nil, fmt.Errorf("start storescp: %w", err)
|
|
}
|
|
|
|
pid := cmd.Process.Pid
|
|
ch := make(chan StorescpResult, 1)
|
|
|
|
go func() {
|
|
err := cmd.Wait()
|
|
ch <- StorescpResult{
|
|
PID: pid,
|
|
Port: port,
|
|
Stderr: stderrBuf.String(),
|
|
Err: err,
|
|
}
|
|
close(ch)
|
|
}()
|
|
|
|
stop = func() {
|
|
if cmd.Process != nil {
|
|
slog.Info("stopping storescp", "pid", pid, "port", port)
|
|
cmd.Process.Signal(os.Interrupt) // SIGINT for clean shutdown
|
|
go func() {
|
|
cmd.Wait()
|
|
}()
|
|
}
|
|
}
|
|
|
|
return ch, stop, nil
|
|
}
|
|
|
|
// RunMoveSCU executes movescu for the given accession number.
|
|
// It uses Study Root query/retrieve model (via -S flag).
|
|
func RunMoveSCU(ctx context.Context, bin, ourAE, pacsAE, pacsHost string, pacsPort, moveDestPort int, accessionNumber string, timeoutSec int) (exitCode int, stdout, stderr string, err error) {
|
|
args := []string{
|
|
"-aet", ourAE,
|
|
"-aec", pacsAE,
|
|
"-aem", ourAE,
|
|
pacsHost, strconv.Itoa(pacsPort),
|
|
"-S", // Study Root query/retrieve
|
|
"-k", fmt.Sprintf("0008,0050=%s", accessionNumber),
|
|
"-k", "0010,0020=", // Patient ID wildcard (required for Study Root)
|
|
"--no-port",
|
|
}
|
|
|
|
if timeoutSec > 0 {
|
|
args = append(args, "-to", strconv.Itoa(timeoutSec))
|
|
}
|
|
|
|
slog.Info("running movescu",
|
|
"bin", bin,
|
|
"accession", accessionNumber,
|
|
"pacs", fmt.Sprintf("%s@%s:%d", pacsAE, pacsHost, pacsPort),
|
|
"dest", fmt.Sprintf("%s:%d", ourAE, moveDestPort),
|
|
)
|
|
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
var outBuf, errBuf bytes.Buffer
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
|
|
err = cmd.Run()
|
|
stdout = outBuf.String()
|
|
stderr = errBuf.String()
|
|
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
return exitCode, stdout, stderr, fmt.Errorf("movescu failed (exit %d): %s", exitCode, strings.TrimSpace(stderr))
|
|
}
|
|
|
|
return 0, stdout, stderr, nil
|
|
}
|
|
|
|
// RunStoreSCU sends DICOM files to a remote AE via C-STORE.
|
|
// Equivalent to: storescu -aet <ae> +sd +r <host> <port> <dir>
|
|
func RunStoreSCU(ctx context.Context, bin, aeTitle, host string, port int, dir string) (exitCode int, stdout, stderr string, err error) {
|
|
args := []string{
|
|
"-aet", aeTitle,
|
|
"+sd", // scan directories
|
|
"+r", // recurse
|
|
host,
|
|
strconv.Itoa(port),
|
|
dir,
|
|
}
|
|
|
|
slog.Info("running storescu",
|
|
"bin", bin,
|
|
"ae", aeTitle,
|
|
"destination", fmt.Sprintf("%s:%d", host, port),
|
|
"dir", dir,
|
|
)
|
|
|
|
cmd := exec.CommandContext(ctx, bin, args...)
|
|
var outBuf, errBuf bytes.Buffer
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
|
|
err = cmd.Run()
|
|
stdout = outBuf.String()
|
|
stderr = errBuf.String()
|
|
|
|
if err != nil {
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = -1
|
|
}
|
|
return exitCode, stdout, stderr, fmt.Errorf("storescu failed (exit %d): %s", exitCode, strings.TrimSpace(stderr))
|
|
}
|
|
|
|
return 0, stdout, stderr, nil
|
|
}
|
|
|
|
// CopyDir copies a directory recursively using cp -r.
|
|
func CopyDir(src, dst string) error {
|
|
cmd := exec.Command("/bin/cp", "-r", src, dst)
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
return fmt.Errorf("copy %s -> %s: %w (stderr: %s)", src, dst, err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FileExists checks if a path exists and is a regular file.
|
|
func FileExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && !info.IsDir()
|
|
}
|
|
|
|
// DirExists checks if a path exists and is a directory.
|
|
func DirExists(path string) bool {
|
|
info, err := os.Stat(path)
|
|
return err == nil && info.IsDir()
|
|
}
|
|
|
|
// CountFiles counts regular files in a directory (non-recursive).
|
|
func CountFiles(dir string) (int, error) {
|
|
dirents, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("read dir %q: %w", dir, err)
|
|
}
|
|
count := 0
|
|
for _, de := range dirents {
|
|
if !de.IsDir() {
|
|
count++
|
|
}
|
|
}
|
|
return count, nil
|
|
}
|