Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 25 additions & 5 deletions docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -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**
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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_ |
Expand Down Expand Up @@ -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}`
Expand All @@ -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}`
Expand Down
9 changes: 6 additions & 3 deletions shelfmark/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand All @@ -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}",
Expand Down
1 change: 1 addition & 0 deletions shelfmark/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions shelfmark/core/naming.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"firstauthor",
"partnumber",
"language",
"narrator",
"subtitle",
"author",
"series",
Expand Down Expand Up @@ -246,13 +247,24 @@ def render_block(content: str) -> str | None:
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)

# 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("/")

Expand Down
19 changes: 18 additions & 1 deletion shelfmark/download/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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)),
Expand Down
1 change: 1 addition & 0 deletions shelfmark/download/postprocess/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
34 changes: 27 additions & 7 deletions shelfmark/release_sources/prowlarr/mam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = ""
Expand All @@ -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
Expand All @@ -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,
)
Expand Down
6 changes: 6 additions & 0 deletions shelfmark/release_sources/prowlarr/source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions src/frontend/src/tests/namingTemplatePreview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading