LazyLibrarian reads config.ini once, at start-up, and writes its in-memory config back over the file when it shuts down. Edit the file while the container runs and the next docker stop erases your change, with no error. Stop the container before you edit, or set the value through the writeCFG API.
# Either: edit with LazyLibrarian stopped
docker stop lazylibrarian
# ... edit config.ini ...
docker start lazylibrarian
# Or: change one value while it runs
curl "http://HOST:5299/api?cmd=writeCFG&name=TOR_DOWNLOADER_DELUGE&group=TORRENT&value=1&apikey=$KEY"
writeCFG sets the value in memory and saves the file, so the next shutdown save keeps it.
Shutdown writes memory over the file
Mine ran in Docker on my Unraid server, with Jackett for indexer searches and Deluge for torrents. When it found a book and tried to download it:
WARNING: No torrent download method is enabled, check config.
The config had tor_downloader_deluge = True in a [TORRENT] section. After a restart the error repeated, and the [TORRENT] section was gone from the file.
Three things in the Python inside the container explain it. First, the file is read only at start-up. Don't reach for loadCFG to reload it. The API docs describe it as "reload config", but its whole body is a telemetry call and the comment # No need to reload the config.
Second, docker stop sends SIGTERM. LazyLibrarian traps it and runs shutdown(), which saves the config. The API's cmd=shutdown takes the same path.
Third, the save writes only values that differ from their defaults:
# config2.py: save_config_to_string, add_to_parser
if aitem.do_persist() and (save_all or not aitem.is_default()):
if asectionname not in aparser:
aparser[asectionname] = {}
save_str = aitem.get_save_str()
# ...
aparser[asectionname][akey] = save_str
A section is created only when one of its values is written. Put together:
- You edit
config.iniwithtor_downloader_deluge = Truewhile LazyLibrarian is running - LazyLibrarian never rereads the file, so memory still holds
0, the default - The container stops, the config saves, and
[TORRENT]is left out (all defaults) - You check the file, and your edit is gone
Use writeCFG, never config_update
The API reads and writes single config values at runtime:
# Read a config value
curl "http://HOST:5299/api?cmd=readCFG&name=TOR_DOWNLOADER_DELUGE&group=TORRENT&apikey=$KEY"
# Returns: [1]
# Write a config value (takes effect immediately)
curl "http://HOST:5299/api?cmd=writeCFG&name=TOR_DOWNLOADER_DELUGE&group=TORRENT&value=1&apikey=$KEY"
# Returns: OK
To switch a boolean off, send an empty value=:
# WRONG: value=0 switches the boolean on
curl "http://HOST:5299/api?cmd=writeCFG&name=TOR_DOWNLOADER_DELUGE&group=TORRENT&value=0&apikey=$KEY"
# CORRECT: an empty value switches it off
curl "http://HOST:5299/api?cmd=writeCFG&name=TOR_DOWNLOADER_DELUGE&group=TORRENT&value=&apikey=$KEY"
writeCFG hands value to ConfigBool.set_from_ui, which calls Python's bool() on the string. Any non-empty string is true, so value=0 and value=False both switch a boolean on. This was tested against the ConfigBool class from LazyLibrarian's source, not against a running instance.
Take key names from configdefs.py, which writes them in uppercase. group is the INI section. writeCFG changes one item in memory, then rewrites config.ini from memory and keeps the previous file as config.ini.bak. Because the file is rebuilt from memory, a hand edit made since start-up is lost at that point too. readCFG returns [1] for a boolean that is on and [] for one that is off.
The web UI's settings page saves through a different route, /config_update. It walks every config item, and any boolean missing from the request is set to False:
# webServe.py: config_update
for key, itm in CONFIG.config.items():
if key.lower() in kwargs:
value = kwargs[key.lower()]
# ...
CONFIG.set_from_ui(key, value)
else:
if isinstance(itm, ConfigBool) and itm.get_read_count() > 0:
itm.set_from_ui(False) # Set other items to False that we've seen (i.e. are shown)
A browser leaves an unticked checkbox out of a form post, so for the settings page "missing" means "unticked". From a script it means "not sent". POST one parameter to config_update and every boolean read since the settings page last loaded, or since the last full config save, is reset to False.
True works for booleans. Non-integers in integer fields load as 0
ConfigBool loads with configparser.getboolean(), which accepts True, False, 1 and 0. LazyLibrarian saves its own booleans as True and False.
ConfigInt is the type that drops values. It parses with getint() and swallows the error:
# configtypes.py: ConfigInt.update_from_parser
def update_from_parser(self, parser: ConfigParser, tmpsection: str, name: str) -> bool:
# ...
# noinspection PyBroadException
try:
value = parser.getint(tmpsection, name, fallback=0)
except Exception:
value = 0
return self.set_int(value)
True, 7.5 or anything else that isn't a whole number loads as 0, and the error is never logged.
readCFG needs the full-access key
LazyLibrarian has two API keys: API_KEY for full access and API_RO_KEY for read-only. Every call is checked against both. Then any command flagged in cmd_dict as "not available with the read-only key" is refused to it:
# api.py: check_params
if kwargs['apikey'] != CONFIG.get_str('API_KEY') and kwargs['apikey'] != CONFIG.get_str('API_RO_KEY'):
self.data = {'Success': False, 'Data': '', 'Error': {'Code': 401, 'Message': 'Incorrect API key'}}
return
# ...
if get_case_insensitive_key_value(cmd_dict, kwargs['cmd'])[0] != 0 and self.apikey != CONFIG.get_str('API_KEY'):
self.data = {'Success': False, 'Data': '',
'Error': {'Code': 405,
'Message': f"Command: {kwargs['cmd']} "
f"not available with read-only api access key, try cmd=help"}}
return
searchBook, addAuthor and queueBook carry that flag. So do readCFG and writeCFG, which means the read-only key cannot read config.
A non-empty API_KEY must also be exactly 32 characters. Any other length fails every call with code 503, Invalid API key, before the key you sent is compared. An empty one fails with 501, No API key. generate_api makes a valid one:
# Generate a new full-access key
curl "http://HOST:5299/generate_api"
# Generate a read-only key
curl "http://HOST:5299/generate_api?ro=true"
It is a web UI route behind @require_auth, not an API command, so a bare curl works only with web login turned off. It sets the key in memory, and config.ini gets it at the next save.
Where to look in the source
readCFG and writeCFG are listed in the API docs. The behaviour described above comes from these files. Read them with docker exec lazylibrarian cat /app/lazylibrarian/lazylibrarian/FILENAME.
configtypes.py:ConfigBool,ConfigInt,update_from_parserconfig2.py:load_configfile, section loading,save_config_and_backup_oldconfigdefs.py: every config key and its defaultstartup.py:shutdown(), which saves the config (line 768)webServe.py:config_update(line ~2021),generate_api(line ~8293), Deluge test (line ~8460)api.py: key checks (line ~381),writeCFGandreadCFG(line ~2619)downloadmethods.py: the torrent download method check (line ~975)
Line numbers are from LazyLibrarian commit 67e06ca7 (15 February 2026).