feat(naming): add {FirstAuthor} template token (#1322)

Closes #930.

## What

New `{FirstAuthor}` naming-template token. It renders only the first
author when metadata lists several ("Author1, Author2, Author3"), so
multi-author books can be filed alongside the rest of that author's work
instead of getting their own "Author1, Author2, ..." folder.

```
{Author}       -> Terry Pratchett, Neil Gaiman
{FirstAuthor}  -> Terry Pratchett
```

## How

- Added to `KNOWN_TOKENS` in `shelfmark/core/naming.py`, positioned
before `author` so `{FirstAuthor}` isn't parsed as literal `First` +
`{Author}`.
- Derived inside `parse_naming_template` from the existing `Author`
value (split on `,` / `;`), so every caller — folder transfer, rename,
the settings preview — picks it up with no extra wiring. An explicit
`FirstAuthor` key in the metadata still wins if one is ever passed.
- `{Author}` behaviour is unchanged.
- Frontend `namingTemplatePreview.ts` token list + `KNOWN_TOKENS` kept
in lockstep (there's a test enforcing that), with a matching
`firstAuthor` helper.
- Settings field descriptions + `docs/environment-variables.md` list the
new token.

## Known limitation

A lone author written `Last, First` is split on the comma too and
renders as `Last` — the source metadata doesn't mark which form it is.
Called out in the token help text and covered by a test. `{Author}`
remains available for anyone who wants the raw string.

## Checks

- `make python-test` — 2963 passed
- `make python-lint` / `make python-format` / `make python-typecheck` /
vulture — clean
- `make frontend-test` — 187 passed · `frontend-lint` /
`frontend-format` / `frontend-typecheck` — clean

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Vinicius Gabriel
2026-09-11 00:47:37 -04:00
committed by GitHub
co-authored by Claude Sonnet 5
parent 265da07d7f
commit c576003319
7 changed files with 128 additions and 14 deletions
+8 -8
View File
@@ -434,8 +434,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `TEMPLATE_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
@@ -456,8 +456,8 @@ The release source tab to open by default in the release modal for audiobooks. U
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
@@ -501,7 +501,7 @@ Choose how downloaded book files are named and organized.
**Naming Template**
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string
- **Default:** `{Author} - {Title} ({Year})`
@@ -510,7 +510,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
**Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title} ({Year})`
@@ -711,7 +711,7 @@ Choose how downloaded audiobook files are named and organized.
**Naming Template**
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
- **Type:** string
- **Default:** `{Author} - {Title}`
@@ -720,7 +720,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
**Path Template**
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
- **Type:** string
- **Default:** `{Author}/{Title}/{Title}`
+4 -4
View File
@@ -1028,7 +1028,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_RENAME",
label="Naming Template",
description=(
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
"Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
@@ -1047,7 +1047,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_ORGANIZE",
label="Path Template",
description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
"Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
@@ -1315,7 +1315,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description=(
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
"Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
@@ -1335,7 +1335,7 @@ def download_settings() -> list[SettingsField]:
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description=(
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
"Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
+18 -1
View File
@@ -14,11 +14,12 @@ logger = setup_logger(__name__)
# Known variable tokens, sorted longest-first to avoid partial matches
# e.g., "SeriesPosition" must match before "Series"
# e.g., "SeriesPosition" must match before "Series", "FirstAuthor" before "Author"
KNOWN_TOKENS = [
"seriesposition",
"primarytitle",
"originalname",
"firstauthor",
"partnumber",
"language",
"subtitle",
@@ -29,6 +30,12 @@ KNOWN_TOKENS = [
"user",
]
# Authors reach naming already joined as "First Author, Second Author, ...".
# {FirstAuthor} keeps only the first entry. A single name written "Last, First"
# is split on the comma too and renders as "Last" -- the source metadata does
# not mark which form it is (see #930).
AUTHOR_LIST_SEPARATOR = re.compile(r"\s*[,;]\s*")
# Match any {...} block for template parsing
BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
@@ -56,6 +63,14 @@ def sanitize_filename(name: str | None, max_length: int = 245) -> str:
sanitize_path_component = sanitize_filename
def first_author(value: object) -> str:
"""Return the first entry from an author string joined with ',' or ';'."""
text = " ".join(str(value or "").split())
if not text:
return ""
return AUTHOR_LIST_SEPARATOR.split(text, maxsplit=1)[0].strip()
def format_series_position(position: str | float | None) -> str:
"""Format a series position for naming templates."""
if position is None:
@@ -163,6 +178,8 @@ def parse_naming_template(
value = normalized.get(placeholder_name)
if placeholder_name == "seriesposition":
value = format_series_position(value)
elif placeholder_name == "firstauthor" and not value:
value = first_author(normalized.get("author"))
if value is None:
return ""
return str(value).strip()
@@ -40,6 +40,23 @@ describe('namingTemplatePreview', () => {
expect(preview.value).toBe('Arthur Conan Doyle/The Hound of the Baskervilles');
});
it('keeps only the first of several authors for FirstAuthor', () => {
const preview = renderNamingTemplate(
'{FirstAuthor}/{Year}',
{ ...SAMPLE_NAMING_METADATA, Author: 'Terry Pratchett, Neil Gaiman', FirstAuthor: '' },
{ allowPathSeparators: true },
);
expect(preview.value).toBe('Terry Pratchett/1902');
});
it('offers FirstAuthor as a core variable', () => {
const token = NAMING_TEMPLATE_TOKENS.find((t) => t.token === 'FirstAuthor');
expect(token?.group).toBe('Core');
expect(token?.audiobookOnly).toBeFalsy();
});
it('reports unknown bare variables', () => {
const preview = renderNamingTemplate('{Author}/{NotAThing}', SAMPLE_NAMING_METADATA, {
allowPathSeparators: true,
@@ -27,6 +27,13 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
value: 'Arthur Conan Doyle',
group: 'Core',
},
{
token: 'FirstAuthor',
label: 'First author',
description: 'First author only, when metadata lists several',
value: 'Arthur Conan Doyle',
group: 'Core',
},
{
token: 'Title',
label: 'Full title',
@@ -104,6 +111,7 @@ const KNOWN_TOKENS = [
'seriesposition',
'primarytitle',
'originalname',
'firstauthor',
'partnumber',
'language',
'subtitle',
@@ -114,6 +122,10 @@ const KNOWN_TOKENS = [
'user',
];
// Mirrors AUTHOR_LIST_SEPARATOR in shelfmark/core/naming.py: authors arrive
// pre-joined with ',' or ';' and {FirstAuthor} keeps only the first entry.
const firstAuthor = (value: string): string => value.split(/\s*[,;]\s*/)[0]?.trim() ?? '';
const BRACE_PATTERN = /\{([^}]+)\}/g;
const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g;
@@ -163,6 +175,9 @@ export const renderNamingTemplate = (
const unknownTokens: string[] = [];
const placeholderValue = (placeholderName: string): string => {
if (placeholderName === 'firstauthor' && !normalized['firstauthor']) {
return firstAuthor(normalized['author'] ?? '');
}
return (normalized[placeholderName] ?? '').trim();
};
+2 -1
View File
@@ -53,7 +53,8 @@ def test_generated_env_docs_include_custom_component_value_fields() -> None:
assert (
"| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: "
"{Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
"{Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, "
"{User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix:"
) in docs
@@ -0,0 +1,64 @@
"""Tests for the {FirstAuthor} template variable support (issue #930)."""
import pytest
from shelfmark.core.naming import KNOWN_TOKENS, first_author, parse_naming_template
class TestFirstAuthorInKnownTokens:
def test_first_author_in_known_tokens(self):
assert "firstauthor" in KNOWN_TOKENS
def test_first_author_is_matched_before_author(self):
# "firstauthor" contains "author"; the longest-first ordering must keep
# {FirstAuthor} from being parsed as literal "First" + {Author}.
assert KNOWN_TOKENS.index("firstauthor") < KNOWN_TOKENS.index("author")
class TestFirstAuthorHelper:
@pytest.mark.parametrize(
("value", "expected"),
[
("Arthur Conan Doyle", "Arthur Conan Doyle"),
("Terry Pratchett, Neil Gaiman", "Terry Pratchett"),
("Terry Pratchett; Neil Gaiman", "Terry Pratchett"),
(" Ursula K. Le Guin , Someone Else ", "Ursula K. Le Guin"),
("", ""),
(None, ""),
],
)
def test_first_author(self, value, expected):
assert first_author(value) == expected
def test_last_comma_first_is_split_too(self):
# Documented limitation: a lone "Last, First" name is indistinguishable
# from a two-author list, so it collapses to "Last" (see #930).
assert first_author("Doyle, Arthur Conan") == "Doyle"
class TestFirstAuthorTemplateRendering:
def test_single_author_matches_author(self):
metadata = {"Author": "Arthur Conan Doyle", "Title": "A Study in Scarlet"}
assert parse_naming_template("{FirstAuthor} - {Title}", metadata) == (
"Arthur Conan Doyle - A Study in Scarlet"
)
def test_multiple_authors_keep_only_the_first(self):
metadata = {"Author": "Terry Pratchett, Neil Gaiman", "Title": "Good Omens"}
assert parse_naming_template("{FirstAuthor}/{Title}", metadata) == (
"Terry Pratchett/Good Omens"
)
def test_case_insensitive(self):
assert parse_naming_template("{firstauthor}", {"Author": "A, B"}) == "A"
def test_explicit_first_author_key_wins_over_derivation(self):
metadata = {"Author": "A, B", "FirstAuthor": "Custom"}
assert parse_naming_template("{FirstAuthor}", metadata) == "Custom"
def test_empty_author_renders_nothing(self):
assert parse_naming_template("{FirstAuthor}", {"Author": ""}) == ""
def test_author_token_is_unaffected(self):
metadata = {"Author": "Terry Pratchett, Neil Gaiman"}
assert parse_naming_template("{Author}", metadata) == "Terry Pratchett, Neil Gaiman"