diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 0a859c24..22636992 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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}` diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py index 1ee760a1..b9a62dbf 100644 --- a/shelfmark/config/settings.py +++ b/shelfmark/config/settings.py @@ -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}", diff --git a/shelfmark/core/models.py b/shelfmark/core/models.py index a596c6c6..5aafc2eb 100644 --- a/shelfmark/core/models.py +++ b/shelfmark/core/models.py @@ -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 diff --git a/shelfmark/core/naming.py b/shelfmark/core/naming.py index ed313b1b..2ea9f2c6 100644 --- a/shelfmark/core/naming.py +++ b/shelfmark/core/naming.py @@ -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("/") diff --git a/shelfmark/download/orchestrator.py b/shelfmark/download/orchestrator.py index 1f5af7d7..5f5b8ad2 100644 --- a/shelfmark/download/orchestrator.py +++ b/shelfmark/download/orchestrator.py @@ -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)), diff --git a/shelfmark/download/postprocess/transfer.py b/shelfmark/download/postprocess/transfer.py index 40490160..0be089c9 100644 --- a/shelfmark/download/postprocess/transfer.py +++ b/shelfmark/download/postprocess/transfer.py @@ -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, } diff --git a/shelfmark/release_sources/prowlarr/mam.py b/shelfmark/release_sources/prowlarr/mam.py index c6745208..6284e6e6 100644 --- a/shelfmark/release_sources/prowlarr/mam.py +++ b/shelfmark/release_sources/prowlarr/mam.py @@ -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, ) diff --git a/shelfmark/release_sources/prowlarr/source.py b/shelfmark/release_sources/prowlarr/source.py index 1c1f68cf..eb920e8e 100644 --- a/shelfmark/release_sources/prowlarr/source.py +++ b/shelfmark/release_sources/prowlarr/source.py @@ -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 diff --git a/src/frontend/src/tests/namingTemplatePreview.test.ts b/src/frontend/src/tests/namingTemplatePreview.test.ts index c866df0f..5175dc38 100644 --- a/src/frontend/src/tests/namingTemplatePreview.test.ts +++ b/src/frontend/src/tests/namingTemplatePreview.test.ts @@ -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', diff --git a/src/frontend/src/utils/namingTemplatePreview.ts b/src/frontend/src/utils/namingTemplatePreview.ts index bade551d..c7fdfd85 100644 --- a/src/frontend/src/utils/namingTemplatePreview.ts +++ b/src/frontend/src/utils/namingTemplatePreview.ts @@ -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, ''); diff --git a/tests/config/test_generate_env_docs.py b/tests/config/test_generate_env_docs.py index 9204b512..475b28e1 100644 --- a/tests/config/test_generate_env_docs.py +++ b/tests/config/test_generate_env_docs.py @@ -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 diff --git a/tests/core/test_narrator_template_variable.py b/tests/core/test_narrator_template_variable.py new file mode 100644 index 00000000..fd05194a --- /dev/null +++ b/tests/core/test_narrator_template_variable.py @@ -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