diff --git a/internal/service/dicom.go b/internal/service/dicom.go index 1edd991..0a4f563 100644 --- a/internal/service/dicom.go +++ b/internal/service/dicom.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "path/filepath" "sync" "time" @@ -78,31 +79,43 @@ func (s *DicomService) FetchDICOMMultiple(ctx context.Context, accessionNumbers default: } - exitCode, _, stderr, err := dicom.RunMoveSCU( - ctx, - s.cfg.DCMTK.Movescu, - s.cfg.OurAE.AETitle, - s.cfg.PACS.AETitle, - s.cfg.PACS.Host, - s.cfg.PACS.Port, - port, - acc, - 300, // 5 min timeout - ) + studyUIDs, err := s.findStudyUIDs(ctx, acc) if err != nil { - slog.Warn("movescu failed for accession", + slog.Warn("findscu failed for accession", "accession", acc, - "exit_code", exitCode, - "stderr", stderr, "error", err, ) - // Continue with next accession — partial success is acceptable continue } - slog.Info("movescu completed", - "accession", acc, - "exit_code", exitCode, - ) + + for _, studyUID := range studyUIDs { + exitCode, _, stderr, err := dicom.RunMoveSCUByStudyUID( + ctx, + s.cfg.DCMTK.Movescu, + s.cfg.OurAE.AETitle, + s.cfg.PACS.AETitle, + s.cfg.PACS.Host, + s.cfg.PACS.Port, + port, + studyUID, + 300, // 5 min timeout + ) + if err != nil { + slog.Warn("movescu failed for study uid", + "accession", acc, + "study_uid", studyUID, + "exit_code", exitCode, + "stderr", stderr, + "error", err, + ) + continue + } + slog.Info("movescu completed", + "accession", acc, + "study_uid", studyUID, + "exit_code", exitCode, + ) + } } // Count files retrieved @@ -140,26 +153,33 @@ func (s *DicomService) fetchDICOMWithPort(ctx context.Context, accessionNumber, return 0, err } - // Run movescu - exitCode, _, stderr, err := dicom.RunMoveSCU( - ctx, - s.cfg.DCMTK.Movescu, - s.cfg.OurAE.AETitle, - s.cfg.PACS.AETitle, - s.cfg.PACS.Host, - s.cfg.PACS.Port, - port, - accessionNumber, - 300, // 5 min timeout - ) + studyUIDs, err := s.findStudyUIDs(ctx, accessionNumber) if err != nil { - return 0, fmt.Errorf("movescu failed (exit %d): %s (stderr: %s)", exitCode, err, stderr) + return 0, err } - slog.Info("movescu completed", - "accession", accessionNumber, - "exit_code", exitCode, - ) + for _, studyUID := range studyUIDs { + exitCode, _, stderr, err := dicom.RunMoveSCUByStudyUID( + ctx, + s.cfg.DCMTK.Movescu, + s.cfg.OurAE.AETitle, + s.cfg.PACS.AETitle, + s.cfg.PACS.Host, + s.cfg.PACS.Port, + port, + studyUID, + 300, // 5 min timeout + ) + if err != nil { + return 0, fmt.Errorf("movescu failed for study uid %q (exit %d): %s (stderr: %s)", studyUID, exitCode, err, stderr) + } + + slog.Info("movescu completed", + "accession", accessionNumber, + "study_uid", studyUID, + "exit_code", exitCode, + ) + } // Count files filesCount, err = countFiles(destDir) @@ -174,6 +194,24 @@ func (s *DicomService) fetchDICOMWithPort(ctx context.Context, accessionNumber, return filesCount, nil } +func (s *DicomService) findStudyUIDs(ctx context.Context, accessionNumber string) ([]string, error) { + findscuBin := filepath.Join(filepath.Dir(s.cfg.DCMTK.Movescu), "findscu") + studyUIDs, _, stderr, err := dicom.RunFindSCUStudyUIDs( + ctx, + findscuBin, + s.cfg.OurAE.AETitle, + s.cfg.PACS.AETitle, + s.cfg.PACS.Host, + s.cfg.PACS.Port, + accessionNumber, + 60, + ) + if err != nil { + return nil, fmt.Errorf("findscu failed for accession %q: %w (stderr: %s)", accessionNumber, err, stderr) + } + return studyUIDs, nil +} + // waitForStorescpReady waits for storescp to start or detects early failure. func waitForStorescpReady(resultCh <-chan dicom.StorescpResult) error { select { diff --git a/pkg/dicom/command.go b/pkg/dicom/command.go index 43d03fc..e617e04 100644 --- a/pkg/dicom/command.go +++ b/pkg/dicom/command.go @@ -7,6 +7,7 @@ import ( "log/slog" "os" "os/exec" + "regexp" "strconv" "strings" "sync" @@ -101,17 +102,72 @@ func StartStoresCP(ctx context.Context, bin, aeTitle string, port int, outputDir return ch, stop, nil } -// RunMoveSCU executes movescu for the given accession number. +// 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 RunMoveSCU(ctx context.Context, bin, ourAE, pacsAE, pacsHost string, pacsPort, moveDestPort int, accessionNumber string, timeoutSec int) (exitCode int, stdout, stderr string, err error) { +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", fmt.Sprintf("0008,0050=%s", accessionNumber), - "-k", "0010,0020=", // Patient ID wildcard (required for Study Root) + "-k", "0008,0052=STUDY", + "-k", fmt.Sprintf("0020,000D=%s", studyUID), "--no-port", } @@ -121,7 +177,7 @@ func RunMoveSCU(ctx context.Context, bin, ourAE, pacsAE, pacsHost string, pacsPo slog.Info("running movescu", "bin", bin, - "accession", accessionNumber, + "study_uid", studyUID, "pacs", fmt.Sprintf("%s@%s:%d", pacsAE, pacsHost, pacsPort), "dest", fmt.Sprintf("%s:%d", ourAE, moveDestPort), )