mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 05:40:26 +02:00
refactor: migrate FFmpeg metadata functions to infrastructure/exporters.py
- Extract FFmpeg metadata functions to infrastructure/exporters.py as ExportService - _escape_ffmetadata_value → _escape_ffmetadata_value - _render_ffmetadata → render_ffmetadata - _write_ffmetadata_file → write_ffmetadata_file - _metadata_to_ffmpeg_args → _metadata_to_ffmpeg_args - _apply_m4b_chapters_with_mutagen → _apply_m4b_chapters_mutagen - _embed_m4b_metadata → embed_m4b_metadata - Add tests/test_exporters.py with 28 tests for ExportService - Update tests/test_ffmetadata.py to use ExportService - Update conversion_runner.py to use ExportService - All tests pass with new implementation matching old behavior
This commit is contained in:
@@ -0,0 +1,673 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Mapping, Sequence
|
||||||
|
|
||||||
|
import static_ffmpeg
|
||||||
|
|
||||||
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
|
from abogen.integrations.audiobookshelf import (
|
||||||
|
AudiobookshelfClient,
|
||||||
|
AudiobookshelfConfig,
|
||||||
|
AudiobookshelfUploadError,
|
||||||
|
)
|
||||||
|
from abogen.utils import create_process
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExportConfig:
|
||||||
|
"""Configuration for export operations."""
|
||||||
|
ffmpeg_path: str = "ffmpeg"
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ExportService:
|
||||||
|
"""Unified service for audiobook exports (M4B, FFMETADATA, EPUB3, Audiobookshelf)."""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[ExportConfig] = None):
|
||||||
|
self.config = config or ExportConfig()
|
||||||
|
static_ffmpeg.add_paths()
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# FFMETADATA
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_ffmetadata(
|
||||||
|
self,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
) -> str:
|
||||||
|
"""Render FFMETADATA content."""
|
||||||
|
lines = [";FFMETADATA1"]
|
||||||
|
|
||||||
|
for key, value in (metadata or {}).items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
key_str = str(key).strip()
|
||||||
|
if not key_str:
|
||||||
|
continue
|
||||||
|
lines.append(f"{key_str}={self._escape_ffmetadata_value(value)}")
|
||||||
|
|
||||||
|
for chapter in chapters or []:
|
||||||
|
start = chapter.get("start")
|
||||||
|
end = chapter.get("end")
|
||||||
|
if start is None or end is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start_ms = max(0, int(round(float(start) * 1000)))
|
||||||
|
end_ms = int(round(float(end) * 1000))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if end_ms <= start_ms:
|
||||||
|
end_ms = start_ms + 1
|
||||||
|
lines.append("[CHAPTER]")
|
||||||
|
lines.append("TIMEBASE=1/1000")
|
||||||
|
lines.append(f"START={start_ms}")
|
||||||
|
lines.append(f"END={end_ms}")
|
||||||
|
title = chapter.get("title")
|
||||||
|
if title:
|
||||||
|
lines.append(f"title={self._escape_ffmetadata_value(title)}")
|
||||||
|
voice = chapter.get("voice")
|
||||||
|
if voice:
|
||||||
|
lines.append(f"voice={self._escape_ffmetadata_value(voice)}")
|
||||||
|
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _escape_ffmetadata_value(value: Any) -> str:
|
||||||
|
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
|
||||||
|
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
|
||||||
|
return escaped
|
||||||
|
|
||||||
|
def write_ffmetadata_file(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Write FFMETADATA file to temp location."""
|
||||||
|
content = self.render_ffmetadata(metadata, chapters)
|
||||||
|
if content.strip() == ";FFMETADATA1":
|
||||||
|
return None
|
||||||
|
|
||||||
|
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w",
|
||||||
|
encoding="utf-8",
|
||||||
|
suffix=".ffmeta",
|
||||||
|
delete=False,
|
||||||
|
dir=str(directory),
|
||||||
|
) as handle:
|
||||||
|
handle.write(content)
|
||||||
|
return Path(handle.name)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# M4B Export
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def embed_m4b_metadata(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
cover_path: Optional[Path] = None,
|
||||||
|
cover_mime: Optional[str] = None,
|
||||||
|
log_callback: Optional[callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Embed metadata and chapters into M4B file using FFmpeg + Mutagen."""
|
||||||
|
ffmetadata_path = self.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||||
|
|
||||||
|
metadata_args = self._metadata_to_ffmpeg_args(metadata)
|
||||||
|
|
||||||
|
cmd = ["ffmpeg", "-y", "-i", str(audio_path)]
|
||||||
|
|
||||||
|
if ffmetadata_path:
|
||||||
|
cmd.extend(["-f", "ffmetadata", "-i", str(ffmetadata_path)])
|
||||||
|
|
||||||
|
if cover_path and cover_path.exists():
|
||||||
|
cmd.extend(["-i", str(cover_path)])
|
||||||
|
cmd.extend(["-map", "0:a"])
|
||||||
|
cmd.extend(["-map", "1:v:0", "-c:v:0", "mjpeg", "-disposition:v:0", "attached_pic"])
|
||||||
|
if cover_mime:
|
||||||
|
cmd.extend(["-metadata:s:v:0", f"mimetype={cover_mime}"])
|
||||||
|
cmd.extend(["-metadata:s:v:0", "title=Cover Art"])
|
||||||
|
else:
|
||||||
|
cmd.extend(["-map", "0:a"])
|
||||||
|
|
||||||
|
cmd.extend(["-c:a", "copy"])
|
||||||
|
|
||||||
|
if ffmetadata_path:
|
||||||
|
cmd.extend(["-map_metadata", "1", "-map_chapters", "1"])
|
||||||
|
else:
|
||||||
|
cmd.extend(["-map_metadata", "0"])
|
||||||
|
|
||||||
|
if metadata_args:
|
||||||
|
cmd.extend(metadata_args)
|
||||||
|
|
||||||
|
cmd.extend(["-movflags", "+faststart+use_metadata_tags"])
|
||||||
|
|
||||||
|
temp_output = audio_path.with_suffix(audio_path.suffix + ".tmp")
|
||||||
|
if audio_path.suffix.lower() in {".m4b", ".mp4", ".m4a"}:
|
||||||
|
cmd.extend(["-f", "mp4"])
|
||||||
|
cmd.append(str(temp_output))
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Embedding metadata into M4B output")
|
||||||
|
|
||||||
|
process = create_process(cmd, text=True)
|
||||||
|
return_code = process.wait()
|
||||||
|
|
||||||
|
if ffmetadata_path and ffmetadata_path.exists():
|
||||||
|
try:
|
||||||
|
ffmetadata_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if return_code != 0:
|
||||||
|
if temp_output.exists():
|
||||||
|
temp_output.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError(f"ffmpeg failed to embed metadata (exit code {return_code})")
|
||||||
|
|
||||||
|
temp_output.replace(audio_path)
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Embedded metadata and chapters into M4B output", "info")
|
||||||
|
|
||||||
|
# Apply chapters via Mutagen for better compatibility
|
||||||
|
self._apply_m4b_chapters_mutagen(audio_path, chapters, log_callback)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
|
||||||
|
args = []
|
||||||
|
for key, value in (metadata or {}).items():
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
key_str = str(key).strip()
|
||||||
|
if not key_str:
|
||||||
|
continue
|
||||||
|
normalized_key = key_str.lower()
|
||||||
|
if normalized_key == "year":
|
||||||
|
ffmpeg_key = "date"
|
||||||
|
else:
|
||||||
|
ffmpeg_key = key_str
|
||||||
|
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
||||||
|
return args
|
||||||
|
|
||||||
|
def _apply_m4b_chapters_mutagen(
|
||||||
|
self,
|
||||||
|
audio_path: Path,
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
log_callback: Optional[callable] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Apply chapter atoms using Mutagen."""
|
||||||
|
if not chapters:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from fractions import Fraction
|
||||||
|
from mutagen.mp4 import MP4, MP4Chapter
|
||||||
|
except ImportError:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Unable to write MP4 chapter atoms because mutagen is not installed.", "warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
mp4 = MP4(str(audio_path))
|
||||||
|
except Exception as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Failed to open m4b for chapter embedding: {exc}", "warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
chapter_objects = []
|
||||||
|
for index, entry in enumerate(sorted(chapters, key=lambda item: float(item.get("start") or 0.0))):
|
||||||
|
start_raw = entry.get("start")
|
||||||
|
if start_raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
start_seconds = max(0.0, float(start_raw))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
title_value = entry.get("title")
|
||||||
|
title_text = str(title_value) if title_value else f"Chapter {index + 1}"
|
||||||
|
|
||||||
|
start_fraction = Fraction(int(round(start_seconds * 1000)), 1000)
|
||||||
|
chapter_atom = MP4Chapter(start_fraction, title_text)
|
||||||
|
|
||||||
|
end_raw = entry.get("end")
|
||||||
|
if end_raw is not None:
|
||||||
|
try:
|
||||||
|
end_seconds = float(end_raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
end_seconds = None
|
||||||
|
if end_seconds is not None and end_seconds > start_seconds:
|
||||||
|
chapter_atom.end = Fraction(int(round(end_seconds * 1000)), 1000)
|
||||||
|
|
||||||
|
chapter_objects.append(chapter_atom)
|
||||||
|
|
||||||
|
if not chapter_objects:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
mp4.chapters = chapter_objects
|
||||||
|
mp4.save()
|
||||||
|
except Exception as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Failed to persist MP4 chapter atoms: {exc}", "warning")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Applied {len(chapter_objects)} chapter markers via mutagen", "info")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# EPUB3 Export
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def export_epub3(
|
||||||
|
self,
|
||||||
|
output_path: Path,
|
||||||
|
book_id: str,
|
||||||
|
extraction: Any, # ExtractionResult
|
||||||
|
metadata_tags: Dict[str, Any],
|
||||||
|
chapter_markers: Sequence[Dict[str, Any]],
|
||||||
|
chunk_markers: Sequence[Dict[str, Any]],
|
||||||
|
chunks: Iterable[Dict[str, Any]],
|
||||||
|
audio_path: Path,
|
||||||
|
speaker_mode: str = "single",
|
||||||
|
cover_path: Optional[Path] = None,
|
||||||
|
cover_mime: Optional[str] = None,
|
||||||
|
) -> Path:
|
||||||
|
"""Export EPUB3 with media overlays."""
|
||||||
|
return build_epub3_package(
|
||||||
|
output_path=output_path,
|
||||||
|
book_id=book_id,
|
||||||
|
extraction=extraction,
|
||||||
|
metadata_tags=metadata_tags,
|
||||||
|
chapter_markers=chapter_markers,
|
||||||
|
chunk_markers=chunk_markers,
|
||||||
|
chunks=chunks,
|
||||||
|
audio_path=audio_path,
|
||||||
|
speaker_mode=speaker_mode,
|
||||||
|
cover_image_path=cover_path,
|
||||||
|
cover_image_mime=cover_mime,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Audiobookshelf Integration
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_audiobookshelf_metadata(self, job: Any) -> Dict[str, Any]:
|
||||||
|
"""Build Audiobookshelf metadata from job."""
|
||||||
|
tags = self._normalize_metadata_casefold(getattr(job, "metadata_tags", {}))
|
||||||
|
filename = Path(getattr(job, "original_filename", "") or "").stem or "Audiobook"
|
||||||
|
|
||||||
|
title = self._first_nonempty(
|
||||||
|
tags.get("title"),
|
||||||
|
tags.get("book_title"),
|
||||||
|
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]]]:
|
||||||
|
"""Load chapters from job artifacts for Audiobookshelf."""
|
||||||
|
metadata_ref = job.result.artifacts.get("metadata") if getattr(job, "result", None) else None
|
||||||
|
if not metadata_ref:
|
||||||
|
return None
|
||||||
|
metadata_path = metadata_ref if isinstance(metadata_ref, Path) else Path(str(metadata_ref))
|
||||||
|
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 = []
|
||||||
|
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(
|
||||||
|
self,
|
||||||
|
job: Any,
|
||||||
|
audio_path: Path,
|
||||||
|
subtitle_paths: List[Path],
|
||||||
|
chapters: List[Dict[str, Any]],
|
||||||
|
metadata: Dict[str, Any],
|
||||||
|
cover_path: Optional[Path] = None,
|
||||||
|
config: Optional[AudiobookshelfConfig] = None,
|
||||||
|
log_callback: Optional[callable] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Upload to Audiobookshelf."""
|
||||||
|
if config is None:
|
||||||
|
# Load from job or global config
|
||||||
|
cfg = getattr(job, "_abs_config", None)
|
||||||
|
if cfg is None:
|
||||||
|
from abogen.utils import load_config
|
||||||
|
global_cfg = load_config() or {}
|
||||||
|
abs_cfg = global_cfg.get("audiobookshelf")
|
||||||
|
if isinstance(abs_cfg, Mapping):
|
||||||
|
config = AudiobookshelfConfig(
|
||||||
|
base_url=str(abs_cfg.get("base_url") or "").strip(),
|
||||||
|
api_token=str(abs_cfg.get("api_token") or "").strip(),
|
||||||
|
library_id=str(abs_cfg.get("library_id") or "").strip(),
|
||||||
|
collection_id=(str(abs_cfg.get("collection_id") or "").strip() or None),
|
||||||
|
folder_id=str(abs_cfg.get("folder_id") or "").strip(),
|
||||||
|
verify_ssl=self._coerce_bool(abs_cfg.get("verify_ssl"), True),
|
||||||
|
send_cover=self._coerce_bool(abs_cfg.get("send_cover"), True),
|
||||||
|
send_chapters=self._coerce_bool(abs_cfg.get("send_chapters"), True),
|
||||||
|
send_subtitles=self._coerce_bool(abs_cfg.get("send_subtitles"), False),
|
||||||
|
timeout=float(abs_cfg.get("timeout", 3600.0)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: not configured", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not config.base_url or not config.api_token or not config.library_id:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: configure base URL, API token, and library ID first", "warning")
|
||||||
|
return
|
||||||
|
if not config.folder_id:
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: enter folder name or ID in settings", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not audio_path.exists():
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload skipped: audio output not found", "warning")
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_subtitles = [p for p in subtitle_paths if p.exists()] if config.send_subtitles else None
|
||||||
|
chapters_to_send = chapters if config.send_chapters else None
|
||||||
|
|
||||||
|
client = AudiobookshelfClient(config)
|
||||||
|
|
||||||
|
display_title = metadata.get("title") or audio_path.stem
|
||||||
|
try:
|
||||||
|
existing_items = client.find_existing_items(display_title, folder_id=config.folder_id)
|
||||||
|
except AudiobookshelfUploadError as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Audiobookshelf lookup failed: {exc}", "error")
|
||||||
|
return
|
||||||
|
|
||||||
|
if existing_items:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Removing existing Audiobookshelf item(s) for '{display_title}' before upload.", "info")
|
||||||
|
try:
|
||||||
|
client.delete_items(existing_items)
|
||||||
|
except Exception as exc:
|
||||||
|
if log_callback:
|
||||||
|
log_callback(f"Failed to remove existing item(s): {exc}", "warning")
|
||||||
|
|
||||||
|
cover_to_send = cover_path
|
||||||
|
if config.send_cover and cover_to_send:
|
||||||
|
if isinstance(cover_to_send, str):
|
||||||
|
cover_to_send = Path(cover_to_send)
|
||||||
|
if not cover_to_send.exists():
|
||||||
|
cover_to_send = None
|
||||||
|
|
||||||
|
client.upload_audiobook(
|
||||||
|
audio_path,
|
||||||
|
metadata=metadata,
|
||||||
|
cover_path=cover_to_send,
|
||||||
|
chapters=chapters_to_send,
|
||||||
|
subtitles=existing_subtitles,
|
||||||
|
)
|
||||||
|
|
||||||
|
if log_callback:
|
||||||
|
log_callback("Audiobookshelf upload queued.", "info")
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 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
|
||||||
|
def _coerce_bool(value: Any, default: bool = True) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
lowered = value.strip().lower()
|
||||||
|
if lowered in {"true", "1", "yes", "on"}:
|
||||||
|
return True
|
||||||
|
if lowered in {"false", "0", "no", "off"}:
|
||||||
|
return False
|
||||||
|
return default
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ExportConfig",
|
||||||
|
"ExportService",
|
||||||
|
]
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import math
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
|
||||||
import traceback
|
import traceback
|
||||||
import gc
|
import gc
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -21,6 +19,7 @@ import soundfile as sf
|
|||||||
import static_ffmpeg
|
import static_ffmpeg
|
||||||
|
|
||||||
from abogen.tts_plugin.utils import get_voices, is_plugin_registered, resolve_voice_to_plugin
|
from abogen.tts_plugin.utils import get_voices, is_plugin_registered, resolve_voice_to_plugin
|
||||||
|
from abogen.infrastructure.exporters import ExportService
|
||||||
from abogen.epub3.exporter import build_epub3_package
|
from abogen.epub3.exporter import build_epub3_package
|
||||||
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
|
from abogen.kokoro_text_normalization import ApostropheConfig, normalize_for_pipeline, HAS_NUM2WORDS
|
||||||
from abogen.normalization_settings import (
|
from abogen.normalization_settings import (
|
||||||
@@ -52,6 +51,8 @@ from abogen.infrastructure.subtitle_writer import create_subtitle_writer, Subtit
|
|||||||
from .service import Job, JobStatus
|
from .service import Job, JobStatus
|
||||||
|
|
||||||
|
|
||||||
|
_export_svc = ExportService()
|
||||||
|
|
||||||
SPLIT_PATTERN = r"\n+"
|
SPLIT_PATTERN = r"\n+"
|
||||||
SAMPLE_RATE = 24000
|
SAMPLE_RATE = 24000
|
||||||
|
|
||||||
@@ -1277,85 +1278,6 @@ def _chunk_text_for_tts(entry: Mapping[str, Any]) -> str:
|
|||||||
).strip()
|
).strip()
|
||||||
|
|
||||||
|
|
||||||
def _escape_ffmetadata_value(value: str) -> str:
|
|
||||||
escaped = str(value).replace("\\", "\\\\").replace("\n", "\\n")
|
|
||||||
escaped = escaped.replace("=", "\\=").replace(";", "\\;").replace("#", "\\#")
|
|
||||||
return escaped
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_to_ffmpeg_args(metadata: Dict[str, Any]) -> List[str]:
|
|
||||||
args: List[str] = []
|
|
||||||
for key, value in (metadata or {}).items():
|
|
||||||
if value in (None, ""):
|
|
||||||
continue
|
|
||||||
key_str = str(key).strip()
|
|
||||||
if not key_str:
|
|
||||||
continue
|
|
||||||
normalized_key = key_str.lower()
|
|
||||||
if normalized_key == "year":
|
|
||||||
ffmpeg_key = "date"
|
|
||||||
else:
|
|
||||||
ffmpeg_key = key_str
|
|
||||||
args.extend(["-metadata", f"{ffmpeg_key}={value}"])
|
|
||||||
return args
|
|
||||||
|
|
||||||
|
|
||||||
def _render_ffmetadata(metadata: Dict[str, Any], chapters: List[Dict[str, Any]]) -> str:
|
|
||||||
lines: List[str] = [";FFMETADATA1"]
|
|
||||||
for key, value in (metadata or {}).items():
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
key_str = str(key).strip()
|
|
||||||
if not key_str:
|
|
||||||
continue
|
|
||||||
lines.append(f"{key_str}={_escape_ffmetadata_value(value)}")
|
|
||||||
|
|
||||||
for chapter in chapters or []:
|
|
||||||
start = chapter.get("start")
|
|
||||||
end = chapter.get("end")
|
|
||||||
if start is None or end is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
start_ms = max(0, int(round(float(start) * 1000)))
|
|
||||||
end_ms = int(round(float(end) * 1000))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if end_ms <= start_ms:
|
|
||||||
end_ms = start_ms + 1
|
|
||||||
lines.append("[CHAPTER]")
|
|
||||||
lines.append("TIMEBASE=1/1000")
|
|
||||||
lines.append(f"START={start_ms}")
|
|
||||||
lines.append(f"END={end_ms}")
|
|
||||||
title = chapter.get("title")
|
|
||||||
if title:
|
|
||||||
lines.append(f"title={_escape_ffmetadata_value(title)}")
|
|
||||||
voice = chapter.get("voice")
|
|
||||||
if voice:
|
|
||||||
lines.append(f"voice={_escape_ffmetadata_value(voice)}")
|
|
||||||
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def _write_ffmetadata_file(
|
|
||||||
audio_path: Path,
|
|
||||||
metadata: Dict[str, Any],
|
|
||||||
chapters: List[Dict[str, Any]],
|
|
||||||
) -> Optional[Path]:
|
|
||||||
content = _render_ffmetadata(metadata, chapters)
|
|
||||||
if content.strip() == ";FFMETADATA1":
|
|
||||||
return None
|
|
||||||
directory = audio_path.parent if audio_path.parent.exists() else Path(tempfile.gettempdir())
|
|
||||||
with tempfile.NamedTemporaryFile(
|
|
||||||
mode="w",
|
|
||||||
encoding="utf-8",
|
|
||||||
suffix=".ffmeta",
|
|
||||||
delete=False,
|
|
||||||
dir=str(directory),
|
|
||||||
) as handle:
|
|
||||||
handle.write(content)
|
|
||||||
return Path(handle.name)
|
|
||||||
|
|
||||||
|
|
||||||
def _apply_m4b_chapters_with_mutagen(
|
def _apply_m4b_chapters_with_mutagen(
|
||||||
audio_path: Path,
|
audio_path: Path,
|
||||||
chapters: List[Dict[str, Any]],
|
chapters: List[Dict[str, Any]],
|
||||||
@@ -1427,14 +1349,14 @@ def _embed_m4b_metadata(
|
|||||||
) -> None:
|
) -> None:
|
||||||
metadata_map = dict(metadata_payload.get("metadata") or {})
|
metadata_map = dict(metadata_payload.get("metadata") or {})
|
||||||
chapter_entries = list(metadata_payload.get("chapters") or [])
|
chapter_entries = list(metadata_payload.get("chapters") or [])
|
||||||
ffmetadata_path = _write_ffmetadata_file(audio_path, metadata_map, chapter_entries)
|
ffmetadata_path = _export_svc.write_ffmetadata_file(audio_path, metadata_map, chapter_entries)
|
||||||
cover_path: Optional[Path] = None
|
cover_path: Optional[Path] = None
|
||||||
if job.cover_image_path:
|
if job.cover_image_path:
|
||||||
candidate = Path(job.cover_image_path)
|
candidate = Path(job.cover_image_path)
|
||||||
if candidate.exists():
|
if candidate.exists():
|
||||||
cover_path = candidate
|
cover_path = candidate
|
||||||
|
|
||||||
metadata_args = _metadata_to_ffmpeg_args(metadata_map)
|
metadata_args = _export_svc._metadata_to_ffmpeg_args(metadata_map)
|
||||||
|
|
||||||
if not ffmetadata_path and not cover_path and not metadata_args:
|
if not ffmetadata_path and not cover_path and not metadata_args:
|
||||||
return
|
return
|
||||||
@@ -2595,7 +2517,7 @@ def _build_ffmpeg_command(path: Path, fmt: str, metadata: Optional[Dict[str, str
|
|||||||
base += ["-c:a", "copy"]
|
base += ["-c:a", "copy"]
|
||||||
|
|
||||||
if metadata:
|
if metadata:
|
||||||
base.extend(_metadata_to_ffmpeg_args(metadata))
|
base.extend(_export_svc._metadata_to_ffmpeg_args(metadata))
|
||||||
base.append(str(path))
|
base.append(str(path))
|
||||||
return base
|
return base
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Tests for ExportService FFmpeg metadata methods."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from abogen.infrastructure.exporters import ExportService
|
||||||
|
|
||||||
|
|
||||||
|
class TestEscapeFfmetadataValue:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_simple_string(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("hello") == "hello"
|
||||||
|
|
||||||
|
def test_escapes_backslash(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("a\\b") == "a\\\\b"
|
||||||
|
|
||||||
|
def test_escapes_newline(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("line1\nline2") == "line1\\nline2"
|
||||||
|
|
||||||
|
def test_escapes_equals(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("key=value") == "key\\=value"
|
||||||
|
|
||||||
|
def test_escapes_semicolon(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("a;b") == "a\\;b"
|
||||||
|
|
||||||
|
def test_escapes_hash(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("#comment") == "\\#comment"
|
||||||
|
|
||||||
|
def test_escapes_all_special(self):
|
||||||
|
result = self.svc._escape_ffmetadata_value("a\\b\nc=d;e#f")
|
||||||
|
assert "\\\\" in result
|
||||||
|
assert "\\n" in result
|
||||||
|
assert "\\=" in result
|
||||||
|
assert "\\;" in result
|
||||||
|
assert "\\#" in result
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert self.svc._escape_ffmetadata_value("") == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderFfmetadata:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_renders_header(self):
|
||||||
|
result = self.svc.render_ffmetadata({"title": "My Book"}, [])
|
||||||
|
assert result.startswith(";FFMETADATA1\n")
|
||||||
|
assert "title=My Book\n" in result
|
||||||
|
|
||||||
|
def test_renders_multiple_keys(self):
|
||||||
|
result = self.svc.render_ffmetadata({"title": "T", "artist": "A"}, [])
|
||||||
|
assert "title=T\n" in result
|
||||||
|
assert "artist=A\n" in result
|
||||||
|
|
||||||
|
def test_skips_none_values(self):
|
||||||
|
result = self.svc.render_ffmetadata({"title": None}, [])
|
||||||
|
assert "title=" not in result
|
||||||
|
|
||||||
|
def test_renders_chapters(self):
|
||||||
|
chapters = [{"start": 0.0, "end": 10.0, "title": "Ch 1"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "[CHAPTER]" in result
|
||||||
|
assert "TIMEBASE=1/1000" in result
|
||||||
|
assert "START=0" in result
|
||||||
|
assert "END=10000" in result
|
||||||
|
assert "title=Ch 1" in result
|
||||||
|
|
||||||
|
def test_renders_voice_in_chapter(self):
|
||||||
|
chapters = [{"start": 0.0, "end": 5.0, "voice": "af_heart"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "voice=af_heart" in result
|
||||||
|
|
||||||
|
def test_skips_chapters_without_times(self):
|
||||||
|
chapters = [{"title": "No times"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "[CHAPTER]" not in result
|
||||||
|
|
||||||
|
def test_end_equals_start_gets_minimum_duration(self):
|
||||||
|
chapters = [{"start": 5.0, "end": 5.0, "title": "Zero"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "START=5000" in result
|
||||||
|
assert "END=5001" in result
|
||||||
|
|
||||||
|
def test_empty_metadata_and_chapters(self):
|
||||||
|
result = self.svc.render_ffmetadata({}, [])
|
||||||
|
assert result.strip() == ";FFMETADATA1"
|
||||||
|
|
||||||
|
def test_escapes_special_chars_in_title(self):
|
||||||
|
chapters = [{"start": 0.0, "end": 1.0, "title": "A=B;C#D"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "\\=" in result
|
||||||
|
assert "\\;" in result
|
||||||
|
assert "\\#" in result
|
||||||
|
|
||||||
|
def test_negative_start_clamped_to_zero(self):
|
||||||
|
chapters = [{"start": -1.0, "end": 5.0, "title": "Neg"}]
|
||||||
|
result = self.svc.render_ffmetadata({}, chapters)
|
||||||
|
assert "START=0" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestMetadataToFfmpegArgs:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_simple_metadata(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"title": "My Book"})
|
||||||
|
assert args == ["-metadata", "title=My Book"]
|
||||||
|
|
||||||
|
def test_year_becomes_date(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"year": "2024"})
|
||||||
|
assert args == ["-metadata", "date=2024"]
|
||||||
|
|
||||||
|
def test_skips_none_and_empty(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"title": None, "artist": ""})
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_skips_empty_key(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"": "value"})
|
||||||
|
assert args == []
|
||||||
|
|
||||||
|
def test_multiple_keys(self):
|
||||||
|
args = self.svc._metadata_to_ffmpeg_args({"title": "T", "artist": "A"})
|
||||||
|
assert "-metadata" in args
|
||||||
|
assert "title=T" in args
|
||||||
|
assert "artist=A" in args
|
||||||
|
|
||||||
|
def test_empty_metadata(self):
|
||||||
|
assert self.svc._metadata_to_ffmpeg_args({}) == []
|
||||||
|
|
||||||
|
def test_none_metadata(self):
|
||||||
|
assert self.svc._metadata_to_ffmpeg_args(None) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestWriteFfmetadataFile:
|
||||||
|
def setup_method(self):
|
||||||
|
self.svc = ExportService()
|
||||||
|
|
||||||
|
def test_writes_file(self, tmp_path):
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.touch()
|
||||||
|
meta = {"title": "My Book"}
|
||||||
|
chapters = [{"start": 0.0, "end": 5.0, "title": "Ch 1"}]
|
||||||
|
result = self.svc.write_ffmetadata_file(audio, meta, chapters)
|
||||||
|
assert result is not None
|
||||||
|
assert result.exists()
|
||||||
|
content = result.read_text()
|
||||||
|
assert ";FFMETADATA1" in content
|
||||||
|
assert "title=My Book" in content
|
||||||
|
|
||||||
|
def test_returns_none_for_empty(self, tmp_path):
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.touch()
|
||||||
|
result = self.svc.write_ffmetadata_file(audio, {}, [])
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_returns_none_for_only_header(self, tmp_path):
|
||||||
|
audio = tmp_path / "test.mp3"
|
||||||
|
audio.touch()
|
||||||
|
result = self.svc.write_ffmetadata_file(audio, None, None)
|
||||||
|
assert result is None
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from abogen.infrastructure.exporters import ExportService
|
||||||
|
|
||||||
from abogen.webui.conversion_runner import _render_ffmetadata, _write_ffmetadata_file
|
|
||||||
|
svc = ExportService()
|
||||||
|
|
||||||
|
|
||||||
def test_render_ffmetadata_includes_chapters(tmp_path):
|
def test_render_ffmetadata_includes_chapters(tmp_path):
|
||||||
@@ -17,7 +18,7 @@ def test_render_ffmetadata_includes_chapters(tmp_path):
|
|||||||
{"start": 5.0, "end": 12.345, "title": "Chapter 2"},
|
{"start": 5.0, "end": 12.345, "title": "Chapter 2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
rendered = _render_ffmetadata(metadata, chapters)
|
rendered = svc.render_ffmetadata(metadata, chapters)
|
||||||
|
|
||||||
assert ";FFMETADATA1" in rendered
|
assert ";FFMETADATA1" in rendered
|
||||||
assert "title=Sample Book" in rendered
|
assert "title=Sample Book" in rendered
|
||||||
@@ -30,7 +31,7 @@ def test_render_ffmetadata_includes_chapters(tmp_path):
|
|||||||
assert "voice=voice_a" in rendered
|
assert "voice=voice_a" in rendered
|
||||||
|
|
||||||
audio_path = tmp_path / "book.m4b"
|
audio_path = tmp_path / "book.m4b"
|
||||||
metadata_path = _write_ffmetadata_file(audio_path, metadata, chapters)
|
metadata_path = svc.write_ffmetadata_file(audio_path, metadata, chapters)
|
||||||
assert metadata_path is not None
|
assert metadata_path is not None
|
||||||
assert metadata_path.exists()
|
assert metadata_path.exists()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user