changeset 6633:a0bbb4d460b8 limited-memory

integration mainline->limited-memory
author Sebastien Jodogne <s.jodogne@gmail.com>
date Fri, 20 Mar 2026 15:37:04 +0100
parents b7243c4efe18 (current diff) b8e78ccac532 (diff)
children 2c137c796946
files NEWS OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake OrthancFramework/Sources/Compression/ZipReader.cpp OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.cpp OrthancServer/Sources/main.cpp
diffstat 26 files changed, 594 insertions(+), 96 deletions(-) [+]
line wrap: on
line diff
--- a/NEWS	Thu Mar 05 15:40:24 2026 +0100
+++ b/NEWS	Fri Mar 20 15:37:04 2026 +0100
@@ -6,6 +6,7 @@
 
 * Experimental WIP: try to limit the memory used by the HTTP server when multiple HTTP clients upload 
   large DICOM files at the same time.  Right now, the limit is hardcoded to 8GB.  Check the TODO-MEM in the code.
+* New experimental configuration "PatientLevelEnabled" (TODO: work in progree)
 
 REST API
 --------
@@ -23,6 +24,12 @@
 * New option "Utf8" available in the "{...}/archive" and "/tools/create-archive" routes
   to use UTF-8 filenames in the generated ZIP archives. It defaults to the value
   of the new configuration option "ZipUseUtf8".
+* Support for OD, OF, and OL value representations in "/instances/{...}/file" with content type
+  "application/dicom+json" (i.e. DICOMweb) and in "/instances/{...}/tags" (contribution by
+  Yusuf Sayıta, Philips).
+* New "Content.Resources" field in "DicomModalityStore", "OrthancPeerStore" and "ResourceModification" 
+  jobs that contains JSON objects with "ID" and "Type" of each resource.  The "Content.ParentResources"
+  field that only contains the IDs is preserved for backward compatibility.
 
 Lua
 ---
@@ -50,6 +57,7 @@
   https://orthanc.uclouvain.be/bugs/show_bug.cgi?id=255
 * Save the jobs registry in DB only if it has changed.
   https://discourse.orthanc-server.org/t/frequent-idle-messages-between-postgres-and-orthanc/6406
+* New CMake option: "USE_SYSTEM_MINIZIP" to use the system-wide version of minizip
 * Upgraded dependencies for static builds:
   - boost 1.89.0
   - dcmtk 3.7.0
@@ -1734,7 +1742,7 @@
 * API version has been upgraded to 3
 * "/modalities/{id}/query": New argument "Normalize" can be set to "false"
   to bypass the automated correction of outgoing C-FIND queries
-* Reporting of "ParentResources" in "DicomModalityStore" and "DicomModalityStore" jobs
+* Reporting of "ParentResources" in "DicomModalityStore", "OrthancPeerStore" and "ResourceModification" jobs
 
 Plugins
 -------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/OrthancFramework/Resources/CMake/MinizipConfiguration.cmake	Fri Mar 20 15:37:04 2026 +0100
@@ -0,0 +1,53 @@
+# Orthanc - A Lightweight, RESTful DICOM Store
+# Copyright (C) 2012-2016 Sebastien Jodogne, Medical Physics
+# Department, University Hospital of Liege, Belgium
+# Copyright (C) 2017-2023 Osimis S.A., Belgium
+# Copyright (C) 2024-2026 Orthanc Team SRL, Belgium
+# Copyright (C) 2021-2026 Sebastien Jodogne, ICTEAM UCLouvain, Belgium
+#
+# This program is free software: you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# as published by the Free Software Foundation, either version 3 of
+# the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with this program. If not, see
+# <http://www.gnu.org/licenses/>.
+
+
+if (NOT ENABLE_ZLIB)
+  message(FATAL_ERROR "This file cannot be used if zlib is disabled")
+endif()
+
+if (STATIC_BUILD OR
+    ORTHANC_SANDBOXED OR   # For WebAssembly
+    NOT USE_SYSTEM_MINIZIP)
+  add_definitions(-DORTHANC_USE_SYSTEM_MINIZIP=0)
+
+  list(APPEND ORTHANC_CORE_SOURCES_DEPENDENCIES
+    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/ioapi.c
+    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/unzip.c
+    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/zip.c
+    )
+
+else()
+  add_definitions(-DORTHANC_USE_SYSTEM_MINIZIP=1)
+
+  CHECK_INCLUDE_FILE_CXX(minizip/zip.h HAVE_MINIZIP_H)
+  if (NOT HAVE_MINIZIP_H)
+    message(FATAL_ERROR "Please install the libminizip-dev package")
+  endif()
+
+  CHECK_LIBRARY_EXISTS(minizip "zipOpen2_64" "" HAVE_MINIZIP_LIB)
+  if (NOT HAVE_MINIZIP_LIB)
+    message(FATAL_ERROR "Please install the libminizip-dev package")
+  endif()
+
+  link_libraries(minizip)
+
+endif()
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkConfiguration.cmake	Fri Mar 20 15:37:04 2026 +0100
@@ -753,13 +753,9 @@
   ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/base64/base64.cpp
   )
 
-if (ENABLE_ZLIB AND NOT ORTHANC_SANDBOXED)
-  list(APPEND ORTHANC_CORE_SOURCES_DEPENDENCIES
-    # This is the minizip distribution to create/decode ZIP files using zlib
-    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/ioapi.c
-    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/unzip.c
-    ${CMAKE_CURRENT_LIST_DIR}/../../Resources/ThirdParty/minizip/zip.c
-    )
+if (ENABLE_ZLIB)
+  # This is the minizip distribution to create/decode ZIP files using zlib
+  include(${CMAKE_CURRENT_LIST_DIR}/MinizipConfiguration.cmake)
 endif()
 
 
--- a/OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Resources/CMake/OrthancFrameworkParameters.cmake	Fri Mar 20 15:37:04 2026 +0100
@@ -69,6 +69,7 @@
 set(USE_SYSTEM_LIBP11 OFF CACHE BOOL "Use the system version of libp11 (PKCS#11 wrapper library)")
 set(USE_SYSTEM_LIBPNG ON CACHE BOOL "Use the system version of libpng")
 set(USE_SYSTEM_LUA ON CACHE BOOL "Use the system version of Lua")
+set(USE_SYSTEM_MINIZIP OFF CACHE BOOL "Use the system version minizip (new in Orthanc 1.12.11)")
 set(USE_SYSTEM_MONGOOSE ON CACHE BOOL "Use the system version of Mongoose")
 set(USE_SYSTEM_OPENSSL ON CACHE BOOL "Use the system version of OpenSSL")
 set(USE_SYSTEM_PROTOBUF ON CACHE BOOL "Use the system version of Google Protocol Buffers")
--- a/OrthancFramework/Sources/Compression/GzipCompressor.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/Compression/GzipCompressor.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -47,7 +47,7 @@
      *
      * There is an unreliable way to determine the uncompressed size,
      * which is to look at the last four bytes of the gzip file, which
