Files
dicom-iso/pkg/dicom/command.go
2026-06-05 08:33:09 +07:00

283 lines
6.8 KiB
Go

package dicom
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"regexp"
"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
}
// RunFindSCUStudyUIDs queries PACS by accession number and returns matching StudyInstanceUID values.
func RunFindSCUStudyUIDs(ctx context.Context, bin, ourAE, pacsAE, pacsHost string, pacsPort int, accessionNumber string, timeoutSec int) (studyUIDs []string, stdout, stderr string, err error) {
args := []string{
"-v",
"-aet", ourAE,
"-aec", pacsAE,
pacsHost, strconv.Itoa(pacsPort),
"-S",
"-k", "0008,0052=STUDY",
"-k", fmt.Sprintf("0008,0050=%s", accessionNumber),
"-k", "0020,000D",
}
if timeoutSec > 0 {
args = append(args, "-to", strconv.Itoa(timeoutSec))
}
slog.Info("running findscu",
"bin", bin,
"accession", accessionNumber,
"pacs", fmt.Sprintf("%s@%s:%d", pacsAE, pacsHost, pacsPort),
)
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()
combined := stdout + "\n" + stderr
if err != nil {
return nil, stdout, stderr, fmt.Errorf("findscu failed: %w", err)
}
uidRe := regexp.MustCompile(`\(0020,000d\) UI \[([^\]]+)\]`)
seen := make(map[string]bool)
for _, match := range uidRe.FindAllStringSubmatch(combined, -1) {
uid := strings.TrimSpace(match[1])
if uid == "" || seen[uid] {
continue
}
seen[uid] = true
studyUIDs = append(studyUIDs, uid)
}
if len(studyUIDs) == 0 {
return nil, stdout, stderr, fmt.Errorf("no StudyInstanceUID found for accession %q", accessionNumber)
}
return studyUIDs, stdout, stderr, nil
}
// RunMoveSCUByStudyUID executes movescu for the given StudyInstanceUID.
// It uses Study Root query/retrieve model (via -S flag).
func RunMoveSCUByStudyUID(ctx context.Context, bin, ourAE, pacsAE, pacsHost string, pacsPort, moveDestPort int, studyUID 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", "0008,0052=STUDY",
"-k", fmt.Sprintf("0020,000D=%s", studyUID),
"--no-port",
}
if timeoutSec > 0 {
args = append(args, "-to", strconv.Itoa(timeoutSec))
}
slog.Info("running movescu",
"bin", bin,
"study_uid", studyUID,
"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
}