@danhachuel/thunderbolt 0.3.70 → 0.3.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,12 @@ from __future__ import annotations
9
9
 
10
10
  import base64
11
11
  import binascii
12
+ import json
13
+ import mimetypes
14
+ import os
15
+ import shutil
16
+ import subprocess
17
+ import tempfile
12
18
  import time
13
19
  from pathlib import Path
14
20
  from typing import Any, Mapping
@@ -544,3 +550,332 @@ def generate_video_from_pool(
544
550
  if not _is_retryable_media_error(exc):
545
551
  raise
546
552
  raise MediaGenerationError("Todos os providers do pool de vídeo falharam: " + " | ".join(errors))
553
+
554
+
555
+ KIE_FILE_UPLOAD_ENDPOINT = "https://kieai.redpandaai.co/api/file-stream-upload"
556
+ KIE_MOTION_MODEL = "kling-2.6/motion-control"
557
+ KIE_MOTION_IMAGE_MAX_BYTES = 10 * 1024 * 1024
558
+ KIE_MOTION_VIDEO_MAX_BYTES = 100 * 1024 * 1024
559
+ KIE_MOTION_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
560
+ KIE_MOTION_VIDEO_EXTENSIONS = {".mp4", ".mov"}
561
+ KIE_VEO_MODELS = {"veo3", "veo3_fast", "veo3_lite"}
562
+
563
+
564
+ def _require_kie_card(card: Mapping[str, Any]) -> dict[str, Any]:
565
+ current = dict(card)
566
+ if str(current.get("provider") or "").strip().lower() != "kie_ai":
567
+ raise MediaGenerationError("Este workflow requer um cartão KIE AI activo no pool de vídeo.")
568
+ if not _api_key(current):
569
+ raise MediaGenerationError("Configure a API key do KIE AI em Configuração API > API Keys > Imagem e Video IA.")
570
+ return current
571
+
572
+
573
+ def _kie_json_error(payload: Any, *, fallback: str) -> ProviderCallError | None:
574
+ if not isinstance(payload, Mapping):
575
+ return ProviderCallError(fallback, category="payload", retryable=False)
576
+ try:
577
+ code = int(payload.get("code", 200))
578
+ except (TypeError, ValueError):
579
+ code = 200
580
+ if code == 200:
581
+ return None
582
+ category = "quota" if code in {402, 429, 433} else "credential" if code == 401 else "endpoint_or_model" if code in {404, 455, 505} else "transient" if code >= 500 else "payload"
583
+ return ProviderCallError(str(payload.get("msg") or fallback)[:500], status_code=code, category=category, retryable=category in {"quota", "transient"})
584
+
585
+
586
+ def validate_motion_control_file(path: str | Path, *, kind: str) -> dict[str, Any]:
587
+ """Validate KIE Motion Control local input limits before uploading it."""
588
+ candidate = Path(path).expanduser()
589
+ if not candidate.is_file():
590
+ raise MediaGenerationError(f"O ficheiro de {kind} não está disponível.")
591
+ suffix = candidate.suffix.lower()
592
+ if kind == "imagem":
593
+ allowed, maximum = KIE_MOTION_IMAGE_EXTENSIONS, KIE_MOTION_IMAGE_MAX_BYTES
594
+ elif kind == "vídeo":
595
+ allowed, maximum = KIE_MOTION_VIDEO_EXTENSIONS, KIE_MOTION_VIDEO_MAX_BYTES
596
+ else:
597
+ raise MediaGenerationError("Tipo de input Motion Control inválido.")
598
+ if suffix not in allowed:
599
+ raise MediaGenerationError(f"O {kind} Motion Control deve estar em {', '.join(sorted(allowed))}.")
600
+ size = candidate.stat().st_size
601
+ if size <= 0:
602
+ raise MediaGenerationError(f"O ficheiro de {kind} está vazio.")
603
+ if size > maximum:
604
+ raise MediaGenerationError(f"O ficheiro de {kind} excede o limite KIE de {maximum // (1024 * 1024)} MB.")
605
+ duration = None
606
+ if kind == "vídeo":
607
+ ffprobe = shutil.which("ffprobe")
608
+ if not ffprobe:
609
+ raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control: instale FFmpeg/ffprobe e tente novamente.")
610
+ try:
611
+ probe = subprocess.run(
612
+ [ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", str(candidate)],
613
+ capture_output=True,
614
+ text=True,
615
+ timeout=30,
616
+ check=False,
617
+ )
618
+ duration = float(probe.stdout.strip())
619
+ except (OSError, ValueError, subprocess.TimeoutExpired) as exc:
620
+ raise MediaGenerationError("Não foi possível verificar a duração do vídeo Motion Control com ffprobe.") from exc
621
+ if duration < 3 or duration > 30:
622
+ raise MediaGenerationError(f"O vídeo Motion Control deve ter entre 3 e 30 segundos; o ficheiro tem {duration:.1f}s.")
623
+ return {"path": str(candidate), "name": candidate.name, "size_bytes": size, "extension": suffix, "duration_seconds": duration}
624
+
625
+
626
+ def upload_kie_file(path: str | Path, card: Mapping[str, Any], *, upload_path: str = "thunderbolt/influencers") -> str:
627
+ """Upload one local file to KIE's temporary public file store using the selected card."""
628
+ current = _require_kie_card(card)
629
+ candidate = Path(path).expanduser()
630
+ if not candidate.is_file():
631
+ raise MediaGenerationError("O ficheiro seleccionado não está disponível para upload KIE.")
632
+ mime = mimetypes.guess_type(candidate.name)[0] or "application/octet-stream"
633
+ try:
634
+ with candidate.open("rb") as handle:
635
+ response = requests.post(
636
+ KIE_FILE_UPLOAD_ENDPOINT,
637
+ headers={"Authorization": f"Bearer {_api_key(current)}"},
638
+ files={"file": (candidate.name, handle, mime)},
639
+ data={"uploadPath": upload_path, "fileName": candidate.name},
640
+ timeout=300,
641
+ )
642
+ except requests.RequestException as exc:
643
+ raise MediaGenerationError(f"Falha no upload temporário do ficheiro para KIE: {str(exc)[:220]}") from exc
644
+ if response.status_code >= 400:
645
+ raise MediaGenerationError(f"O upload temporário KIE devolveu HTTP {response.status_code}.")
646
+ try:
647
+ payload = response.json()
648
+ except ValueError as exc:
649
+ raise MediaGenerationError("O upload temporário KIE devolveu uma resposta inválida.") from exc
650
+ error = _kie_json_error(payload, fallback="O upload temporário KIE falhou.")
651
+ if error:
652
+ raise MediaGenerationError(str(error))
653
+ data = payload.get("data") if isinstance(payload, Mapping) else None
654
+ url = str((data or {}).get("downloadUrl") or (data or {}).get("fileUrl") or payload.get("downloadUrl") or payload.get("fileUrl") or "").strip() if isinstance(payload, Mapping) else ""
655
+ if not url.startswith(("http://", "https://")):
656
+ raise MediaGenerationError("O upload KIE terminou sem devolver um URL público temporário.")
657
+ return url
658
+
659
+
660
+ def _download_video_url(url: str, destination: Path, *, card: Mapping[str, Any] | None = None) -> Path:
661
+ try:
662
+ response = requests.get(url, headers=_headers(card or {}) if card else {}, timeout=300)
663
+ response.raise_for_status()
664
+ except requests.RequestException as exc:
665
+ raise MediaGenerationError(f"Não foi possível descarregar o vídeo gerado: {str(exc)[:220]}") from exc
666
+ if not response.content:
667
+ raise MediaGenerationError("O provider devolveu um vídeo vazio.")
668
+ destination.parent.mkdir(parents=True, exist_ok=True)
669
+ destination.write_bytes(response.content)
670
+ return destination
671
+
672
+
673
+ def _kie_result_urls(value: Any) -> list[str]:
674
+ if isinstance(value, str):
675
+ raw = value.strip()
676
+ try:
677
+ value = json.loads(raw)
678
+ except json.JSONDecodeError:
679
+ value = [raw] if raw.startswith(("http://", "https://")) else []
680
+ if isinstance(value, Mapping):
681
+ value = value.get("resultUrls") or value.get("result_urls") or value.get("urls") or []
682
+ if not isinstance(value, list):
683
+ return []
684
+ return [str(item).strip() for item in value if str(item).strip().startswith(("http://", "https://"))]
685
+
686
+
687
+ def _poll_kie_task(card: Mapping[str, Any], task_id: str, *, endpoint: str, attempts: int = 40, interval_seconds: float = 5.0, veo: bool = False) -> list[str]:
688
+ base = _base_url(card) or "https://api.kie.ai/api/v1"
689
+ url = f"{base}{endpoint}"
690
+ for index in range(max(1, attempts)):
691
+ try:
692
+ response = requests.get(url, headers=_headers(card), params={"taskId": task_id}, timeout=60)
693
+ if response.status_code >= 400:
694
+ category = "quota" if response.status_code == 429 else "transient" if response.status_code >= 500 else "endpoint_or_model"
695
+ raise ProviderCallError(f"Consulta KIE devolveu HTTP {response.status_code}.", status_code=response.status_code, category=category, retryable=category in {"quota", "transient"})
696
+ payload = response.json()
697
+ except requests.RequestException as exc:
698
+ raise ProviderCallError(f"Falha ao consultar a tarefa KIE: {str(exc)[:220]}", category="transient", retryable=True) from exc
699
+ error = _kie_json_error(payload, fallback="A consulta KIE devolveu um erro.")
700
+ if error:
701
+ raise error
702
+ data = payload.get("data") if isinstance(payload, Mapping) and isinstance(payload.get("data"), Mapping) else {}
703
+ if veo:
704
+ flag = data.get("successFlag")
705
+ if str(flag) in {"2", "3"}:
706
+ raise ProviderCallError(str(data.get("errorMessage") or payload.get("msg") or "A tarefa VEO falhou."), category="provider", retryable=False)
707
+ response_data = data.get("response") if isinstance(data.get("response"), Mapping) else {}
708
+ urls = _kie_result_urls(response_data.get("resultUrls") or data.get("resultUrls"))
709
+ if str(flag) == "1" and urls:
710
+ return urls
711
+ else:
712
+ state = str(data.get("state") or payload.get("state") or "").lower()
713
+ if state in {"fail", "failed", "error", "cancelled", "canceled"}:
714
+ raise ProviderCallError(str(data.get("failMsg") or payload.get("msg") or "A tarefa KIE falhou."), category="provider", retryable=False)
715
+ urls = _kie_result_urls(data.get("resultJson"))
716
+ if state == "success" and urls:
717
+ return urls
718
+ if index + 1 < attempts:
719
+ time.sleep(max(0.2, interval_seconds))
720
+ raise ProviderCallError("A tarefa KIE não concluiu dentro do limite de polling local.", category="transient", retryable=True)
721
+
722
+
723
+ def generate_motion_control_video(
724
+ settings: Mapping[str, Any],
725
+ card: Mapping[str, Any],
726
+ *,
727
+ image_url: str,
728
+ video_url: str,
729
+ prompt: str = "",
730
+ output_path: Path | None = None,
731
+ ) -> tuple[Path, str]:
732
+ """Create a Kling 2.6 Motion Control video and download it locally."""
733
+ current = _require_kie_card(card)
734
+ if not image_url.startswith(("http://", "https://")) or not video_url.startswith(("http://", "https://")):
735
+ raise MediaGenerationError("Motion Control requer URLs KIE acessíveis para a imagem e o vídeo enviados.")
736
+ clean_prompt = str(prompt or "").strip()
737
+ if len(clean_prompt) > 2500:
738
+ raise MediaGenerationError("O prompt Motion Control não pode exceder 2500 caracteres.")
739
+ base = _base_url(current) or "https://api.kie.ai/api/v1"
740
+ body: dict[str, Any] = {
741
+ "model": KIE_MOTION_MODEL,
742
+ "input": {
743
+ "prompt": clean_prompt or "Preserve a identidade visual da imagem de referência e aplique os movimentos do vídeo de forma natural, estável e fisicamente plausível.",
744
+ "input_urls": [image_url],
745
+ "video_urls": [video_url],
746
+ "character_orientation": "video",
747
+ "mode": "720p",
748
+ },
749
+ }
750
+
751
+ def request(_: dict[str, Any]) -> Any:
752
+ response = requests.post(f"{base}/jobs/createTask", headers=_headers(current), json=body, timeout=180)
753
+ if response.status_code < 400:
754
+ try:
755
+ payload = response.json()
756
+ except ValueError:
757
+ payload = {}
758
+ error = _kie_json_error(payload, fallback="A criação Motion Control KIE falhou.")
759
+ if error:
760
+ raise error
761
+ return response
762
+
763
+ try:
764
+ routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
765
+ data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
766
+ task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
767
+ if not task_id:
768
+ raise MediaGenerationError("KIE aceitou Motion Control mas não devolveu taskId.")
769
+ urls = _poll_kie_task(routed.card, task_id, endpoint="/jobs/recordInfo", veo=False)
770
+ except (ProviderRoutingError, ProviderCallError) as exc:
771
+ raise MediaGenerationError(str(exc)) from exc
772
+ destination = output_path or (STORAGE / "influencer_workflows" / f"motion-control-{task_id}.mp4")
773
+ return _download_video_url(urls[0], destination, card=routed.card), task_id
774
+
775
+
776
+ def generate_ugc_segment(
777
+ settings: Mapping[str, Any],
778
+ card: Mapping[str, Any],
779
+ *,
780
+ image_url: str,
781
+ prompt: str,
782
+ output_path: Path,
783
+ duration: int = 8,
784
+ ) -> tuple[Path, str]:
785
+ """Create one VEO3.1 image-to-video segment through the official KIE endpoint."""
786
+ current = _require_kie_card(card)
787
+ if not image_url.startswith(("http://", "https://")):
788
+ raise MediaGenerationError("UGC Products requer um URL KIE acessível para a imagem do produto.")
789
+ model = _model(current).lower() or "veo3_fast"
790
+ if model not in KIE_VEO_MODELS:
791
+ model = "veo3_fast"
792
+ if duration not in {4, 6, 8}:
793
+ raise MediaGenerationError("A duração de um segmento VEO3 deve ser 4, 6 ou 8 segundos.")
794
+ base = _base_url(current) or "https://api.kie.ai/api/v1"
795
+ body = {
796
+ "prompt": str(prompt or "").strip(),
797
+ "imageUrls": [image_url],
798
+ "model": model,
799
+ "aspect_ratio": "16:9",
800
+ "resolution": "720p",
801
+ "duration": duration,
802
+ }
803
+
804
+ def request(_: dict[str, Any]) -> Any:
805
+ response = requests.post(f"{base}/veo/generate", headers=_headers(current), json=body, timeout=180)
806
+ if response.status_code < 400:
807
+ try:
808
+ payload = response.json()
809
+ except ValueError:
810
+ payload = {}
811
+ error = _kie_json_error(payload, fallback="A criação de segmento VEO KIE falhou.")
812
+ if error:
813
+ raise error
814
+ return response
815
+
816
+ try:
817
+ routed = route_json_request(settings, pool=POOL_VIDEO, cards=[current], request=request)
818
+ data = routed.payload.get("data") if isinstance(routed.payload.get("data"), Mapping) else {}
819
+ task_id = str(data.get("taskId") or routed.payload.get("taskId") or "").strip()
820
+ if not task_id:
821
+ raise MediaGenerationError("KIE aceitou VEO3 mas não devolveu taskId.")
822
+ urls = _poll_kie_task(routed.card, task_id, endpoint="/veo/record-info", veo=True)
823
+ except (ProviderRoutingError, ProviderCallError) as exc:
824
+ raise MediaGenerationError(str(exc)) from exc
825
+ return _download_video_url(urls[0], output_path, card=routed.card), task_id
826
+
827
+
828
+ def concatenate_video_files(paths: list[Path], output_path: Path) -> Path:
829
+ """Join generated clips locally with FFmpeg, without uploading the result anywhere."""
830
+ valid = [Path(path) for path in paths if Path(path).is_file()]
831
+ if not valid:
832
+ raise MediaGenerationError("Não existem segmentos locais para concatenar.")
833
+ if len(valid) == 1:
834
+ output_path.parent.mkdir(parents=True, exist_ok=True)
835
+ if valid[0] != output_path:
836
+ output_path.write_bytes(valid[0].read_bytes())
837
+ return output_path
838
+ try:
839
+ from .metadata_cleaner import _resolve_ffmpeg
840
+
841
+ ffmpeg = _resolve_ffmpeg()
842
+ except Exception as exc:
843
+ raise MediaGenerationError(str(exc)) from exc
844
+ output_path.parent.mkdir(parents=True, exist_ok=True)
845
+ descriptor, manifest_name = tempfile.mkstemp(prefix="ugc-concat-", suffix=".txt", dir=str(output_path.parent))
846
+ os.close(descriptor)
847
+ manifest = Path(manifest_name)
848
+ try:
849
+ manifest.write_text("\n".join(f"file '{str(path).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'" for path in valid) + "\n", encoding="utf-8")
850
+ output_path.parent.mkdir(parents=True, exist_ok=True)
851
+ command = [ffmpeg, "-y", "-f", "concat", "-safe", "0", "-i", str(manifest), "-c", "copy", str(output_path)]
852
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=600, check=False)
853
+ if completed.returncode != 0:
854
+ raise MediaGenerationError(f"FFmpeg não conseguiu juntar os segmentos UGC: {completed.stderr[-500:]}")
855
+ except OSError as exc:
856
+ raise MediaGenerationError(f"Não foi possível executar FFmpeg para juntar os segmentos UGC: {exc}") from exc
857
+ finally:
858
+ manifest.unlink(missing_ok=True)
859
+ return output_path
860
+
861
+
862
+ def generate_ugc_product_video(
863
+ settings: Mapping[str, Any],
864
+ card: Mapping[str, Any],
865
+ *,
866
+ image_url: str,
867
+ prompts: list[str],
868
+ output_path: Path | None = None,
869
+ ) -> tuple[Path, list[str]]:
870
+ """Generate two 8-second KIE VEO3 clips and concatenate them locally."""
871
+ clean_prompts = [str(item or "").strip() for item in prompts if str(item or "").strip()]
872
+ if len(clean_prompts) != 2:
873
+ raise MediaGenerationError("UGC Products requer exactamente dois prompts de segmento.")
874
+ ensure_storage()
875
+ destination = output_path or (STORAGE / "influencer_workflows" / f"ugc-products-{abs(hash((image_url, *clean_prompts))) & 0xffffffffffffffff:x}.mp4")
876
+ segment_paths = [destination.with_name(f"{destination.stem}-segment-{index + 1}.mp4") for index in range(2)]
877
+ task_ids: list[str] = []
878
+ for prompt, segment_path in zip(clean_prompts, segment_paths):
879
+ _, task_id = generate_ugc_segment(settings, card, image_url=image_url, prompt=prompt, output_path=segment_path, duration=8)
880
+ task_ids.append(task_id)
881
+ return concatenate_video_files(segment_paths, destination), task_ids
@@ -0,0 +1,259 @@
1
+ """Upload de vídeo para Bilibili usando bilibili-api-python.
2
+
3
+ A biblioteca é opcional no import para que o Thunderbolt continue a arrancar sem
4
+ credenciais Bilibili. As operações do SDK são assíncronas; este adapter expõe uma
5
+ interface síncrona adequada à UI Streamlit e nunca devolve os cookies nos dados.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import subprocess
11
+ import tempfile
12
+ import threading
13
+ from pathlib import Path
14
+ from typing import Any, Awaitable
15
+
16
+ from .platforms import IntegrationResult
17
+
18
+ BILIBILI_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm"}
19
+ BILIBILI_MAX_TITLE = 80
20
+ BILIBILI_MAX_DESCRIPTION = 2000
21
+ BILIBILI_MAX_TAGS = 10
22
+ BILIBILI_DEFAULT_TID = 130
23
+ BILIBILI_API_VERSION = "17.4.2"
24
+ _SENSITIVE_FIELDS = {"sessdata", "bili_jct", "buvid3", "buvid4", "dedeuserid", "ac_time_value", "proxy"}
25
+
26
+
27
+ def _safe_card(card: dict[str, Any], index: int) -> dict[str, Any]:
28
+ return {
29
+ "id": str(card.get("id") or f"bilibili-api-{index + 1}").strip(),
30
+ "label": str(card.get("label") or card.get("name") or f"Conta Bilibili {index + 1}").strip(),
31
+ "active": bool(card.get("active", True)),
32
+ "sessdata": str(card.get("sessdata") or "").strip(),
33
+ "bili_jct": str(card.get("bili_jct") or "").strip(),
34
+ "buvid3": str(card.get("buvid3") or "").strip(),
35
+ "buvid4": str(card.get("buvid4") or "").strip(),
36
+ "dedeuserid": str(card.get("dedeuserid") or "").strip(),
37
+ "ac_time_value": str(card.get("ac_time_value") or "").strip(),
38
+ "proxy": str(card.get("proxy") or "").strip(),
39
+ }
40
+
41
+
42
+ def normalise_bilibili_api_cards(settings: dict[str, Any] | None) -> tuple[list[dict[str, Any]], bool]:
43
+ """Normalise multi-account cards and migrate one legacy settings record."""
44
+ settings = settings or {}
45
+ raw = settings.get("bilibili_api_cards")
46
+ cards: list[dict[str, Any]] = []
47
+ changed = not isinstance(raw, list)
48
+ if isinstance(raw, list):
49
+ for index, item in enumerate(raw):
50
+ if isinstance(item, dict):
51
+ card = _safe_card(item, index)
52
+ cards.append(card)
53
+ if any(str(item.get(key) or "").strip() != str(card.get(key) or "").strip() for key in card if key not in {"active"}):
54
+ changed = True
55
+ else:
56
+ changed = True
57
+ legacy_keys = ("bilibili_sessdata", "bilibili_bili_jct", "bilibili_buvid3", "bilibili_buvid4", "bilibili_dedeuserid", "bilibili_ac_time_value", "bilibili_proxy")
58
+ legacy_values = {key.removeprefix("bilibili_"): str(settings.get(key) or "").strip() for key in legacy_keys}
59
+ if not cards and any(legacy_values.values()):
60
+ cards.append(_safe_card({"id": "bilibili-api-1", "label": "Conta Bilibili 1", **legacy_values}, 0))
61
+ changed = True
62
+ return cards, changed
63
+
64
+
65
+ def _redacted_card(card: dict[str, Any]) -> dict[str, Any]:
66
+ return {key: ("***" if key in _SENSITIVE_FIELDS and value else value) for key, value in card.items() if key not in _SENSITIVE_FIELDS or not value}
67
+
68
+
69
+ def _safe_card_payload(card: dict[str, Any]) -> dict[str, Any]:
70
+ return {key: value for key, value in _safe_card(card, 0).items() if key != "id"}
71
+
72
+
73
+ def _import_sdk() -> tuple[Any, Any]:
74
+ try:
75
+ from bilibili_api import Credential, video_uploader
76
+ except ImportError as exc:
77
+ raise RuntimeError("A dependência bilibili-api-python não está instalada. Instale/actualize o Thunderbolt e tente novamente.") from exc
78
+ return Credential, video_uploader
79
+
80
+
81
+ def _run_async(awaitable: Awaitable[Any]) -> Any:
82
+ """Run one SDK coroutine from Streamlit, including when a loop already exists."""
83
+ try:
84
+ asyncio.get_running_loop()
85
+ except RuntimeError:
86
+ return asyncio.run(awaitable)
87
+ result: list[Any] = []
88
+ error: list[BaseException] = []
89
+
90
+ def runner() -> None:
91
+ try:
92
+ result.append(asyncio.run(awaitable))
93
+ except BaseException as exc: # preserve SDK exception for UI attribution
94
+ error.append(exc)
95
+
96
+ thread = threading.Thread(target=runner, name="thunderbolt-bilibili-sdk", daemon=True)
97
+ thread.start()
98
+ thread.join()
99
+ if error:
100
+ raise error[0]
101
+ return result[0] if result else None
102
+
103
+
104
+ def _credential(card: dict[str, Any], Credential: Any) -> Any:
105
+ values = _safe_card_payload(card)
106
+ values.pop("label", None)
107
+ values.pop("active", None)
108
+ values.pop("proxy", None)
109
+ return Credential(proxy=str(card.get("proxy") or "").strip() or None, **values)
110
+
111
+
112
+ def _derive_cover(video_path: Path, directory: Path) -> Path:
113
+ try:
114
+ from imageio_ffmpeg import get_ffmpeg_exe
115
+ executable = get_ffmpeg_exe()
116
+ except Exception as exc:
117
+ raise RuntimeError("Não foi possível localizar FFmpeg para criar a capa automática do vídeo Bilibili.") from exc
118
+ target = directory / "bilibili-cover.jpg"
119
+ completed = subprocess.run(
120
+ [executable, "-y", "-hide_banner", "-loglevel", "error", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(target)],
121
+ capture_output=True, text=True, timeout=90, check=False,
122
+ )
123
+ if completed.returncode != 0 or not target.is_file():
124
+ raise RuntimeError("FFmpeg não conseguiu criar uma capa a partir do vídeo Bilibili.")
125
+ return target
126
+
127
+ def _normalise_tags(tags: str | list[str] | tuple[str, ...] | None) -> list[str]:
128
+ values = tags if isinstance(tags, (list, tuple)) else str(tags or "").replace("\n", ",").split(",")
129
+ output: list[str] = []
130
+ seen: set[str] = set()
131
+ for value in values:
132
+ clean = str(value or "").strip()
133
+ if clean and clean not in seen:
134
+ output.append(clean)
135
+ seen.add(clean)
136
+ return output
137
+
138
+
139
+ class BilibiliApiAdapter:
140
+ """Synchronous wrapper around bilibili-api-python's asynchronous video uploader."""
141
+
142
+ def __init__(self, card: dict[str, Any] | None = None, settings: dict[str, Any] | None = None):
143
+ self.card = _safe_card(card or {}, 0)
144
+ self.settings = settings or {}
145
+
146
+ def status(self) -> IntegrationResult:
147
+ missing = [field for field in ("sessdata", "bili_jct", "buvid3") if not str(self.card.get(field) or "").strip()]
148
+ if missing:
149
+ return IntegrationResult(False, "Conta Bilibili incompleta: configure SESSDATA, bili_jct e BUVID3.", {"missing_fields": missing, "account": self.card.get("label", "Conta Bilibili")})
150
+ try:
151
+ _import_sdk()
152
+ except RuntimeError as exc:
153
+ return IntegrationResult(False, str(exc), {"account": self.card.get("label", "Conta Bilibili")})
154
+ return IntegrationResult(True, f"Conta Bilibili pronta: {self.card.get('label', 'Conta Bilibili')}.", {"account": self.card.get("label", "Conta Bilibili"), "sdk": BILIBILI_API_VERSION})
155
+
156
+ def test_connection(self) -> IntegrationResult:
157
+ status = self.status()
158
+ if not status.ok:
159
+ return status
160
+ try:
161
+ Credential, _ = _import_sdk()
162
+ credential = _credential(self.card, Credential)
163
+ valid = _run_async(credential.check_valid())
164
+ if not bool(valid):
165
+ return IntegrationResult(False, "A sessão Bilibili foi rejeitada ou expirou. Actualize SESSDATA, bili_jct e BUVID3.", {"account": self.card.get("label", "Conta Bilibili"), "valid": False})
166
+ return IntegrationResult(True, f"Chamada Bilibili concluída para {self.card.get('label', 'Conta Bilibili')}.", {"account": self.card.get("label", "Conta Bilibili"), "valid": True})
167
+ except Exception as exc:
168
+ return IntegrationResult(False, f"A chamada Bilibili falhou: {exc}", {"account": self.card.get("label", "Conta Bilibili"), "api": "bilibili-api-python"})
169
+
170
+ def upload_video(
171
+ self,
172
+ video_path: str | Path,
173
+ *,
174
+ title: str,
175
+ description: str = "",
176
+ tags: str | list[str] | tuple[str, ...] | None = None,
177
+ tid: int = BILIBILI_DEFAULT_TID,
178
+ cover_path: str | Path | None = None,
179
+ original: bool = True,
180
+ dynamic: str = "",
181
+ ) -> IntegrationResult:
182
+ status = self.status()
183
+ if not status.ok:
184
+ return status
185
+ path = Path(video_path).expanduser()
186
+ if not path.is_file():
187
+ return IntegrationResult(False, f"Vídeo Bilibili não encontrado: {path}", {"api": "bilibili-api-python"})
188
+ if path.suffix.lower() not in BILIBILI_VIDEO_EXTENSIONS:
189
+ return IntegrationResult(False, f"Formato Bilibili não suportado: {path.suffix}. Use MP4, MOV, MKV ou WEBM.", {"api": "bilibili-api-python"})
190
+ clean_title = str(title or "Vídeo Thunderbolt").strip()
191
+ clean_description = str(description or "").strip()
192
+ clean_tags = _normalise_tags(tags)
193
+ if not clean_title or len(clean_title) > BILIBILI_MAX_TITLE:
194
+ return IntegrationResult(False, "O título Bilibili é obrigatório e deve ter no máximo 80 caracteres.", {"api": "bilibili-api-python"})
195
+ if len(clean_description) > BILIBILI_MAX_DESCRIPTION:
196
+ return IntegrationResult(False, "A descrição Bilibili deve ter no máximo 2000 caracteres.", {"api": "bilibili-api-python"})
197
+ if not clean_tags or len(clean_tags) > BILIBILI_MAX_TAGS:
198
+ return IntegrationResult(False, "Indique entre 1 e 10 tags Bilibili, separadas por vírgulas.", {"api": "bilibili-api-python"})
199
+ try:
200
+ tid_value = int(tid)
201
+ except (TypeError, ValueError):
202
+ return IntegrationResult(False, "O ID da secção Bilibili deve ser numérico.", {"api": "bilibili-api-python"})
203
+ cover = Path(cover_path).expanduser() if cover_path else None
204
+ if cover is not None and not cover.is_file():
205
+ return IntegrationResult(False, f"Capa Bilibili não encontrada: {cover}", {"api": "bilibili-api-python"})
206
+ try:
207
+ Credential, video_uploader = _import_sdk()
208
+ credential = _credential(self.card, Credential)
209
+ page = video_uploader.VideoUploaderPage(str(path), clean_title, clean_description)
210
+ meta: dict[str, Any] = {
211
+ "title": clean_title,
212
+ "copyright": 1 if original else 2,
213
+ "tid": tid_value,
214
+ "tag": ",".join(clean_tags),
215
+ "desc_format_id": 9999,
216
+ "desc": clean_description,
217
+ "recreate": -1,
218
+ "dynamic": str(dynamic or "")[:233],
219
+ "interactive": 0,
220
+ "act_reserve_create": 0,
221
+ "no_disturbance": 0,
222
+ "no_reprint": 0,
223
+ "subtitle": {"open": 0, "lan": ""},
224
+ "dolby": 0,
225
+ "lossless_music": 0,
226
+ "web_os": 1,
227
+ }
228
+ temporary_cover: tempfile.TemporaryDirectory[str] | None = None
229
+ if cover is None:
230
+ temporary_cover = tempfile.TemporaryDirectory(prefix="thunderbolt-bilibili-")
231
+ cover = _derive_cover(path, Path(temporary_cover.name))
232
+ try:
233
+ uploader = video_uploader.VideoUploader([page], meta, credential, cover=str(cover))
234
+ result = _run_async(uploader.start())
235
+ finally:
236
+ if temporary_cover is not None:
237
+ temporary_cover.cleanup()
238
+ payload = result if isinstance(result, dict) else {"response": result}
239
+ public_data = {key: payload.get(key) for key in ("bvid", "aid", "code", "message") if key in payload}
240
+ public_data.update({"account": self.card.get("label", "Conta Bilibili"), "api": "bilibili-api-python", "title": clean_title})
241
+ bvid = str(payload.get("bvid") or "").strip()
242
+ message = "Upload Bilibili concluído."
243
+ if bvid:
244
+ message += f" BVID: {bvid}."
245
+ return IntegrationResult(True, message, public_data)
246
+ except Exception as exc:
247
+ return IntegrationResult(False, f"Upload Bilibili falhou na API bilibili-api-python: {exc}", {"account": self.card.get("label", "Conta Bilibili"), "api": "bilibili-api-python"})
248
+
249
+
250
+ __all__ = [
251
+ "BILIBILI_API_VERSION",
252
+ "BILIBILI_DEFAULT_TID",
253
+ "BILIBILI_MAX_DESCRIPTION",
254
+ "BILIBILI_MAX_TAGS",
255
+ "BILIBILI_MAX_TITLE",
256
+ "BILIBILI_VIDEO_EXTENSIONS",
257
+ "BilibiliApiAdapter",
258
+ "normalise_bilibili_api_cards",
259
+ ]