-     * is the uncompressed length of that entry modulo 232 in little
+     * is the uncompressed length of that entry modulo 2^32 in little
      * endian order.
      * 
      * It is unreliable because a) the uncompressed data may be longer
--- a/OrthancFramework/Sources/Compression/ZipReader.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/Compression/ZipReader.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -28,10 +28,20 @@
 #define NOMINMAX
 #endif
 
+#if !defined(ORTHANC_USE_SYSTEM_MINIZIP)
+#  error The macro ORTHANC_USE_SYSTEM_MINIZIP must be defined
+#endif
+
 #include "ZipReader.h"
 
+#if ORTHANC_USE_SYSTEM_MINIZIP == 1
+#  include <minizip/unzip.h>
+#else
+#  include "../../Resources/ThirdParty/minizip/unzip.h"
+#endif
+
+
 #include "../OrthancException.h"
-#include "../../Resources/ThirdParty/minizip/unzip.h"
 
 #if ORTHANC_SANDBOXED != 1
 #  include "../SystemToolbox.h"
--- a/OrthancFramework/Sources/Compression/ZipWriter.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/Compression/ZipWriter.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -28,13 +28,23 @@
 #define NOMINMAX
 #endif
 
+#if !defined(ORTHANC_USE_SYSTEM_MINIZIP)
+#  error The macro ORTHANC_USE_SYSTEM_MINIZIP must be defined
+#endif
+
 #include "ZipWriter.h"
 
+
 #include <limits>
 #include <boost/filesystem.hpp>
 #include <boost/date_time/posix_time/posix_time.hpp>
 
-#include "../../Resources/ThirdParty/minizip/zip.h"
+#if ORTHANC_USE_SYSTEM_MINIZIP == 1
+#  include <minizip/zip.h>
+#else
+#  include "../../Resources/ThirdParty/minizip/zip.h"
+#endif
+
 #include "../Logging.h"
 #include "../OrthancException.h"
 #include "../SystemToolbox.h"
@@ -586,6 +596,21 @@
     }
   }
 
