oss_archive.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564
  1. from __future__ import annotations
  2. import os
  3. import time
  4. from datetime import datetime, timedelta, timezone
  5. from threading import Lock
  6. from typing import Any, Callable
  7. from urllib.parse import urlparse
  8. from content_agent.integrations import oss_upload, timeout_config
  9. from content_agent.integrations.bounded_pool import DaemonThreadPoolExecutor, run_bounded
  10. from content_agent.interfaces import RuntimeFileStore
  11. # 单次 OSS 上传尝试上限(原 3600,即便生效也能挂 1 小时)。env 可覆盖,被硬上限钳制。
  12. DEFAULT_OSS_ATTEMPT_TIMEOUT_SECONDS = timeout_config.total_timeout("oss")
  13. # 主线程等单条 OSS worker 的看门狗:略大于单次尝试上限,只当兜底。
  14. OSS_WORKER_RESULT_TIMEOUT_SECONDS = timeout_config.total_timeout("oss") + 60.0
  15. DEFAULT_OSS_ARCHIVE_WINDOW_SECONDS = 24 * 60 * 60.0
  16. DEFAULT_OSS_RETRY_DELAY_SECONDS = 15 * 60.0
  17. DEFAULT_OSS_ARCHIVE_MAX_WORKERS = 5
  18. DEFAULT_OSS_URL_FALLBACK_MAX_ATTEMPTS = 3
  19. DEFAULT_OSS_MEDIA_UPLOAD_BUDGET_SECONDS = 360.0
  20. class AsyncArchiveDispatcher:
  21. def __init__(
  22. self,
  23. runtime: RuntimeFileStore,
  24. run_id: str,
  25. *,
  26. upload_fn: Callable[..., dict[str, Any]] = oss_upload.upload_video_from_env,
  27. now_fn: Callable[[], datetime] | None = None,
  28. attempt_timeout_seconds: float = DEFAULT_OSS_ATTEMPT_TIMEOUT_SECONDS,
  29. retry_delay_seconds: float = DEFAULT_OSS_RETRY_DELAY_SECONDS,
  30. max_workers: int | None = None,
  31. ) -> None:
  32. self.runtime = runtime
  33. self.run_id = run_id
  34. self.upload_fn = upload_fn
  35. self.now_fn = now_fn
  36. self.attempt_timeout_seconds = attempt_timeout_seconds
  37. self.retry_delay_seconds = retry_delay_seconds
  38. self._max_workers = _resolve_max_workers(max_workers)
  39. # daemon 池:卡死的上传 worker 不阻止进程退出(配合 OSS read 短超时,worker 必有限时间自终)。
  40. self._executor = DaemonThreadPoolExecutor(
  41. max_workers=self._max_workers, thread_name_prefix="oss-archive"
  42. )
  43. self._lock = Lock()
  44. self._write_lock = Lock()
  45. self._completed: list[dict[str, Any]] = []
  46. self._runtime_writes_enabled = False
  47. def submit_records(self, records: list[dict[str, Any]]) -> None:
  48. now = _utc_now(self.now_fn)
  49. for record in records:
  50. if not _is_due(record, now):
  51. continue
  52. future = self._executor.submit(
  53. _archive_one,
  54. record,
  55. now,
  56. upload_fn=self.upload_fn,
  57. attempt_timeout_seconds=self.attempt_timeout_seconds,
  58. retry_delay_seconds=self.retry_delay_seconds,
  59. )
  60. future.add_done_callback(self._store_completed)
  61. def archive_records(self, records: list[dict[str, Any]]) -> list[dict[str, Any]]:
  62. return archive_due_records(
  63. records,
  64. upload_fn=self.upload_fn,
  65. now_fn=self.now_fn,
  66. attempt_timeout_seconds=self.attempt_timeout_seconds,
  67. retry_delay_seconds=self.retry_delay_seconds,
  68. max_workers=self._max_workers,
  69. )
  70. def drain_completed(self) -> list[dict[str, Any]]:
  71. with self._lock:
  72. completed = list(self._completed)
  73. self._completed.clear()
  74. return completed
  75. def enable_runtime_writes(self) -> None:
  76. with self._lock:
  77. self._runtime_writes_enabled = True
  78. completed = list(self._completed)
  79. self._completed.clear()
  80. self._write_records(completed)
  81. def shutdown(self, *, wait: bool = False) -> None:
  82. # wait=True 是"排空全部归档"语义 → 不取消队列;wait=False 是"放弃卡住的"→ 取消未启动任务。
  83. self._executor.shutdown(wait=wait, cancel_futures=not wait)
  84. def _store_completed(self, future: Any) -> None:
  85. try:
  86. record = future.result()
  87. except Exception:
  88. return
  89. write_now = False
  90. with self._lock:
  91. if self._runtime_writes_enabled:
  92. write_now = True
  93. else:
  94. self._completed.append(record)
  95. if write_now:
  96. self._write_records([record])
  97. def _write_records(self, records: list[dict[str, Any]]) -> None:
  98. if not records:
  99. return
  100. with self._write_lock:
  101. self.runtime.append_jsonl(self.run_id, "content_media_records.jsonl", records)
  102. def mark_archive_pending(
  103. records: list[dict[str, Any]],
  104. *,
  105. now_fn: Callable[[], datetime] | None = None,
  106. archive_window_seconds: float = DEFAULT_OSS_ARCHIVE_WINDOW_SECONDS,
  107. ) -> list[dict[str, Any]]:
  108. now = _utc_now(now_fn)
  109. return [
  110. _mark_one_pending(record, now, archive_window_seconds=archive_window_seconds)
  111. for record in records
  112. ]
  113. def archive_due_records(
  114. records: list[dict[str, Any]],
  115. *,
  116. upload_fn: Callable[..., dict[str, Any]] = oss_upload.upload_video_from_env,
  117. now_fn: Callable[[], datetime] | None = None,
  118. attempt_timeout_seconds: float = DEFAULT_OSS_ATTEMPT_TIMEOUT_SECONDS,
  119. retry_delay_seconds: float = DEFAULT_OSS_RETRY_DELAY_SECONDS,
  120. max_workers: int | None = None,
  121. ) -> list[dict[str, Any]]:
  122. now = _utc_now(now_fn)
  123. archived = list(records)
  124. due_records: list[tuple[int, dict[str, Any]]] = []
  125. for index, record in enumerate(records):
  126. if not _is_due(record, now):
  127. continue
  128. due_records.append((index, record))
  129. if not due_records:
  130. return archived
  131. worker_count = _resolve_max_workers(max_workers)
  132. if worker_count <= 1 or len(due_records) == 1:
  133. for index, record in due_records:
  134. archived[index] = _archive_one(
  135. record,
  136. now,
  137. upload_fn=upload_fn,
  138. attempt_timeout_seconds=attempt_timeout_seconds,
  139. retry_delay_seconds=retry_delay_seconds,
  140. )
  141. return archived
  142. due_only = [record for _, record in due_records]
  143. def _work(record: dict[str, Any]) -> dict[str, Any]:
  144. return _archive_one(
  145. record,
  146. now,
  147. upload_fn=upload_fn,
  148. attempt_timeout_seconds=attempt_timeout_seconds,
  149. retry_delay_seconds=retry_delay_seconds,
  150. )
  151. def _on_timeout(record: dict[str, Any], _offset: int) -> dict[str, Any]:
  152. raw_payload = dict(record.get("raw_payload") or {})
  153. attempt_count = int(raw_payload.get("oss_archive_attempt_count") or 0) + 1
  154. return _with_failed_archive(record, raw_payload, now, attempt_count, "oss_worker_timeout")
  155. results = run_bounded(
  156. due_only,
  157. _work,
  158. max_workers=worker_count,
  159. per_future_timeout=OSS_WORKER_RESULT_TIMEOUT_SECONDS,
  160. on_timeout=_on_timeout,
  161. thread_name_prefix="oss-archive",
  162. )
  163. for (index, _record), result in zip(due_records, results):
  164. archived[index] = result
  165. return archived
  166. def archive_record(
  167. record: dict[str, Any],
  168. *,
  169. upload_fn: Callable[..., dict[str, Any]] = oss_upload.upload_video_from_env,
  170. now_fn: Callable[[], datetime] | None = None,
  171. attempt_timeout_seconds: float = DEFAULT_OSS_ATTEMPT_TIMEOUT_SECONDS,
  172. retry_delay_seconds: float = DEFAULT_OSS_RETRY_DELAY_SECONDS,
  173. ) -> dict[str, Any]:
  174. """Archive one due media record using the same status contract as batch archive."""
  175. now = _utc_now(now_fn)
  176. if not _is_due(record, now):
  177. return record
  178. return _archive_one(
  179. record,
  180. now,
  181. upload_fn=upload_fn,
  182. attempt_timeout_seconds=attempt_timeout_seconds,
  183. retry_delay_seconds=retry_delay_seconds,
  184. )
  185. def archive_pending_for_run(
  186. runtime: RuntimeFileStore,
  187. run_id: str,
  188. *,
  189. upload_fn: Callable[..., dict[str, Any]] = oss_upload.upload_video_from_env,
  190. now_fn: Callable[[], datetime] | None = None,
  191. attempt_timeout_seconds: float = DEFAULT_OSS_ATTEMPT_TIMEOUT_SECONDS,
  192. retry_delay_seconds: float = DEFAULT_OSS_RETRY_DELAY_SECONDS,
  193. max_workers: int | None = None,
  194. ) -> list[dict[str, Any]]:
  195. records = runtime.read_jsonl(run_id, "content_media_records.jsonl")
  196. archived = archive_due_records(
  197. records,
  198. upload_fn=upload_fn,
  199. now_fn=now_fn,
  200. attempt_timeout_seconds=attempt_timeout_seconds,
  201. retry_delay_seconds=retry_delay_seconds,
  202. max_workers=max_workers,
  203. )
  204. runtime.append_jsonl(run_id, "content_media_records.jsonl", archived)
  205. return archived
  206. def _mark_one_pending(
  207. record: dict[str, Any],
  208. now: datetime,
  209. *,
  210. archive_window_seconds: float,
  211. ) -> dict[str, Any]:
  212. if not record.get("play_url"):
  213. return record
  214. if record.get("content_media_status") == "oss_uploaded":
  215. return record
  216. raw_payload = dict(record.get("raw_payload") or {})
  217. deadline = raw_payload.get("oss_archive_deadline_at")
  218. if not deadline:
  219. deadline = _iso(now + timedelta(seconds=archive_window_seconds))
  220. updated_raw = {
  221. **raw_payload,
  222. "content_media_status": "oss_upload_pending",
  223. "oss_url": record.get("oss_url"),
  224. "local_path": record.get("local_path"),
  225. "oss_archive_status": "pending",
  226. "oss_archive_attempt_count": int(raw_payload.get("oss_archive_attempt_count") or 0),
  227. "oss_archive_next_retry_at": raw_payload.get("oss_archive_next_retry_at") or _iso(now),
  228. "oss_archive_deadline_at": deadline,
  229. "oss_archive_updated_at": _iso(now),
  230. }
  231. return {
  232. **record,
  233. "content_media_status": "oss_upload_pending",
  234. "oss_url": record.get("oss_url"),
  235. "local_path": record.get("local_path"),
  236. "raw_payload": _compact(updated_raw),
  237. }
  238. def _archive_one(
  239. record: dict[str, Any],
  240. now: datetime,
  241. *,
  242. upload_fn: Callable[..., dict[str, Any]],
  243. attempt_timeout_seconds: float,
  244. retry_delay_seconds: float,
  245. ) -> dict[str, Any]:
  246. raw_payload = dict(record.get("raw_payload") or {})
  247. previous_attempt_count = int(raw_payload.get("oss_archive_attempt_count") or 0)
  248. deadline = _parse_time(raw_payload.get("oss_archive_deadline_at"))
  249. if deadline and now >= deadline:
  250. return _with_failed_archive(record, raw_payload, now, previous_attempt_count + 1, "oss_upload_failed")
  251. media_started_at = time.monotonic()
  252. candidates = _upload_candidates(record, raw_payload)
  253. max_attempts = _resolve_url_fallback_max_attempts()
  254. media_budget_seconds = _resolve_media_upload_budget_seconds()
  255. upload_result: dict[str, Any] = {"status": "failed", "failure_type": "missing_src_url"}
  256. selected_candidate: dict[str, Any] | None = None
  257. oss_url_attempts: list[dict[str, Any]] = []
  258. for candidate in candidates[:max_attempts]:
  259. remaining_seconds = media_budget_seconds - (time.monotonic() - media_started_at)
  260. if remaining_seconds <= 0:
  261. upload_result = {
  262. "status": "failed",
  263. "failure_type": "oss_upload_budget_exceeded",
  264. "attempt_timeout_seconds": attempt_timeout_seconds,
  265. "response_absent": True,
  266. }
  267. break
  268. candidate_started_at = time.monotonic()
  269. upload_result = upload_fn(
  270. candidate.get("url") or "",
  271. timeout_seconds=min(attempt_timeout_seconds, remaining_seconds),
  272. )
  273. oss_url_attempts.append(
  274. _oss_url_attempt_summary(
  275. candidate,
  276. upload_result,
  277. duration_ms=_elapsed_ms(candidate_started_at, time.monotonic()),
  278. )
  279. )
  280. if upload_result.get("status") == "ok":
  281. selected_candidate = candidate
  282. break
  283. if not oss_url_attempts and upload_result.get("failure_type") == "missing_src_url":
  284. oss_url_attempts.append(
  285. {
  286. "candidate_index": 0,
  287. "host": "",
  288. "path": "",
  289. "status": "failed",
  290. "failure_type": "missing_src_url",
  291. "duration_ms": 0,
  292. }
  293. )
  294. attempt_count = previous_attempt_count + len(oss_url_attempts)
  295. timing_metrics = {
  296. "oss_upload_duration_ms": _elapsed_ms(media_started_at, time.monotonic()),
  297. "oss_upload_timeout_seconds": attempt_timeout_seconds,
  298. "oss_media_upload_budget_seconds": media_budget_seconds,
  299. "oss_archive_attempt_started_at": _iso(now),
  300. }
  301. if upload_result.get("status") == "ok":
  302. selected_candidate = selected_candidate or {}
  303. updated_raw = {
  304. **raw_payload,
  305. "content_media_status": "oss_uploaded",
  306. "oss_url": upload_result.get("oss_url"),
  307. "local_path": None,
  308. "oss_archive_status": "uploaded",
  309. "oss_archive_attempt_count": attempt_count,
  310. "oss_archive_next_retry_at": None,
  311. "oss_archive_updated_at": _iso(now),
  312. "oss_object_key": upload_result.get("oss_object_key"),
  313. "save_oss_timestamp": upload_result.get("save_oss_timestamp"),
  314. "oss_timing_metrics": timing_metrics,
  315. "oss_payload_mode": upload_result.get("oss_payload_mode"),
  316. "oss_url_attempts": oss_url_attempts,
  317. "oss_archive_attempted_candidate_count": len(oss_url_attempts),
  318. "oss_archive_selected_video_url_host": selected_candidate.get("host"),
  319. "oss_archive_selected_video_url_path": selected_candidate.get("path"),
  320. "oss_archive_selected_video_url_index": selected_candidate.get("candidate_index"),
  321. }
  322. return {
  323. **record,
  324. "content_media_status": "oss_uploaded",
  325. "oss_url": upload_result.get("oss_url"),
  326. "local_path": None,
  327. "raw_payload": _compact(updated_raw),
  328. }
  329. failure = upload_result.get("failure_type") or "oss_upload_failed"
  330. if deadline and now >= deadline:
  331. return _with_failed_archive(record, raw_payload, now, attempt_count, failure, upload_result)
  332. updated_raw = {
  333. **raw_payload,
  334. "content_media_status": "oss_upload_pending",
  335. "oss_url": None,
  336. "local_path": record.get("local_path"),
  337. "failure_reason": failure,
  338. "oss_archive_status": "pending",
  339. "oss_archive_attempt_count": attempt_count,
  340. "oss_archive_next_retry_at": _iso(now + timedelta(seconds=retry_delay_seconds)),
  341. "oss_archive_last_error": failure,
  342. "oss_archive_last_exception_type": upload_result.get("exception_type"),
  343. "oss_archive_last_error_message": upload_result.get("error_message"),
  344. "oss_archive_last_http_status_code": upload_result.get("http_status_code"),
  345. "oss_payload_mode": upload_result.get("oss_payload_mode"),
  346. "oss_response_summary": upload_result.get("oss_response_summary"),
  347. "oss_endpoint_host": upload_result.get("endpoint_host"),
  348. "oss_read_timeout_seconds": upload_result.get("read_timeout_seconds"),
  349. "oss_attempt_timeout_seconds": upload_result.get("attempt_timeout_seconds"),
  350. "oss_response_absent": upload_result.get("response_absent"),
  351. "oss_archive_updated_at": _iso(now),
  352. "upload_failure_reason": failure,
  353. "oss_timing_metrics": timing_metrics,
  354. "oss_url_attempts": oss_url_attempts,
  355. "oss_archive_attempted_candidate_count": len(oss_url_attempts),
  356. }
  357. return {
  358. **record,
  359. "content_media_status": "oss_upload_pending",
  360. "oss_url": None,
  361. "local_path": record.get("local_path"),
  362. "failure_reason": failure,
  363. "raw_payload": _compact(updated_raw),
  364. }
  365. def _with_failed_archive(
  366. record: dict[str, Any],
  367. raw_payload: dict[str, Any],
  368. now: datetime,
  369. attempt_count: int,
  370. failure: str,
  371. upload_result: dict[str, Any] | None = None,
  372. ) -> dict[str, Any]:
  373. upload_result = upload_result or {}
  374. updated_raw = {
  375. **raw_payload,
  376. "content_media_status": "oss_upload_failed",
  377. "oss_url": None,
  378. "local_path": record.get("local_path"),
  379. "failure_reason": failure,
  380. "oss_archive_status": "failed",
  381. "oss_archive_attempt_count": attempt_count,
  382. "oss_archive_next_retry_at": None,
  383. "oss_archive_last_error": failure,
  384. "oss_archive_last_exception_type": upload_result.get("exception_type"),
  385. "oss_archive_last_error_message": upload_result.get("error_message"),
  386. "oss_archive_last_http_status_code": upload_result.get("http_status_code"),
  387. "oss_payload_mode": upload_result.get("oss_payload_mode"),
  388. "oss_response_summary": upload_result.get("oss_response_summary"),
  389. "oss_archive_updated_at": _iso(now),
  390. "upload_failure_reason": failure,
  391. }
  392. return {
  393. **record,
  394. "content_media_status": "oss_upload_failed",
  395. "oss_url": None,
  396. "local_path": record.get("local_path"),
  397. "failure_reason": failure,
  398. "raw_payload": _compact(updated_raw),
  399. }
  400. def _upload_candidates(record: dict[str, Any], raw_payload: dict[str, Any]) -> list[dict[str, Any]]:
  401. candidates = raw_payload.get("video_url_candidates")
  402. if not isinstance(candidates, list):
  403. candidates = []
  404. result: list[dict[str, Any]] = []
  405. seen: set[str] = set()
  406. play_url = str(record.get("play_url") or "")
  407. if play_url:
  408. matched = next(
  409. (
  410. candidate
  411. for candidate in candidates
  412. if isinstance(candidate, dict) and str(candidate.get("url") or "") == play_url
  413. ),
  414. None,
  415. )
  416. first = dict(matched or {})
  417. first.setdefault("url", play_url)
  418. first.setdefault("host", urlparse(play_url).netloc)
  419. first.setdefault("path", "play_url")
  420. first.setdefault("source", "play_url")
  421. first.setdefault("candidate_index", 0)
  422. result.append(first)
  423. seen.add(play_url)
  424. for candidate in candidates:
  425. if not isinstance(candidate, dict):
  426. continue
  427. url = str(candidate.get("url") or "")
  428. if not url or url in seen:
  429. continue
  430. seen.add(url)
  431. item = dict(candidate)
  432. item.setdefault("host", urlparse(url).netloc)
  433. item.setdefault("path", "")
  434. item.setdefault("candidate_index", len(result))
  435. result.append(item)
  436. return result
  437. def _oss_url_attempt_summary(
  438. candidate: dict[str, Any],
  439. upload_result: dict[str, Any],
  440. *,
  441. duration_ms: int,
  442. ) -> dict[str, Any]:
  443. return _compact(
  444. {
  445. "candidate_index": candidate.get("candidate_index"),
  446. "host": candidate.get("host"),
  447. "path": candidate.get("path"),
  448. "status": upload_result.get("status"),
  449. "failure_type": upload_result.get("failure_type"),
  450. "exception_type": upload_result.get("exception_type"),
  451. "duration_ms": duration_ms,
  452. }
  453. )
  454. def _is_due(record: dict[str, Any], now: datetime) -> bool:
  455. if record.get("content_media_status") == "oss_uploaded":
  456. return False
  457. if not record.get("play_url"):
  458. return False
  459. raw_payload = record.get("raw_payload") if isinstance(record.get("raw_payload"), dict) else {}
  460. status = raw_payload.get("oss_archive_status")
  461. if record.get("content_media_status") != "oss_upload_pending" and status != "pending":
  462. return False
  463. if status not in {"pending", None}:
  464. return False
  465. due_at = _parse_time(raw_payload.get("oss_archive_next_retry_at"))
  466. return due_at is None or now >= due_at
  467. def _utc_now(now_fn: Callable[[], datetime] | None) -> datetime:
  468. now = now_fn() if now_fn else datetime.now(timezone.utc)
  469. if now.tzinfo is None:
  470. return now.replace(tzinfo=timezone.utc)
  471. return now.astimezone(timezone.utc)
  472. def _parse_time(value: Any) -> datetime | None:
  473. if not value:
  474. return None
  475. try:
  476. parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
  477. except ValueError:
  478. return None
  479. if parsed.tzinfo is None:
  480. return parsed.replace(tzinfo=timezone.utc)
  481. return parsed.astimezone(timezone.utc)
  482. def _iso(value: datetime) -> str:
  483. return value.astimezone(timezone.utc).isoformat()
  484. def _compact(payload: dict[str, Any]) -> dict[str, Any]:
  485. return dict(payload)
  486. def _elapsed_ms(start: float, end: float) -> int:
  487. return max(0, int((end - start) * 1000))
  488. def _resolve_max_workers(value: int | None = None) -> int:
  489. if value is not None and value > 0:
  490. return value
  491. raw = os.environ.get("CONTENT_AGENT_OSS_ARCHIVE_MAX_WORKERS")
  492. try:
  493. parsed = int(raw) if raw else DEFAULT_OSS_ARCHIVE_MAX_WORKERS
  494. except ValueError:
  495. return DEFAULT_OSS_ARCHIVE_MAX_WORKERS
  496. return parsed if parsed > 0 else DEFAULT_OSS_ARCHIVE_MAX_WORKERS
  497. def _resolve_url_fallback_max_attempts() -> int:
  498. raw = os.environ.get("CONTENT_AGENT_OSS_URL_FALLBACK_MAX_ATTEMPTS")
  499. try:
  500. value = int(raw) if raw else DEFAULT_OSS_URL_FALLBACK_MAX_ATTEMPTS
  501. except (TypeError, ValueError):
  502. value = DEFAULT_OSS_URL_FALLBACK_MAX_ATTEMPTS
  503. return max(1, min(value, DEFAULT_OSS_URL_FALLBACK_MAX_ATTEMPTS))
  504. def _resolve_media_upload_budget_seconds() -> float:
  505. raw = os.environ.get("CONTENT_AGENT_OSS_MEDIA_UPLOAD_BUDGET_SECONDS")
  506. try:
  507. value = float(raw) if raw else DEFAULT_OSS_MEDIA_UPLOAD_BUDGET_SECONDS
  508. except (TypeError, ValueError):
  509. value = DEFAULT_OSS_MEDIA_UPLOAD_BUDGET_SECONDS
  510. return max(1.0, min(value, DEFAULT_OSS_MEDIA_UPLOAD_BUDGET_SECONDS))