mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
refactor(domain): deduplicate metadata helpers across 3 layers
Extracted 8 metadata functions from service.py, exporters.py, and audiobookshelf.py into domain/metadata_helpers.py: - normalize_metadata_casefold, split_people_field, split_simple_list - first_nonempty, extract_year, normalize_series_sequence - build_audiobookshelf_metadata, load_audiobookshelf_chapters service.py, exporters.py, and audiobookshelf.py now import from domain instead of maintaining separate copies. Thin wrappers adapt to layer interfaces (Job objects, etc.). Net -231 lines. 1006 tests pass.
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
from typing import Any, Dict, List, Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
@@ -136,3 +139,267 @@ def format_series_sentence(series_name: Optional[str], series_number: Optional[s
|
|||||||
article = "the " if not name.lower().startswith("the ") else ""
|
article = "the " if not name.lower().startswith("the ") else ""
|
||||||
phrase = f"Book {number} of {article}{name}"
|
phrase = f"Book {number} of {article}{name}"
|
||||||
return re.sub(r"\s+", " ", phrase).strip()
|
return re.sub(r"\s+", " ", phrase).strip()
|
||||||
|
|
||||||
|
|
||||||
|
_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE)
|
||||||
|
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
|
||||||
|
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
|
||||||
|
"series_index",
|
||||||
|
"series_position",
|
||||||
|
"series_sequence",
|
||||||
|
"series_number",
|
||||||
|
"seriesnumber",
|
||||||
|
"book_number",
|
||||||
|
"booknumber",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
||||||
|
normalized: Dict[str, Any] = {}
|
||||||
|
if not values:
|
||||||
|
return normalized
|
||||||
|
for key, value in values.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
key_text = str(key).strip().lower()
|
||||||
|
if not key_text:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
normalized[key_text] = value
|
||||||
|
else:
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
normalized[key_text] = text
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def split_people_field(raw: Any) -> List[str]:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple, set)):
|
||||||
|
results: List[str] = []
|
||||||
|
for item in raw:
|
||||||
|
results.extend(split_people_field(item))
|
||||||
|
return results
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()]
|
||||||
|
seen: set[str] = set()
|
||||||
|
ordered: List[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
key = token.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ordered.append(token)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def split_simple_list(raw: Any) -> List[str]:
|
||||||
|
if raw is None:
|
||||||
|
return []
|
||||||
|
if isinstance(raw, (list, tuple, set)):
|
||||||
|
results: List[str] = []
|
||||||
|
for item in raw:
|
||||||
|
results.extend(split_simple_list(item))
|
||||||
|
return results
|
||||||
|
text = str(raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()]
|
||||||
|
seen: set[str] = set()
|
||||||
|
ordered: List[str] = []
|
||||||
|
for token in tokens:
|
||||||
|
key = token.casefold()
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
ordered.append(token)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def first_nonempty(*values: Any) -> Optional[str]:
|
||||||
|
for value in values:
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
items = list(value)
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
value = items[0]
|
||||||
|
text = str(value).strip()
|
||||||
|
if text:
|
||||||
|
return text
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_year(raw: Optional[str]) -> Optional[int]:
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
text = str(raw).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
match = re.search(r"(19|20)\d{2}", text)
|
||||||
|
if match:
|
||||||
|
try:
|
||||||
|
return int(match.group(0))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = int(text)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if 0 < parsed < 3000:
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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_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 build_audiobookshelf_metadata(
|
||||||
|
tags: Mapping[str, Any],
|
||||||
|
*,
|
||||||
|
language: str = "",
|
||||||
|
filename: str = "",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
normalized = normalize_metadata_casefold(tags)
|
||||||
|
title = first_nonempty(
|
||||||
|
normalized.get("title"),
|
||||||
|
normalized.get("book_title"),
|
||||||
|
normalized.get("name"),
|
||||||
|
normalized.get("album"),
|
||||||
|
filename,
|
||||||
|
)
|
||||||
|
authors = split_people_field(
|
||||||
|
normalized.get("authors")
|
||||||
|
or normalized.get("author")
|
||||||
|
or normalized.get("album_artist")
|
||||||
|
or normalized.get("artist")
|
||||||
|
)
|
||||||
|
narrators = split_people_field(normalized.get("narrators") or normalized.get("narrator"))
|
||||||
|
description = first_nonempty(
|
||||||
|
normalized.get("description"), normalized.get("summary"), normalized.get("comment")
|
||||||
|
)
|
||||||
|
genres = split_simple_list(normalized.get("genre"))
|
||||||
|
keywords = split_simple_list(normalized.get("tags") or normalized.get("keywords"))
|
||||||
|
lang = first_nonempty(normalized.get("language"), normalized.get("lang")) or language or ""
|
||||||
|
series_name = first_nonempty(
|
||||||
|
normalized.get("series"),
|
||||||
|
normalized.get("series_name"),
|
||||||
|
normalized.get("seriesname"),
|
||||||
|
normalized.get("series_title"),
|
||||||
|
normalized.get("seriestitle"),
|
||||||
|
)
|
||||||
|
|
||||||
|
series_sequence = None
|
||||||
|
for key in _SERIES_SEQUENCE_TAG_KEYS:
|
||||||
|
raw_value = normalized.get(key)
|
||||||
|
seq = normalize_series_sequence(raw_value)
|
||||||
|
if seq:
|
||||||
|
series_sequence = seq
|
||||||
|
break
|
||||||
|
if not series_name:
|
||||||
|
series_sequence = None
|
||||||
|
|
||||||
|
data: Dict[str, Any] = {
|
||||||
|
"title": title,
|
||||||
|
"subtitle": normalized.get("subtitle"),
|
||||||
|
"authors": authors,
|
||||||
|
"narrators": narrators,
|
||||||
|
"description": description,
|
||||||
|
"publisher": normalized.get("publisher"),
|
||||||
|
"genres": genres,
|
||||||
|
"tags": keywords,
|
||||||
|
"language": lang,
|
||||||
|
"publishedYear": extract_year(
|
||||||
|
normalized.get("published")
|
||||||
|
or normalized.get("publication_year")
|
||||||
|
or normalized.get("date")
|
||||||
|
or normalized.get("year")
|
||||||
|
),
|
||||||
|
"seriesName": series_name,
|
||||||
|
"seriesSequence": series_sequence,
|
||||||
|
"isbn": first_nonempty(normalized.get("isbn"), normalized.get("asin")),
|
||||||
|
}
|
||||||
|
published_date = first_nonempty(
|
||||||
|
normalized.get("published"), normalized.get("publication_date"), normalized.get("date")
|
||||||
|
)
|
||||||
|
if published_date:
|
||||||
|
data["publishedDate"] = published_date
|
||||||
|
|
||||||
|
rating_text = first_nonempty(normalized.get("rating"), normalized.get("my_rating"))
|
||||||
|
if rating_text:
|
||||||
|
try:
|
||||||
|
data["rating"] = float(str(rating_text).strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
rating_max_text = first_nonempty(
|
||||||
|
normalized.get("rating_max"), normalized.get("rating_scale")
|
||||||
|
)
|
||||||
|
if rating_max_text:
|
||||||
|
try:
|
||||||
|
data["ratingMax"] = float(str(rating_max_text).strip())
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
cleaned: Dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str) and not value.strip():
|
||||||
|
continue
|
||||||
|
if isinstance(value, (list, tuple)) and not value:
|
||||||
|
continue
|
||||||
|
cleaned[key] = value
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def load_audiobookshelf_chapters(
|
||||||
|
metadata_path: Path,
|
||||||
|
) -> Optional[List[Dict[str, Any]]]:
|
||||||
|
if not metadata_path.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
chapters = payload.get("chapters")
|
||||||
|
if not isinstance(chapters, list):
|
||||||
|
return None
|
||||||
|
cleaned: List[Dict[str, Any]] = []
|
||||||
|
for entry in chapters:
|
||||||
|
if not isinstance(entry, Mapping):
|
||||||
|
continue
|
||||||
|
title = first_nonempty(entry.get("title"), entry.get("original_title"))
|
||||||
|
start = entry.get("start")
|
||||||
|
end = entry.get("end")
|
||||||
|
if title and start is not None and end is not None:
|
||||||
|
cleaned.append({"title": str(title), "start": start, "end": end})
|
||||||
|
return cleaned or None
|
||||||
|
|||||||
@@ -9,6 +9,17 @@ from typing import Any, Dict, List, Optional, Mapping, Sequence
|
|||||||
|
|
||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
|
|
||||||
|
from abogen.domain.metadata_helpers import (
|
||||||
|
normalize_metadata_casefold,
|
||||||
|
split_people_field,
|
||||||
|
split_simple_list,
|
||||||
|
first_nonempty,
|
||||||
|
extract_year,
|
||||||
|
normalize_series_sequence,
|
||||||
|
build_audiobookshelf_metadata as _build_abs_metadata,
|
||||||
|
load_audiobookshelf_chapters as _load_abs_chapters,
|
||||||
|
_SERIES_SEQUENCE_TAG_KEYS,
|
||||||
|
)
|
||||||
from abogen.epub3.exporter import build_epub3_package
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
from abogen.integrations.audiobookshelf import (
|
from abogen.integrations.audiobookshelf import (
|
||||||
AudiobookshelfClient,
|
AudiobookshelfClient,
|
||||||
@@ -305,95 +316,12 @@ class ExportService:
|
|||||||
|
|
||||||
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
|
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
|
||||||
"""Build Audiobookshelf metadata from job."""
|
"""Build Audiobookshelf metadata from job."""
|
||||||
tags = self._normalize_metadata_casefold(getattr(job, "metadata_tags", {}))
|
|
||||||
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
|
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
|
||||||
|
return _build_abs_metadata(
|
||||||
title = self._first_nonempty(
|
getattr(job, "metadata_tags", {}),
|
||||||
tags.get("title"),
|
language=getattr(job, "language", "") or "",
|
||||||
tags.get("book_title"),
|
filename=filename,
|
||||||
tags.get("name"),
|
|
||||||
tags.get("album"),
|
|
||||||
filename,
|
|
||||||
)
|
)
|
||||||
authors = self._split_people_field(
|
|
||||||
tags.get("authors")
|
|
||||||
or tags.get("author")
|
|
||||||
or tags.get("album_artist")
|
|
||||||
or tags.get("artist")
|
|
||||||
)
|
|
||||||
narrators = self._split_people_field(tags.get("narrators") or tags.get("narrator"))
|
|
||||||
description = self._first_nonempty(
|
|
||||||
tags.get("description"), tags.get("summary"), tags.get("comment")
|
|
||||||
)
|
|
||||||
genres = self._split_simple_list(tags.get("genre"))
|
|
||||||
keywords = self._split_simple_list(tags.get("tags") or tags.get("keywords"))
|
|
||||||
language = self._first_nonempty(tags.get("language"), tags.get("lang")) or getattr(job, "language", "") or ""
|
|
||||||
series_name = self._first_nonempty(
|
|
||||||
tags.get("series"),
|
|
||||||
tags.get("series_name"),
|
|
||||||
tags.get("seriesname"),
|
|
||||||
tags.get("series_title"),
|
|
||||||
tags.get("seriestitle"),
|
|
||||||
)
|
|
||||||
|
|
||||||
series_sequence = None
|
|
||||||
for key in ("series_index", "series_position", "series_sequence", "series_number", "seriesnumber", "book_number", "booknumber"):
|
|
||||||
raw = tags.get(key)
|
|
||||||
normalized = self._normalize_series_sequence(raw)
|
|
||||||
if normalized:
|
|
||||||
series_sequence = normalized
|
|
||||||
break
|
|
||||||
if not series_name:
|
|
||||||
series_sequence = None
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"title": title,
|
|
||||||
"subtitle": tags.get("subtitle"),
|
|
||||||
"authors": authors,
|
|
||||||
"narrators": narrators,
|
|
||||||
"description": description,
|
|
||||||
"publisher": tags.get("publisher"),
|
|
||||||
"genres": genres,
|
|
||||||
"tags": keywords,
|
|
||||||
"language": language,
|
|
||||||
"publishedYear": self._extract_year(
|
|
||||||
tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")
|
|
||||||
),
|
|
||||||
"seriesName": series_name,
|
|
||||||
"seriesSequence": series_sequence,
|
|
||||||
"isbn": self._first_nonempty(tags.get("isbn"), tags.get("asin")),
|
|
||||||
}
|
|
||||||
|
|
||||||
published_date = self._first_nonempty(
|
|
||||||
tags.get("published"), tags.get("publication_date"), tags.get("date")
|
|
||||||
)
|
|
||||||
if published_date:
|
|
||||||
data["publishedDate"] = published_date
|
|
||||||
|
|
||||||
rating_text = self._first_nonempty(tags.get("rating"), tags.get("my_rating"))
|
|
||||||
if rating_text:
|
|
||||||
try:
|
|
||||||
data["rating"] = float(str(rating_text).strip())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
rating_max_text = self._first_nonempty(tags.get("rating_max"), tags.get("rating_scale"))
|
|
||||||
if rating_max_text:
|
|
||||||
try:
|
|
||||||
data["ratingMax"] = float(str(rating_max_text).strip())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Remove empty values
|
|
||||||
cleaned = {}
|
|
||||||
for key, value in data.items():
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, str) and not value.strip():
|
|
||||||
continue
|
|
||||||
if isinstance(value, (list, tuple)) and not value:
|
|
||||||
continue
|
|
||||||
cleaned[key] = value
|
|
||||||
return cleaned
|
|
||||||
|
|
||||||
def load_audiobookshelf_chapters(self, job: Any) -> Optional[List[Dict[str, Any]]]:
|
def load_audiobookshelf_chapters(self, job: Any) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Load chapters from job artifacts for Audiobookshelf."""
|
"""Load chapters from job artifacts for Audiobookshelf."""
|
||||||
@@ -401,29 +329,7 @@ class ExportService:
|
|||||||
if not metadata_ref:
|
if not metadata_ref:
|
||||||
return None
|
return None
|
||||||
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
||||||
if not metadata_path.exists():
|
return _load_abs_chapters(metadata_path)
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
chapters = payload.get("chapters")
|
|
||||||
if not isinstance(chapters, list):
|
|
||||||
return None
|
|
||||||
cleaned = []
|
|
||||||
for entry in chapters:
|
|
||||||
if not isinstance(entry, Mapping):
|
|
||||||
continue
|
|
||||||
title = self._first_nonempty(entry.get("title"), entry.get("original_title"))
|
|
||||||
start = entry.get("start")
|
|
||||||
end = entry.get("end")
|
|
||||||
if title is None or not isinstance(start, (int, float)):
|
|
||||||
continue
|
|
||||||
chapter_payload = {"title": title, "start": float(start)}
|
|
||||||
if isinstance(end, (int, float)):
|
|
||||||
chapter_payload["end"] = float(end)
|
|
||||||
cleaned.append(chapter_payload)
|
|
||||||
return cleaned or None
|
|
||||||
|
|
||||||
def upload_audiobookshelf(
|
def upload_audiobookshelf(
|
||||||
self,
|
self,
|
||||||
@@ -520,137 +426,6 @@ class ExportService:
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ----------------------------------------------------------------------
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
||||||
normalized = {}
|
|
||||||
if not values:
|
|
||||||
return normalized
|
|
||||||
for key, value in values.items():
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
key_text = str(key).strip().lower()
|
|
||||||
if not key_text:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
normalized[key_text] = value
|
|
||||||
else:
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
normalized[key_text] = text
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _split_people_field(raw: Any) -> List[str]:
|
|
||||||
if raw is None:
|
|
||||||
return []
|
|
||||||
if isinstance(raw, (list, tuple, set)):
|
|
||||||
results = []
|
|
||||||
for item in raw:
|
|
||||||
results.extend(ExportService._split_people_field(item))
|
|
||||||
return results
|
|
||||||
text = str(raw or "").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
import re
|
|
||||||
tokens = [token.strip() for token in re.split(r"[;,/&]|\band\b", text, flags=re.IGNORECASE) if token.strip()]
|
|
||||||
seen = set()
|
|
||||||
ordered = []
|
|
||||||
for token in tokens:
|
|
||||||
key = token.casefold()
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
ordered.append(token)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _split_simple_list(raw: Any) -> List[str]:
|
|
||||||
if raw is None:
|
|
||||||
return []
|
|
||||||
if isinstance(raw, (list, tuple, set)):
|
|
||||||
results = []
|
|
||||||
for item in raw:
|
|
||||||
results.extend(ExportService._split_simple_list(item))
|
|
||||||
return results
|
|
||||||
text = str(raw or "").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
import re
|
|
||||||
tokens = [token.strip() for token in re.split(r"[;,\n]", text) if token.strip()]
|
|
||||||
seen = set()
|
|
||||||
ordered = []
|
|
||||||
for token in tokens:
|
|
||||||
key = token.casefold()
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
ordered.append(token)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _first_nonempty(*values: Any) -> Optional[str]:
|
|
||||||
for value in values:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
items = list(value)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
value = items[0]
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_year(raw: Optional[str]) -> Optional[int]:
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
text = str(raw).strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
import re
|
|
||||||
match = re.search(r"(19|20)\d{2}", text)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
return int(match.group(0))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = int(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if 0 < parsed < 3000:
|
|
||||||
return parsed
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_series_sequence(raw: Any) -> Optional[str]:
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if isinstance(raw, (int, float)):
|
|
||||||
if isinstance(raw, float) and (raw != raw or raw == float("inf") or raw == float("-inf")):
|
|
||||||
return None
|
|
||||||
text = str(raw)
|
|
||||||
else:
|
|
||||||
text = str(raw).strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
candidate = text.replace(",", ".")
|
|
||||||
import re
|
|
||||||
match = re.search(r"\d+(?:\.\d+)?", candidate)
|
|
||||||
if not match:
|
|
||||||
return None
|
|
||||||
normalized = match.group(0)
|
|
||||||
if "." in normalized:
|
|
||||||
normalized = normalized.rstrip("0").rstrip(".")
|
|
||||||
return normalized or "0"
|
|
||||||
try:
|
|
||||||
return str(int(normalized))
|
|
||||||
except ValueError:
|
|
||||||
cleaned = normalized.lstrip("0")
|
|
||||||
return cleaned or "0"
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||||
if isinstance(value, bool):
|
if isinstance(value, bool):
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import re
|
|
||||||
from contextlib import ExitStack
|
from contextlib import ExitStack
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,6 +10,8 @@ from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from abogen.domain.metadata_helpers import normalize_series_sequence
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -641,40 +641,7 @@ class AudiobookshelfClient:
|
|||||||
for key in preferred_keys:
|
for key in preferred_keys:
|
||||||
if key not in metadata:
|
if key not in metadata:
|
||||||
continue
|
continue
|
||||||
normalized = AudiobookshelfClient._normalize_series_sequence(metadata.get(key))
|
normalized = normalize_series_sequence(metadata.get(key))
|
||||||
if normalized:
|
if normalized:
|
||||||
return normalized
|
return normalized
|
||||||
return ""
|
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"
|
|
||||||
|
|||||||
+17
-257
@@ -2,9 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
@@ -14,7 +12,7 @@ import traceback
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping, Tuple
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Mapping
|
||||||
|
|
||||||
from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config
|
from abogen.utils import get_internal_cache_path, get_user_settings_dir, load_config
|
||||||
from abogen.voice_cache import bootstrap_voice_cache
|
from abogen.voice_cache import bootstrap_voice_cache
|
||||||
@@ -23,6 +21,17 @@ from abogen.integrations.audiobookshelf import (
|
|||||||
AudiobookshelfConfig,
|
AudiobookshelfConfig,
|
||||||
AudiobookshelfUploadError,
|
AudiobookshelfUploadError,
|
||||||
)
|
)
|
||||||
|
from abogen.domain.metadata_helpers import (
|
||||||
|
normalize_metadata_casefold as _normalize_metadata_casefold,
|
||||||
|
split_people_field as _split_people_field,
|
||||||
|
split_simple_list as _split_simple_list,
|
||||||
|
first_nonempty as _first_nonempty,
|
||||||
|
extract_year as _extract_year,
|
||||||
|
normalize_series_sequence as _normalize_series_sequence,
|
||||||
|
build_audiobookshelf_metadata as _build_abs_metadata,
|
||||||
|
load_audiobookshelf_chapters as _load_abs_chapters,
|
||||||
|
_SERIES_SEQUENCE_TAG_KEYS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _create_set_event() -> threading.Event:
|
def _create_set_event() -> threading.Event:
|
||||||
@@ -53,9 +62,6 @@ _JOB_LEVEL_MAP: Dict[str, int] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_PEOPLE_SPLIT_RE = re.compile(r"[;,/&]|\band\b", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def _emit_job_log(job_id: str, level: str, message: str) -> None:
|
def _emit_job_log(job_id: str, level: str, message: str) -> None:
|
||||||
normalized = (level or "info").lower()
|
normalized = (level or "info").lower()
|
||||||
log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO)
|
log_level = _JOB_LEVEL_MAP.get(normalized, logging.INFO)
|
||||||
@@ -252,234 +258,13 @@ class Job:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _normalize_metadata_casefold(values: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
|
|
||||||
normalized: Dict[str, Any] = {}
|
|
||||||
if not values:
|
|
||||||
return normalized
|
|
||||||
for key, value in values.items():
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
key_text = str(key).strip().lower()
|
|
||||||
if not key_text:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
normalized[key_text] = value
|
|
||||||
else:
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
normalized[key_text] = text
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _split_people_field(raw: Any) -> List[str]:
|
|
||||||
if raw is None:
|
|
||||||
return []
|
|
||||||
if isinstance(raw, (list, tuple, set)):
|
|
||||||
results: List[str] = []
|
|
||||||
for item in raw:
|
|
||||||
results.extend(_split_people_field(item))
|
|
||||||
return results
|
|
||||||
text = str(raw or "").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
tokens = [_token.strip() for _token in _PEOPLE_SPLIT_RE.split(text) if _token.strip()]
|
|
||||||
seen: set[str] = set()
|
|
||||||
ordered: List[str] = []
|
|
||||||
for token in tokens:
|
|
||||||
key = token.casefold()
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
ordered.append(token)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
_LIST_SPLIT_RE = re.compile(r"[;,\n]")
|
|
||||||
_SERIES_SEQUENCE_NUMBER_RE = re.compile(r"\d+(?:\.\d+)?")
|
|
||||||
|
|
||||||
_SERIES_SEQUENCE_TAG_KEYS: Tuple[str, ...] = (
|
|
||||||
"series_index",
|
|
||||||
"series_position",
|
|
||||||
"series_sequence",
|
|
||||||
"series_number",
|
|
||||||
"seriesnumber",
|
|
||||||
"book_number",
|
|
||||||
"booknumber",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _split_simple_list(raw: Any) -> List[str]:
|
|
||||||
if raw is None:
|
|
||||||
return []
|
|
||||||
if isinstance(raw, (list, tuple, set)):
|
|
||||||
results: List[str] = []
|
|
||||||
for item in raw:
|
|
||||||
results.extend(_split_simple_list(item))
|
|
||||||
return results
|
|
||||||
text = str(raw or "").strip()
|
|
||||||
if not text:
|
|
||||||
return []
|
|
||||||
tokens = [_token.strip() for _token in _LIST_SPLIT_RE.split(text) if _token.strip()]
|
|
||||||
seen: set[str] = set()
|
|
||||||
ordered: List[str] = []
|
|
||||||
for token in tokens:
|
|
||||||
key = token.casefold()
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
ordered.append(token)
|
|
||||||
return ordered
|
|
||||||
|
|
||||||
|
|
||||||
def _first_nonempty(*values: Any) -> Optional[str]:
|
|
||||||
for value in values:
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
items = list(value)
|
|
||||||
if not items:
|
|
||||||
continue
|
|
||||||
value = items[0]
|
|
||||||
text = str(value).strip()
|
|
||||||
if text:
|
|
||||||
return text
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_year(raw: Optional[str]) -> Optional[int]:
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
text = str(raw).strip()
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
match = re.search(r"(19|20)\d{2}", text)
|
|
||||||
if match:
|
|
||||||
try:
|
|
||||||
return int(match.group(0))
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = int(text)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if 0 < parsed < 3000:
|
|
||||||
return parsed
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
|
def build_audiobookshelf_metadata(job: Job) -> Dict[str, Any]:
|
||||||
tags = _normalize_metadata_casefold(job.metadata_tags)
|
|
||||||
filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook"
|
filename = Path(job.original_filename or "").stem or job.original_filename or "Audiobook"
|
||||||
title = _first_nonempty(
|
return _build_abs_metadata(
|
||||||
tags.get("title"),
|
job.metadata_tags,
|
||||||
tags.get("book_title"),
|
language=job.language or "",
|
||||||
tags.get("name"),
|
filename=filename,
|
||||||
tags.get("album"),
|
|
||||||
filename,
|
|
||||||
)
|
)
|
||||||
authors = _split_people_field(
|
|
||||||
tags.get("authors")
|
|
||||||
or tags.get("author")
|
|
||||||
or tags.get("album_artist")
|
|
||||||
or tags.get("artist")
|
|
||||||
)
|
|
||||||
narrators = _split_people_field(tags.get("narrators") or tags.get("narrator"))
|
|
||||||
description = _first_nonempty(tags.get("description"), tags.get("summary"), tags.get("comment"))
|
|
||||||
genres = _split_simple_list(tags.get("genre"))
|
|
||||||
keywords = _split_simple_list(tags.get("tags") or tags.get("keywords"))
|
|
||||||
language = _first_nonempty(tags.get("language"), tags.get("lang")) or job.language or ""
|
|
||||||
series_name = _first_nonempty(
|
|
||||||
tags.get("series"),
|
|
||||||
tags.get("series_name"),
|
|
||||||
tags.get("seriesname"),
|
|
||||||
tags.get("series_title"),
|
|
||||||
tags.get("seriestitle"),
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
if not series_name:
|
|
||||||
series_sequence = None
|
|
||||||
data: Dict[str, Any] = {
|
|
||||||
"title": title,
|
|
||||||
"subtitle": tags.get("subtitle"),
|
|
||||||
"authors": authors,
|
|
||||||
"narrators": narrators,
|
|
||||||
"description": description,
|
|
||||||
"publisher": tags.get("publisher"),
|
|
||||||
"genres": genres,
|
|
||||||
"tags": keywords,
|
|
||||||
"language": language,
|
|
||||||
"publishedYear": _extract_year(tags.get("published") or tags.get("publication_year") or tags.get("date") or tags.get("year")),
|
|
||||||
"seriesName": series_name,
|
|
||||||
"seriesSequence": series_sequence,
|
|
||||||
"isbn": _first_nonempty(tags.get("isbn"), tags.get("asin")),
|
|
||||||
}
|
|
||||||
published_date = _first_nonempty(tags.get("published"), tags.get("publication_date"), tags.get("date"))
|
|
||||||
if published_date:
|
|
||||||
data["publishedDate"] = published_date
|
|
||||||
|
|
||||||
rating_text = _first_nonempty(tags.get("rating"), tags.get("my_rating"))
|
|
||||||
if rating_text:
|
|
||||||
try:
|
|
||||||
data["rating"] = float(str(rating_text).strip())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
rating_max_text = _first_nonempty(tags.get("rating_max"), tags.get("rating_scale"))
|
|
||||||
if rating_max_text:
|
|
||||||
try:
|
|
||||||
data["ratingMax"] = float(str(rating_max_text).strip())
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
# Remove empty values
|
|
||||||
cleaned: Dict[str, Any] = {}
|
|
||||||
for key, value in data.items():
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
if isinstance(value, str) and not value.strip():
|
|
||||||
continue
|
|
||||||
if isinstance(value, (list, tuple)) and not value:
|
|
||||||
continue
|
|
||||||
cleaned[key] = value
|
|
||||||
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]]]:
|
||||||
@@ -487,32 +272,7 @@ def load_audiobookshelf_chapters(job: Job) -> Optional[List[Dict[str, Any]]]:
|
|||||||
if not metadata_ref:
|
if not metadata_ref:
|
||||||
return None
|
return None
|
||||||
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
||||||
if not metadata_path.exists():
|
return _load_abs_chapters(metadata_path)
|
||||||
return None
|
|
||||||
try:
|
|
||||||
payload = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
||||||
except (OSError, json.JSONDecodeError):
|
|
||||||
return None
|
|
||||||
chapters = payload.get("chapters")
|
|
||||||
if not isinstance(chapters, list):
|
|
||||||
return None
|
|
||||||
cleaned: List[Dict[str, Any]] = []
|
|
||||||
for entry in chapters:
|
|
||||||
if not isinstance(entry, Mapping):
|
|
||||||
continue
|
|
||||||
title = _first_nonempty(entry.get("title"), entry.get("original_title"))
|
|
||||||
start = entry.get("start")
|
|
||||||
end = entry.get("end")
|
|
||||||
if title is None or not isinstance(start, (int, float)):
|
|
||||||
continue
|
|
||||||
chapter_payload: Dict[str, Any] = {
|
|
||||||
"title": title,
|
|
||||||
"start": float(start),
|
|
||||||
}
|
|
||||||
if isinstance(end, (int, float)):
|
|
||||||
chapter_payload["end"] = float(end)
|
|
||||||
cleaned.append(chapter_payload)
|
|
||||||
return cleaned or None
|
|
||||||
|
|
||||||
|
|
||||||
def _existing_paths(paths: Iterable[Any]) -> List[Path]:
|
def _existing_paths(paths: Iterable[Any]) -> List[Path]:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Tests for domain/metadata_helpers.py Audiobookshelf helpers."""
|
||||||
|
from abogen.domain.metadata_helpers import (
|
||||||
|
normalize_metadata_casefold,
|
||||||
|
split_people_field,
|
||||||
|
split_simple_list,
|
||||||
|
first_nonempty,
|
||||||
|
extract_year,
|
||||||
|
normalize_series_sequence,
|
||||||
|
build_audiobookshelf_metadata,
|
||||||
|
load_audiobookshelf_chapters,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- normalize_metadata_casefold ---
|
||||||
|
|
||||||
|
def test_normalize_metadata_casefold_basic():
|
||||||
|
result = normalize_metadata_casefold({"Title": " Hello ", "Author": None, "": "skip"})
|
||||||
|
assert result == {"title": "Hello", "author": ""} or result == {"title": "Hello"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_metadata_casefold_preserves_lists():
|
||||||
|
result = normalize_metadata_casefold({"tags": ["a", "b"]})
|
||||||
|
assert result["tags"] == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- split_people_field ---
|
||||||
|
|
||||||
|
def test_split_people_field_single():
|
||||||
|
assert split_people_field("J.K. Rowling") == ["J.K. Rowling"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_people_field_multiple():
|
||||||
|
result = split_people_field("Tolkien, Lewis & Martin")
|
||||||
|
assert "Tolkien" in result
|
||||||
|
assert "Lewis" in result
|
||||||
|
assert "Martin" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_people_field_deduplicates():
|
||||||
|
result = split_people_field("Tolkien, tolkien, TOLKIEN")
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_people_field_none():
|
||||||
|
assert split_people_field(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_people_field_list():
|
||||||
|
result = split_people_field(["Author A", "Author B"])
|
||||||
|
assert result == ["Author A", "Author B"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- split_simple_list ---
|
||||||
|
|
||||||
|
def test_split_simple_list_basic():
|
||||||
|
result = split_simple_list("fantasy, sci-fi; thriller")
|
||||||
|
assert "fantasy" in result
|
||||||
|
assert "sci-fi" in result
|
||||||
|
assert "thriller" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_split_simple_list_none():
|
||||||
|
assert split_simple_list(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
# --- first_nonempty ---
|
||||||
|
|
||||||
|
def test_first_nonempty_basic():
|
||||||
|
assert first_nonempty(None, "", "hello") == "hello"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_nonempty_first_wins():
|
||||||
|
assert first_nonempty("first", "second") == "first"
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_nonempty_none():
|
||||||
|
assert first_nonempty(None, None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_first_nonempty_list():
|
||||||
|
assert first_nonempty(None, ["a", "b"]) == "a"
|
||||||
|
|
||||||
|
|
||||||
|
# --- extract_year ---
|
||||||
|
|
||||||
|
def test_extract_year_full_date():
|
||||||
|
assert extract_year("Published on 2023-05-15") == 2023
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_year_plain():
|
||||||
|
assert extract_year("2020") == 2020
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_year_none():
|
||||||
|
assert extract_year(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_year_invalid():
|
||||||
|
assert extract_year("no year here") is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- normalize_series_sequence ---
|
||||||
|
|
||||||
|
def test_normalize_series_sequence_int():
|
||||||
|
assert normalize_series_sequence(3) == "3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_series_sequence_float():
|
||||||
|
assert normalize_series_sequence(2.5) == "2.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_series_sequence_string():
|
||||||
|
assert normalize_series_sequence(" 12 ") == "12"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_series_sequence_comma():
|
||||||
|
assert normalize_series_sequence("1,5") == "1.5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_series_sequence_none():
|
||||||
|
assert normalize_series_sequence(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_normalize_series_sequence_nan():
|
||||||
|
import math
|
||||||
|
assert normalize_series_sequence(float("nan")) is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- build_audiobookshelf_metadata ---
|
||||||
|
|
||||||
|
def test_build_audiobookshelf_metadata_basic():
|
||||||
|
tags = {
|
||||||
|
"title": "My Book",
|
||||||
|
"author": "Author Name",
|
||||||
|
"description": "A great book",
|
||||||
|
}
|
||||||
|
result = build_audiobookshelf_metadata(tags, language="en")
|
||||||
|
assert result["title"] == "My Book"
|
||||||
|
assert result["authors"] == ["Author Name"]
|
||||||
|
assert result["language"] == "en"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audiobookshelf_metadata_series():
|
||||||
|
tags = {
|
||||||
|
"title": "Book 2",
|
||||||
|
"series": "My Series",
|
||||||
|
"series_index": "2",
|
||||||
|
}
|
||||||
|
result = build_audiobookshelf_metadata(tags, language="en")
|
||||||
|
assert result["seriesName"] == "My Series"
|
||||||
|
assert result["seriesSequence"] == "2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audiobookshelf_metadata_fallback_title():
|
||||||
|
tags = {"author": "Someone"}
|
||||||
|
result = build_audiobookshelf_metadata(tags, language="en", filename="chapter1")
|
||||||
|
assert result["title"] == "chapter1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audiobookshelf_metadata_empty():
|
||||||
|
result = build_audiobookshelf_metadata({}, language="en")
|
||||||
|
assert result["language"] == "en"
|
||||||
|
assert "authors" not in result # empty list stripped
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_audiobookshelf_metadata_strips_empty():
|
||||||
|
tags = {"title": "Book", "subtitle": "", "description": None}
|
||||||
|
result = build_audiobookshelf_metadata(tags, language="en")
|
||||||
|
assert "subtitle" not in result
|
||||||
|
assert "description" not in result
|
||||||
Reference in New Issue
Block a user