feat(naming): {Narrator} placeholder and MyAnonamouse series fallback (#1407)

## Human-Written explanation:

Following up on previous PR to use MAM ID to get nararrator and series
to show up in search, this PR will allow the series and nararrator
fields to be added to the path when saving an audiobook.

I have tested my ghcr.io image on my instance and it seemed to work
properly.

All the text below is written by Claude.

# feat(naming): {Narrator} placeholder and MyAnonamouse series fallback

Follow-up to #1390 and #1399. Related to #605 and #934 (narrator
support).

## Problem

Several narrations of the same audiobook currently land in the same
folder, because nothing in the path template tells them apart. Keeping
more than one version means renaming folders by hand after every
download.

Since #1390, MyAnonamouse results carry the release's narrator and
series in `extra`, but the naming templates can't use them: there is no
`{Narrator}` placeholder, and `{Series}` / `{SeriesPosition}` only come
from the metadata provider.

## Changes

- **`{Narrator}` placeholder** (`core/naming.py`,
`download/postprocess/transfer.py`, `core/models.py`): `DownloadTask`
gains `narrator`, read from `narrator` or `extra.narrator` when a
release is queued, and kept in the restart-safe retry payload. It's
available in audiobook templates like any other placeholder, including
prefix/suffix blocks such as `{ - Narrator}`.
- **Audiobookshelf folder style**: Audiobookshelf reads the narrator
from folder names like `Title {Narrator}`. The template `{Title}
{{Narrator}}` already matches as `{` + `{Narrator}`; the parser now also
drops the closing brace when the narrator is empty, so the folder is
`Title` rather than `Title }`.
- **No stray spaces around `/`**: an empty placeholder at the start or
end of a folder name no longer leaves a space there (e.g. `Title
/Title`). This applies to all templates.
- **Series fallback** (`release_sources/prowlarr/mam.py`, `source.py`,
`download/orchestrator.py`): MAM enrichment also stores the first
series' name and number as `extra.series_name` /
`extra.series_position`, which queueing already falls back to when the
metadata provider has no series. The provider's series still wins. A
release-level series number is only used when it names the same series
as the provider, so a provider series is never paired with the number of
a different MAM series (an omnibus, for example).
- **Settings and preview** (`config/settings.py`,
`namingTemplatePreview.ts`): the audiobook template descriptions list
`{Narrator}` and explain `{{Narrator}}`. The settings page preview
mirrors the parser changes and offers `{Narrator}` under "Insert
variable" for audiobook templates only.
- **Docs**: regenerated `environment-variables.md`. This also picked up
the Library Check settings, which weren't in the generated docs yet;
happy to drop that part to keep the diff focused.

## Example

Audiobook Path Template `{Author}/{Title} {{Narrator}}/{Title}`:

| Narrator | Result |
|----------|--------|
| Samuel Roukin | `Christopher Ruocchio/Empire of Silence {Samuel
Roukin}/Empire of Silence.m4b` |
| none | `Christopher Ruocchio/Empire of Silence/Empire of Silence.m4b`
|

## Testing

- `tests/core/test_narrator_template_variable.py` (new, 16 tests):
`{{Narrator}}` with and without a value, the `{ {Narrator}}` prefix
form, prefix/suffix blocks, sanitizing, `build_library_path`, task
metadata, retry payload round trip, MAM series name/number parsing
(including a `1-3` range having no number), and queueing (narrator from
`extra`, series fallback, no cross-series number, blank narrator).
- `namingTemplatePreview.test.ts`: the same `{{Narrator}}` cases for the
preview.
- Updated `test_generate_env_docs.py` for the new template description.
- Full `pytest -m "not integration and not e2e"` compared with an
upstream `main` worktree on the same machine: no new failures (the
remaining ones are Windows-only on both).
- `ruff check`, `ruff format --check`, `basedpyright` (0 errors) and
`vulture` on touched files; frontend `tsc --noEmit`, `oxlint`, `oxfmt
--check`, `vitest` (218 passed).
- Checked the settings page locally: the audiobook preview renders `...
{Simon Vance}.mp3` and `{Narrator}` is listed for audiobook templates
only.

