diff --git a/docs/environment-variables.md b/docs/environment-variables.md index c78cd22..5ac035f 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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}` diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 07113fd..543f185 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -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." diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index 4f3a73e..2c69cf3 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -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() diff --git a/src/frontend/src/tests/namingTemplatePreview.test.ts b/src/frontend/src/tests/namingTemplatePreview.test.ts index e17eae0..0da2c38 100644 --- a/src/frontend/src/tests/namingTemplatePreview.test.ts +++ b/src/frontend/src/tests/namingTemplatePreview.test.ts @@ -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, diff --git a/src/frontend/src/utils/namingTemplatePreview.ts b/src/frontend/src/utils/namingTemplatePreview.ts index 0e09c57..1dca04a 100644 --- a/src/frontend/src/utils/namingTemplatePreview.ts +++ b/src/frontend/src/utils/namingTemplatePreview.ts @@ -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(); }; diff --git a/tests/config/test_generate_env_docs.py b/tests/config/test_generate_env_docs.py index 214f3dd..9204b51 100644 --- a/tests/config/test_generate_env_docs.py +++ b/tests/config/test_generate_env_docs.py @@ -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 diff --git a/tests/core/test_first_author_template_variable.py b/tests/core/test_first_author_template_variable.py new file mode 100644 index 0000000..3fa8b2b --- /dev/null +++ b/tests/core/test_first_author_template_variable.py @@ -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"