Description: Replace fmt::localtime removed in fmt 12
 Use Gerbera's existing time utility module for local calendar conversion.
 Preserve the former Linux localtime_r behavior, caller-owned tm storage,
 and format_error on an out-of-range time_t without changing date formats.
Author: Aron Xu <aron@debian.org>
Forwarded: no
Last-Update: 2026-10-14

--- a/src/content/import_service.cc
+++ b/src/content/import_service.cc
@@ -39,6 +39,7 @@
 #include "metadata/metadata_enums.h"
 #include "metadata/metadata_handler.h"
 #include "metadata/metadata_service.h"
+#include "util/grb_time.h"
 #include "util/mime.h"
 #include "util/string_converter.h"
 #include "util/tools.h"
@@ -763,7 +764,7 @@
 void ImportService::updateItemData(const std::shared_ptr<CdsItem>& item, const std::string& mimetype)
 {
     if (hasDefaultDate && item->getMetaData(MetadataFields::M_DATE).empty())
-        item->addMetaData(MetadataFields::M_DATE, fmt::format("{:%FT%T%z}", fmt::localtime(item->getMTime().count())));
+        item->addMetaData(MetadataFields::M_DATE, fmt::format("{:%FT%T%z}", getLocalTime(item->getMTime().count())));
     for (auto&& upnpPattern : upnpMap) {
         if (upnpPattern.isMatch(item, mimetype)) {
             item->setClass(upnpPattern.upnpClass);
--- a/src/content/scripting/script.cc
+++ b/src/content/scripting/script.cc
@@ -53,6 +53,7 @@
 #include "script_names.h"
 #include "script_property.h"
 #include "scripting_runtime.h"
+#include "util/grb_time.h"
 #include "util/string_converter.h"
 #include "util/tools.h"
 
@@ -231,7 +232,7 @@
             setBoolProperty(ConfigDefinition::removeAttribute(ConfigVal::A_AUTOSCAN_DIRECTORY_HIDDENFILES), adir->getHidden());
             setIntProperty(ConfigDefinition::removeAttribute(ConfigVal::A_AUTOSCAN_DIRECTORY_SCANCOUNT), adir->getActiveScanCount());
             setIntProperty(ConfigDefinition::removeAttribute(ConfigVal::A_AUTOSCAN_DIRECTORY_TASKCOUNT), adir->getTaskCount());
-            setProperty(ConfigDefinition::removeAttribute(ConfigVal::A_AUTOSCAN_DIRECTORY_LMT), fmt::format("{:%Y-%m-%d %H:%M:%S}", fmt::localtime(adir->getPreviousLMT().count())));
+            setProperty(ConfigDefinition::removeAttribute(ConfigVal::A_AUTOSCAN_DIRECTORY_LMT), fmt::format("{:%Y-%m-%d %H:%M:%S}", getLocalTime(adir->getPreviousLMT().count())));
 
             duk_put_prop_string(ctx, -2, fmt::to_string(adir->getScanID()).c_str());
         }
--- a/src/database/sql_database.cc
+++ b/src/database/sql_database.cc
@@ -47,6 +47,7 @@
 #include "search_handler.h"
 #include "upnp/clients.h"
 #include "upnp/xml_builder.h"
+#include "util/grb_time.h"
 #include "util/grb_net.h"
 #include "util/mime.h"
 #include "util/string_converter.h"
@@ -1447,7 +1448,7 @@
         *changedContainer = parentID;
 
     std::vector<std::pair<std::string, std::string>> itemMetadata;
-    itemMetadata.emplace_back(MetaEnumMapper::getMetaFieldName(MetadataFields::M_DATE), fmt::format("{:%FT%T%z}", fmt::localtime(toSeconds(fs::last_write_time(path)).count())));
+    itemMetadata.emplace_back(MetaEnumMapper::getMetaFieldName(MetadataFields::M_DATE), fmt::format("{:%FT%T%z}", getLocalTime(toSeconds(fs::last_write_time(path)).count())));
 
     auto f2i = converterManager->f2i();
     return createContainer(parentID, f2i->convert(path.filename()), path, OBJECT_FLAG_RESTRICTED, false, "", INVALID_OBJECT_ID, itemMetadata);
@@ -1641,7 +1642,7 @@
     commit("addContainer");
 
     if (cont->getMetaData(MetadataFields::M_DATE).empty())
-        cont->addMetaData(MetadataFields::M_DATE, fmt::format("{:%FT%T%z}", fmt::localtime(cont->getMTime().count())));
+        cont->addMetaData(MetadataFields::M_DATE, fmt::format("{:%FT%T%z}", getLocalTime(cont->getMTime().count())));
 
     *containerID = createContainer(parentContainerId, cont->getTitle(), virtualPath, cont->getFlags(), cont->isVirtual(), cont->getClass(), cont->getFlag(OBJECT_FLAG_PLAYLIST_REF) ? cont->getRefID() : INVALID_OBJECT_ID, cont->getMetaData(), cont->getResources());
     return true;
@@ -2582,7 +2583,7 @@
         stats["name"] = row->col(0);
         stats["count"] = fmt::format("{}", row->col_int(1, -1));
         stats["playCount"] = fmt::format("{}", row->col_int(2, -1));
-        stats["last"] = fmt::format("{:%a %b %d %H:%M:%S %Y}", fmt::localtime(std::chrono::seconds(row->col_int(3, 0)).count()));
+        stats["last"] = fmt::format("{:%a %b %d %H:%M:%S %Y}", getLocalTime(std::chrono::seconds(row->col_int(3, 0)).count()));
         stats["bookmarks"] = fmt::format("{}", row->col_int(4, -1));
         result.push_back(std::move(stats));
     }
@@ -2708,7 +2709,7 @@
         interval = std::stoi(getCol(row, AutoscanColumn::Interval));
     auto lastModified = std::chrono::seconds(std::stol(getCol(row, AutoscanColumn::LastModified)));
 
-    log_info("Loading autoscan location: {}; recursive: {}, mt: {}/{}, last_modified: {}", location.c_str(), recursive, mt, AutoscanDirectory::mapMediaType(mt), lastModified > std::chrono::seconds::zero() ? fmt::format("{:%Y-%m-%d %H:%M:%S}", fmt::localtime(lastModified.count())) : "unset");
+    log_info("Loading autoscan location: {}; recursive: {}, mt: {}/{}, last_modified: {}", location.c_str(), recursive, mt, AutoscanDirectory::mapMediaType(mt), lastModified > std::chrono::seconds::zero() ? fmt::format("{:%Y-%m-%d %H:%M:%S}", getLocalTime(lastModified.count())) : "unset");
 
     auto dir = std::make_shared<AutoscanDirectory>(location, mode, recursive, persistent, interval, hidden, followSymlinks, mt, containerMap);
     dir->setObjectID(objectID);
--- a/src/metadata/ffmpeg_handler.cc
+++ b/src/metadata/ffmpeg_handler.cc
@@ -204,7 +204,7 @@
                         if (utcTime == -1) {
                             continue;
                         }
-                        tmWork = fmt::localtime(utcTime);
+                        tmWork = getLocalTime(utcTime);
                     } else if (strptime(e->value, "%Y-%m-%d", &tmWork)) {
                         ; // use the value as is
                     } else if (strptime(e->value, "%Y", &tmWork)) {
--- a/src/upnp/xml_builder.cc
+++ b/src/upnp/xml_builder.cc
@@ -45,6 +45,7 @@
 #include "request_handler/device_description_handler.h"
 #include "request_handler/request_handler.h"
 #include "upnp/clients.h"
+#include "util/grb_time.h"
 #include "util/url_utils.h"
 
 #include <algorithm>
@@ -451,7 +452,7 @@
         auto playStatus = item->getPlayStatus();
         if (playStatus) {
             auxData[UPNP_SEARCH_PLAY_COUNT] = fmt::format("{}", playStatus->getPlayCount());
-            auxData[UPNP_SEARCH_LAST_PLAYED] = fmt::format("{:%Y-%m-%d T %H:%M:%S}", fmt::localtime(playStatus->getLastPlayed().count()));
+            auxData[UPNP_SEARCH_LAST_PLAYED] = fmt::format("{:%Y-%m-%d T %H:%M:%S}", getLocalTime(playStatus->getLastPlayed().count()));
             auxData["upnp:lastPlaybackPosition"] = fmt::format("{}", millisecondsToHMSF(playStatus->getLastPlayedPosition().count()));
             propNames.push_back(addField(result, objFilter, UPNP_SEARCH_PLAY_COUNT, auxData[UPNP_SEARCH_PLAY_COUNT]));
             propNames.push_back(addField(result, objFilter, UPNP_SEARCH_LAST_PLAYED, auxData[UPNP_SEARCH_LAST_PLAYED]));
@@ -487,7 +488,7 @@
     // make sure a date is set
     auto dateNode = result.child(DC_DATE);
     if (!dateNode) {
-        auto fDate = fmt::format("{:%FT%T%z}", fmt::localtime(obj->getMTime().count()));
+        auto fDate = fmt::format("{:%FT%T%z}", getLocalTime(obj->getMTime().count()));
         if (simpleDate)
             fDate = makeSimpleDate(fDate);
         result.append_child(DC_DATE).append_child(pugi::node_pcdata).set_value(fDate.c_str());
--- a/src/web/clients.cc
+++ b/src/web/clients.cc
@@ -33,6 +33,7 @@
 #include "upnp/client_manager.h"
 #include "upnp/clients.h"
 #include "upnp/xml_builder.h"
+#include "util/grb_time.h"
 #include "util/grb_net.h"
 #include "util/xml_to_json.h"
 
@@ -40,7 +41,7 @@
 
 static std::string secondsToString(const std::chrono::seconds& t)
 {
-    return fmt::format("{:%a %b %d %H:%M:%S %Y}", fmt::localtime(t.count()));
+    return fmt::format("{:%a %b %d %H:%M:%S %Y}", getLocalTime(t.count()));
 }
 
 void Web::Clients::process()
--- a/src/web/config_load.cc
+++ b/src/web/config_load.cc
@@ -45,6 +45,7 @@
 #include "content/content.h"
 #include "database/database.h"
 #include "database/db_param.h"
+#include "util/grb_time.h"
 #include "util/xml_to_json.h"
 
 #include <fmt/chrono.h>
@@ -917,7 +918,7 @@
             // Last modified
             item = values.append_child(CONFIG_LOAD_ITEM);
             createItem(item, ascs->getItemPath(indexList, { ConfigVal::A_AUTOSCAN_DIRECTORY_LMT }), ascs->option, ConfigVal::A_AUTOSCAN_DIRECTORY_LMT);
-            setValue(item, fmt::format("{:%Y-%m-%d %H:%M:%S}", fmt::localtime(adir->getPreviousLMT().count())));
+            setValue(item, fmt::format("{:%Y-%m-%d %H:%M:%S}", getLocalTime(adir->getPreviousLMT().count())));
         }
         // Allow creation of entry in blank config
         {
--- a/src/web/edit_load.cc
+++ b/src/web/edit_load.cc
@@ -40,6 +40,7 @@
 #include "exceptions.h"
 #include "upnp/clients.h"
 #include "upnp/xml_builder.h"
+#include "util/grb_time.h"
 #include "util/tools.h"
 #include "util/xml_to_json.h"
 
@@ -80,7 +81,7 @@
 
     if (obj->getMTime() > std::chrono::seconds::zero()) {
         auto lmtEl = item.append_child("last_modified");
-        lmtEl.append_attribute("value") = fmt::format("{:%Y-%m-%d %H:%M:%S}", fmt::localtime(obj->getMTime().count())).c_str();
+        lmtEl.append_attribute("value") = fmt::format("{:%Y-%m-%d %H:%M:%S}", getLocalTime(obj->getMTime().count())).c_str();
         lmtEl.append_attribute("editable") = false;
     } else {
         auto lmtEl = item.append_child("last_modified");
@@ -90,7 +91,7 @@
 
     if (obj->getUTime() > std::chrono::seconds::zero()) {
         auto lmtEl = item.append_child("last_updated");
-        lmtEl.append_attribute("value") = fmt::format("{:%Y-%m-%d %H:%M:%S}", fmt::localtime(obj->getUTime().count())).c_str();
+        lmtEl.append_attribute("value") = fmt::format("{:%Y-%m-%d %H:%M:%S}", getLocalTime(obj->getUTime().count())).c_str();
         lmtEl.append_attribute("editable") = false;
     } else {
         auto lmtEl = item.append_child("last_updated");
@@ -222,7 +223,7 @@
 
             metaEntry = metaData.append_child("metadata");
             metaEntry.append_attribute("metaname") = fmt::format("upnp:lastPlaybackTime@group[{}]", playStatus->getGroup()).c_str();
-            metaEntry.append_attribute("metavalue") = fmt::format("{:%Y-%m-%d T %H:%M:%S}", fmt::localtime(playStatus->getLastPlayed().count())).c_str();
+            metaEntry.append_attribute("metavalue") = fmt::format("{:%Y-%m-%d T %H:%M:%S}", getLocalTime(playStatus->getLastPlayed().count())).c_str();
             metaEntry.append_attribute("editable") = false;
 
             if (playStatus->getLastPlayedPosition() > std::chrono::seconds::zero()) {
--- a/src/util/grb_time.h
+++ b/src/util/grb_time.h
@@ -25,6 +25,7 @@
 #define __GRB_TIME_H__
 
 #include <chrono>
+#include <ctime>
 #include <string>
 #include <string_view>
 
@@ -49,6 +50,9 @@
 long long HMSFToMilliseconds(std::string_view time);
 std::string millisecondsToString(long long milliseconds, bool all = false);
 
+/// \brief Converts epoch seconds to local calendar time.
+std::tm getLocalTime(std::time_t time);
+
 std::chrono::seconds currentTime();
 std::chrono::milliseconds currentTimeMS();
 
--- a/src/util/grb_time.cc
+++ b/src/util/grb_time.cc
@@ -30,6 +30,14 @@
 #include <iomanip>
 #include <regex>
 #include <sstream>
+
+std::tm getLocalTime(std::time_t time)
+{
+    std::tm result {};
+    if (::localtime_r(&time, &result) == nullptr)
+        throw fmt::format_error("time_t value out of range");
+    return result;
+}
 
 std::chrono::seconds currentTime()
 {