+
+  void ZipWriter::SetAllowUtf8(bool allowUtf8)
+  {
+    allowUtf8_ = allowUtf8;
+
+#if ORTHANC_USE_SYSTEM_MINIZIP == 1
+    if (allowUtf8_)
+    {
+      LOG(WARNING) << "UTF-8 paths requested in ZIP file, but Orthanc is linked against "
+                   << "the system-wide minizip library, which may not support this feature";
+    }
+#endif
+  }
+
+
   void ZipWriter::SetCompressionLevel(uint8_t level)
   {
     if (level >= 10)
--- a/OrthancFramework/Sources/Compression/ZipWriter.h	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/Compression/ZipWriter.h	Fri Mar 20 15:37:04 2026 +0100
@@ -154,13 +154,13 @@
      * The behavior of Orthanc <= 1.12.10 corresponds to
      * "SetAllowUtf8(false)".
      *
+     * Note that if linking against the system-wide version of minizip,
+     * there is no way to check that its version is above 1.3.2.
+     *
      * https://zlib.net/ChangeLog.txt
      * https://discourse.orthanc-server.org/t/seriesdescription-characters-and-removed-during-oe2-zip-export/6397
      **/
-    void SetAllowUtf8(bool allowUtf8)
-    {
-      allowUtf8_ = allowUtf8;
-    }
+    void SetAllowUtf8(bool allowUtf8);
 
     bool IsAllowUtf8() const
     {
--- a/OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/DicomParsing/FromDcmtkBridge.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -803,7 +803,6 @@
          **/
 
         case EVR_OB:  // other byte
-        case EVR_OF:  // other float
         case EVR_OW:  // other word
         case EVR_UN:  // unknown value representation
         case EVR_ox:  // OB or OW depending on context
@@ -876,6 +875,107 @@
           return ApplyDcmtkToCTypeConverter<DcmtkToFloat64Converter>(element);
         }
 
+        case EVR_OF:  // other float - binary array of 32-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OF stores a binary array of 32-bit IEEE floats. Unlike FL where getVM()
+           * returns the count of values, for OF getVM() returns 1 (the entire binary
+           * blob is considered one "value"). We must use getFloat32Array() to access
+           * the raw float buffer, then build a string of all values.
+           * The resulting string is formatted as in ApplyDcmtkToCTypeConverter().
+           **/
+          DcmFloatingPointSingle& content = dynamic_cast<DcmFloatingPointSingle&>(element);
+          Float32* floatArray = NULL;
+          if (content.getFloat32Array(floatArray).good() && floatArray != NULL)
+          {
+            if (element.getLength() % sizeof(Float32) != 0)
+            {
+              throw OrthancException(ErrorCode_BadFileFormat);
+            }
+
+            const unsigned long numFloats = element.getLength() / sizeof(Float32);
+            std::string result;
+            for (unsigned long i = 0; i < numFloats; i++)
+            {
+              if (i > 0)
+              {
+                result += "\\";
+              }
+              result += boost::lexical_cast<std::string>(floatArray[i]);
+            }
+            return new DicomValue(result, false);
+          }
+          return new DicomValue;
+        }
+
+#if DCMTK_VERSION_NUMBER >= 361
+        case EVR_OD:  // other double - binary array of 64-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OD stores a binary array of 64-bit IEEE doubles. Similar to OF,
+           * getVM() returns 1 for OD. We must use getFloat64Array() to access
+           * the raw double buffer.
+           * The resulting string is formatted as in ApplyDcmtkToCTypeConverter().
+           **/
+          DcmFloatingPointDouble& content = dynamic_cast<DcmFloatingPointDouble&>(element);
+          Float64* doubleArray = NULL;
+          if (content.getFloat64Array(doubleArray).good() && doubleArray != NULL)
+          {
+            if (element.getLength() % sizeof(Float64) != 0)
+            {
+              throw OrthancException(ErrorCode_BadFileFormat);
+            }
+
+            const unsigned long numDoubles = element.getLength() / sizeof(Float64);
+            std::string result;
+            for (unsigned long i = 0; i < numDoubles; i++)
+            {
+              if (i > 0)
+              {
+                result += "\\";
+              }
+              result += boost::lexical_cast<std::string>(doubleArray[i]);
+            }
+            return new DicomValue(result, false);
+          }
+          return new DicomValue;
+        }
+#endif
+
+#if DCMTK_VERSION_NUMBER >= 362
+        case EVR_OL:  // other long - binary array of 32-bit unsigned integers (new in Orthanc 1.12.11)
+        {
+          /**
+           * OL stores a binary array of 32-bit unsigned integers. Like OF/OD,
+           * getVM() returns 1 for OL (the entire binary blob is one "value").
+           * We must use getUint32Array() to access the raw buffer.
+           * The resulting string is formatted as in ApplyDcmtkToCTypeConverter().
+           **/
+          DcmUnsignedLong& content = dynamic_cast<DcmUnsignedLong&>(element);
+          Uint32* uint32Array = NULL;
+          if (content.getUint32Array(uint32Array).good() && uint32Array != NULL)
+          {
+            if (element.getLength() % sizeof(Uint32) != 0)
+            {
+              throw OrthancException(ErrorCode_BadFileFormat);
+            }
+
+            const unsigned long numValues = element.getLength() / sizeof(Uint32);
+            std::string result;
+            for (unsigned long i = 0; i < numValues; i++)
+            {
+              if (i > 0)
+              {
+                result += "\\";
+              }
+              result += boost::lexical_cast<std::string>(uint32Array[i]);
+            }
+            return new DicomValue(result, false);
+          }
+          return new DicomValue;
+        }
+#endif
+
 
         /**
          * Attribute tag.
@@ -3001,9 +3101,6 @@
         }
 
         case EVR_UL:  // unsigned long
-#if DCMTK_VERSION_NUMBER >= 362
-        case EVR_OL:
-#endif
         {
           DcmUnsignedLong& content = dynamic_cast<DcmUnsignedLong&>(element);
 
@@ -3044,7 +3141,6 @@
         }
 
         case EVR_FL:  // float single-precision
-        case EVR_OF:
         {
           DcmFloatingPointSingle& content = dynamic_cast<DcmFloatingPointSingle&>(element);
 
@@ -3065,9 +3161,6 @@
         }
 
         case EVR_FD:  // float double-precision
-#if DCMTK_VERSION_NUMBER >= 361
-        case EVR_OD:
-#endif
         {
           DcmFloatingPointDouble& content = dynamic_cast<DcmFloatingPointDouble&>(element);
 
@@ -3087,6 +3180,109 @@
           break;
         }
 
+        case EVR_OF:  // other float - binary array of 32-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OF stores a binary array of 32-bit IEEE floats. Unlike FL where getVM()
+           * returns the count of values, for OF getVM() returns 1 (the entire binary
+           * blob is considered one "value"). We must use getFloat32Array() to access
+           * the raw float buffer, then iterate over all values.
+           **/
+          DcmFloatingPointSingle& content = dynamic_cast<DcmFloatingPointSingle&>(element);
+
+          std::vector<double> values;
+
+          Float32* floatArray = NULL;
+          if (content.getFloat32Array(floatArray).good() && floatArray != NULL)
+          {
+            if (element.getLength() % sizeof(Float32) != 0)
+            {
+              throw OrthancException(ErrorCode_BadFileFormat);
+            }
+
+            const unsigned long numFloats = static_cast<unsigned long>(element.getLength() / sizeof(Float32));
+            values.reserve(numFloats);
+
+            for (unsigned long i = 0; i < numFloats; i++)
+            {
+              values.push_back(static_cast<double>(floatArray[i]));
+            }
+          }
+
+          action = visitor.VisitDoubles(parentTags, parentIndexes, tag, vr, values);
+          break;
+        }
+
+#if DCMTK_VERSION_NUMBER >= 361
+        case EVR_OD:  // other double - binary array of 64-bit floats (new in Orthanc 1.12.11)
+        {
+          /**
+           * OD stores a binary array of 64-bit IEEE doubles. Unlike FD where getVM()
+           * returns the count of values, for OD getVM() returns 1 (the entire binary
+           * blob is considered one "value"). We must use getFloat64Array() to access
+           * the raw double buffer, then iterate over all values.
+           **/
+          DcmFloatingPointDouble& content = dynamic_cast<DcmFloatingPointDouble&>(element);
+
+          std::vector<double> values;
+
+          Float64* doubleArray = NULL;
+          if (content.getFloat64Array(doubleArray).good() && doubleArray != NULL)
+          {
+            if (element.getLength() % sizeof(Float64) != 0)
+            {
+              throw OrthancException(ErrorCode_BadFileFormat);
+            }
+
+            const unsigned long numDoubles = static_cast<unsigned long>(element.getLength() / sizeof(Float64));
+            values.reserve(numDoubles);
+
+            for (unsigned long i = 0; i < numDoubles; i++)
+            {
+              values.push_back(doubleArray[i]);
+            }
+          }
+
+          action = visitor.VisitDoubles(parentTags, parentIndexes, tag, vr, values);
+          break;
+        }
+#endif
+
+#if DCMTK_VERSION_NUMBER >= 362
+        case EVR_OL:  // other long - binary array of 32-bit unsigned integers (new in Orthanc 1.12.11)
+        {
+          /**
+           * OL stores a binary array of 32-bit unsigned integers. Like OF/OD,
+           * getVM() returns 1 for OL (the entire binary blob is one "value").
+           * We must use getUint32Array() to access the raw buffer, then iterate
+           * over all values based on element length.
+           **/
+          DcmUnsignedLong& content = dynamic_cast<DcmUnsignedLong&>(element);
+
+          std::vector<int64_t> values;
+
+          Uint32* uint32Array = NULL;
+          if (content.getUint32Array(uint32Array).good() && uint32Array != NULL)
+          {
+            if (element.getLength() % sizeof(Uint32) != 0)
+            {
+              throw OrthancException(ErrorCode_BadFileFormat);
+            }
+
+            const unsigned long numValues = static_cast<unsigned long>(element.getLength() / sizeof(Uint32));
+            values.reserve(numValues);
+
+            for (unsigned long i = 0; i < numValues; i++)
+            {
+              values.push_back(static_cast<int64_t>(uint32Array[i]));
+            }
+          }
+
+          action = visitor.VisitIntegers(parentTags, parentIndexes, tag, vr, values);
+          break;
+        }
+#endif
+
 
         /**
          * Attribute tag.
--- a/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -27,6 +27,7 @@
 
 #include "../OrthancException.h"
 #include "../SerializationToolbox.h"
+#include "../Logging.h"
 
 #include <cassert>
 
@@ -130,12 +131,11 @@
   }
 
 
-  void SetOfInstancesJob::AddParentResource(const std::string &resource)
+  void SetOfInstancesJob::AddParentResource(const std::string &resource, ResourceType level)
   {
-    parentResources_.insert(resource);
+    parentResources_[resource] = level;
   }
 
-
   void SetOfInstancesJob::AddInstance(const std::string& instance)
   {
     AddCommand(new InstanceCommand(*this, instance));
@@ -201,28 +201,46 @@
 
   static const char* KEY_TRAILING_STEP = "TrailingStep";
   static const char* KEY_FAILED_INSTANCES = "FailedInstances";
-  static const char* KEY_PARENT_RESOURCES = "ParentResources";
+  static const char* KEY_PARENT_RESOURCES = "ParentResources"; // old style but we keep it for backward compatibility
+  static const char* KEY_RESOURCES = "Resources"; // new style with the Resource type
+
+
+  static void SerializeResources(Json::Value& target, const std::map<std::string, ResourceType>& parentResources, bool includeParentResourcesField)
+  {
+    if (!parentResources.empty())
+    {
+      target[KEY_RESOURCES] = Json::arrayValue;
+      SerializationToolbox::WriteMapOfResourcesAndTypes(target[KEY_RESOURCES], parentResources);
+
+      if (includeParentResourcesField)
+      {
+        std::set<std::string> keys;
+        for (std::map<std::string, ResourceType>::const_iterator it = parentResources.begin(); it != parentResources.end(); ++it) 
+        {
+          keys.insert(it->first);
+        }
+
+        SerializationToolbox::WriteSetOfStrings(target, keys, KEY_PARENT_RESOURCES);
+      }
+    }
+  }
 
   void SetOfInstancesJob::GetPublicContent(Json::Value& target) const
   {
     SetOfCommandsJob::GetPublicContent(target);
     target["InstancesCount"] = static_cast<uint32_t>(GetInstancesCount());
     target["FailedInstancesCount"] = static_cast<uint32_t>(failedInstances_.size());
-
-    if (!parentResources_.empty())
-    {
-      SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
-    }
+    
+    SerializeResources(target, parentResources_, true);
   }
 
-
   bool SetOfInstancesJob::Serialize(Json::Value& target) const 
   {
     if (SetOfCommandsJob::Serialize(target))
     {
       target[KEY_TRAILING_STEP] = hasTrailingStep_;
       SerializationToolbox::WriteSetOfStrings(target, failedInstances_, KEY_FAILED_INSTANCES);
-      SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
+      SerializeResources(target, parentResources_, false);
       return true;
     }
     else
@@ -237,10 +255,13 @@
   {
     SerializationToolbox::ReadSetOfStrings(failedInstances_, source, KEY_FAILED_INSTANCES);
 
-    if (source.isMember(KEY_PARENT_RESOURCES))
+    if (source.isMember(KEY_PARENT_RESOURCES) && !source.isMember(KEY_RESOURCES))
     {
-      // Backward compatibility with Orthanc <= 1.5.6
-      SerializationToolbox::ReadSetOfStrings(parentResources_, source, KEY_PARENT_RESOURCES);
+      LOG(ERROR) << "Unable to read the " << KEY_PARENT_RESOURCES << " of a job that has been saved with the previous version of Orthanc";
+    }
+    else if (source.isMember(KEY_RESOURCES) && source[KEY_RESOURCES].isArray())
+    {
+      SerializationToolbox::ReadMapOfResourcesAndTypes(parentResources_, source, KEY_RESOURCES);
     }
     
     if (source.isMember(KEY_TRAILING_STEP))
--- a/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.h	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/JobsEngine/SetOfInstancesJob.h	Fri Mar 20 15:37:04 2026 +0100
@@ -28,6 +28,7 @@
 #include "SetOfCommandsJob.h"
 
 #include <set>
+#include <map>
 
 namespace Orthanc
 {
@@ -40,7 +41,7 @@
     
     bool                   hasTrailingStep_;
     std::set<std::string>  failedInstances_;
-    std::set<std::string>  parentResources_;
+    std::map<std::string, ResourceType> parentResources_;
 
   protected:
     virtual bool HandleInstance(const std::string& instance) = 0;
@@ -57,7 +58,7 @@
 
     // Only used for reporting in the public content
     // https://groups.google.com/d/msg/orthanc-users/9GCV88GLEzw/6wAgP_PRAgAJ
-    void AddParentResource(const std::string& resource);
+    void AddParentResource(const std::string& resource, ResourceType level);
     
     void AddInstance(const std::string& instance);
 
--- a/OrthancFramework/Sources/SerializationToolbox.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/SerializationToolbox.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -373,6 +373,35 @@
     }
   }
 
+  void SerializationToolbox::ReadMapOfResourcesAndTypes(std::map<std::string, ResourceType>& target,
+                                                        const Json::Value& value,
+                                                        const std::string& field)
+  {
+    if (!value[field].isArray())
+    {
+      throw OrthancException(ErrorCode_BadFileFormat, "Array expected in field: " + field);
+    }
+
+    target.clear();
+    
+    for (Json::ArrayIndex i = 0; i < value[field].size(); ++i)
+    {
+      target[value[field][i]["ID"].asString()] = StringToResourceType(value[field][i]["Type"].asString().c_str());
+    }
+  }
+
+  void SerializationToolbox::WriteMapOfResourcesAndTypes(Json::Value& targetArray,
+                                                         const std::map<std::string, ResourceType>& values)
+  {
+    for (std::map<std::string, ResourceType>::const_iterator it = values.begin(); it != values.end(); ++it) 
+    {
+      Json::Value resource;
+      resource["ID"] = it->first;
+      resource["Type"] = EnumerationToString(it->second);
+      targetArray.append(resource);
+    }
+  }
+
 
   void SerializationToolbox::WriteArrayOfStrings(Json::Value& target,
                                                  const std::vector<std::string>& values,
--- a/OrthancFramework/Sources/SerializationToolbox.h	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancFramework/Sources/SerializationToolbox.h	Fri Mar 20 15:37:04 2026 +0100
@@ -90,6 +90,10 @@
                               const Json::Value& value,
                               const std::string& field);
 
+    static void ReadMapOfResourcesAndTypes(std::map<std::string, ResourceType>& target,
+                                           const Json::Value& value,
+                                           const std::string& field);
+
     static void WriteArrayOfStrings(Json::Value& target,
                                     const std::vector<std::string>& values,
                                     const std::string& field);
@@ -105,6 +109,9 @@
     static void WriteSetOfStrings(Json::Value& targetArray,
                                   const std::set<std::string>& values);
 
+    static void WriteMapOfResourcesAndTypes(Json::Value& targetArray,
+                                            const std::map<std::string, ResourceType>& values);
+
     static void WriteSetOfTags(Json::Value& target,
                                const std::set<DicomTag>& tags,
                                const std::string& field);
--- a/OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.h	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Plugins/Samples/Common/OrthancPluginCppWrapper.h	Fri Mar 20 15:37:04 2026 +0100
@@ -1031,6 +1031,13 @@
                                  value, OrthancPluginMetricsType_Default);
   }
 
+  inline void SetMetricsValue(const char* name,
+                              int64_t value)
+  {
+    OrthancPluginSetMetricsIntegerValue(GetGlobalContext(), name,
+                                        value, OrthancPluginMetricsType_Default);
+  }
+
   class MetricsTimer : public boost::noncopyable
   {
   private:
--- a/OrthancServer/Resources/Configuration.json	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Resources/Configuration.json	Fri Mar 20 15:37:04 2026 +0100
@@ -1106,5 +1106,14 @@
   // Orthanc <= 1.12.10. This default value can be overwritten per archive,
   // by providing the "Utf8" field to the "{...}/archive" and
   // "/tools/create-archives" routes. (new in Orthanc 1.12.11)
-  "ZipUseUtf8" : false
+  "ZipUseUtf8" : false,
+
+  // When set to false, this option disables all /patients routes and
+  // disables patients related sanity checks when performing resource
+  // modification.  This is required e.g when your Orthanc stores
+  // resources from multiple sources in which the same PatientID might
+  // be attached to different patients because there is no centralized
+  // patient id management. (new in Orthanc 1.12.11)
+  // As of 1.12.11, this is an experimental configuration.
+  "PatientLevelEnabled": true
 }
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestAnonymizeModify.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -433,10 +433,13 @@
            it = resources.begin(); it != resources.end(); ++it)
     {
       std::list<std::string> instances;
-      context.GetIndex().GetChildInstances(instances, *it);
+      ResourceType level;
+      context.GetIndex().LookupResourceType(level, *it);
+
+      context.GetIndex().GetChildInstances(instances, *it, level);
       job->AddInstances(instances);
 
-      job->AddParentResource(*it);
+      job->AddParentResource(*it, level);
     }
 
     job->PerformSanityChecks();
@@ -1306,18 +1309,22 @@
     Register("/instances/{id}/modify", ModifyInstance);
     Register("/series/{id}/modify", ModifyResource<ResourceType_Series>);
     Register("/studies/{id}/modify", ModifyResource<ResourceType_Study>);
-    Register("/patients/{id}/modify", ModifyResource<ResourceType_Patient>);
     Register("/tools/bulk-modify", BulkModify);
 
     Register("/instances/{id}/anonymize", AnonymizeInstance);
     Register("/series/{id}/anonymize", AnonymizeResource<ResourceType_Series>);
     Register("/studies/{id}/anonymize", AnonymizeResource<ResourceType_Study>);
-    Register("/patients/{id}/anonymize", AnonymizeResource<ResourceType_Patient>);
     Register("/tools/bulk-anonymize", BulkAnonymize);
 
     Register("/tools/create-dicom", CreateDicom);
 
     Register("/studies/{id}/split", SplitStudy);
     Register("/studies/{id}/merge", MergeStudy);
+
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/modify", ModifyResource<ResourceType_Patient>);
+      Register("/patients/{id}/anonymize", AnonymizeResource<ResourceType_Patient>);
+    }
   }
 }
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestArchive.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestArchive.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -862,10 +862,14 @@
     
   void OrthancRestApi::RegisterArchive()
   {
-    Register("/patients/{id}/archive", CreateSingleGet<ResourceType_Patient, false /* ZIP */>);
-    Register("/patients/{id}/archive", CreateSinglePost<ResourceType_Patient, false /* ZIP */>);
-    Register("/patients/{id}/media",   CreateSingleGet<ResourceType_Patient, true /* media */>);
-    Register("/patients/{id}/media",   CreateSinglePost<ResourceType_Patient, true /* media */>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/archive", CreateSingleGet<ResourceType_Patient, false /* ZIP */>);
+      Register("/patients/{id}/archive", CreateSinglePost<ResourceType_Patient, false /* ZIP */>);
+      Register("/patients/{id}/media",   CreateSingleGet<ResourceType_Patient, true /* media */>);
+      Register("/patients/{id}/media",   CreateSinglePost<ResourceType_Patient, true /* media */>);
+    }
+
     Register("/series/{id}/archive",   CreateSingleGet<ResourceType_Series, false /* ZIP */>);
     Register("/series/{id}/archive",   CreateSinglePost<ResourceType_Series, false /* ZIP */>);
     Register("/series/{id}/media",     CreateSingleGet<ResourceType_Series, true /* media */>);
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestModalities.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -227,7 +227,7 @@
         .SetDescription("Trigger C-ECHO SCU command against the DICOM modality whose identifier is provided in URL: "
                         "https://orthanc.uclouvain.be/book/users/rest.html#performing-c-echo")
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-ECHO command, in seconds", false)
+                         "Timeout for the C-ECHO command, in seconds.  Orthanc will close the DICOM association if no C-ECHO answer is received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
@@ -684,7 +684,7 @@
                          "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                          "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-FIND command and subsequent C-MOVE retrievals, in seconds (new in Orthanc 1.9.1)", false)
