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 +sd +r 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 }