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" }