+                         "Timeout for the C-FIND command and subsequent C-GET/C-MOVE retrievals, in seconds (new in Orthanc 1.9.1).  Orthanc will close the related DICOM associations if no DICOM messages are received within this time period.", false)
         .SetAnswerField("ID", RestApiCallDocumentation::Type_String,
                         "Identifier of the query, to be used with `/queries/{id}`")
         .SetAnswerField("Path", RestApiCallDocumentation::Type_String,
@@ -1032,7 +1032,7 @@
                        "AET of the target modality. By default, the AET of Orthanc is used, as defined in the "
                        "`DicomAet` configuration option.", false)
       .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                       "Timeout for the C-MOVE command, in seconds", false)
+                       "Timeout for the C-MOVE command, in seconds.  Orthanc will close the DICOM association if no DICOM messages (e.g., C-STORE sub-operations or responses) are received within this time period.", false)
       .SetRequestField(KEY_RETRIEVE_METHOD, RestApiCallDocumentation::Type_String,
                         "Force usage of C-MOVE or C-GET to retrieve the resource.  If note defined in the payload, "
                         "the retrieve method is defined in the DicomDefaultRetrieveMethod configuration or in "
@@ -1446,9 +1446,12 @@
                                "This string is not a valid Orthanc identifier: " + stripped);
       }
 
