Spaces:
Build error
Build error
feat: analysis schemas XAI inference YouTube downloader ve analysis job servisleri eklendi
Browse files- app/routes/analyze_schemas.py +12 -2
- app/services/analysis_jobs.py +135 -0
- app/services/inference_xai.py +30 -33
- app/services/youtube_downloader.py +17 -2
app/routes/analyze_schemas.py
CHANGED
|
@@ -52,17 +52,24 @@ class AudioInfo(BaseModel):
|
|
| 52 |
|
| 53 |
|
| 54 |
class ConfidenceBandModel(BaseModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
tier: str
|
| 56 |
labelTr: str
|
| 57 |
labelEn: str
|
| 58 |
-
|
| 59 |
-
upperBound: float
|
| 60 |
|
| 61 |
|
| 62 |
class ModelVoteModel(BaseModel):
|
| 63 |
name: str
|
| 64 |
probability: float
|
| 65 |
vote: str
|
|
|
|
|
|
|
| 66 |
|
| 67 |
|
| 68 |
class FeatureContributionModel(BaseModel):
|
|
@@ -131,6 +138,9 @@ class AnalysisResult(BaseModel):
|
|
| 131 |
xai: Optional[XAIExplanation] = None
|
| 132 |
towerScores: Dict[str, float] = Field(default_factory=dict)
|
| 133 |
metaClassifier: Optional[MetaClassifierExplanation] = None
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
|
| 136 |
class AnalyzeResponse(BaseModel):
|
|
|
|
| 52 |
|
| 53 |
|
| 54 |
class ConfidenceBandModel(BaseModel):
|
| 55 |
+
"""How far the probability sits from the decision threshold.
|
| 56 |
+
|
| 57 |
+
A qualitative tier only — there is no bootstrap behind it, so no
|
| 58 |
+
interval bounds are reported.
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
tier: str
|
| 62 |
labelTr: str
|
| 63 |
labelEn: str
|
| 64 |
+
margin: float = 0.0
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
class ModelVoteModel(BaseModel):
|
| 68 |
name: str
|
| 69 |
probability: float
|
| 70 |
vote: str
|
| 71 |
+
# False when the model file failed to load; probability is then a 0.5 placeholder.
|
| 72 |
+
available: bool = True
|
| 73 |
|
| 74 |
|
| 75 |
class FeatureContributionModel(BaseModel):
|
|
|
|
| 138 |
xai: Optional[XAIExplanation] = None
|
| 139 |
towerScores: Dict[str, float] = Field(default_factory=dict)
|
| 140 |
metaClassifier: Optional[MetaClassifierExplanation] = None
|
| 141 |
+
# Per-layer status: which model actually ran and how (e.g. CLAP embedding
|
| 142 |
+
# vs. its spectral heuristic fallback), so the UI can label each score.
|
| 143 |
+
layers: Dict[str, Dict[str, Any]] = Field(default_factory=dict)
|
| 144 |
|
| 145 |
|
| 146 |
class AnalyzeResponse(BaseModel):
|
app/services/analysis_jobs.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Background analysis jobs with real, per-step progress.
|
| 3 |
+
|
| 4 |
+
POST /api/analyze/jobs starts a job and returns its id; the client polls
|
| 5 |
+
GET /api/analyze/jobs/{id}. Each pipeline step reports when it starts and
|
| 6 |
+
finishes, so the waiting screen shows what the server is actually doing
|
| 7 |
+
instead of a simulated progress bar. A job also survives the client
|
| 8 |
+
leaving: a reloaded page can resume polling the same id.
|
| 9 |
+
|
| 10 |
+
State lives in process memory — the Space runs one replica, and a job
|
| 11 |
+
only needs to outlive the request that started it, not a restart.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import asyncio
|
| 17 |
+
import time
|
| 18 |
+
import uuid
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
| 21 |
+
|
| 22 |
+
JOB_TTL_SEC = 15 * 60
|
| 23 |
+
MAX_JOBS = 200
|
| 24 |
+
|
| 25 |
+
STEP_PENDING = "pending"
|
| 26 |
+
STEP_RUNNING = "running"
|
| 27 |
+
STEP_DONE = "done"
|
| 28 |
+
STEP_FAILED = "failed"
|
| 29 |
+
STEP_SKIPPED = "skipped"
|
| 30 |
+
|
| 31 |
+
FILE_STEPS = ["features", "vocals", "wav2vec2", "clap", "fst", "xai", "meta"]
|
| 32 |
+
URL_STEPS = ["download", *FILE_STEPS]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Progress:
|
| 36 |
+
"""Ordered step states for one analysis. A no-op sink is fine too."""
|
| 37 |
+
|
| 38 |
+
def __init__(self, steps: List[str]) -> None:
|
| 39 |
+
self._steps: Dict[str, str] = {s: STEP_PENDING for s in steps}
|
| 40 |
+
self._started: Dict[str, float] = {}
|
| 41 |
+
self._took: Dict[str, float] = {}
|
| 42 |
+
|
| 43 |
+
def set(self, step: str, state: str) -> None:
|
| 44 |
+
if step not in self._steps:
|
| 45 |
+
return
|
| 46 |
+
now = time.monotonic()
|
| 47 |
+
if state == STEP_RUNNING:
|
| 48 |
+
self._started.setdefault(step, now)
|
| 49 |
+
elif step in self._started and step not in self._took:
|
| 50 |
+
self._took[step] = round(now - self._started[step], 2)
|
| 51 |
+
self._steps[step] = state
|
| 52 |
+
|
| 53 |
+
def running(self, *steps: str) -> None:
|
| 54 |
+
for s in steps:
|
| 55 |
+
self.set(s, STEP_RUNNING)
|
| 56 |
+
|
| 57 |
+
def snapshot(self) -> List[Dict[str, Any]]:
|
| 58 |
+
out: List[Dict[str, Any]] = []
|
| 59 |
+
for step, state in self._steps.items():
|
| 60 |
+
item: Dict[str, Any] = {"id": step, "state": state}
|
| 61 |
+
if step in self._took:
|
| 62 |
+
item["seconds"] = self._took[step]
|
| 63 |
+
out.append(item)
|
| 64 |
+
return out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@dataclass
|
| 68 |
+
class Job:
|
| 69 |
+
id: str
|
| 70 |
+
source_type: str
|
| 71 |
+
progress: Progress
|
| 72 |
+
created: float = field(default_factory=time.time)
|
| 73 |
+
updated: float = field(default_factory=time.time)
|
| 74 |
+
status: str = "running" # running | done | error
|
| 75 |
+
response: Optional[Dict[str, Any]] = None
|
| 76 |
+
task: Optional[asyncio.Task] = None
|
| 77 |
+
|
| 78 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 79 |
+
return {
|
| 80 |
+
"jobId": self.id,
|
| 81 |
+
"sourceType": self.source_type,
|
| 82 |
+
"status": self.status,
|
| 83 |
+
"steps": self.progress.snapshot(),
|
| 84 |
+
"elapsedSec": round((self.updated if self.status != "running" else time.time()) - self.created, 2),
|
| 85 |
+
"response": self.response,
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class JobStore:
|
| 90 |
+
def __init__(self) -> None:
|
| 91 |
+
self._jobs: Dict[str, Job] = {}
|
| 92 |
+
|
| 93 |
+
def _prune(self) -> None:
|
| 94 |
+
cutoff = time.time() - JOB_TTL_SEC
|
| 95 |
+
for job_id in [j.id for j in self._jobs.values() if j.status != "running" and j.updated < cutoff]:
|
| 96 |
+
del self._jobs[job_id]
|
| 97 |
+
# Hard cap: drop the oldest finished jobs first.
|
| 98 |
+
if len(self._jobs) > MAX_JOBS:
|
| 99 |
+
finished = sorted((j for j in self._jobs.values() if j.status != "running"), key=lambda j: j.updated)
|
| 100 |
+
for job in finished[: len(self._jobs) - MAX_JOBS]:
|
| 101 |
+
del self._jobs[job.id]
|
| 102 |
+
|
| 103 |
+
def running_count(self) -> int:
|
| 104 |
+
return sum(1 for j in self._jobs.values() if j.status == "running")
|
| 105 |
+
|
| 106 |
+
def start(
|
| 107 |
+
self,
|
| 108 |
+
source_type: str,
|
| 109 |
+
steps: List[str],
|
| 110 |
+
work: Callable[[Progress], Awaitable[Dict[str, Any]]],
|
| 111 |
+
) -> Job:
|
| 112 |
+
self._prune()
|
| 113 |
+
job = Job(id=uuid.uuid4().hex, source_type=source_type, progress=Progress(steps))
|
| 114 |
+
self._jobs[job.id] = job
|
| 115 |
+
|
| 116 |
+
async def runner() -> None:
|
| 117 |
+
try:
|
| 118 |
+
job.response = await work(job.progress)
|
| 119 |
+
job.status = "error" if job.response.get("errors") and not job.response.get("result") else "done"
|
| 120 |
+
except Exception: # noqa: BLE001 — the job must always settle
|
| 121 |
+
job.response = {"result": None, "warnings": [], "errors": ["internal_error"]}
|
| 122 |
+
job.status = "error"
|
| 123 |
+
finally:
|
| 124 |
+
job.updated = time.time()
|
| 125 |
+
|
| 126 |
+
# Keep a reference: asyncio only holds weak references to tasks.
|
| 127 |
+
job.task = asyncio.create_task(runner())
|
| 128 |
+
return job
|
| 129 |
+
|
| 130 |
+
def get(self, job_id: str) -> Optional[Job]:
|
| 131 |
+
self._prune()
|
| 132 |
+
return self._jobs.get(job_id)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
job_store = JobStore()
|
app/services/inference_xai.py
CHANGED
|
@@ -369,12 +369,11 @@ class FeatureContribution:
|
|
| 369 |
|
| 370 |
@dataclass
|
| 371 |
class ConfidenceBand:
|
| 372 |
-
"""Human-readable confidence tier."""
|
| 373 |
tier: str # "uncertain" | "likely" | "strong" | "very_strong"
|
| 374 |
label_tr: str
|
| 375 |
label_en: str
|
| 376 |
-
|
| 377 |
-
upper_bound: float # bootstrap CI upper
|
| 378 |
|
| 379 |
|
| 380 |
@dataclass
|
|
@@ -383,6 +382,7 @@ class ModelVote:
|
|
| 383 |
name: str # XGBoost / LightGBM / ...
|
| 384 |
probability: float
|
| 385 |
vote: str # "ai" | "human"
|
|
|
|
| 386 |
|
| 387 |
|
| 388 |
@dataclass
|
|
@@ -639,21 +639,24 @@ class XAIInferenceService:
|
|
| 639 |
return m
|
| 640 |
|
| 641 |
def _confidence_band(self, prob: float) -> ConfidenceBand:
|
| 642 |
-
"""Map probability to
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
| 651 |
tier = "uncertain"
|
| 652 |
label_tr, label_en = "Belirsiz", "Uncertain"
|
| 653 |
-
elif
|
| 654 |
tier = "likely"
|
| 655 |
label_tr, label_en = "Muhtemelen", "Likely"
|
| 656 |
-
elif
|
| 657 |
tier = "strong"
|
| 658 |
label_tr, label_en = "Güçlü İşaret", "Strong"
|
| 659 |
else:
|
|
@@ -664,8 +667,7 @@ class XAIInferenceService:
|
|
| 664 |
tier=tier,
|
| 665 |
label_tr=label_tr,
|
| 666 |
label_en=label_en,
|
| 667 |
-
|
| 668 |
-
upper_bound=round(upper, 3),
|
| 669 |
)
|
| 670 |
|
| 671 |
def _load_ensemble_models(self) -> None:
|
|
@@ -699,8 +701,9 @@ class XAIInferenceService:
|
|
| 699 |
def _build_votes(self, x_scaled: "np.ndarray") -> List[ModelVote]:
|
| 700 |
"""Run real inference on all loaded ensemble models.
|
| 701 |
|
| 702 |
-
|
| 703 |
-
|
|
|
|
| 704 |
"""
|
| 705 |
votes: List[ModelVote] = []
|
| 706 |
best_name = self.training_results.get("_best_model", "LightGBM")
|
|
@@ -708,23 +711,16 @@ class XAIInferenceService:
|
|
| 708 |
all_names = list({**_ML_MODEL_FILES, **_DL_MODEL_FILES}.keys())
|
| 709 |
for name in all_names:
|
| 710 |
model = self.ensemble_models.get(name)
|
|
|
|
| 711 |
if model is not None:
|
| 712 |
try:
|
| 713 |
prob = float(model.predict_proba(x_scaled)[0, 1])
|
| 714 |
except Exception as e:
|
| 715 |
logger.warning(f"Inference failed for {name}: {e}")
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
data = self.training_results.get(name, {})
|
| 721 |
-
acc = data.get("accuracy", 0.5) if isinstance(data, dict) else 0.5
|
| 722 |
-
# Use best model's actual prob as anchor
|
| 723 |
-
best_data = self.training_results.get(best_name, {})
|
| 724 |
-
best_acc = best_data.get("accuracy", 0.8) if isinstance(best_data, dict) else 0.8
|
| 725 |
-
# Scale approximation relative to best model's training accuracy
|
| 726 |
-
ratio = acc / best_acc if best_acc > 0 else 1.0
|
| 727 |
-
prob = round(max(0.03, min(0.97, 0.5 + (x_scaled.flatten()[0] * 0.0 + 0.5 - 0.5) * ratio)), 3)
|
| 728 |
|
| 729 |
threshold = self.threshold if name == best_name else 0.5
|
| 730 |
votes.append(ModelVote(
|
|
@@ -733,7 +729,8 @@ class XAIInferenceService:
|
|
| 733 |
vote="ai" if prob >= threshold else "human",
|
| 734 |
))
|
| 735 |
|
| 736 |
-
|
|
|
|
| 737 |
|
| 738 |
def to_dict(self, result: XAIResult) -> Dict[str, Any]:
|
| 739 |
"""Serialize XAIResult for JSON response."""
|
|
@@ -745,8 +742,7 @@ class XAIInferenceService:
|
|
| 745 |
"tier": result.confidence_band.tier,
|
| 746 |
"labelTr": result.confidence_band.label_tr,
|
| 747 |
"labelEn": result.confidence_band.label_en,
|
| 748 |
-
"
|
| 749 |
-
"upperBound": result.confidence_band.upper_bound,
|
| 750 |
},
|
| 751 |
"baseProbability": round(result.base_probability, 4),
|
| 752 |
"modelVotes": [
|
|
@@ -754,6 +750,7 @@ class XAIInferenceService:
|
|
| 754 |
"name": v.name,
|
| 755 |
"probability": round(v.probability, 4),
|
| 756 |
"vote": v.vote,
|
|
|
|
| 757 |
}
|
| 758 |
for v in result.model_votes
|
| 759 |
],
|
|
|
|
| 369 |
|
| 370 |
@dataclass
|
| 371 |
class ConfidenceBand:
|
| 372 |
+
"""Human-readable confidence tier (qualitative, no interval)."""
|
| 373 |
tier: str # "uncertain" | "likely" | "strong" | "very_strong"
|
| 374 |
label_tr: str
|
| 375 |
label_en: str
|
| 376 |
+
margin: float # 0 at the threshold, 1 at the far end of the verdict's side
|
|
|
|
| 377 |
|
| 378 |
|
| 379 |
@dataclass
|
|
|
|
| 382 |
name: str # XGBoost / LightGBM / ...
|
| 383 |
probability: float
|
| 384 |
vote: str # "ai" | "human"
|
| 385 |
+
available: bool = True # False: model failed to load, probability is a placeholder
|
| 386 |
|
| 387 |
|
| 388 |
@dataclass
|
|
|
|
| 639 |
return m
|
| 640 |
|
| 641 |
def _confidence_band(self, prob: float) -> ConfidenceBand:
|
| 642 |
+
"""Map probability to a qualitative tier.
|
| 643 |
+
|
| 644 |
+
Measured from the decision threshold (not 0.5): the margin is how far
|
| 645 |
+
the probability sits into its side of the threshold, 0 at the
|
| 646 |
+
threshold and 1 at 0.0 / 1.0. With a 0.5 threshold the cut points
|
| 647 |
+
match the old distance-from-0.5 tiers (0.1 / 0.25 / 0.4).
|
| 648 |
+
"""
|
| 649 |
+
t = min(max(self.threshold, 1e-6), 1 - 1e-6)
|
| 650 |
+
margin = (prob - t) / (1.0 - t) if prob >= t else (t - prob) / t
|
| 651 |
+
margin = max(0.0, min(1.0, margin))
|
| 652 |
+
|
| 653 |
+
if margin < 0.2:
|
| 654 |
tier = "uncertain"
|
| 655 |
label_tr, label_en = "Belirsiz", "Uncertain"
|
| 656 |
+
elif margin < 0.5:
|
| 657 |
tier = "likely"
|
| 658 |
label_tr, label_en = "Muhtemelen", "Likely"
|
| 659 |
+
elif margin < 0.8:
|
| 660 |
tier = "strong"
|
| 661 |
label_tr, label_en = "Güçlü İşaret", "Strong"
|
| 662 |
else:
|
|
|
|
| 667 |
tier=tier,
|
| 668 |
label_tr=label_tr,
|
| 669 |
label_en=label_en,
|
| 670 |
+
margin=round(margin, 3),
|
|
|
|
| 671 |
)
|
| 672 |
|
| 673 |
def _load_ensemble_models(self) -> None:
|
|
|
|
| 701 |
def _build_votes(self, x_scaled: "np.ndarray") -> List[ModelVote]:
|
| 702 |
"""Run real inference on all loaded ensemble models.
|
| 703 |
|
| 704 |
+
A model that failed to load or raises at inference time still gets
|
| 705 |
+
a row, marked unavailable with a neutral 0.5 placeholder — never an
|
| 706 |
+
invented probability.
|
| 707 |
"""
|
| 708 |
votes: List[ModelVote] = []
|
| 709 |
best_name = self.training_results.get("_best_model", "LightGBM")
|
|
|
|
| 711 |
all_names = list({**_ML_MODEL_FILES, **_DL_MODEL_FILES}.keys())
|
| 712 |
for name in all_names:
|
| 713 |
model = self.ensemble_models.get(name)
|
| 714 |
+
prob: Optional[float] = None
|
| 715 |
if model is not None:
|
| 716 |
try:
|
| 717 |
prob = float(model.predict_proba(x_scaled)[0, 1])
|
| 718 |
except Exception as e:
|
| 719 |
logger.warning(f"Inference failed for {name}: {e}")
|
| 720 |
+
|
| 721 |
+
if prob is None:
|
| 722 |
+
votes.append(ModelVote(name=name, probability=0.5, vote="human", available=False))
|
| 723 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
|
| 725 |
threshold = self.threshold if name == best_name else 0.5
|
| 726 |
votes.append(ModelVote(
|
|
|
|
| 729 |
vote="ai" if prob >= threshold else "human",
|
| 730 |
))
|
| 731 |
|
| 732 |
+
# Loaded models first, highest probability first; placeholders last.
|
| 733 |
+
return sorted(votes, key=lambda v: (v.available, v.probability), reverse=True)
|
| 734 |
|
| 735 |
def to_dict(self, result: XAIResult) -> Dict[str, Any]:
|
| 736 |
"""Serialize XAIResult for JSON response."""
|
|
|
|
| 742 |
"tier": result.confidence_band.tier,
|
| 743 |
"labelTr": result.confidence_band.label_tr,
|
| 744 |
"labelEn": result.confidence_band.label_en,
|
| 745 |
+
"margin": result.confidence_band.margin,
|
|
|
|
| 746 |
},
|
| 747 |
"baseProbability": round(result.base_probability, 4),
|
| 748 |
"modelVotes": [
|
|
|
|
| 750 |
"name": v.name,
|
| 751 |
"probability": round(v.probability, 4),
|
| 752 |
"vote": v.vote,
|
| 753 |
+
"available": v.available,
|
| 754 |
}
|
| 755 |
for v in result.model_votes
|
| 756 |
],
|
app/services/youtube_downloader.py
CHANGED
|
@@ -13,7 +13,7 @@ import re
|
|
| 13 |
import tempfile
|
| 14 |
from dataclasses import dataclass, field
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import List, Optional
|
| 17 |
|
| 18 |
import yt_dlp
|
| 19 |
|
|
@@ -170,9 +170,18 @@ class _YtDlpLogCollector:
|
|
| 170 |
|
| 171 |
|
| 172 |
class YouTubeDownloader:
|
| 173 |
-
def __init__(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
self.output_dir = output_dir
|
| 175 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
| 176 |
|
| 177 |
def download(self, url: str, video_id: str) -> DownloadResult:
|
| 178 |
if not url or not url.strip():
|
|
@@ -257,7 +266,13 @@ class YouTubeDownloader:
|
|
| 257 |
"""Try downloading with different player client combinations."""
|
| 258 |
failures: List[DownloadAttemptFailure] = []
|
| 259 |
|
|
|
|
|
|
|
|
|
|
| 260 |
if with_postprocessor:
|
|
|
|
|
|
|
|
|
|
| 261 |
options["format"] = "bestaudio/best"
|
| 262 |
options["postprocessors"] = [
|
| 263 |
{
|
|
|
|
| 13 |
import tempfile
|
| 14 |
from dataclasses import dataclass, field
|
| 15 |
from pathlib import Path
|
| 16 |
+
from typing import List, Optional, Tuple
|
| 17 |
|
| 18 |
import yt_dlp
|
| 19 |
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
class YouTubeDownloader:
|
| 173 |
+
def __init__(
|
| 174 |
+
self,
|
| 175 |
+
output_dir: Path,
|
| 176 |
+
clip: Optional[Tuple[float, float]] = None,
|
| 177 |
+
max_filesize: Optional[int] = None,
|
| 178 |
+
) -> None:
|
| 179 |
+
"""clip=(start, end) seconds downloads only that section (needs ffmpeg);
|
| 180 |
+
max_filesize caps the fallback path that downloads without ffmpeg."""
|
| 181 |
self.output_dir = output_dir
|
| 182 |
self.output_dir.mkdir(parents=True, exist_ok=True)
|
| 183 |
+
self.clip = clip
|
| 184 |
+
self.max_filesize = max_filesize
|
| 185 |
|
| 186 |
def download(self, url: str, video_id: str) -> DownloadResult:
|
| 187 |
if not url or not url.strip():
|
|
|
|
| 266 |
"""Try downloading with different player client combinations."""
|
| 267 |
failures: List[DownloadAttemptFailure] = []
|
| 268 |
|
| 269 |
+
if self.max_filesize:
|
| 270 |
+
options["max_filesize"] = self.max_filesize
|
| 271 |
+
|
| 272 |
if with_postprocessor:
|
| 273 |
+
if self.clip:
|
| 274 |
+
options["download_ranges"] = yt_dlp.utils.download_range_func(None, [self.clip])
|
| 275 |
+
options["force_keyframes_at_cuts"] = True
|
| 276 |
options["format"] = "bestaudio/best"
|
| 277 |
options["postprocessors"] = [
|
| 278 |
{
|