feat: base go mkiso
This commit is contained in:
222
internal/service/dicom.go
Normal file
222
internal/service/dicom.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// DicomService handles DICOM retrieval from PACS via storescp + movescu.
|
||||
type DicomService struct {
|
||||
cfg *config.Config
|
||||
portAllocMgr *portManager
|
||||
}
|
||||
|
||||
// NewDicomService creates a new DicomService.
|
||||
func NewDicomService(cfg *config.Config) *DicomService {
|
||||
return &DicomService{
|
||||
cfg: cfg,
|
||||
portAllocMgr: newPortManager(),
|
||||
}
|
||||
}
|
||||
|
||||
// FetchDICOM retrieves all DICOM files for a single accession number.
|
||||
// It starts storescp, runs movescu, then stops storescp.
|
||||
// Returns the number of files retrieved, or an error.
|
||||
func (s *DicomService) FetchDICOM(ctx context.Context, accessionNumber, destDir string) (filesCount int, err error) {
|
||||
port, release := s.portAllocMgr.allocate(s.cfg.OurAE.BasePort, s.cfg.OurAE.PortRange)
|
||||
if port == 0 {
|
||||
return 0, fmt.Errorf("no available port for storescp")
|
||||
}
|
||||
defer release()
|
||||
|
||||
return s.fetchDICOMWithPort(ctx, accessionNumber, destDir, port)
|
||||
}
|
||||
|
||||
// FetchDICOMMultiple retrieves DICOM files for multiple accession numbers.
|
||||
// It starts storescp ONCE, runs movescu for each accession, then stops storescp.
|
||||
// Returns the total number of files retrieved, or an error.
|
||||
func (s *DicomService) FetchDICOMMultiple(ctx context.Context, accessionNumbers []string, destDir string) (totalFiles int, err error) {
|
||||
port, release := s.portAllocMgr.allocate(s.cfg.OurAE.BasePort, s.cfg.OurAE.PortRange)
|
||||
if port == 0 {
|
||||
return 0, fmt.Errorf("no available port for storescp")
|
||||
}
|
||||
defer release()
|
||||
|
||||
// Start storescp once
|
||||
storescpCtx, cancelStorescp := context.WithCancel(ctx)
|
||||
defer cancelStorescp()
|
||||
|
||||
resultCh, stop, err := dicom.StartStoresCP(
|
||||
storescpCtx,
|
||||
s.cfg.DCMTK.Storescp,
|
||||
s.cfg.OurAE.AETitle,
|
||||
port,
|
||||
destDir,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("start storescp: %w", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
// Wait for storescp to be ready
|
||||
if err := waitForStorescpReady(resultCh); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Run movescu for each accession
|
||||
for _, acc := range accessionNumbers {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
stop()
|
||||
return totalFiles, ctx.Err()
|
||||
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
|
||||
)
|
||||
if err != nil {
|
||||
slog.Warn("movescu 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,
|
||||
)
|
||||
}
|
||||
|
||||
// Count files retrieved
|
||||
filesCount, err := countFiles(destDir)
|
||||
if err != nil {
|
||||
slog.Warn("count files failed", "dir", destDir, "error", err)
|
||||
}
|
||||
|
||||
if filesCount == 0 {
|
||||
return 0, fmt.Errorf("no DICOM data retrieved")
|
||||
}
|
||||
|
||||
return filesCount, nil
|
||||
}
|
||||
|
||||
// fetchDICOMWithPort uses a specific port for storescp + movescu.
|
||||
func (s *DicomService) fetchDICOMWithPort(ctx context.Context, accessionNumber, destDir string, port int) (filesCount int, err error) {
|
||||
storescpCtx, cancelStorescp := context.WithCancel(ctx)
|
||||
defer cancelStorescp()
|
||||
|
||||
resultCh, stop, err := dicom.StartStoresCP(
|
||||
storescpCtx,
|
||||
s.cfg.DCMTK.Storescp,
|
||||
s.cfg.OurAE.AETitle,
|
||||
port,
|
||||
destDir,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("start storescp: %w", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
// Wait for storescp to be ready
|
||||
if err := waitForStorescpReady(resultCh); err != nil {
|
||||
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
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("movescu failed (exit %d): %s (stderr: %s)", exitCode, err, stderr)
|
||||
}
|
||||
|
||||
slog.Info("movescu completed",
|
||||
"accession", accessionNumber,
|
||||
"exit_code", exitCode,
|
||||
)
|
||||
|
||||
// Count files
|
||||
filesCount, err = countFiles(destDir)
|
||||
if err != nil {
|
||||
slog.Warn("count files failed", "dir", destDir, "error", err)
|
||||
}
|
||||
|
||||
if filesCount == 0 {
|
||||
return 0, fmt.Errorf("no DICOM data for accession %q", accessionNumber)
|
||||
}
|
||||
|
||||
return filesCount, nil
|
||||
}
|
||||
|
||||
// waitForStorescpReady waits for storescp to start or detects early failure.
|
||||
func waitForStorescpReady(resultCh <-chan dicom.StorescpResult) error {
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
return fmt.Errorf("storescp exited prematurely (pid %d, err: %v, stderr: %s)",
|
||||
result.PID, result.Err, result.Stderr)
|
||||
case <-time.After(2 * time.Second):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// countFiles counts regular files in a directory (non-recursive).
|
||||
func countFiles(dir string) (int, error) {
|
||||
return dicom.CountFiles(dir)
|
||||
}
|
||||
|
||||
// portManager manages a pool of allocated ports with mutex safety.
|
||||
type portManager struct {
|
||||
mu sync.Mutex
|
||||
portsInUse map[int]bool
|
||||
}
|
||||
|
||||
func newPortManager() *portManager {
|
||||
return &portManager{
|
||||
portsInUse: make(map[int]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// allocate picks a free port from [base, base+size). Returns 0 if none available.
|
||||
func (pm *portManager) allocate(base, size int) (int, func()) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
for i := 0; i < size; i++ {
|
||||
port := base + i
|
||||
if !pm.portsInUse[port] {
|
||||
pm.portsInUse[port] = true
|
||||
return port, func() {
|
||||
pm.mu.Lock()
|
||||
delete(pm.portsInUse, port)
|
||||
pm.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
185
internal/service/iso.go
Normal file
185
internal/service/iso.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/internal/isobuilder"
|
||||
"mkiso-server/internal/repo"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// ISOService handles ISO creation from DICOM data.
|
||||
type ISOService struct {
|
||||
cfg *config.Config
|
||||
dicomSvc *DicomService
|
||||
patientRepo *repo.PatientRepo
|
||||
}
|
||||
|
||||
// NewISOService creates a new ISOService.
|
||||
func NewISOService(cfg *config.Config, dicomSvc *DicomService, patientRepo *repo.PatientRepo) *ISOService {
|
||||
return &ISOService{
|
||||
cfg: cfg,
|
||||
dicomSvc: dicomSvc,
|
||||
patientRepo: patientRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// ISOItem is the result of ISO generation.
|
||||
type ISOItem struct {
|
||||
Path string // Full path to the generated ISO file
|
||||
Cleanup func() // Call to remove temp files
|
||||
Filename string // Suggested download filename
|
||||
}
|
||||
|
||||
// GenerateISO creates an ISO for a single accession number.
|
||||
func (s *ISOService) GenerateISO(ctx context.Context, accessionNumber string) (*ISOItem, error) {
|
||||
// Create temp working directory
|
||||
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(tempDir) }
|
||||
|
||||
dicomDir := filepath.Join(tempDir, "DICOMDIR")
|
||||
if err := os.MkdirAll(dicomDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("create DICOMDIR: %w", err)
|
||||
}
|
||||
|
||||
// Copy microdicom viewer files
|
||||
if err := s.copyMicrodicom(tempDir); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("copy microdicom: %w", err)
|
||||
}
|
||||
|
||||
// Fetch DICOM from PACS
|
||||
_, err = s.dicomSvc.FetchDICOM(ctx, accessionNumber, dicomDir)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Generate ISO
|
||||
isoName := sanitizeFilename(accessionNumber) + ".iso"
|
||||
isoPath := filepath.Join(s.cfg.ISO.TempDir, isoName)
|
||||
|
||||
if err := isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM"); err != nil {
|
||||
cleanup()
|
||||
os.Remove(isoPath)
|
||||
return nil, fmt.Errorf("ISO creation failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("ISO created",
|
||||
"path", isoPath,
|
||||
"accession", accessionNumber,
|
||||
)
|
||||
|
||||
return &ISOItem{
|
||||
Path: isoPath,
|
||||
Cleanup: func() { cleanup(); os.Remove(isoPath) },
|
||||
Filename: isoName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GenerateISOMultiple creates an ISO for multiple accession numbers.
|
||||
// It tries to get the patient name from the patient API for a descriptive filename.
|
||||
// If the patient API is unavailable, it falls back to the accession list as filename.
|
||||
func (s *ISOService) GenerateISOMultiple(ctx context.Context, accessionNumbers []string) (*ISOItem, error) {
|
||||
// Create temp working directory
|
||||
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
cleanup := func() { os.RemoveAll(tempDir) }
|
||||
|
||||
dicomDir := filepath.Join(tempDir, "DICOMDIR")
|
||||
if err := os.MkdirAll(dicomDir, 0755); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("create DICOMDIR: %w", err)
|
||||
}
|
||||
|
||||
// Copy microdicom viewer files
|
||||
if err := s.copyMicrodicom(tempDir); err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("copy microdicom: %w", err)
|
||||
}
|
||||
|
||||
// Fetch DICOM from PACS
|
||||
_, err = s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
|
||||
}
|
||||
|
||||
// Determine filename: try patient API, fall back to accession list
|
||||
filename := buildMultiFilename(accessionNumbers)
|
||||
|
||||
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
|
||||
if err != nil {
|
||||
slog.Warn("patient API lookup failed, using accession-based filename",
|
||||
"accession", accessionNumbers[0],
|
||||
"error", err,
|
||||
)
|
||||
} else if patient != nil && patient.PatientName != "" {
|
||||
name := sanitizeFilename(patient.PatientName)
|
||||
accPart := sanitizeFilename(accessionList(accessionNumbers))
|
||||
filename = fmt.Sprintf("%s-%s.iso", name, accPart)
|
||||
slog.Info("using patient name for ISO filename",
|
||||
"patient", patient.PatientName,
|
||||
"filename", filename,
|
||||
)
|
||||
}
|
||||
|
||||
isoPath := filepath.Join(s.cfg.ISO.TempDir, filename)
|
||||
|
||||
if err := isobuilder.BuildFromDirectory(tempDir, isoPath, "DICOM"); err != nil {
|
||||
cleanup()
|
||||
os.Remove(isoPath)
|
||||
return nil, fmt.Errorf("ISO creation failed: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("ISO created (multiple)",
|
||||
"path", isoPath,
|
||||
"accessions", accessionNumbers,
|
||||
)
|
||||
|
||||
return &ISOItem{
|
||||
Path: isoPath,
|
||||
Cleanup: func() { cleanup(); os.Remove(isoPath) },
|
||||
Filename: filename,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// copyMicrodicom copies the microdicom viewer files into the temp directory.
|
||||
func (s *ISOService) copyMicrodicom(destDir string) error {
|
||||
if !dicom.DirExists(s.cfg.ISO.MicrodicomPath) {
|
||||
slog.Warn("microdicom path does not exist, skipping copy",
|
||||
"path", s.cfg.ISO.MicrodicomPath,
|
||||
)
|
||||
return nil
|
||||
}
|
||||
return dicom.CopyDir(s.cfg.ISO.MicrodicomPath, destDir)
|
||||
}
|
||||
|
||||
// sanitizeFilename removes characters not safe for filenames.
|
||||
func sanitizeFilename(s string) string {
|
||||
reg := regexp.MustCompile(`[^a-zA-Z0-9\-\.]+`)
|
||||
return strings.TrimRight(reg.ReplaceAllString(strings.ToUpper(s), ""), ".-")
|
||||
}
|
||||
|
||||
// accessionList joins accession numbers with "-".
|
||||
func accessionList(accs []string) string {
|
||||
return strings.Join(accs, "-")
|
||||
}
|
||||
|
||||
// buildMultiFilename creates a default filename from accession numbers.
|
||||
func buildMultiFilename(accs []string) string {
|
||||
return sanitizeFilename(accessionList(accs)) + ".iso"
|
||||
}
|
||||
120
internal/service/relay.go
Normal file
120
internal/service/relay.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"mkiso-server/internal/config"
|
||||
"mkiso-server/internal/repo"
|
||||
"mkiso-server/pkg/dicom"
|
||||
)
|
||||
|
||||
// RelayService handles DICOM relay (print) to the CD Publisher.
|
||||
type RelayService struct {
|
||||
cfg *config.Config
|
||||
dicomSvc *DicomService
|
||||
patientRepo *repo.PatientRepo
|
||||
}
|
||||
|
||||
// NewRelayService creates a new RelayService.
|
||||
func NewRelayService(cfg *config.Config, dicomSvc *DicomService, patientRepo *repo.PatientRepo) *RelayService {
|
||||
return &RelayService{
|
||||
cfg: cfg,
|
||||
dicomSvc: dicomSvc,
|
||||
patientRepo: patientRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// Destination returns the CD Publisher address as "host:port".
|
||||
func (s *RelayService) Destination() string {
|
||||
return fmt.Sprintf("%s:%d", s.cfg.CDPublisher.Host, s.cfg.CDPublisher.Port)
|
||||
}
|
||||
|
||||
// RelayResult describes the outcome of a DICOM relay operation.
|
||||
type RelayResult struct {
|
||||
AccessionsSent []string `json:"accessions_sent"`
|
||||
PatientName string `json:"patient_name"`
|
||||
Destination string `json:"destination"`
|
||||
FilesSent int `json:"files_sent"`
|
||||
}
|
||||
|
||||
// RelayToCDPublisher fetches DICOM studies from PACS and forwards them
|
||||
// to the CD Publisher via storescu (C-STORE).
|
||||
func (s *RelayService) RelayToCDPublisher(ctx context.Context, accessionNumbers []string) (*RelayResult, error) {
|
||||
// Step 1: Try to get patient data from API (graceful if unavailable)
|
||||
var patientName string
|
||||
patient, err := s.patientRepo.ByAccessionNumber(ctx, accessionNumbers[0])
|
||||
if err != nil {
|
||||
slog.Warn("patient API lookup failed for relay",
|
||||
"accession", accessionNumbers[0],
|
||||
"error", err,
|
||||
)
|
||||
} else if patient != nil {
|
||||
patientName = patient.PatientName
|
||||
slog.Info("patient data retrieved for relay",
|
||||
"accession", accessionNumbers[0],
|
||||
"patient", patientName,
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Create temp dir
|
||||
tempDir, err := os.MkdirTemp(s.cfg.ISO.TempDir, "dicomdir_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir) // guaranteed cleanup
|
||||
|
||||
dicomDir := filepath.Join(tempDir, "DICOMDIR")
|
||||
if err := os.MkdirAll(dicomDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("create DICOMDIR: %w", err)
|
||||
}
|
||||
|
||||
// Copy microdicom viewer files (optional — CD Publisher doesn't need it,
|
||||
// but matches PHP behavior of copying before fetch)
|
||||
if dicom.DirExists(s.cfg.ISO.MicrodicomPath) {
|
||||
if err := dicom.CopyDir(s.cfg.ISO.MicrodicomPath, tempDir); err != nil {
|
||||
slog.Warn("copy microdicom for relay failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Fetch DICOM from PACS
|
||||
filesRetrieved, err := s.dicomSvc.FetchDICOMMultiple(ctx, accessionNumbers, dicomDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("DICOM fetch failed: %w", err)
|
||||
}
|
||||
slog.Info("DICOM fetched for relay",
|
||||
"accessions", accessionNumbers,
|
||||
"files", filesRetrieved,
|
||||
)
|
||||
|
||||
// Step 4: Relay to CD Publisher via storescu
|
||||
destination := fmt.Sprintf("%s:%d", s.cfg.CDPublisher.Host, s.cfg.CDPublisher.Port)
|
||||
exitCode, stdout, stderr, err := dicom.RunStoreSCU(
|
||||
ctx,
|
||||
s.cfg.DCMTK.Storescu,
|
||||
s.cfg.OurAE.AETitle,
|
||||
s.cfg.CDPublisher.Host,
|
||||
s.cfg.CDPublisher.Port,
|
||||
dicomDir,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storescu relay failed (exit %d): %w (stderr: %s)", exitCode, err, stderr)
|
||||
}
|
||||
_ = stdout
|
||||
|
||||
slog.Info("DICOM relayed to CD Publisher",
|
||||
"accessions", accessionNumbers,
|
||||
"destination", destination,
|
||||
"files", filesRetrieved,
|
||||
)
|
||||
|
||||
return &RelayResult{
|
||||
AccessionsSent: accessionNumbers,
|
||||
PatientName: patientName,
|
||||
Destination: destination,
|
||||
FilesSent: filesRetrieved,
|
||||
}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user