-      job.AddParentResource(stripped);  // New in Orthanc 1.5.7
+      ResourceType level;
+      context.GetIndex().LookupResourceType(level, stripped);
+
+      job.AddParentResource(stripped, level);  // New in Orthanc 1.5.7
       
-      context.AddChildInstances(job, stripped);
+      context.AddChildInstances(job, stripped, level);
 
       if (logExportedResources)
       {
@@ -1496,7 +1499,7 @@
                          "Whether to chain C-STORE with DICOM storage commitment to validate the success of the transmission: "
                          "https://orthanc.uclouvain.be/book/users/storage-commitment.html#chaining-c-store-with-storage-commitment", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-STORE command, in seconds", false)
+                         "Timeout for the C-STORE command, in seconds.  Orthanc will close the DICOM association if no DICOM messages (e.g., C-STORE responses) are received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
@@ -1655,7 +1658,7 @@
                          "Target AET that will be used by the remote DICOM modality as a target for its C-STORE SCU "
                          "commands, defaults to `DicomAet` configuration option in order to do a simple query/retrieve", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-MOVE command, in seconds", false)
+                         "Timeout for the C-MOVE command, in seconds.  Orthanc will close the DICOM association if no DICOM messages (e.g., C-STORE sub-operations or responses) are received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
@@ -1705,7 +1708,7 @@
                          "Local AET that is used for this commands, defaults to `DicomAet` configuration option. "
                          "Ignored if `DicomModalities` already sets `LocalAet` for this modality.", false)
         .SetRequestField(KEY_TIMEOUT, RestApiCallDocumentation::Type_Number,
-                         "Timeout for the C-GET command, in seconds", false)
+                         "Timeout for the C-GET command, in seconds.  Orthanc will close the DICOM association if no DICOM messages are received within this time period.", false)
         .SetUriArgument("id", "Identifier of the modality of interest");
       return;
     }
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestResources.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -4283,14 +4283,20 @@
   void OrthancRestApi::RegisterResources()
   {
     Register("/instances", ListResources<ResourceType_Instance>);
-    Register("/patients", ListResources<ResourceType_Patient>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients", ListResources<ResourceType_Patient>);
+    }
     Register("/series", ListResources<ResourceType_Series>);
     Register("/studies", ListResources<ResourceType_Study>);
 
     if (!context_.IsReadOnly())
     {
       Register("/instances/{id}", DeleteSingleResource<ResourceType_Instance>);
-      Register("/patients/{id}", DeleteSingleResource<ResourceType_Patient>);
+      if (context_.IsPatientLevelEnabled())
+      {
+        Register("/patients/{id}", DeleteSingleResource<ResourceType_Patient>);
+      }
       Register("/series/{id}", DeleteSingleResource<ResourceType_Series>);
       Register("/studies/{id}", DeleteSingleResource<ResourceType_Study>);
 
@@ -4302,21 +4308,33 @@
     }
 
     Register("/instances/{id}", GetSingleResource<ResourceType_Instance>);
-    Register("/patients/{id}", GetSingleResource<ResourceType_Patient>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}", GetSingleResource<ResourceType_Patient>);
+    }
     Register("/series/{id}", GetSingleResource<ResourceType_Series>);
     Register("/studies/{id}", GetSingleResource<ResourceType_Study>);
 
     Register("/instances/{id}/statistics", GetResourceStatistics);