No behavior change for existing templates, apart from spaces next to `/`
being trimmed.

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adman234
2026-10-02 22:45:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 23d7d482d7
commit 32abaff21e
12 changed files with 284 additions and 17 deletions
+25 -5
View File
@@ -255,6 +255,8 @@ Seconds since the last WireGuard handshake before the healthcheck bounces the tu
| `SEARCH_PAGE_TITLE` | Title shown above the main search box on the homepage. | string | `Shelfmark` |
| `CALIBRE_WEB_URL` | Adds a navigation button to your book library (Calibre-Web Automated, Grimmory, etc). | string | _none_ |
| `AUDIOBOOK_LIBRARY_URL` | Adds a separate navigation button for your audiobook library (Audiobookshelf, Plex, etc). When both URLs are set, icons are shown instead of text. | string | _none_ |
| `LIBRARY_CHECK_CALIBRE_ENABLED` | Read the Calibre metadata.db and mark search results you already own, so you do not download a second copy. Read only, nothing is written to the library. | boolean | `false` |
| `CALIBRE_LIBRARY_DB_PATH` | Path to metadata.db as seen from inside the Shelfmark container. Mount the Calibre library folder read-only, e.g. /path/to/calibre-library:/calibre-library:ro. | string | `/calibre-library/metadata.db` |
| `SUPPORTED_FORMATS` | Book formats to include in search results. ZIP/RAR archives are extracted automatically and book files are used if found. | string (comma-separated) | `epub,mobi,azw3,fb2,djvu,cbz,cbr` |
| `SUPPORTED_AUDIOBOOK_FORMATS` | Audiobook formats to include in search results. ZIP/RAR archives are extracted automatically and audiobook files are used if found. | string (comma-separated) | `m4b,mp3,m4a,mp4,flac,ogg,wma,aac,wav,opus,zip,rar` |
@@ -288,6 +290,24 @@ Adds a separate navigation button for your audiobook library (Audiobookshelf, Pl
- **Type:** string
- **Default:** _none_
#### `LIBRARY_CHECK_CALIBRE_ENABLED`
**Mark books already in your Calibre library**
Read the Calibre metadata.db and mark search results you already own, so you do not download a second copy. Read only, nothing is written to the library.
- **Type:** boolean
- **Default:** `false`
#### `CALIBRE_LIBRARY_DB_PATH`
**Calibre metadata.db path**
Path to metadata.db as seen from inside the Shelfmark container. Mount the Calibre library folder read-only, e.g. /path/to/calibre-library:/calibre-library:ro.
- **Type:** string
- **Default:** `/calibre-library/metadata.db`
#### `SUPPORTED_FORMATS`
**Supported Book Formats**
@@ -315,7 +335,7 @@ Audiobook formats to include in search results. ZIP/RAR archives are extracted a
| `SEARCH_MODE` | How you want to search for and download books. | string (choice) | `universal` |
| `BOOK_LANGUAGE` | Default language filter for searches. Users can override this for their own account. | string (comma-separated) | `en` |
| `DEFAULT_CONTENT_TYPE` | Which tab the search page opens on. Users can override this for their own account, and a browser that has already picked a tab keeps its choice. | string (choice) | `ebook` |
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | `relevance` |
| `AA_DEFAULT_SORT` | Default sort order for search results. | string (choice) | _empty string_ |
| `SHOW_RELEASE_SOURCE_LINKS` | Show clickable release-source links in release and details modals. Metadata provider links stay enabled. | boolean | `true` |
| `SHOW_COMBINED_SELECTOR` | Show the option to search for and download both a book and audiobook together. | boolean | `true` |
| `FORCE_COMBINED_SEARCH` | Force combined search whenever it's available. Locks the combined toggle on. | boolean | `false` |
@@ -506,8 +526,8 @@ Audiobooks only. AudiobookBay lists it for most releases. In Prowlarr results it
| `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}, {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}` |
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}, {Narrator}. 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}, {Narrator}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. {Narrator} and a Series fallback come from MyAnonamouse results (needs a MAM session ID); {{Narrator}} writes Audiobookshelf's '{Name}' style and is left out entirely when there is no narrator. | 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_ |
@@ -770,7 +790,7 @@ Choose how downloaded audiobook files are named and organized.
**Naming Template**
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.
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}, {Narrator}. 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}`
@@ -779,7 +799,7 @@ Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year},
**Path Template**
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.
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}, {Narrator}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. {Narrator} and a Series fallback come from MyAnonamouse results (needs a MAM session ID); {{Narrator}} writes Audiobookshelf's '{Name}' style and is left out entirely when there is no narrator.
- **Type:** string
- **Default:** `{Author}/{Title}/{Title}`
+6 -3
View File
@@ -1427,7 +1427,7 @@ def download_settings() -> list[SettingsField]:
description=(
"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: "
"{PrimaryTitle}, {PartNumber}, {Narrator}. 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."
@@ -1447,8 +1447,11 @@ def download_settings() -> list[SettingsField]:
description=(
"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."
"{Subtitle}, {PrimaryTitle}, {PartNumber}, {Narrator}. Use arbitrary prefix/suffix: "
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
"{Narrator} and a Series fallback come from MyAnonamouse results (needs a MAM "
"session ID); {{Narrator}} writes Audiobookshelf's '{Name}' style and is left "
"out entirely when there is no narrator."
),
default="{Author}/{Title}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
+1
View File
@@ -120,6 +120,7 @@ class DownloadTask:
series_name: str | None = None
series_position: float | None = None # Float for novellas (e.g., 1.5)
subtitle: str | None = None # Book subtitle for naming templates
narrator: str | None = None # Release narrator for the {Narrator} template variable
language: str | None = None # Release language code for the {Language} template variable
# Hardlinking support
+12
View File
@@ -22,6 +22,7 @@ KNOWN_TOKENS = [
"firstauthor",
"partnumber",
"language",
"narrator",
"subtitle",
"author",
"series",
@@ -246,6 +247,13 @@ def parse_naming_template(
parts.append(match.group(0))
cursor = match.end()
if rendered == "":
# "{{Narrator}}" (Audiobookshelf's "Title {Narrator}" folder style) matches as
# "{" + "{Narrator}"; with no value, drop the closing braces it opened too.
for _ in range(content.count("{")):
if template[cursor : cursor + 1] != "}":
break
cursor += 1
parts.append(template[cursor:])
result = "".join(parts)
@@ -253,6 +261,10 @@ def parse_naming_template(
# Clean up any double slashes that might result from empty tokens
result = re.sub(r"/+", "/", result)
# A token left empty at the end or start of a folder name leaves stray spaces there
result = re.sub(r"[ \t]+/", "/", result)
result = re.sub(r"/[ \t]+", "/", result)
# Remove leading/trailing slashes
result = result.strip("/")
+18 -1
View File
@@ -108,6 +108,15 @@ def _resolve_email_destination(
return None, None
def _same_series(release_series: object, chosen_series: object) -> bool:
"""Whether a release's own series (if it names one) is the series being used."""
if not isinstance(release_series, str) or not release_series.strip():
return True
if not isinstance(chosen_series, str):
return False
return release_series.strip().casefold() == chosen_series.strip().casefold()
def _parse_release_search_mode(value: object) -> SearchMode:
if isinstance(value, SearchMode):
return value
@@ -263,8 +272,13 @@ def queue_release(
# Get series info for library naming templates
series_name = release_data.get("series_name") or extra.get("series_name")
series_position = release_data.get("series_position") or extra.get("series_position")
series_position = release_data.get("series_position")
if not series_position and _same_series(extra.get("series_name"), series_name):
# A release-level series (e.g. MyAnonamouse's) may number a different
# series than the metadata provider named; only borrow a matching one.
series_position = extra.get("series_position")
subtitle = release_data.get("subtitle") or extra.get("subtitle")
narrator = release_data.get("narrator") or extra.get("narrator")
language = release_data.get("language") or extra.get("language")
multi_book = bool(release_data.get("multi_book") or extra.get("multi_book"))
book_plan = _normalize_book_plan(release_data.get("book_plan") or extra.get("book_plan"))
@@ -303,6 +317,7 @@ def queue_release(
series_name=series_name,
series_position=series_position,
subtitle=subtitle,
narrator=narrator if isinstance(narrator, str) and narrator.strip() else None,
language=language,
multi_book=multi_book or book_plan is not None,
book_plan=book_plan,
@@ -505,6 +520,7 @@ def serialize_task_for_retry(task: DownloadTask) -> dict[str, Any]:
"series_name": getattr(task, "series_name", None),
"series_position": getattr(task, "series_position", None),
"subtitle": getattr(task, "subtitle", None),
"narrator": getattr(task, "narrator", None),
"language": getattr(task, "language", None),
"search_mode": search_mode,
"multi_book": bool(getattr(task, "multi_book", False)),
@@ -566,6 +582,7 @@ def _restore_task_from_retry_payload(payload: object) -> DownloadTask | None:
series_name=normalize_optional_text(payload.get("series_name")),
series_position=_optional_number(payload.get("series_position")),
subtitle=normalize_optional_text(payload.get("subtitle")),
narrator=normalize_optional_text(payload.get("narrator")),
language=normalize_optional_text(payload.get("language")),
search_mode=search_mode,
multi_book=bool(payload.get("multi_book", False)),
@@ -71,6 +71,7 @@ def build_metadata_dict(task: DownloadTask) -> dict:
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
"Narrator": task.narrator,
"Language": normalize_language_code(task.language),
"User": task.username,
}
+27 -7
View File
@@ -90,7 +90,10 @@ class MamTorrentDetails:
"""The fields Prowlarr drops from a MyAnonamouse search result."""
narrator: str | None = None
series: str | None = None
series: str | None = None # Display text: "Name #1, Other Series #2"
# First series only, for the {Series}/{SeriesPosition} naming placeholders.
series_name: str | None = None
series_position: float | None = None
bitrate: str | None = None
bitrate_kbps: int | None = None
@@ -208,9 +211,9 @@ def _parse_names(raw: object) -> str | None:
return ", ".join(dict.fromkeys(names)) or None
def _parse_series(raw: object) -> str | None:
"""Format series_info ({"id": ["Name", "1"]}) as "Name #1"."""
entries: list[str] = []
def _series_entries(raw: object) -> list[tuple[str, str]]:
"""Return (name, number) pairs from series_info ({"id": ["Name", "1"]})."""
entries: list[tuple[str, str]] = []
for value in _decode_info(raw).values():
name: str = ""
number: str = ""
@@ -220,12 +223,25 @@ def _parse_series(raw: object) -> str | None:
number = str(value[1]).strip()
elif isinstance(value, str):
name = value.strip()
if not name:
continue
entries.append(f"{name} #{number}" if number else name)
if name:
entries.append((name, number))
return entries
def _parse_series(raw: object) -> str | None:
"""Format series_info as "Name #1", joining several series with commas."""
entries = [f"{name} #{number}" if number else name for name, number in _series_entries(raw)]
return ", ".join(dict.fromkeys(entries)) or None
def _parse_series_position(number: str) -> float | None:
"""Read a MAM series number ("1", "2.5") as a float; ranges like "1-3" are None."""
try:
return float(number)
except ValueError:
return None
def _parse_bitrate(tags: object) -> tuple[str | None, int | None]:
if not isinstance(tags, str):
return None, None
@@ -239,9 +255,13 @@ def _parse_bitrate(tags: object) -> tuple[str | None, int | None]:
def parse_torrent_details(item: dict[str, Any]) -> MamTorrentDetails:
"""Pull narrator, series and bitrate out of one MAM search result."""
bitrate, bitrate_kbps = _parse_bitrate(item.get("tags"))
series_entries = _series_entries(item.get("series_info"))
first_name, first_number = series_entries[0] if series_entries else (None, "")
return MamTorrentDetails(
narrator=_parse_names(item.get("narrator_info")),
series=_parse_series(item.get("series_info")),
series_name=first_name,
series_position=_parse_series_position(first_number) if first_number else None,
bitrate=bitrate,
bitrate_kbps=bitrate_kbps,
)
@@ -119,6 +119,12 @@ def _enrich_mam_releases(
release.extra["narrator"] = details.narrator
if details.series:
release.extra["series"] = details.series
# Queueing falls back to extra.series_name/series_position for the
# {Series}/{SeriesPosition} placeholders when the metadata provider has none.
if details.series_name:
release.extra["series_name"] = details.series_name
if details.series_position is not None:
release.extra["series_position"] = details.series_position
if details.bitrate and not release.extra.get("bitrate"):
release.extra["bitrate"] = details.bitrate
release.extra["bitrate_value"] = details.bitrate_kbps
@@ -9,6 +9,24 @@ import {
} from '../utils/namingTemplatePreview';
describe('namingTemplatePreview', () => {
it('renders Audiobookshelf-style {{Narrator}} and drops it cleanly when empty', () => {
const template = '{Author}/{Title} {{Narrator}}/{Title}';
const metadata = { Author: 'Christopher Ruocchio', Title: 'Empire of Silence' };
expect(
renderNamingTemplate(
template,
{ ...metadata, Narrator: 'Samuel Roukin' },
{
allowPathSeparators: true,
},
).value,
).toBe('Christopher Ruocchio/Empire of Silence {Samuel Roukin}/Empire of Silence');
expect(renderNamingTemplate(template, metadata, { allowPathSeparators: true }).value).toBe(
'Christopher Ruocchio/Empire of Silence/Empire of Silence',
);
});
it('groups primary title with universal variables', () => {
expect(NAMING_TEMPLATE_TOKENS.find((token) => token.token === 'PrimaryTitle')?.group).toBe(
'Universal',
@@ -85,6 +85,15 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
value: '5',
group: 'Universal',
},
{
token: 'Narrator',
label: 'Narrator',
description:
'Release narrator (MyAnonamouse via Prowlarr, needs a MAM session ID). {{Narrator}} gives "{Name}"',
value: 'Simon Vance',
group: 'Universal',
audiobookOnly: true,
},
{
token: 'Subtitle',
label: 'Subtitle',
@@ -116,6 +125,7 @@ const KNOWN_TOKENS = [
'firstauthor',
'partnumber',
'language',
'narrator',
'subtitle',
'author',
'series',
@@ -245,11 +255,20 @@ export const renderNamingTemplate = (
}
cursor = match.index + match[0].length;
if (rendered === '') {
// Mirrors naming.py: "{{Narrator}}" drops the closing braces it opened when empty.
const opened = (content.match(/\{/g) ?? []).length;
for (let i = 0; i < opened && template[cursor] === '}'; i += 1) {
cursor += 1;
}
}
});
result += template.slice(cursor);
}
result = result.replace(/\/+/g, '/');
result = result.replace(/[ \t]+\//g, '/');
result = result.replace(/\/[ \t]+/g, '/');
result = result.replace(/^\/+|\/+$/g, '');
result = result.replace(/^[\s\-_.]+/g, '');
result = result.replace(/[\s\-_.]+$/g, '');
+1 -1
View File
@@ -56,5 +56,5 @@ def test_generated_env_docs_include_custom_component_value_fields() -> None:
"{Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, "
"{User}, {OriginalName} "
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix:"
"{PrimaryTitle}, {PartNumber}, {Narrator}. Use arbitrary prefix/suffix:"
) in docs
@@ -0,0 +1,150 @@
"""{Narrator} naming placeholder, and MyAnonamouse series/narrator reaching the download task."""
import json
import pytest
from shelfmark.core.models import DownloadTask
from shelfmark.core.naming import KNOWN_TOKENS, build_library_path, parse_naming_template
from shelfmark.download import orchestrator
from shelfmark.download.postprocess.transfer import build_metadata_dict
from shelfmark.release_sources.prowlarr.mam import parse_torrent_details
TEMPLATE = "{Author}/{Title} {{Narrator}}/{Title}"
def _metadata(narrator: str | None) -> dict:
return {"Author": "Christopher Ruocchio", "Title": "Empire of Silence", "Narrator": narrator}
class TestNarratorPlaceholder:
def test_known_token(self):
assert "narrator" in KNOWN_TOKENS
def test_audiobookshelf_braces(self):
assert parse_naming_template(TEMPLATE, _metadata("Samuel Roukin")) == (
"Christopher Ruocchio/Empire of Silence {Samuel Roukin}/Empire of Silence"
)
@pytest.mark.parametrize("narrator", [None, ""])
def test_missing_narrator_leaves_no_braces_or_trailing_space(self, narrator):
assert parse_naming_template(TEMPLATE, _metadata(narrator)) == (
"Christopher Ruocchio/Empire of Silence/Empire of Silence"
)
def test_brace_inside_the_block_prefix(self):
template = "{Title}{ {Narrator}}"
assert parse_naming_template(template, _metadata("Samuel Roukin")) == (
"Empire of Silence {Samuel Roukin}"
)
assert parse_naming_template(template, _metadata(None)) == "Empire of Silence"
def test_plain_and_prefixed_forms(self):
assert parse_naming_template("{Title}{ - Narrator}", _metadata("A")) == (
"Empire of Silence - A"
)
assert parse_naming_template("{Title} ({Narrator})", _metadata(None)) == (
"Empire of Silence"
)
def test_narrator_is_sanitized(self):
rendered = parse_naming_template("{Narrator}", {"Narrator": "A/B: C"})
assert "/" not in rendered.replace("_", "")
assert ":" not in rendered
def test_library_path(self, tmp_path):
path = build_library_path(str(tmp_path), TEMPLATE, _metadata("Samuel Roukin"), "m4b")
assert path.parent.name == "Empire of Silence {Samuel Roukin}"
def test_task_narrator_reaches_template_metadata():
task = DownloadTask(task_id="t", source="prowlarr", title="T", narrator="Samuel Roukin")
assert build_metadata_dict(task)["Narrator"] == "Samuel Roukin"
def test_retry_payload_round_trips_narrator():
task = DownloadTask(task_id="t", source="prowlarr", title="T", narrator="Samuel Roukin")
restored = orchestrator._restore_task_from_retry_payload(
orchestrator.serialize_task_for_retry(task)
)
assert restored is not None
assert restored.narrator == "Samuel Roukin"
def test_mam_series_name_and_position():
details = parse_torrent_details(
{
"id": 1,
"series_info": json.dumps({"3": ["The Sun Eater", "2.5"], "4": ["Other", "7"]}),
}
)
assert details.series == "The Sun Eater #2.5, Other #7"
assert details.series_name == "The Sun Eater"
assert details.series_position == 2.5
def test_mam_series_range_has_no_position():
details = parse_torrent_details({"id": 1, "series_info": json.dumps({"3": ["Saga", "1-3"]})})
assert details.series_name == "Saga"
assert details.series_position is None
class TestQueueRelease:
@pytest.fixture
def queued(self, monkeypatch):
captured: dict[str, DownloadTask] = {}
def fake_add(task: DownloadTask) -> bool:
captured["task"] = task
return True
monkeypatch.setattr(orchestrator.config, "get", lambda _key, default=None, **_kw: default)
monkeypatch.setattr(orchestrator, "_source_unavailable_message", lambda _source: None)
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
monkeypatch.setattr(orchestrator, "ws_manager", None)
def queue(release: dict) -> DownloadTask:
ok, error = orchestrator.queue_release(
{"source": "prowlarr", "source_id": "x", "title": "Empire of Silence", **release},
0,
)
assert ok, error
return captured["task"]
return queue
def test_narrator_and_mam_series_from_extra(self, queued):
task = queued(
{
"extra": {
"narrator": "Samuel Roukin",
"series_name": "The Sun Eater",
"series_position": 1.0,
}
}
)
assert task.narrator == "Samuel Roukin"
assert task.series_name == "The Sun Eater"
assert task.series_position == 1.0
def test_provider_series_wins_and_is_not_paired_with_another_series_number(self, queued):
task = queued(
{
"series_name": "Sun Eater",
"extra": {"series_name": "Some Omnibus", "series_position": 3.0},
}
)
assert task.series_name == "Sun Eater"
assert task.series_position is None
def test_matching_release_series_can_supply_the_number(self, queued):
task = queued(
{
"series_name": "The Sun Eater",
"extra": {"series_name": "the sun eater", "series_position": 1.0},
}
)
assert task.series_position == 1.0
def test_no_narrator(self, queued):
assert queued({"extra": {"narrator": " "}}).narrator is None