mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
feat: Add series sequence handling and normalization for Audiobookshelf metadata
This commit is contained in:
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
import re
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
@@ -121,6 +122,7 @@ class AudiobookshelfClient:
|
|||||||
title = self._extract_title(metadata, audio_path)
|
title = self._extract_title(metadata, audio_path)
|
||||||
author = self._extract_author(metadata)
|
author = self._extract_author(metadata)
|
||||||
series = self._extract_series(metadata)
|
series = self._extract_series(metadata)
|
||||||
|
series_sequence = self._extract_series_sequence(metadata)
|
||||||
|
|
||||||
fields: Dict[str, str] = {
|
fields: Dict[str, str] = {
|
||||||
"library": self._config.library_id,
|
"library": self._config.library_id,
|
||||||
@@ -131,6 +133,8 @@ class AudiobookshelfClient:
|
|||||||
fields["author"] = author
|
fields["author"] = author
|
||||||
if series:
|
if series:
|
||||||
fields["series"] = series
|
fields["series"] = series
|
||||||
|
if series_sequence:
|
||||||
|
fields["seriesSequence"] = series_sequence
|
||||||
if self._config.collection_id:
|
if self._config.collection_id:
|
||||||
fields["collectionId"] = 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():
|
if isinstance(series_name, str) and series_name.strip():
|
||||||
return series_name.strip()
|
return series_name.strip()
|
||||||
return ""
|
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"
|
||||||
|
|||||||
+42
-1
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
@@ -263,6 +264,7 @@ def _split_people_field(raw: Any) -> List[str]:
|
|||||||
|
|
||||||
|
|
||||||
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
|
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
|
||||||
|
_SERIES_SEQUENCE_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
|
||||||
|
|
||||||
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
|
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
|
||||||
"series_index",
|
"series_index",
|
||||||
@@ -357,7 +359,13 @@ def _build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
|
|||||||
tags.get("series_title"),
|
tags.get("series_title"),
|
||||||
tags.get("seriestitle"),
|
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] = {
|
data: Dict[str, Any] = {
|
||||||
"title": title,
|
"title": title,
|
||||||
"authors": authors,
|
"authors": authors,
|
||||||
@@ -400,6 +408,39 @@ def _build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
|
|||||||
return cleaned
|
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]]]:
|
def _load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]:
|
||||||
metadata_ref = job.result.artifacts.get("metadata")
|
metadata_ref = job.result.artifacts.get("metadata")
|
||||||
if not metadata_ref:
|
if not metadata_ref:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -188,3 +188,64 @@ def test_audiobookshelf_metadata_uses_book_number(tmp_path):
|
|||||||
|
|
||||||
assert metadata["seriesName"] == "Example Saga"
|
assert metadata["seriesName"] == "Example Saga"
|
||||||
assert metadata["seriesSequence"] == "7"
|
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"
|
||||||
Reference in New Issue
Block a user