-    Register("/patients/{id}/statistics", GetResourceStatistics);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/statistics", GetResourceStatistics);
+    }
     Register("/studies/{id}/statistics", GetResourceStatistics);
     Register("/series/{id}/statistics", GetResourceStatistics);
 
-    Register("/patients/{id}/shared-tags", GetSharedTags);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/shared-tags", GetSharedTags);
+    }
     Register("/series/{id}/shared-tags", GetSharedTags);
     Register("/studies/{id}/shared-tags", GetSharedTags);
 
     Register("/instances/{id}/module", GetModule<ResourceType_Instance, DicomModule_Instance>);
-    Register("/patients/{id}/module", GetModule<ResourceType_Patient, DicomModule_Patient>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/module", GetModule<ResourceType_Patient, DicomModule_Patient>);
+    }
     Register("/series/{id}/module", GetModule<ResourceType_Series, DicomModule_Series>);
     Register("/studies/{id}/module", GetModule<ResourceType_Study, DicomModule_Study>);
     Register("/studies/{id}/module-patient", GetModule<ResourceType_Study, DicomModule_Patient>);
@@ -4347,11 +4365,17 @@
     Register("/instances/{id}/header", GetInstanceHeader);
     Register("/instances/{id}/numpy", GetNumpyInstance);  // New in Orthanc 1.10.0
 
-    Register("/patients/{id}/protected", IsProtectedPatient);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/protected", IsProtectedPatient);
+    }
   
     if (!context_.IsReadOnly())
     {
-      Register("/patients/{id}/protected", SetPatientProtection);
+      if (context_.IsPatientLevelEnabled())
+      {
+        Register("/patients/{id}/protected", SetPatientProtection);
+      }
     }
     else
     {
@@ -4367,6 +4391,11 @@
 
     for (size_t i = 0; i < resourceTypes.size(); i++)
     {
+      if (resourceTypes[i] == "patients" && !context_.IsPatientLevelEnabled())
+      {
+        continue;
+      }
+
       Register("/" + resourceTypes[i] + "/{id}/metadata", ListMetadata);
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", DeleteMetadata);
       Register("/" + resourceTypes[i] + "/{id}/metadata/{name}", GetMetadata);
@@ -4418,9 +4447,12 @@
     Register("/tools/find", Find<FindType_Find>);
     Register("/tools/count-resources", Find<FindType_Count>);
 
-    Register("/patients/{id}/studies", GetChildResources<ResourceType_Patient, ResourceType_Study>);
-    Register("/patients/{id}/series", GetChildResources<ResourceType_Patient, ResourceType_Series>);
-    Register("/patients/{id}/instances", GetChildResources<ResourceType_Patient, ResourceType_Instance>);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/studies", GetChildResources<ResourceType_Patient, ResourceType_Study>);
+      Register("/patients/{id}/series", GetChildResources<ResourceType_Patient, ResourceType_Series>);
+      Register("/patients/{id}/instances", GetChildResources<ResourceType_Patient, ResourceType_Instance>);
+    }
     Register("/studies/{id}/series", GetChildResources<ResourceType_Study, ResourceType_Series>);
     Register("/studies/{id}/instances", GetChildResources<ResourceType_Study, ResourceType_Instance>);
     Register("/series/{id}/instances", GetChildResources<ResourceType_Series, ResourceType_Instance>);
@@ -4432,7 +4464,10 @@
     Register("/instances/{id}/study", GetParentResource<ResourceType_Instance, ResourceType_Study>);
     Register("/instances/{id}/series", GetParentResource<ResourceType_Instance, ResourceType_Series>);
 
-    Register("/patients/{id}/instances-tags", GetChildInstancesTags);
+    if (context_.IsPatientLevelEnabled())
+    {
+      Register("/patients/{id}/instances-tags", GetChildInstancesTags);
+    }
     Register("/studies/{id}/instances-tags", GetChildInstancesTags);
     Register("/series/{id}/instances-tags", GetChildInstancesTags);
 
@@ -4443,7 +4478,10 @@
 
     if (!context_.IsReadOnly())
     {
-      Register("/patients/{id}/reconstruct", ReconstructResource<ResourceType_Patient>);
+      if (context_.IsPatientLevelEnabled())
+      {
+        Register("/patients/{id}/reconstruct", ReconstructResource<ResourceType_Patient>);
+      }
       Register("/studies/{id}/reconstruct", ReconstructResource<ResourceType_Study>);
       Register("/series/{id}/reconstruct", ReconstructResource<ResourceType_Series>);
       Register("/instances/{id}/reconstruct", ReconstructResource<ResourceType_Instance>);
--- a/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/OrthancRestApi/OrthancRestSystem.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -100,6 +100,7 @@
     static const char* const HAS_EXTENDED_FIND = "HasExtendedFind";
     static const char* const READ_ONLY = "ReadOnly";
     static const char* const HAS_RESERVE_QUEUE_VALUE = "HasReserveQueueValue";
+    static const char* const PATIENT_LEVEL_ENABLED = "PatientLevelEnabled";
 
     if (call.IsDocumentation())
     {
@@ -153,6 +154,8 @@
                         "and 'HasReserveQueueValue' (new in Orthanc 1.12.10)")
         .SetAnswerField(READ_ONLY, RestApiCallDocumentation::Type_Boolean,
                         "Whether Orthanc is running in read only mode (new in Orthanc 1.12.5)")
+        .SetAnswerField(PATIENT_LEVEL_ENABLED, RestApiCallDocumentation::Type_Boolean,
+                        "Whether Patient level routes and sanity checks are enabled (new in Orthanc 1.12.11)")
         .SetHttpGetSample("https://orthanc.uclouvain.be/demo/system", true);
       return;
     }
@@ -186,6 +189,7 @@
     result[STORAGE_AREA_PLUGIN] = Json::nullValue;
     result[DATABASE_BACKEND_PLUGIN] = Json::nullValue;
     result[READ_ONLY] = context.IsReadOnly();
+    result[PATIENT_LEVEL_ENABLED] = context.IsPatientLevelEnabled();
 
 #if ORTHANC_ENABLE_PLUGINS == 1
     result[PLUGINS_ENABLED] = true;
--- a/OrthancServer/Sources/ServerContext.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/ServerContext.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -400,6 +400,7 @@
     ingestTranscodingOfCompressed_(true),
     preferredTransferSyntax_(DicomTransferSyntax_LittleEndianExplicit),
     readOnly_(readOnly),
+    patientLevelEnabled_(true),
     deidentifyLogs_(false),
     serverStartTimeUtc_(boost::posix_time::second_clock::universal_time())
   {
@@ -624,6 +625,17 @@
   }
 
 
+  void ServerContext::SetPatientLevelEnabled(bool enabled)
+  {
+    if (enabled)
+      LOG(WARNING) << "Patient level is enabled";
+    else
+      LOG(WARNING) << "Patient level  is disabled";
+
+    patientLevelEnabled_ = enabled;
+  }
+
+
   void ServerContext::RemoveFile(const std::string& fileUuid,
                                  FileContentType type,
                                  const std::string& customData)
@@ -1798,10 +1810,11 @@
 
 
   void ServerContext::AddChildInstances(SetOfInstancesJob& job,
-                                        const std::string& publicId)
+                                        const std::string& publicId,
+                                        ResourceType level)
   {
     std::list<std::string> instances;
-    GetIndex().GetChildInstances(instances, publicId);
+    GetIndex().GetChildInstances(instances, publicId, level);
 
     job.Reserve(job.GetInstancesCount() + instances.size());
 
--- a/OrthancServer/Sources/ServerContext.h	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/ServerContext.h	Fri Mar 20 15:37:04 2026 +0100
@@ -271,7 +271,8 @@
     std::set<DicomTransferSyntax>  acceptedTransferSyntaxes_;
     std::list<std::string>         acceptedSopClasses_;  // ordered; the most 120 common ones first
     bool readOnly_;
-
+    bool patientLevelEnabled_;
+    
     StoreResult StoreAfterTranscoding(std::string& resultPublicId,
                                       DicomInstanceToStore& dicom,
                                       StoreInstanceMode mode,
@@ -342,6 +343,13 @@
       return storageCache_.SetMaximumSize(size);
     }
 
+    void SetPatientLevelEnabled(bool enabled);
+
+    bool IsPatientLevelEnabled() const
+    {
+      return patientLevelEnabled_;
+    }
+
     void SetCompressionEnabled(bool enabled);
 
     bool IsCompressionEnabled() const
@@ -506,7 +514,8 @@
     bool HasPlugins() const;
 
     void AddChildInstances(SetOfInstancesJob& job,
-                           const std::string& publicId);
+                           const std::string& publicId,
+                           ResourceType level);
 
     void SignalUpdatedModalities();
 
