Asem75/aiocr_asistant
1254
1# storage_manager.py2# -*- coding: utf-8 -*-3 4import io5import json6import os7import tempfile8import threading9import time10from contextlib import contextmanager11from dataclasses import dataclass12from typing import Any, Dict, List, Optional, Sequence, Tuple13 14from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download15from report_manager import ReportManager16 17 18VERSION = "storage_manager_v5_single_repo_three_internal_folders"19 20 21@dataclass(frozen=True)22class RepositoryConfig:23 key: str24 repo_id: str25 repo_type: str = "dataset"26 input_file: str = "tts.txt"27 output_file: str = "tts.wav"28 29 30class StorageManager:31 LEGACY_REPO_KEY = "aiocr_assistant"32 RAW_BOOKS_REPO_KEY = "raw_books"33 PREPARED_BOOKS_REPO_KEY = "prepared_books"34 AUDIO_BOOKS_REPO_KEY = "audio_books"35 36 """37 مدير التخزين المركزي لمحطة الصوت.38 39 يدعم عقد TTS المباشر:40 tts.txt -> tts.wav41 42 ويضيف عقد الكتب:43 books/<book_id>/source/original.txt44 books/<book_id>/prepared/full.txt45 books/<book_id>/manifest.json46 books/<book_id>/conversions/<engine>/text_segments/segment_XXXX.txt47 books/<book_id>/conversions/<engine>/audio_segments/segment_XXXX.mp348 books/<book_id>/conversions/<engine>/full/book_full.mp349 50 BookPreparationManager و AITaskManager لا يتعاملان مباشرة مع Hugging Face.51 """52 53 def __init__(self, token: Optional[str] = None, report_manager: Optional[ReportManager] = None):54 self.token = (token or os.getenv("HF_TOKEN", "")).strip()55 self.report_manager = report_manager56 self._report_context = threading.local()57 self.api = HfApi(token=self.token or None)58 59 # Dataset واحد فعلياً على Hugging Face:60 # Asem75/aiocr_asistant61 #62 # المفاتيح raw_books / prepared_books / audio_books ليست Repositories مستقلة،63 # بل أسماء منطقية لثلاثة مجلدات داخل الـDataset نفسه.64 # نبقي المفاتيح الثلاثة للتوافق مع بقية النظام، لكن repo_id واحد للجميع.65 self._repositories: Dict[str, RepositoryConfig] = {66 "aiocr_assistant": RepositoryConfig(67 key="aiocr_assistant",68 repo_id=os.getenv(69 "AIOCR_DATASET_REPO",70 "Asem75/aiocr_asistant",71 ).strip(),72 repo_type="dataset",73 input_file="tts.txt",74 output_file="tts.wav",75 ),76 "raw_books": RepositoryConfig(77 key="raw_books",78 repo_id=os.getenv(79 "AIOCR_RAW_BOOKS_REPO",80 "Asem75/aiocr_asistant",81 ).strip(),82 repo_type="dataset",83 input_file="",84 output_file="",85 ),86 "prepared_books": RepositoryConfig(87 key="prepared_books",88 repo_id=os.getenv(89 "AIOCR_PREPARED_BOOKS_REPO",90 "Asem75/aiocr_asistant",91 ).strip(),92 repo_type="dataset",93 input_file="",94 output_file="",95 ),96 "audio_books": RepositoryConfig(97 key="audio_books",98 repo_id=os.getenv(99 "AIOCR_AUDIO_BOOKS_REPO",100 "Asem75/aiocr_asistant",101 ).strip(),102 repo_type="dataset",103 input_file="",104 output_file="",105 ),106 }107 self._repo_locks: Dict[str, threading.Lock] = {}108 self._load_extra_repositories()109 110 def set_report_context(self, *, trace_id: str = "", task_id: str = "", book_id: str = "") -> None:111 self._report_context.trace_id = trace_id or ""112 self._report_context.task_id = task_id or ""113 self._report_context.book_id = book_id or ""114 115 @contextmanager116 def report_context(self, *, trace_id: str = "", task_id: str = "", book_id: str = ""):117 old = (getattr(self._report_context, "trace_id", ""), getattr(self._report_context, "task_id", ""), getattr(self._report_context, "book_id", ""))118 self.set_report_context(trace_id=trace_id, task_id=task_id, book_id=book_id)119 try:120 yield self121 finally:122 self.set_report_context(trace_id=old[0], task_id=old[1], book_id=old[2])123 124 def _report(self, event: str, *, status: str = "ok", repo_key: str = "", path: str = "", duration_ms: float = 0.0, input_size_bytes: int = 0, output_size_bytes: int = 0, error: str = "", metadata: Optional[Dict[str, Any]] = None) -> None:125 if not self.report_manager:126 return127 try:128 self.report_manager.record(129 event=event,130 trace_id=getattr(self._report_context, "trace_id", ""),131 task_id=getattr(self._report_context, "task_id", ""),132 book_id=getattr(self._report_context, "book_id", ""),133 manager="StorageManager",134 status=status,135 repo_key=repo_key,136 path=path,137 duration_ms=duration_ms,138 input_size_bytes=input_size_bytes,139 output_size_bytes=output_size_bytes,140 metadata=metadata or {},141 error=error,142 )143 except Exception:144 pass145 146 # ---------------------------------------------------------147 # إعداد الخزنات148 # ---------------------------------------------------------149 150 def _load_extra_repositories(self) -> None:151 raw = os.getenv("AIOCR_TTS_REPOSITORIES_JSON", "").strip()152 if not raw:153 return154 try:155 data = json.loads(raw)156 except Exception as exc:157 print("⚠️ تعذر قراءة AIOCR_TTS_REPOSITORIES_JSON:", exc)158 return159 if not isinstance(data, dict):160 return161 162 for key, item in data.items():163 if not isinstance(item, dict):164 continue165 repo_id = str(item.get("repo_id", "")).strip()166 if not repo_id:167 continue168 self._repositories[str(key)] = RepositoryConfig(169 key=str(key),170 repo_id=repo_id,171 repo_type=str(item.get("repo_type", "dataset")).strip() or "dataset",172 input_file=str(item.get("input_file", "tts.txt")).strip() or "tts.txt",173 output_file=str(item.get("output_file", "tts.wav")).strip() or "tts.wav",174 )175 176 def list_repository_keys(self) -> List[str]:177 return sorted(self._repositories.keys())178 179 def get_repository(self, repo_key: str) -> RepositoryConfig:180 key = (repo_key or "aiocr_assistant").strip()181 if key not in self._repositories:182 raise KeyError(f"الخزنة غير معرفة في StorageManager: {key}")183 return self._repositories[key]184 185 def get_repository_lock(self, repo_key: str) -> threading.Lock:186 key = (repo_key or "aiocr_assistant").strip()187 if key not in self._repo_locks:188 self._repo_locks[key] = threading.Lock()189 return self._repo_locks[key]190 191 def describe_repository(self, repo_key: str) -> Dict[str, str]:192 repo = self.get_repository(repo_key)193 role_map = {194 self.LEGACY_REPO_KEY: "legacy_api_tts",195 self.RAW_BOOKS_REPO_KEY: "raw_books",196 self.PREPARED_BOOKS_REPO_KEY: "prepared_books",197 self.AUDIO_BOOKS_REPO_KEY: "audio_books",198 }199 return {200 "repo_key": repo.key,201 "repo_id": repo.repo_id,202 "repo_type": repo.repo_type,203 "input_file": repo.input_file,204 "output_file": repo.output_file,205 "role": role_map.get(repo.key, "custom"),206 }207 208 # ---------------------------------------------------------209 # عمليات عامة210 # ---------------------------------------------------------211 212 def list_repo_files(self, repo_key: str) -> List[str]:213 repo = self.get_repository(repo_key)214 files = self.api.list_repo_files(215 repo_id=repo.repo_id,216 repo_type=repo.repo_type,217 token=self.token or None,218 )219 return sorted(str(path) for path in files if path and not str(path).endswith("/"))220 221 def file_exists(self, repo_key: str, path_in_repo: str, known_files: Optional[set] = None) -> bool:222 target = (path_in_repo or "").strip()223 if not target:224 return False225 if known_files is not None:226 return target in known_files227 return target in set(self.list_repo_files(repo_key))228 229 def list_text_files(self, repo_key: str) -> List[str]:230 return [p for p in self.list_repo_files(repo_key) if p.lower().endswith(".txt")]231 232 def list_audio_files(self, repo_key: str) -> List[str]:233 extensions = (".wav", ".mp3", ".m4a", ".ogg", ".flac")234 return [p for p in self.list_repo_files(repo_key) if p.lower().endswith(extensions)]235 236 def list_report_files(237 self,238 repo_key: str,239 *,240 book_id: str = "",241 engine: str = "",242 ) -> List[str]:243 files = self.list_repo_files(repo_key)244 prefix = ""245 if book_id:246 prefix = (247 self.audio_book_conversion_root(book_id, engine)248 if engine249 else self.book_root(book_id)250 )251 prefix = prefix.rstrip("/") + "/"252 reports = [253 path for path in files254 if path.lower().endswith(".json")255 and "/reports/" in f"/{path.lower()}"256 and "/report_" in f"/{path.lower()}"257 and (not prefix or path.startswith(prefix))258 ]259 return sorted(reports, reverse=True)260 261 def download_file(self, repo_key: str, file_path: str) -> str:262 repo = self.get_repository(repo_key)263 selected = (file_path or "").strip()264 if not selected:265 raise ValueError("لم يتم تحديد ملف من الخزنة.")266 started = time.perf_counter()267 try:268 local_path = hf_hub_download(269 repo_id=repo.repo_id, filename=selected, repo_type=repo.repo_type,270 token=self.token or None, force_download=True,271 )272 size = os.path.getsize(local_path) if os.path.isfile(local_path) else 0273 self._report("storage.download.completed", repo_key=repo_key, path=selected,274 duration_ms=(time.perf_counter()-started)*1000, output_size_bytes=size)275 return local_path276 except Exception as exc:277 self._report("storage.download.failed", status="error", repo_key=repo_key, path=selected,278 duration_ms=(time.perf_counter()-started)*1000, error=str(exc))279 raise280 281 def read_text_file(self, repo_key: str, file_path: Optional[str] = None) -> str:282 repo = self.get_repository(repo_key)283 selected = (file_path or repo.input_file).strip()284 local_path = self.download_file(repo_key, selected)285 with open(local_path, "r", encoding="utf-8-sig") as handle:286 text = handle.read()287 if not text.strip():288 raise ValueError(f"الملف {selected} موجود لكنه فارغ.")289 return text290 291 def read_json(self, repo_key: str, file_path: str) -> Dict[str, Any]:292 local_path = self.download_file(repo_key, file_path)293 with open(local_path, "r", encoding="utf-8-sig") as handle:294 data = json.load(handle)295 if not isinstance(data, dict):296 raise ValueError(f"ملف JSON ليس كائناً: {file_path}")297 return data298 299 def upload_file(self, repo_key: str, local_path: str, path_in_repo: str) -> str:300 repo = self.get_repository(repo_key)301 if not os.path.isfile(local_path):302 raise FileNotFoundError(f"الملف المحلي غير موجود: {local_path}")303 target = (path_in_repo or "").strip()304 if not target:305 raise ValueError("مسار الحفظ داخل الخزنة فارغ.")306 size = os.path.getsize(local_path)307 started = time.perf_counter()308 try:309 self.api.upload_file(path_or_fileobj=local_path, path_in_repo=target, repo_id=repo.repo_id,310 repo_type=repo.repo_type, token=self.token or None)311 self._report("storage.upload.completed", repo_key=repo_key, path=target,312 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=size)313 return target314 except Exception as exc:315 self._report("storage.upload.failed", status="error", repo_key=repo_key, path=target,316 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=size, error=str(exc))317 raise318 319 def write_text(self, repo_key: str, path_in_repo: str, text: str) -> str:320 repo = self.get_repository(repo_key)321 payload = (text or "").encode("utf-8")322 started = time.perf_counter()323 try:324 self.api.upload_file(path_or_fileobj=io.BytesIO(payload), path_in_repo=path_in_repo, repo_id=repo.repo_id,325 repo_type=repo.repo_type, token=self.token or None)326 self._report("storage.upload.completed", repo_key=repo_key, path=path_in_repo,327 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=len(payload), metadata={"content_type":"text"})328 return path_in_repo329 except Exception as exc:330 self._report("storage.upload.failed", status="error", repo_key=repo_key, path=path_in_repo,331 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=len(payload), error=str(exc))332 raise333 334 def write_json(self, repo_key: str, path_in_repo: str, data: Dict[str, Any]) -> str:335 payload = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")336 repo = self.get_repository(repo_key)337 started = time.perf_counter()338 try:339 self.api.upload_file(path_or_fileobj=io.BytesIO(payload), path_in_repo=path_in_repo, repo_id=repo.repo_id,340 repo_type=repo.repo_type, token=self.token or None)341 self._report("storage.upload.completed", repo_key=repo_key, path=path_in_repo,342 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=len(payload), metadata={"content_type":"json"})343 return path_in_repo344 except Exception as exc:345 self._report("storage.upload.failed", status="error", repo_key=repo_key, path=path_in_repo,346 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=len(payload), error=str(exc))347 raise348 349 def upload_many(self, repo_key: str, items: Sequence[Tuple[str, str]], commit_message: str) -> List[str]:350 repo = self.get_repository(repo_key)351 operations: List[CommitOperationAdd] = []352 targets: List[str] = []353 total_size = 0354 for local_path, target in items:355 if not os.path.isfile(local_path):356 raise FileNotFoundError(f"الملف المحلي غير موجود: {local_path}")357 operations.append(CommitOperationAdd(path_in_repo=target, path_or_fileobj=local_path))358 targets.append(target)359 total_size += os.path.getsize(local_path)360 started = time.perf_counter()361 try:362 if operations:363 self.api.create_commit(repo_id=repo.repo_id, repo_type=repo.repo_type, operations=operations,364 commit_message=commit_message, token=self.token or None)365 self._report("storage.upload.completed", repo_key=repo_key, path=f"batch:{len(targets)}",366 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=total_size,367 metadata={"files_count":len(targets),"targets":targets})368 return targets369 except Exception as exc:370 self._report("storage.upload.failed", status="error", repo_key=repo_key, path=f"batch:{len(targets)}",371 duration_ms=(time.perf_counter()-started)*1000, input_size_bytes=total_size, error=str(exc))372 raise373 374 # ---------------------------------------------------------375 # عقد TTS المباشر376 # ---------------------------------------------------------377 378 def read_tts_text(self, repo_key: str) -> str:379 return self.read_text_file(repo_key, self.get_repository(repo_key).input_file).strip()380 381 def upload_tts_audio(self, repo_key: str, local_wav_path: str) -> str:382 repo = self.get_repository(repo_key)383 return self.upload_file(repo_key, local_wav_path, repo.output_file)384 385 def resolve_output_path(self, repo_key: str, source_text_file: Optional[str] = None) -> str:386 repo = self.get_repository(repo_key)387 selected = (source_text_file or repo.input_file).strip()388 if selected == repo.input_file:389 return repo.output_file390 root, _ = os.path.splitext(selected)391 return root + ".wav"392 393 def upload_audio(self, repo_key: str, local_wav_path: str, output_path: Optional[str] = None) -> str:394 repo = self.get_repository(repo_key)395 return self.upload_file(repo_key, local_wav_path, (output_path or repo.output_file).strip())396 397 def download_audio_file(self, repo_key: str, file_path: str) -> str:398 return self.download_file(repo_key, file_path)399 400 # ---------------------------------------------------------401 # مسارات الكتب - ثلاث خزنات منفصلة402 # ---------------------------------------------------------403 404 # 1) خزنة المادة الخام405 @staticmethod406 def raw_book_root(book_id: str) -> str:407 return f"raw_books/{book_id}"408 409 @classmethod410 def raw_book_original_path(cls, book_id: str, filename: str = "original.txt") -> str:411 safe_name = (filename or "original.txt").strip().replace("/", "_").replace("\\", "_")412 return f"{cls.raw_book_root(book_id)}/{safe_name}"413 414 @classmethod415 def raw_book_manifest_path(cls, book_id: str) -> str:416 return f"{cls.raw_book_root(book_id)}/raw_manifest.json"417 418 # 2) خزنة النص المجهز والمقسم419 @staticmethod420 def book_root(book_id: str) -> str:421 # الاسم محفوظ للتوافق مع بقية النظام، لكنه الآن يعني prepared_books.422 return f"prepared_books/{book_id}"423 424 @classmethod425 def book_original_text_path(cls, book_id: str) -> str:426 # للتوافق فقط مع نسخ أقدم. الأصل الحقيقي يجب أن يكون في raw_books.427 return f"{cls.book_root(book_id)}/source/original.txt"428 429 @classmethod430 def book_prepared_text_path(cls, book_id: str) -> str:431 return f"{cls.book_root(book_id)}/prepared/full.txt"432 433 @classmethod434 def book_manifest_path(cls, book_id: str) -> str:435 return f"{cls.book_root(book_id)}/manifest.json"436 437 @classmethod438 def book_conversion_root(cls, book_id: str, engine: str) -> str:439 # هذا الجذر خاص بالتقسيم النصي للمحرك داخل prepared_books.440 safe_engine = (engine or "unknown").strip().lower()441 return f"{cls.book_root(book_id)}/conversions/{safe_engine}"442 443 @classmethod444 def book_segmentation_manifest_path(cls, book_id: str, engine: str) -> str:445 return f"{cls.book_conversion_root(book_id, engine)}/segmentation_manifest.json"446 447 @classmethod448 def book_text_segment_path(cls, book_id: str, engine: str, segment_id: str) -> str:449 return f"{cls.book_conversion_root(book_id, engine)}/text_segments/{segment_id}.txt"450 451 # 3) خزنة الصوت452 @staticmethod453 def audio_book_root(book_id: str) -> str:454 return f"audio_books/{book_id}"455 456 @classmethod457 def audio_book_conversion_root(cls, book_id: str, engine: str) -> str:458 safe_engine = (engine or "unknown").strip().lower()459 return f"{cls.audio_book_root(book_id)}/conversions/{safe_engine}"460 461 @classmethod462 def book_audio_segment_path(cls, book_id: str, engine: str, segment_id: str) -> str:463 return f"{cls.audio_book_conversion_root(book_id, engine)}/audio_segments/{segment_id}.mp3"464 465 @classmethod466 def book_conversion_manifest_path(cls, book_id: str, engine: str) -> str:467 return f"{cls.audio_book_conversion_root(book_id, engine)}/conversion_manifest.json"468 469 @classmethod470 def book_full_audio_path(cls, book_id: str, engine: str) -> str:471 return f"{cls.audio_book_conversion_root(book_id, engine)}/full/book_full.mp3"472 473 @classmethod474 def book_report_path(cls, book_id: str, engine: str, trace_id: str) -> str:475 safe_trace = (trace_id or "latest").strip().replace("/", "_").replace("\\", "_")476 return f"{cls.audio_book_conversion_root(book_id, engine)}/reports/report_{safe_trace}.json"477 478 @classmethod479 def book_operation_report_path(cls, book_id: str, trace_id: str) -> str:480 safe_trace = (trace_id or "latest").strip().replace("/", "_").replace("\\", "_")481 return f"{cls.book_root(book_id)}/reports/report_{safe_trace}.json"482 483 @staticmethod484 def system_report_path(trace_id: str) -> str:485 safe_trace = (trace_id or "latest").strip().replace("/", "_").replace("\\", "_")486 return f"reports/system/report_{safe_trace}.json"487 488 def save_raw_book_bundle(489 self,490 repo_key: str,491 book_id: str,492 title: str,493 source_files: Sequence[Tuple[str, str]],494 *,495 created_at: str,496 total_chars: int,497 ) -> Dict[str, Any]:498 """499 يحفظ المادة الخام فقط في خزنة raw_books.500 source_files = [(local_path, original_filename), ...]501 """502 if repo_key != self.RAW_BOOKS_REPO_KEY:503 raise ValueError(504 f"المادة الخام يجب أن تحفظ في الخزنة {self.RAW_BOOKS_REPO_KEY} وليس {repo_key}."505 )506 507 manifest = {508 "book_id": book_id,509 "book_name": title,510 "created_at": created_at,511 "source_files": [name for _, name in source_files],512 "total_files": len(source_files),513 "total_chars": int(total_chars or 0),514 }515 516 with tempfile.TemporaryDirectory(prefix="aiocr_raw_book_") as tmp:517 items: List[Tuple[str, str]] = []518 for index, (local_path, original_name) in enumerate(source_files, start=1):519 if not os.path.isfile(local_path):520 raise FileNotFoundError(f"الملف الخام غير موجود: {local_path}")521 safe_name = (original_name or f"original_{index:02d}.txt").strip().replace("/", "_").replace("\\", "_")522 target = self.raw_book_original_path(book_id, safe_name)523 items.append((local_path, target))524 525 local_manifest = os.path.join(tmp, "raw_manifest.json")526 with open(local_manifest, "w", encoding="utf-8") as handle:527 json.dump(manifest, handle, ensure_ascii=False, indent=2)528 items.append((local_manifest, self.raw_book_manifest_path(book_id)))529 530 self.upload_many(531 repo_key,532 items,533 commit_message=f"Save raw book {book_id}",534 )535 536 return {537 "manifest": manifest,538 "manifest_path": self.raw_book_manifest_path(book_id),539 "root": self.raw_book_root(book_id),540 }541 542 def save_prepared_book_bundle(543 self,544 repo_key: str,545 book_id: str,546 original_text: str,547 prepared_text: str,548 manifest: Dict[str, Any],549 ) -> Dict[str, str]:550 paths = {551 "original": self.book_original_text_path(book_id),552 "prepared": self.book_prepared_text_path(book_id),553 "manifest": self.book_manifest_path(book_id),554 }555 with tempfile.TemporaryDirectory(prefix="aiocr_book_prepare_") as tmp:556 local_original = os.path.join(tmp, "original.txt")557 local_prepared = os.path.join(tmp, "full.txt")558 local_manifest = os.path.join(tmp, "manifest.json")559 with open(local_original, "w", encoding="utf-8") as handle:560 handle.write(original_text or "")561 with open(local_prepared, "w", encoding="utf-8") as handle:562 handle.write(prepared_text or "")563 with open(local_manifest, "w", encoding="utf-8") as handle:564 json.dump(manifest, handle, ensure_ascii=False, indent=2)565 self.upload_many(566 repo_key,567 [568 (local_original, paths["original"]),569 (local_prepared, paths["prepared"]),570 (local_manifest, paths["manifest"]),571 ],572 commit_message=f"Prepare book {book_id}",573 )574 return paths575 576 def save_book_segmentation(577 self,578 repo_key: str,579 book_id: str,580 engine: str,581 segments: List[Dict[str, Any]],582 manifest: Dict[str, Any],583 ) -> str:584 manifest_path = self.book_segmentation_manifest_path(book_id, engine)585 with tempfile.TemporaryDirectory(prefix="aiocr_book_segments_") as tmp:586 items: List[Tuple[str, str]] = []587 for item in segments:588 segment_id = str(item["segment_id"])589 local_path = os.path.join(tmp, f"{segment_id}.txt")590 with open(local_path, "w", encoding="utf-8") as handle:591 handle.write(str(item.get("text") or ""))592 items.append((local_path, str(item["text_path"])))593 594 local_manifest = os.path.join(tmp, "segmentation_manifest.json")595 with open(local_manifest, "w", encoding="utf-8") as handle:596 json.dump(manifest, handle, ensure_ascii=False, indent=2)597 items.append((local_manifest, manifest_path))598 599 self.upload_many(600 repo_key,601 items,602 commit_message=f"Segment book {book_id} for {engine}",603 )604 return manifest_path605 606 def list_book_ids(self, repo_key: str) -> List[str]:607 prefix = "prepared_books/"608 suffix = "/manifest.json"609 book_ids: List[str] = []610 for path in self.list_repo_files(repo_key):611 if path.startswith(prefix) and path.endswith(suffix):612 middle = path[len(prefix):-len(suffix)]613 if middle and "/" not in middle:614 book_ids.append(middle)615 return sorted(set(book_ids))616 617 def save_audio_segment(618 self,619 repo_key: str,620 book_id: str,621 engine: str,622 segment_id: str,623 local_mp3_path: str,624 ) -> str:625 target = self.book_audio_segment_path(book_id, engine, segment_id)626 return self.upload_file(repo_key, local_mp3_path, target)627 628 def save_conversion_manifest(629 self,630 repo_key: str,631 book_id: str,632 engine: str,633 manifest: Dict[str, Any],634 ) -> str:635 target = self.book_conversion_manifest_path(book_id, engine)636 return self.write_json(repo_key, target, manifest)637 638 def save_full_book_audio(639 self,640 repo_key: str,641 book_id: str,642 engine: str,643 local_mp3_path: str,644 ) -> str:645 target = self.book_full_audio_path(book_id, engine)646 return self.upload_file(repo_key, local_mp3_path, target)647 