CoolFace
Datasetpublic

Asem75/aiocr_asistant

sourceHugging Faceupdated 2d agoView on Hugging Face
1likes254downloads
storage_manager.py647 linesDownload Raw Back to root
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