--- a/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ResourceModificationJob.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -727,9 +727,9 @@
       }
     }
 
-    if (modificationLevel == ResourceType_Study && replacePatientMainDicomTags)
+    if (GetContext().IsPatientLevelEnabled() && modificationLevel == ResourceType_Study && replacePatientMainDicomTags)
     {
-      for (std::set<std::string>::const_iterator studyId = parentResources_.begin(); studyId != parentResources_.end(); ++studyId)
+      for (std::map<std::string, ResourceType>::const_iterator it = parentResources_.begin(); it != parentResources_.end(); ++it)
       {
         // When modifying a study, you may not modify patient tags as you wish.
         // - If this is the patient's only study, you may modify all patient tags. This could be performed in 2 steps (modify the patient and then, the study) but, 
@@ -737,6 +737,7 @@
         // - If the patient already has other studies, you may only 'attach' the study to an existing patient by modifying 
         //   all patient tags from the study to match those of the target patient.
         // - Otherwise, you can't modify the patient tags
+        const std::string& studyId = it->first;
         
         std::string targetPatientId;
         if (modification_->IsReplaced(DICOM_TAG_PATIENT_ID))
@@ -746,7 +747,7 @@
         else
         {
           FindRequest request(ResourceType_Study);
-          request.SetOrthancStudyId(*studyId);
+          request.SetOrthancStudyId(studyId);
           request.SetRetrieveMainDicomTags(true);
 
           FindResponse response;
@@ -788,7 +789,7 @@
             bool targetPatientHasOtherStudies = childrenIds.size() > 1;
             if (childrenIds.size() == 1)
             {
-              targetPatientHasOtherStudies = (childrenIds.find(*studyId) == childrenIds.end());  // if the patient has one study that is not the one being modified
+              targetPatientHasOtherStudies = (childrenIds.find(studyId) == childrenIds.end());  // if the patient has one study that is not the one being modified
             }
 
             if (targetPatientHasOtherStudies)
@@ -807,12 +808,21 @@
                    mainPatientTag != mainPatientTags.end(); ++mainPatientTag)
               {
                 if (targetPatientTags.HasTag(*mainPatientTag) &&
-                    (!modification_->IsReplaced(*mainPatientTag) ||
+                    ((!modification_->IsReplaced(*mainPatientTag) && targetPatientTags.GetStringValue(*mainPatientTag, "", false).size() > 0) ||
                      modification_->GetReplacementAsString(*mainPatientTag) != targetPatientTags.GetStringValue(*mainPatientTag, "", false)))
                 {
-                  throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  " 
-                    "The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags "
-                    "and you should specify all Patient MainDicomTags in your query.  Try using /patients/../modify instead to modify the patient. Failing tag: ") + mainPatientTag->Format());
+                  if (!modification_->IsReplaced(*mainPatientTag))
+                  {
+                    throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  " 
+                      "The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags "
+                      "and you should specify all Patient MainDicomTags in your query.  Try using /patients/../modify instead to modify the patient. Missing tag in the 'Replace' tags: ") + mainPatientTag->Format());
+                  }
+                  else
+                  {
+                    throw OrthancException(ErrorCode_BadRequest, std::string("Trying to change patient tags in a study.  " 
+                      "The Patient already exists and has other studies.  All the 'Replace' tags should match the existing patient main dicom tags "
+                      "and you should specify all Patient MainDicomTags in your query.  Try using /patients/../modify instead to modify the patient. Failing tag: ") + mainPatientTag->Format());
+                  }
                 }
                 else if (!targetPatientTags.HasTag(*mainPatientTag) && modification_->IsReplaced(*mainPatientTag) )
                 {
--- a/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -371,6 +371,7 @@
 
   static const char* KEY_FAILED_INSTANCES = "FailedInstances";
   static const char* KEY_PARENT_RESOURCES = "ParentResources";
+  static const char* KEY_RESOURCES = "Resources";
   static const char* KEY_DESCRIPTION = "Description";
   static const char* KEY_PERMISSIVE = "Permissive";
   static const char* KEY_USER_DATA = "UserData";
@@ -382,6 +383,26 @@
   static const char* KEY_KEEP_SOURCE = "KeepSource";
   static const char* KEY_WORKERS_COUNT = "WorkersCount";
 
+  static void SerializeResources(Json::Value& target, const std::map<std::string, ResourceType>& parentResources, bool includeParentResourcesField)
+  {
+    if (!parentResources.empty())
+    {
+      target[KEY_RESOURCES] = Json::arrayValue;
+      SerializationToolbox::WriteMapOfResourcesAndTypes(target[KEY_RESOURCES], parentResources);
+
+      if (includeParentResourcesField)
+      {
+        std::set<std::string> keys;
+        for (std::map<std::string, ResourceType>::const_iterator it = parentResources.begin(); it != parentResources.end(); ++it) 
+        {
+          keys.insert(it->first);
+        }
+
+        SerializationToolbox::WriteSetOfStrings(target, keys, KEY_PARENT_RESOURCES);
+      }
+    }
+  }
+
 
   void ThreadedSetOfInstancesJob::GetPublicContent(Json::Value& target) const
   {
@@ -391,10 +412,7 @@
     target[KEY_INSTANCES_COUNT] = static_cast<uint32_t>(GetInstancesCount());
     target[KEY_FAILED_INSTANCES_COUNT] = static_cast<uint32_t>(failedInstances_.size());
 
-    if (!parentResources_.empty())
-    {
-      SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
-    }
+    SerializeResources(target, parentResources_, true);
   }
 
 
@@ -417,7 +435,7 @@
     
     SerializationToolbox::WriteSetOfStrings(target, instancesToProcess_, KEY_INSTANCES);
     SerializationToolbox::WriteSetOfStrings(target, failedInstances_, KEY_FAILED_INSTANCES);
-    SerializationToolbox::WriteSetOfStrings(target, parentResources_, KEY_PARENT_RESOURCES);
+    SerializeResources(target, parentResources_, false);
 
     return true;
   }
