diff --git a/abogen/integrations/audiobookshelf.py b/abogen/integrations/audiobookshelf.py index 1f1b14c..40176ad 100644 --- a/abogen/integrations/audiobookshelf.py +++ b/abogen/integrations/audiobookshelf.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import math import mimetypes import re from contextlib import ExitStack @@ -121,6 +122,7 @@ class AudiobookshelfClient: title = self._extract_title(metadata, audio_path) author = self._extract_author(metadata) series = self._extract_series(metadata) + series_sequence = self._extract_series_sequence(metadata) fields: Dict[str, str] = { "library": self._config.library_id, @@ -131,6 +133,8 @@ class AudiobookshelfClient: fields["author"] = author if series: fields["series"] = series + if series_sequence: + fields["seriesSequence"] = series_sequence if self._config.collection_id: fields["collectionId"] = self._config.collection_id @@ -596,3 +600,60 @@ class AudiobookshelfClient: if isinstance(series_name, str) and series_name.strip(): return series_name.strip() return "" + + @staticmethod + def _extract_series_sequence(metadata: Mapping[str, Any]) -> str: + if not isinstance(metadata, Mapping): + return "" + + preferred_keys = ( + "seriesSequence", + "series_sequence", + "seriesIndex", + "series_index", + "seriesNumber", + "series_number", + "bookNumber", + "book_number", + ) + + for key in preferred_keys: + if key not in metadata: + continue + normalized = AudiobookshelfClient._normalize_series_sequence(metadata.get(key)) + if normalized: + return normalized + return "" + + @staticmethod + def _normalize_series_sequence(raw: Any) -> str: + if raw is None: + return "" + + if isinstance(raw, (int, float)): + if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)): + return "" + text = str(raw) + else: + text = str(raw).strip() + + if not text: + return "" + + candidate = text.replace(",", ".") + match = re.search(r"\d+(?:\.\d+)?", candidate) + if not match: + return "" + + normalized = match.group(0) + if "." in normalized: + normalized = normalized.rstrip("0").rstrip(".") + if not normalized: + normalized = "0" + return normalized + + try: + return str(int(normalized)) + except ValueError: + cleaned = normalized.lstrip("0") + return cleaned or "0" diff --git a/abogen/web/service.py b/abogen/web/service.py index c3f8050..6f7af49 100644 --- a/abogen/web/service.py +++ b/abogen/web/service.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import logging +import math import os import re import shutil @@ -263,6 +264,7 @@ def _split_people_field(raw: Any) -> List[str]: _LIST_SPLIT_RE = re.compile(r"[;,\n]") +_SERIES_SEQUENCE_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?") _SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = ( "series_index", @@ -357,7 +359,13 @@ def _build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]: tags.get("series_title"), tags.get("seriestitle"), ) - series_sequence = _first_nonempty(*(tags.get(key) for key in _SERIES_SEQUENCE_TAG_KEYS)) + series_sequence = None + for key in _SERIES_SEQUENCE_TAG_KEYS: + raw_value = tags.get(key) + normalized_sequence = _normalize_series_sequence(raw_value) + if normalized_sequence: + series_sequence = normalized_sequence + break data: Dict[str, Any] = { "title": title, "authors": authors, @@ -400,6 +408,39 @@ def _build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]: return cleaned +def _normalize_series_sequence(raw: Any) -> Optional[str]: + if raw is None: + return None + + if isinstance(raw, (int, float)): + if isinstance(raw, float) and (math.isnan(raw) or math.isinf(raw)): + return None + text = str(raw) + else: + text = str(raw).strip() + + if not text: + return None + + candidate = text.replace(",", ".") + match = _SERIES_SEQUENCE_NUMBER_RE.search(candidate) + if not match: + return None + + normalized = match.group(0) + if "." in normalized: + normalized = normalized.rstrip("0").rstrip(".") + if not normalized: + normalized = "0" + return normalized + + try: + return str(int(normalized)) + except ValueError: + cleaned = normalized.lstrip("0") + return cleaned or "0" + + def _load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]: metadata_ref = job.result.artifacts.get("metadata") if not metadata_ref: diff --git a/tests/test_audiobookshelf_client.py b/tests/test_audiobookshelf_client.py new file mode 100644 index 0000000..1491829 --- /dev/null +++ b/tests/test_audiobookshelf_client.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json + +from abogen.integrations.audiobookshelf import AudiobookshelfClient, AudiobookshelfConfig + + +def test_upload_fields_include_series_sequence(tmp_path): + audio_path = tmp_path / "book.mp3" + audio_path.write_bytes(b"audio") + + config = AudiobookshelfConfig( + base_url="https://example.test", + api_token="token", + library_id="library-id", + folder_id="folder-id", + ) + client = AudiobookshelfClient(config) + + client._folder_cache = ("folder-id", "Folder", "Library") + + metadata = { + "title": "Example Title", + "seriesName": "Example Saga", + "seriesSequence": "7", + } + + fields = client._build_upload_fields(audio_path, metadata, chapters=None) + + assert fields["series"] == "Example Saga" + assert fields["seriesSequence"] == "7" + + assert "metadata" in fields + payload = json.loads(fields["metadata"]) + assert payload["seriesSequence"] == "7" + + +def test_upload_fields_normalize_alternate_sequence_keys(tmp_path): + audio_path = tmp_path / "book.mp3" + audio_path.write_bytes(b"audio") + + config = AudiobookshelfConfig( + base_url="https://example.test", + api_token="token", + library_id="library-id", + folder_id="folder-id", + ) + client = AudiobookshelfClient(config) + client._folder_cache = ("folder-id", "Folder", "Library") + + metadata = { + "title": "Example Title", + "seriesName": "Example Saga", + "series_index": "Book 3", + } + + fields = client._build_upload_fields(audio_path, metadata, chapters=None) + + assert fields["series"] == "Example Saga" + assert fields["seriesSequence"] == "3" + + +def test_upload_fields_preserve_decimal_sequence(tmp_path): + audio_path = tmp_path / "book.mp3" + audio_path.write_bytes(b"audio") + + config = AudiobookshelfConfig( + base_url="https://example.test", + api_token="token", + library_id="library-id", + folder_id="folder-id", + ) + client = AudiobookshelfClient(config) + client._folder_cache = ("folder-id", "Folder", "Library") + + metadata = { + "title": "Example Title", + "seriesName": "Example Saga", + "seriesSequence": "0.5", + } + + fields = client._build_upload_fields(audio_path, metadata, chapters=None) + + assert fields["seriesSequence"] == "0.5" diff --git a/tests/test_service.py b/tests/test_service.py index c0fe906..652679b 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -187,4 +187,65 @@ def test_audiobookshelf_metadata_uses_book_number(tmp_path): metadata = _build_audiobookshelf_metadata(job) assert metadata["seriesName"] == "Example Saga" - assert metadata["seriesSequence"] == "7" \ No newline at end of file + assert metadata["seriesSequence"] == "7" + + +def test_audiobookshelf_metadata_normalizes_sequence_value(tmp_path): + source = tmp_path / "book.txt" + source.write_text("content", encoding="utf-8") + + job = Job( + id="job-abs-normalize", + original_filename="book.txt", + stored_path=source, + language="en", + voice="af_alloy", + speed=1.0, + use_gpu=False, + subtitle_mode="Sentence", + output_format="mp3", + save_mode="Save next to input file", + output_folder=tmp_path, + replace_single_newlines=False, + subtitle_format="srt", + created_at=time.time(), + metadata_tags={ + "series": "Example Saga", + "series_index": "Book 7 of the Series", + }, + ) + + metadata = _build_audiobookshelf_metadata(job) + + assert metadata["seriesName"] == "Example Saga" + assert metadata["seriesSequence"] == "7" + + +def test_audiobookshelf_metadata_allows_decimal_sequence(tmp_path): + source = tmp_path / "book.txt" + source.write_text("content", encoding="utf-8") + + job = Job( + id="job-abs-decimal", + original_filename="book.txt", + stored_path=source, + language="en", + voice="af_alloy", + speed=1.0, + use_gpu=False, + subtitle_mode="Sentence", + output_format="mp3", + save_mode="Save next to input file", + output_folder=tmp_path, + replace_single_newlines=False, + subtitle_format="srt", + created_at=time.time(), + metadata_tags={ + "series": "Example Saga", + "series_number": "Book 4.5", + }, + ) + + metadata = _build_audiobookshelf_metadata(job) + + assert metadata["seriesSequence"] == "4.5" \ No newline at end of file