Use {title} - {authors} as the default download template

This is Calibre's default. An empty template now restores the default.
This commit is contained in:
tim
2026-10-03 08:09:41 +02:00
parent 323bec3c04
commit 2c8a1bb187
6 changed files with 33 additions and 25 deletions
+5 -3
View File
@@ -447,10 +447,12 @@ downloaded before.
## File names of downloaded books ## File names of downloaded books
"Download ePub" on a story page sends the book to your browser with a file name. Without a template, "Download ePub" on a story page sends the book to your browser with a file name. A template gives
the name is the title, for example `The Book.epub`. the name. The default template is `{title} - {authors}`, as in Calibre. For "The Book" by Ann
Writer, it gives `Book, The - Ann Writer.epub`.
An admin can set a template for the name under "Book Downloads" in Settings. The template follows An admin can change the template under "Book Downloads" in Settings. To go back to the default,
clear the field and save. The template follows
the rules of [Calibre's templates](https://manual.calibre-ebook.com/template_lang.html) for file the rules of [Calibre's templates](https://manual.calibre-ebook.com/template_lang.html) for file
names, so a template from Calibre gives the same name in Bindery. Bindery adds `.epub`. For example, names, so a template from Calibre gives the same name in Bindery. Bindery adds `.epub`. For example,
`{series:|| - }{series_index:0>2s|| - }{title} - {authors}` gives `{series:|| - }{series_index:0>2s|| - }{title} - {authors}` gives
+2
View File
@@ -15,6 +15,8 @@ from typing import Any
from bindery.ingest import sanitize_filename from bindery.ingest import sanitize_filename
# Calibre's default template for a file that it sends to a device.
DEFAULT_TEMPLATE = "{title} - {authors}"
MAX_TEMPLATE_LENGTH = 1024 MAX_TEMPLATE_LENGTH = 1024
# The most characters that a format can ask for, as a width or a precision. # The most characters that a format can ask for, as a width or a precision.
MAX_WIDTH = 255 MAX_WIDTH = 255
+17 -15
View File
@@ -380,7 +380,7 @@ def get_settings() -> dict[str, Any]:
"linking_available": _linking_available(), "linking_available": _linking_available(),
"hold_new_links": bool(store.get_setting(None, "hold_new_links")), "hold_new_links": bool(store.get_setting(None, "hold_new_links")),
"personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "", "personal_ini": ini.read_text(encoding="utf-8") if ini.exists() else "",
"download_template": store.get_setting(None, "download_template") or "", "download_template": _download_template(),
**job_list_settings(), **job_list_settings(),
} }
@@ -648,22 +648,24 @@ def download_fields() -> dict[str, str]:
return fields return fields
def _download_template() -> str:
return store.get_setting(None, "download_template") or filename_template.DEFAULT_TEMPLATE
def _download_name(story: dict[str, Any]) -> str: def _download_name(story: dict[str, Any]) -> str:
"""The file name of a downloaded book. It comes from the download template. Without a template, or if the """The file name of a downloaded book, from the download template. If the template fails or gives no name for
template fails or gives no name for this book, it is the title.""" this book, the name is the title."""
template = store.get_setting(None, "download_template") meta = _book_metadata(story["site_values"], _epub_overrides(story["pinned"]))
meta["title"] = meta.get("title") or story["title"]
meta["authors"] = meta.get("authors") or [story["author"]]
meta.update(publisher=story["site"], timestamp=story["created_at"], last_modified=story["last_changed"])
# Without CWA, the saved CWA book IDs do not apply.
book_id = story["calibre_id"] if config.CWA_ENABLED and story["calibre_id"] is not None else story["id"]
name = "" name = ""
if template: try:
meta = _book_metadata(story["site_values"], _epub_overrides(story["pinned"])) name = filename_template.file_name(_download_template(), filename_template.prepare(meta, book_id))
meta["title"] = meta.get("title") or story["title"] except filename_template.TemplateError as e:
meta["authors"] = meta.get("authors") or [story["author"]] logger.warning(f"The download template fails for {story['url']}: {e}")
meta.update(publisher=story["site"], timestamp=story["created_at"], last_modified=story["last_changed"])
# Without CWA, the saved CWA book IDs do not apply.
book_id = story["calibre_id"] if config.CWA_ENABLED and story["calibre_id"] is not None else story["id"]
try:
name = filename_template.file_name(template, filename_template.prepare(meta, book_id))
except filename_template.TemplateError as e:
logger.warning(f"The download template fails for {story['url']}: {e}")
return f"{name or sanitize_filename(story['title'])}.epub" return f"{name or sanitize_filename(story['title'])}.epub"
+2 -1
View File
@@ -162,7 +162,8 @@ export function settingsPage(main, _params, config) {
'This template gives the file name of a book that you download from a story page. It follows the ', 'This template gives the file name of a book that you download from a story page. It follows the ',
externalLink(TEMPLATE_DOCS, 'template rules of Calibre'), externalLink(TEMPLATE_DOCS, 'template rules of Calibre'),
' for file names. Bindery replaces each field in braces with the value of the book, and adds .epub. ' + ' for file names. Bindery replaces each field in braces with the value of the book, and adds .epub. ' +
'A field with no value becomes empty text. Leave the template empty to name the file after the title. ' + 'A field with no value becomes empty text. To go back to the default, {title} - {authors}, clear the field ' +
'and save. ' +
'For example, ', 'For example, ',
code('{series:|| - }{series_index:0>2s|| - }{title} - {authors}'), code('{series:|| - }{series_index:0>2s|| - }{title} - {authors}'),
' gives "Saga, The - 02 - Book, The - Ann Writer.epub".', ' gives "Saga, The - 02 - Book, The - Ann Writer.epub".',
+1 -1
View File
@@ -472,7 +472,7 @@ def test_settings(client, monkeypatch, tmp_path):
"pause_uploads": False, "pause_uploads": False,
"ingest_available": True, "ingest_available": True,
"personal_ini": "[defaults]\nis_adult:true\n", "personal_ini": "[defaults]\nis_adult:true\n",
"download_template": "", "download_template": "{title} - {authors}",
"story_jobs_hours": 36, "story_jobs_hours": 36,
"story_jobs_count": 10, "story_jobs_count": 10,
"main_jobs_hours": 6, "main_jobs_hours": 6,
+6 -5
View File
@@ -142,7 +142,7 @@ def test_download_builds_from_stored_chapters_without_changing_them(client, stor
response = client.get(f"/api/stories/{sid}/epub") response = client.get(f"/api/stories/{sid}/epub")
assert response.status_code == 200 assert response.status_code == 200
assert response.data.startswith(b"PK") assert response.data.startswith(b"PK")
assert "filename=A_B.epub" in response.headers["Content-Disposition"] assert 'filename="A_B - Writer.epub"' in response.headers["Content-Disposition"]
assert o.store.revision_html(cid, 1) == original assert o.store.revision_html(cid, 1) == original
response.close() response.close()
@@ -166,11 +166,12 @@ def test_download_template_names_the_file(client, story):
name = download_name(client, sid) name = download_name(client, sid)
assert 'filename="Cafe - 77.epub"' in name and "filename*=UTF-8''Caf%C3%A9%20-%2077.epub" in name assert 'filename="Cafe - 77.epub"' in name and "filename*=UTF-8''Caf%C3%A9%20-%2077.epub" in name
# A template with folders gives the name of the file alone. # A template with folders gives the name of the file alone.
client.put("/api/settings", json={"download_template": "{author_sort}/{title} - {authors}"}, headers=H) client.put("/api/settings", json={"download_template": "{author_sort}/{title}"}, headers=H)
assert "filename*=UTF-8''Caf%C3%A9.epub" in download_name(client, sid)
# An empty template restores the default.
saved = client.put("/api/settings", json={"download_template": " "}, headers=H).json
assert saved["download_template"] == "{title} - {authors}"
assert "filename*=UTF-8''Caf%C3%A9%20-%20Ann%20Writer.epub" in download_name(client, sid) assert "filename*=UTF-8''Caf%C3%A9%20-%20Ann%20Writer.epub" in download_name(client, sid)
# Without a template, the name is the title of the story, with its article in front.
assert client.put("/api/settings", json={"download_template": ""}, headers=H).json["download_template"] == ""
assert 'filename="The Book.epub"' in download_name(client, sid)
def test_download_template_that_fails_gives_the_title(client, story, caplog): def test_download_template_that_fails_gives_the_title(client, story, caplog):