@@ -439,10 +457,29 @@
   {
     SerializationToolbox::ReadSetOfStrings(failedInstances_, source, KEY_FAILED_INSTANCES);
 
-    if (source.isMember(KEY_PARENT_RESOURCES))
+    if (source.isMember(KEY_PARENT_RESOURCES) && !source.isMember(KEY_RESOURCES))
     {
-      // Backward compatibility with Orthanc <= 1.5.6
-      SerializationToolbox::ReadSetOfStrings(parentResources_, source, KEY_PARENT_RESOURCES);
+      // Backward compatibility with Orthanc <= 1.12.11 (a KEY_PARENT_RESOURCES field with the resources ids)
+      std::set<std::string> parentResources;
+      SerializationToolbox::ReadSetOfStrings(parentResources, source, KEY_PARENT_RESOURCES);
+
+      for (std::set<std::string>::const_iterator it = parentResources.begin(); it != parentResources.end(); ++it)
+      {
+        try
+        {
+          ResourceType level;
+          context.GetIndex().LookupResourceType(level, *it);
+          parentResources_[*it] = level;
+        }
+        catch(...)
+        {
+          // ignore errors, the resource might have disappear 
+        }
+      }
+    }
+    else if (source.isMember(KEY_RESOURCES) && source[KEY_RESOURCES].isArray())
+    {
+      SerializationToolbox::ReadMapOfResourcesAndTypes(parentResources_, source, KEY_RESOURCES);
     }
     
     if (source.isMember(KEY_KEEP_SOURCE))
@@ -635,11 +672,11 @@
   }
 
 
-  void ThreadedSetOfInstancesJob::AddParentResource(const std::string &resource)
+  void ThreadedSetOfInstancesJob::AddParentResource(const std::string &resource, ResourceType level)
   {
     boost::recursive_mutex::scoped_lock lock(mutex_);
 
-    parentResources_.insert(resource);
+    parentResources_[resource] = level;
   }
 
 }
--- a/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.h	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/ServerJobs/ThreadedSetOfInstancesJob.h	Fri Mar 20 15:37:04 2026 +0100
@@ -71,7 +71,7 @@
   
   protected:
     mutable boost::recursive_mutex      mutex_;
-    std::set<std::string>               parentResources_;
+    std::map<std::string, ResourceType> parentResources_;
 
   public:
     ThreadedSetOfInstancesJob(ServerContext& context,
@@ -129,7 +129,7 @@
 
     void AddInstances(const std::list<std::string>& instances);
 
-    void AddParentResource(const std::string &resource);
+    void AddParentResource(const std::string &resource, ResourceType level);
 
     bool IsPermissive() const;
 
--- a/OrthancServer/Sources/main.cpp	Thu Mar 05 15:40:24 2026 +0100
+++ b/OrthancServer/Sources/main.cpp	Fri Mar 20 15:37:04 2026 +0100
@@ -323,7 +323,7 @@
 
     if (alwaysAllowMove_)
     {
-      LOG(WARNING) << "Security risk in DICOM SCP: C-MOOVE requests are always allowed, even from unknown modalities";
+      LOG(WARNING) << "Security risk in DICOM SCP: C-MOVE requests are always allowed, even from unknown modalities";
     }
   }
 
@@ -1679,6 +1679,8 @@
   {
     OrthancConfiguration::ReaderLock lock;
 
+    context.SetPatientLevelEnabled(lock.GetConfiguration().GetBooleanParameter("PatientLevelEnabled", true));
+
     if (context.IsReadOnly())
     {
       LOG(WARNING) << "READ-ONLY SYSTEM: ignoring these configurations: StorageCompression, StoreMD5ForAttachments, OverwriteInstances, MaximumPatientCount, MaximumStorageSize, MaximumStorageMode, SaveJobs"; 
@@ -1691,9 +1693,17 @@
       // New option in Orthanc 1.4.2
       context.SetOverwriteInstances(lock.GetConfiguration().GetBooleanParameter("OverwriteInstances", false));
 
+      unsigned int maximumPatientCount = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumPatientCount", 0);
+      unsigned int maximumStorageSize = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumStorageSize", 0);
+
+      if (!context.IsPatientLevelEnabled() && (maximumPatientCount != 0 || maximumStorageSize != 0))
+      {
+        LOG(ERROR) << "Can not set MaximumPatientCount or MaximumStorageSize when PatientLevelEnabled is set to false since these options require patient recycling.";
+      }
+
       try
       {
-        context.GetIndex().SetMaximumPatientCount(lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumPatientCount", 0));
+        context.GetIndex().SetMaximumPatientCount(maximumPatientCount);
       }
       catch (...)
       {
@@ -1702,7 +1712,7 @@
 
       try
       {
-        uint64_t size = lock.GetConfiguration().GetUnsignedIntegerParameter("MaximumStorageSize", 0);
+        uint64_t size = maximumStorageSize;
         context.GetIndex().SetMaximumStorageSize(size * 1024 * 1024);
       }
       catch (...)