Initial commit: InkFlow — EPUB vers livre audio local (MLX/Kokoro)
This commit is contained in:
0
backend/inkflow/pipeline/__init__.py
Normal file
0
backend/inkflow/pipeline/__init__.py
Normal file
364
backend/inkflow/pipeline/orchestrator.py
Normal file
364
backend/inkflow/pipeline/orchestrator.py
Normal file
@@ -0,0 +1,364 @@
|
||||
"""Orchestrateur : execute les etapes du pipeline en tache de fond, piste l'etat
|
||||
et diffuse l'etat complet a l'UI a chaque changement.
|
||||
|
||||
- Un seul worker thread execute les jobs en serie (un Mac = une charge MLX a la
|
||||
fois). Les jobs sont enfiles et rendent la main immediatement a l'API.
|
||||
- L'etat (ProjectState) est persiste dans data/<slug>/state.json -> reprenable.
|
||||
- La diffusion passe par un `broadcaster` injecte par la couche API (pour rester
|
||||
independant de FastAPI). Il recoit (slug, dict_etat).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ..config import book_data_dir, book_output_dir
|
||||
from ..epub.parser import load_book, load_chapter_text
|
||||
from ..models import ChapterRenderState, ProjectState, StageStatus
|
||||
from ..store import artifacts
|
||||
|
||||
Broadcaster = Callable[[str, dict], None]
|
||||
|
||||
|
||||
def state_path(slug: str) -> Path:
|
||||
return book_data_dir(slug) / "state.json"
|
||||
|
||||
|
||||
def load_state(slug: str) -> ProjectState:
|
||||
path = state_path(slug)
|
||||
if path.exists():
|
||||
state = ProjectState.model_validate_json(path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
book = load_book(slug)
|
||||
state = ProjectState(slug=slug, title=book.title,
|
||||
stages={"parse": StageStatus.DONE})
|
||||
return _reconcile(slug, state)
|
||||
|
||||
|
||||
def _reconcile(slug: str, state: ProjectState) -> ProjectState:
|
||||
"""Aligne l'etat sur les artefacts presents sur disque (reprise robuste).
|
||||
|
||||
Permet a l'UI de refleter ce qui a deja ete fait, meme via la CLI ou apres
|
||||
un redemarrage, sans rejouer les etapes.
|
||||
"""
|
||||
book = load_book(slug)
|
||||
state.stages.setdefault("parse", StageStatus.DONE)
|
||||
|
||||
# Analyse : chapitres possedant un artefact d'analyse.
|
||||
analyzed = [c.index for c in book.render_chapters
|
||||
if artifacts.analysis_path(slug, c.index).exists()]
|
||||
if analyzed:
|
||||
for idx in analyzed:
|
||||
if idx not in state.analyzed_chapters:
|
||||
state.analyzed_chapters.append(idx)
|
||||
if state.stage("analyze") == StageStatus.PENDING:
|
||||
state.stages["analyze"] = (
|
||||
StageStatus.DONE if len(analyzed) == len(book.render_chapters)
|
||||
else StageStatus.RUNNING)
|
||||
|
||||
# Casting : au moins une voix attribuee.
|
||||
cast = artifacts.load_cast(slug)
|
||||
if cast.narrator_voice_id or any(c.voice_id for c in cast.characters):
|
||||
state.stages.setdefault("cast", StageStatus.DONE)
|
||||
|
||||
# Prononciation : au moins une entree.
|
||||
if artifacts.load_pronunciation(slug).entries:
|
||||
state.stages.setdefault("pronounce", StageStatus.DONE)
|
||||
|
||||
# Rendu : mp3 presents en sortie.
|
||||
out_dir = book_output_dir(book.title)
|
||||
for ch in book.render_chapters:
|
||||
existing = state.render.get(ch.index)
|
||||
if existing and existing.mp3:
|
||||
continue
|
||||
if ch.output_name and (out_dir / ch.output_name).exists():
|
||||
state.render[ch.index] = ChapterRenderState(
|
||||
index=ch.index, status=StageStatus.DONE, progress=1.0,
|
||||
mp3=ch.output_name)
|
||||
return state
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
def __init__(self) -> None:
|
||||
self._q: "queue.Queue[tuple[str, Callable[[], None]]]" = queue.Queue()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
self._broadcaster: Optional[Broadcaster] = None
|
||||
self._lock = threading.Lock()
|
||||
self.busy_slug: Optional[str] = None
|
||||
|
||||
# --- infra ---------------------------------------------------------------
|
||||
def set_broadcaster(self, fn: Broadcaster) -> None:
|
||||
self._broadcaster = fn
|
||||
|
||||
def _ensure_worker(self) -> None:
|
||||
if self._worker is None or not self._worker.is_alive():
|
||||
self._worker = threading.Thread(target=self._loop, daemon=True)
|
||||
self._worker.start()
|
||||
|
||||
def _loop(self) -> None:
|
||||
while True:
|
||||
slug, job = self._q.get()
|
||||
self.busy_slug = slug
|
||||
try:
|
||||
job()
|
||||
except Exception: # noqa: BLE001
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
self.busy_slug = None
|
||||
self._q.task_done()
|
||||
|
||||
def _save_and_emit(self, state: ProjectState) -> None:
|
||||
path = state_path(state.slug)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(state.model_dump_json(indent=2), encoding="utf-8")
|
||||
if self._broadcaster:
|
||||
self._broadcaster(state.slug, state.model_dump(mode="json"))
|
||||
|
||||
def enqueue(self, slug: str, job: Callable[[], None]) -> None:
|
||||
self._ensure_worker()
|
||||
self._q.put((slug, job))
|
||||
|
||||
# --- etapes --------------------------------------------------------------
|
||||
def run_analyze(self, slug: str, chapter_indexes: Optional[list[int]] = None) -> None:
|
||||
def job() -> None:
|
||||
from ..analysis.gemma import Gemma
|
||||
from ..analysis.segmenter import analyze_chapter
|
||||
from ..models import Cast
|
||||
from ..settings import get_settings
|
||||
|
||||
state = load_state(slug)
|
||||
book = load_book(slug)
|
||||
targets = [c for c in book.render_chapters
|
||||
if chapter_indexes is None or c.index in chapter_indexes]
|
||||
state.stages["analyze"] = StageStatus.RUNNING
|
||||
state.active_stage = "analyze"
|
||||
self._save_and_emit(state)
|
||||
|
||||
gemma = Gemma()
|
||||
dedup_gemma = gemma if get_settings().dedup_use_gemma else None
|
||||
cast = artifacts.load_cast(slug)
|
||||
chars = list(cast.characters)
|
||||
total = len(targets)
|
||||
for i, ch in enumerate(targets):
|
||||
state.active_detail = f"Analyse {ch.title}"
|
||||
state.active_progress = i / max(total, 1)
|
||||
self._save_and_emit(state)
|
||||
ct = load_chapter_text(slug, ch)
|
||||
try:
|
||||
# La dedup est faite dans analyze_chapter : `chars` recoit le
|
||||
# cast cumule reconcilie.
|
||||
analysis, chars = analyze_chapter(
|
||||
ch, ct, gemma, book_chars=chars, dedup_gemma=dedup_gemma)
|
||||
except Exception: # noqa: BLE001 — chapitre ignore, on continue
|
||||
traceback.print_exc()
|
||||
continue
|
||||
artifacts.save_analysis(slug, analysis)
|
||||
if ch.index not in state.analyzed_chapters:
|
||||
state.analyzed_chapters.append(ch.index)
|
||||
self._save_and_emit(state)
|
||||
|
||||
artifacts.save_cast(slug, Cast(
|
||||
narrator_voice_id=cast.narrator_voice_id, characters=chars))
|
||||
state.stages["analyze"] = StageStatus.DONE
|
||||
self._finish(state)
|
||||
self.enqueue(slug, job)
|
||||
|
||||
def run_cast(self, slug: str) -> None:
|
||||
def job() -> None:
|
||||
from ..casting.assign import assign_voices
|
||||
from ..casting.voicebank import build_voicebank, load_voicebank
|
||||
|
||||
state = load_state(slug)
|
||||
state.stages["cast"] = StageStatus.RUNNING
|
||||
state.active_stage = "cast"
|
||||
state.active_detail = "Preparation de la voicebank"
|
||||
self._save_and_emit(state)
|
||||
|
||||
vb = load_voicebank()
|
||||
if not vb.entries or not any(e.ref_audio for e in vb.entries):
|
||||
vb = build_voicebank()
|
||||
cast = artifacts.load_cast(slug)
|
||||
cast = assign_voices(cast.characters, vb,
|
||||
narrator_voice_id=cast.narrator_voice_id)
|
||||
artifacts.save_cast(slug, cast)
|
||||
state.stages["cast"] = StageStatus.DONE
|
||||
self._finish(state)
|
||||
self.enqueue(slug, job)
|
||||
|
||||
def run_cast_analyze(self, slug: str, chapter_indexes: Optional[list[int]] = None) -> None:
|
||||
"""(Re)extrait les personnages d'un/des chapitre(s) et les reconcilie.
|
||||
|
||||
Plus leger que `run_analyze` : ne re-segmente pas (les artefacts d'analyse
|
||||
existants restent intacts). Sert le casting "a l'echelle d'un chapitre"
|
||||
tout en maintenant la coherence du livre (deduplication).
|
||||
"""
|
||||
def job() -> None:
|
||||
from ..analysis.gemma import Gemma
|
||||
from ..analysis.segmenter import extract_characters
|
||||
from ..casting.dedup import reconcile_characters
|
||||
from ..models import Cast
|
||||
from ..settings import get_settings
|
||||
|
||||
state = load_state(slug)
|
||||
book = load_book(slug)
|
||||
targets = [c for c in book.render_chapters
|
||||
if chapter_indexes is None or c.index in chapter_indexes]
|
||||
state.active_stage = "cast"
|
||||
self._save_and_emit(state)
|
||||
|
||||
gemma = Gemma()
|
||||
dedup_gemma = gemma if get_settings().dedup_use_gemma else None
|
||||
cast = artifacts.load_cast(slug)
|
||||
chars = list(cast.characters)
|
||||
total = len(targets)
|
||||
for i, ch in enumerate(targets):
|
||||
state.active_detail = f"Casting — {ch.title}"
|
||||
state.active_progress = i / max(total, 1)
|
||||
self._save_and_emit(state)
|
||||
ct = load_chapter_text(slug, ch)
|
||||
try:
|
||||
found = extract_characters("\n".join(ct.paragraphs), gemma)
|
||||
speakers: list[str] = []
|
||||
if artifacts.analysis_path(slug, ch.index).exists():
|
||||
analysis = artifacts.load_analysis(slug, ch.index)
|
||||
speakers = [s.speaker for s in analysis.segments]
|
||||
chars, _ = reconcile_characters(
|
||||
chars, found, dedup_gemma, speaker_names=speakers)
|
||||
except Exception: # noqa: BLE001 — chapitre ignore, on continue
|
||||
traceback.print_exc()
|
||||
continue
|
||||
artifacts.save_cast(slug, Cast(
|
||||
narrator_voice_id=cast.narrator_voice_id, characters=chars))
|
||||
self._save_and_emit(state)
|
||||
self._finish(state)
|
||||
self.enqueue(slug, job)
|
||||
|
||||
def run_dedup_cast(self, slug: str) -> None:
|
||||
"""Replie les doublons d'un casting deja constitue (Holden/James Holden...)."""
|
||||
def job() -> None:
|
||||
from ..analysis.gemma import Gemma
|
||||
from ..casting.dedup import dedup_cast
|
||||
from ..models import Cast
|
||||
from ..settings import get_settings
|
||||
|
||||
state = load_state(slug)
|
||||
state.active_stage = "cast"
|
||||
state.active_detail = "Deduplication du casting"
|
||||
self._save_and_emit(state)
|
||||
|
||||
cast = artifacts.load_cast(slug)
|
||||
gemma = Gemma() if get_settings().dedup_use_gemma else None
|
||||
chars = dedup_cast(cast.characters, gemma)
|
||||
artifacts.save_cast(slug, Cast(
|
||||
narrator_voice_id=cast.narrator_voice_id, characters=chars))
|
||||
self._finish(state)
|
||||
self.enqueue(slug, job)
|
||||
|
||||
def run_pronounce(self, slug: str) -> None:
|
||||
def job() -> None:
|
||||
from ..analysis.gemma import Gemma
|
||||
from ..analysis.pronunciation import (
|
||||
merge_pronunciations,
|
||||
propose_pronunciations,
|
||||
)
|
||||
|
||||
state = load_state(slug)
|
||||
book = load_book(slug)
|
||||
state.stages["pronounce"] = StageStatus.RUNNING
|
||||
state.active_stage = "pronounce"
|
||||
self._save_and_emit(state)
|
||||
|
||||
gemma = Gemma()
|
||||
pron = artifacts.load_pronunciation(slug)
|
||||
targets = book.render_chapters[:3] # echantillon de chapitres
|
||||
for i, ch in enumerate(targets):
|
||||
state.active_detail = f"Mots a risque — {ch.title}"
|
||||
state.active_progress = i / max(len(targets), 1)
|
||||
self._save_and_emit(state)
|
||||
ct = load_chapter_text(slug, ch)
|
||||
pron = merge_pronunciations(
|
||||
pron, propose_pronunciations("\n".join(ct.paragraphs), gemma))
|
||||
artifacts.save_pronunciation(slug, pron)
|
||||
state.stages["pronounce"] = StageStatus.DONE
|
||||
self._finish(state)
|
||||
self.enqueue(slug, job)
|
||||
|
||||
def run_render(self, slug: str, chapter_indexes: list[int],
|
||||
backend: Optional[str] = None, mono: bool = False) -> None:
|
||||
from ..settings import get_settings
|
||||
backend = backend or get_settings().default_backend
|
||||
|
||||
def job() -> None:
|
||||
from ..casting.voicebank import load_voicebank, voice_spec_for
|
||||
from ..pipeline.render import (
|
||||
build_units_mono,
|
||||
build_units_multi,
|
||||
make_voice_resolver,
|
||||
render_chapter_to_mp3,
|
||||
)
|
||||
from ..tts.factory import get_backend
|
||||
|
||||
state = load_state(slug)
|
||||
book = load_book(slug)
|
||||
state.stages["render"] = StageStatus.RUNNING
|
||||
state.active_stage = "render"
|
||||
self._save_and_emit(state)
|
||||
|
||||
tts = get_backend(backend)
|
||||
pron = artifacts.load_pronunciation(slug)
|
||||
cast = artifacts.load_cast(slug)
|
||||
vb = load_voicebank()
|
||||
render_list = [c for c in book.render_chapters if c.index in chapter_indexes]
|
||||
|
||||
for ch in render_list:
|
||||
rs = state.render.get(ch.index) or ChapterRenderState(index=ch.index)
|
||||
rs.status = StageStatus.RUNNING
|
||||
rs.progress = 0.0
|
||||
rs.backend = backend
|
||||
state.render[ch.index] = rs
|
||||
state.active_detail = f"Synthese — {ch.title}"
|
||||
self._save_and_emit(state)
|
||||
try:
|
||||
ct = load_chapter_text(slug, ch)
|
||||
if mono or ch.index not in state.analyzed_chapters:
|
||||
units = build_units_mono(ct, tts.default_voice())
|
||||
else:
|
||||
analysis = artifacts.load_analysis(slug, ch.index)
|
||||
narr = vb.by_id(cast.narrator_voice_id) if cast.narrator_voice_id else None
|
||||
default_voice = (voice_spec_for(narr, backend)
|
||||
if narr else tts.default_voice())
|
||||
resolver = make_voice_resolver(cast, vb, backend)
|
||||
units = build_units_multi(analysis, resolver, default_voice)
|
||||
|
||||
def _p(done: int, total: int, _rs=rs, _state=state) -> None:
|
||||
_rs.progress = done / max(total, 1)
|
||||
_state.active_progress = _rs.progress
|
||||
self._save_and_emit(_state)
|
||||
|
||||
track = book.render_chapters.index(ch) + 1
|
||||
mp3 = render_chapter_to_mp3(book, ch, units, tts, pron=pron,
|
||||
track=track, progress=_p)
|
||||
rs.status = StageStatus.DONE
|
||||
rs.progress = 1.0
|
||||
rs.mp3 = mp3.name
|
||||
except Exception as exc: # noqa: BLE001
|
||||
rs.status = StageStatus.ERROR
|
||||
rs.error = str(exc)
|
||||
self._save_and_emit(state)
|
||||
|
||||
state.stages["render"] = StageStatus.DONE
|
||||
self._finish(state)
|
||||
self.enqueue(slug, job)
|
||||
|
||||
def _finish(self, state: ProjectState) -> None:
|
||||
state.active_stage = None
|
||||
state.active_detail = None
|
||||
state.active_progress = 0.0
|
||||
self._save_and_emit(state)
|
||||
|
||||
|
||||
# Singleton partage par l'API.
|
||||
orchestrator = Orchestrator()
|
||||
158
backend/inkflow/pipeline/render.py
Normal file
158
backend/inkflow/pipeline/render.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Rendu audio d'un chapitre : (segments + voix) -> WAV -> MP3.
|
||||
|
||||
Une `RenderUnit` = un bout de texte + la voix a employer. On construit la liste
|
||||
d'unites (mono-narrateur ou multi-voix selon le casting), on synthetise chacune,
|
||||
on concatene avec des silences, on normalise puis on encode en MP3.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from ..analysis.pronunciation import apply_pronunciation
|
||||
from ..audio.postprocess import concat_segments, encode_mp3, normalize_loudness, write_wav
|
||||
from ..config import book_data_dir, book_output_dir
|
||||
from ..models import (
|
||||
Book,
|
||||
Chapter,
|
||||
ChapterAnalysis,
|
||||
ChapterText,
|
||||
Pronunciation,
|
||||
SegmentType,
|
||||
)
|
||||
from ..tts.base import TTSBackend, VoiceSpec
|
||||
|
||||
# Resout un nom de locuteur en une voix concrete.
|
||||
VoiceResolver = Callable[[str], VoiceSpec]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderUnit:
|
||||
text: str
|
||||
voice: VoiceSpec
|
||||
speaker: str = "narrateur"
|
||||
glued_to_prev: bool = False # incise -> gap reduit avec l'unite precedente
|
||||
|
||||
|
||||
def build_units_mono(ct: ChapterText, narrator: VoiceSpec) -> list[RenderUnit]:
|
||||
"""Mono-narrateur : chaque paragraphe est lu par la voix du narrateur."""
|
||||
return [RenderUnit(text=p, voice=narrator) for p in ct.paragraphs if p.strip()]
|
||||
|
||||
|
||||
def make_voice_resolver(cast, voicebank, engine: str) -> VoiceResolver:
|
||||
"""Construit un resolver locuteur -> VoiceSpec via le casting + la voicebank.
|
||||
|
||||
Replie sur la voix du narrateur si le locuteur n'a pas de voix attribuee.
|
||||
"""
|
||||
from ..casting.assign import resolve_speaker_voice
|
||||
from ..casting.voicebank import voice_spec_for
|
||||
|
||||
def resolve(speaker: str):
|
||||
vid = resolve_speaker_voice(speaker, cast, voicebank)
|
||||
if vid is None:
|
||||
vid = cast.narrator_voice_id
|
||||
entry = voicebank.by_id(vid) if vid else None
|
||||
if entry is None:
|
||||
return None # le backend utilisera sa voix par defaut
|
||||
return voice_spec_for(entry, engine)
|
||||
|
||||
return resolve
|
||||
|
||||
|
||||
def build_units_multi(
|
||||
analysis: ChapterAnalysis,
|
||||
resolve: VoiceResolver,
|
||||
default_voice: "VoiceSpec",
|
||||
) -> list[RenderUnit]:
|
||||
"""Multi-voix : narration -> narrateur, dialogue -> voix du personnage.
|
||||
|
||||
Les incises annotees sur une replique (bornes dans le texte) sont detachees
|
||||
ici, au dernier moment : la sous-chaine d'incise est portee par la voix du
|
||||
narrateur (`glued_to_prev` pour reduire le silence), le reste par la voix du
|
||||
personnage. Les repliques sans incise sont rendues entieres.
|
||||
"""
|
||||
from ..analysis.segmenter import iter_incise_pieces
|
||||
|
||||
narrator = resolve("narrateur") or default_voice
|
||||
units: list[RenderUnit] = []
|
||||
for seg in analysis.segments:
|
||||
if not seg.text.strip():
|
||||
continue
|
||||
if seg.type is SegmentType.NARRATION:
|
||||
units.append(RenderUnit(text=seg.text, voice=narrator,
|
||||
speaker="narrateur",
|
||||
glued_to_prev=seg.glued_to_prev))
|
||||
continue
|
||||
|
||||
char_voice = resolve(seg.speaker) or default_voice
|
||||
if not seg.incises:
|
||||
units.append(RenderUnit(text=seg.text, voice=char_voice,
|
||||
speaker=seg.speaker,
|
||||
glued_to_prev=seg.glued_to_prev))
|
||||
continue
|
||||
|
||||
for k, (is_incise, piece) in enumerate(
|
||||
iter_incise_pieces(seg.text, seg.incises)):
|
||||
glued = seg.glued_to_prev if k == 0 else True
|
||||
if is_incise:
|
||||
units.append(RenderUnit(text=piece, voice=narrator,
|
||||
speaker="narrateur", glued_to_prev=glued))
|
||||
else:
|
||||
units.append(RenderUnit(text=piece, voice=char_voice,
|
||||
speaker=seg.speaker, glued_to_prev=glued))
|
||||
return units
|
||||
|
||||
|
||||
def render_units(
|
||||
units: list[RenderUnit],
|
||||
backend: TTSBackend,
|
||||
*,
|
||||
pron: Optional[Pronunciation] = None,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
) -> tuple["list", int]:
|
||||
"""Synthetise toutes les unites et renvoie (liste (audio,sr), n_units)."""
|
||||
parts = []
|
||||
total = len(units)
|
||||
for i, unit in enumerate(units):
|
||||
text = apply_pronunciation(unit.text, pron) if pron else unit.text
|
||||
audio, sr = backend.synthesize(text, unit.voice)
|
||||
parts.append((audio, sr))
|
||||
if progress:
|
||||
progress(i + 1, total)
|
||||
return parts, total
|
||||
|
||||
|
||||
def render_chapter_to_mp3(
|
||||
book: Book,
|
||||
chapter: Chapter,
|
||||
units: list[RenderUnit],
|
||||
backend: TTSBackend,
|
||||
*,
|
||||
pron: Optional[Pronunciation] = None,
|
||||
track: Optional[int] = None,
|
||||
progress: Optional[Callable[[int, int], None]] = None,
|
||||
) -> Path:
|
||||
"""Pipeline complet pour un chapitre -> output/<livre>/NN-...mp3."""
|
||||
parts, _ = render_units(units, backend, pron=pron, progress=progress)
|
||||
# parts est aligne 1:1 avec units -> on transmet les marqueurs d'incise.
|
||||
audio, sr = concat_segments(parts, glued=[u.glued_to_prev for u in units])
|
||||
audio = normalize_loudness(audio)
|
||||
|
||||
# WAV intermediaire dans data/, MP3 final dans output/.
|
||||
wav_path = book_data_dir(book.slug) / "audio" / f"ch{chapter.index:02d}.wav"
|
||||
write_wav(wav_path, audio, sr)
|
||||
|
||||
out_dir = book_output_dir(book.title)
|
||||
mp3_path = out_dir / (chapter.output_name or f"ch{chapter.index:02d}.mp3")
|
||||
cover = None
|
||||
if book.cover_file:
|
||||
candidate = book_data_dir(book.slug) / book.cover_file
|
||||
cover = candidate if candidate.exists() else None
|
||||
|
||||
encode_mp3(
|
||||
wav_path, mp3_path,
|
||||
title=chapter.title, album=book.title, artist=book.author,
|
||||
track=track, cover_path=cover,
|
||||
)
|
||||
return mp3_path
|
||||
Reference in New Issue
Block a user