@danhachuel/thunderbolt 0.4.11 → 0.4.13
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.
- package/README.md +4 -1
- package/app/main.py +19 -1
- package/hermes_ui/pipeline_worker.py +128 -11
- package/package.json +1 -1
- package/seed/references/tutorial-oauth-google.md +155 -0
package/README.md
CHANGED
|
@@ -46,7 +46,10 @@ A migração é baseada no workflow público [AI Agents A-Z — episódio 35](ht
|
|
|
46
46
|
|
|
47
47
|
Em **Pipeline Vídeos > Criação de Vídeos** e **Automação Youtube**, a opção **Pexels/Pixabay** usa a rota stock do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo): as keywords do roteiro são encaminhadas para a pesquisa da fonte seleccionada, os clips são descarregados e reutilizados localmente, e o motor faz a composição com MoviePy/FFmpeg, respeitando proporção, duração máxima, concatenação, transições, correspondência visual ao roteiro, narração, legendas e música de fundo. As API keys de Pexels e Pixabay são exportadas para o `config.toml` do motor e a fonte efectiva é encaminhada por tarefa, sem depender apenas da fonte global guardada nas configurações. Em **Configurações > Configuração API > API Keys > Imagem e Video Montagem/MoviePy**, cada cartão possui o campo **Prioridade**: o menor número aparece primeiro e a ordenação fica guardada no storage local.
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Quando existem cartões activos de **Pexels** e **Pixabay**, a Pipeline tenta primeiro a fonte seleccionada na tarefa e, se a falha for específica do provider stock, tenta os restantes providers stock configurados por ordem de prioridade. Falhas de LLM, Azure, áudio local ou configuração estrutural não são mascaradas por um fallback de materiais.
|
|
50
|
+
|
|
51
|
+
A ordem persistida da criação é **Tema → Script → Título → Keywords opcional → Vídeo → Prompt Thumbnail em JSON → Thumbnail → Upload**.
|
|
52
|
+
O vídeo é materializado antes do prompt e da imagem da thumbnail; uma falha posterior de thumbnail não invalida um MP4 já pronto. **Full IA** é uma rota separada e usa o pool de vídeo configurável com **FAL AI, KIE AI, Agnes AI, Nano Banana, Replicate AI, Pollinations.ai, Hugging Face Inference API, InferencePort Proxy e HeyGen**, respeitando apenas cartões activos que declarem capacidade de vídeo. **Apenas Música** não chama a pipeline de vídeo nem tenta gerar thumbnail: reutiliza o áudio local/Suno já descarregado e deixa-o pronto para a integração de upload musical.
|
|
50
53
|
|
|
51
54
|
Quando uma etapa falha, a tarefa, a notificação e a página **Configurações > Logs** guardam e mostram sempre a coluna **API/Provider**, o serviço, a rota e, quando aplicável, os campos de configuração em falta. No caso do MoneyPrinterTurbo, os marcadores `LLM_PROVIDER`, `MISSING` e `INVALID` são convertidos em attribution legível; por exemplo, um erro pode indicar simultaneamente **OpenAI / NVIDIA NIM API** e **Pexels API**, em vez de apresentar apenas a mensagem genérica de credenciais adicionais. Os timeouts `azure_tts_v1`/`edge_tts` são identificados como **Azure Speech / edge_tts API**. Quando há Azure Speech key e região, o worker encaminha a voz para o SDK Azure Speech V2. Para evitar o limite de 10 minutos da síntese em tempo real documentado pela [Microsoft](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/speech-services-quotas-and-limits), o helper divide o roteiro em segmentos seguros, sintetiza-os sequencialmente com retry e concatena o MP3 antes de o entregar ao MoneyPrinterTurbo como áudio customizado. O erro `1007`/`600000ms` é atribuído explicitamente à **Azure Speech SDK V2 API**. Sem credenciais Azure, o fallback edge_tts usa um timeout interno de 90 segundos. O worker invoca o helper com `--` antes das flags MoneyPrinterTurbo, porque `mpt_agent.py` reserva esses argumentos para a CLI filha. Registos históricos sem metadata são identificados explicitamente como anteriores à attribution estruturada.
|
|
52
55
|
|
package/app/main.py
CHANGED
|
@@ -6752,6 +6752,22 @@ def render_supabase_tutorial():
|
|
|
6752
6752
|
st.markdown(tutorial_content, unsafe_allow_html=True)
|
|
6753
6753
|
|
|
6754
6754
|
|
|
6755
|
+
def render_google_oauth_tutorial():
|
|
6756
|
+
"""Render the Google OAuth setup guide supplied for Thunderbolt."""
|
|
6757
|
+
tutorial_path = ROOT / "seed" / "references" / "tutorial-oauth-google.md"
|
|
6758
|
+
st.title("Tutorial OAuth do Google")
|
|
6759
|
+
st.caption("Guia completo para configurar a autenticação OAuth do Google e o acesso à YouTube Data API v3.")
|
|
6760
|
+
st.markdown("[Abrir Google Cloud Console](https://console.cloud.google.com/)")
|
|
6761
|
+
try:
|
|
6762
|
+
tutorial_content = tutorial_path.read_text(encoding="utf-8").strip()
|
|
6763
|
+
except OSError:
|
|
6764
|
+
tutorial_content = ""
|
|
6765
|
+
if not tutorial_content:
|
|
6766
|
+
st.error("O conteúdo local do tutorial não está disponível. Consulte a documentação OAuth do Google.")
|
|
6767
|
+
return
|
|
6768
|
+
st.markdown(tutorial_content, unsafe_allow_html=False)
|
|
6769
|
+
|
|
6770
|
+
|
|
6755
6771
|
def render_youtube_frontend_upload_tutorial():
|
|
6756
6772
|
"""Render the safe operational guide for the YouTube direct-upload workflow."""
|
|
6757
6773
|
tutorial_path = ROOT / "seed" / "references" / "youtube-video-upload-frontend.md"
|
|
@@ -7076,6 +7092,7 @@ def main():
|
|
|
7076
7092
|
("Tutorial Kaggle", ":material/menu_book:", "Tutorial Kaggle"),
|
|
7077
7093
|
("Tutorial Apify", ":material/menu_book:", "Tutorial Apify"),
|
|
7078
7094
|
("Tutorial YouTube Video-Upload Frontend", ":material/video_library:", "Tutorial YouTube Video-Upload Frontend"),
|
|
7095
|
+
("Tutorial OAuth do Google", ":material/key:", "Tutorial OAuth do Google"),
|
|
7079
7096
|
]
|
|
7080
7097
|
settings_items = [
|
|
7081
7098
|
("MCP", ":material/hub:", "MCP"),
|
|
@@ -7130,7 +7147,7 @@ def main():
|
|
|
7130
7147
|
"AI Influencers": "/ai-influencers", "Personagens": "/ai-influencers/personagens", "Geração de Conteúdo IA": "/ai-influencers/geracao-conteudo", "Motion Control": "/ai-influencers/motion-control", "UGC Products": "/ai-influencers/ugc-products", "Redes Sociais": "/ai-influencers/redes-sociais",
|
|
7131
7148
|
"Edição": "/edicao", "Limpador de Metadados": "/edicao/limpador-metadados", "Cortes": "/edicao/cortes", "Editor Python": "/edicao/editor-python", "Download Mídia": "/edicao/download-midia",
|
|
7132
7149
|
"Growth": "/growth", "Analista Growth Youtube": "/growth/youtube", "Analista Growth Tiktok": "/growth/tiktok", "Analista Growth Instagram": "/growth/instagram", "Analista Facebook Pages": "/growth/facebook-pages", "Analista Bilibili": "/growth/bilibili",
|
|
7133
|
-
"Documentação": "/documentacao", "Tutorial Meta": "/documentacao/meta", "Tutorial Supabase": "/documentacao/supabase", "Tutorial Kaggle": "/documentacao/kaggle", "Tutorial Apify": "/documentacao/apify", "Tutorial YouTube Video-Upload Frontend": "/documentacao/youtube-video-upload-frontend",
|
|
7150
|
+
"Documentação": "/documentacao", "Tutorial Meta": "/documentacao/meta", "Tutorial Supabase": "/documentacao/supabase", "Tutorial Kaggle": "/documentacao/kaggle", "Tutorial Apify": "/documentacao/apify", "Tutorial YouTube Video-Upload Frontend": "/documentacao/youtube-video-upload-frontend", "Tutorial OAuth do Google": "/documentacao/oauth-google",
|
|
7134
7151
|
"Configurações": "/configuracoes", "MCP": "/configuracoes/mcp", "Notificações": "/configuracoes/notificacoes", "Logs": "/configuracoes/logs", "Configuração API": "/configuracoes/api",
|
|
7135
7152
|
}
|
|
7136
7153
|
|
|
@@ -7238,6 +7255,7 @@ def main():
|
|
|
7238
7255
|
"Tutorial Meta": render_models_ai_tutorial,
|
|
7239
7256
|
"Tutorial Supabase": render_supabase_tutorial,
|
|
7240
7257
|
"Tutorial YouTube Video-Upload Frontend": render_youtube_frontend_upload_tutorial,
|
|
7258
|
+
"Tutorial OAuth do Google": render_google_oauth_tutorial,
|
|
7241
7259
|
"Configurações": lambda: render_edit_placeholder("Configurações", "Seleccione uma opção no menu expansível."),
|
|
7242
7260
|
"MCP": render_mcp,
|
|
7243
7261
|
"Contas Google": render_google_accounts,
|
|
@@ -22,7 +22,7 @@ from hermes_ui.storage import STORAGE, atomic_write, ensure_storage, read_json,
|
|
|
22
22
|
from hermes_ui.llm_providers import active_llm_card, provider_definition
|
|
23
23
|
from hermes_ui.media_generation import MediaGenerationError, _append_generation_constraints, generate_image_from_pool, generate_video_from_pool
|
|
24
24
|
from hermes_ui.media_providers import FULL_IA_VIDEO_PROVIDER_CODES, media_cards_for_pool, media_provider_definition
|
|
25
|
-
from hermes_ui.material_sources import material_api_keys, selected_material_source
|
|
25
|
+
from hermes_ui.material_sources import material_api_keys, material_source_cards, selected_material_source
|
|
26
26
|
from hermes_ui.thumbnail_generation import ThumbnailGenerationError, generate_thumbnail_image
|
|
27
27
|
|
|
28
28
|
PIPELINE_LOCK_FILENAME = "pipeline_worker.lock"
|
|
@@ -38,9 +38,16 @@ CASCADE_STAGE_ORDER = ("topic", "script", "title", "keywords", "video", "thumbna
|
|
|
38
38
|
class PipelineError(RuntimeError):
|
|
39
39
|
"""Raised when a pipeline stage cannot complete with an actionable error."""
|
|
40
40
|
|
|
41
|
-
def __init__(
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
message: str,
|
|
44
|
+
*,
|
|
45
|
+
failure_metadata: dict[str, Any] | None = None,
|
|
46
|
+
fallback_eligible: bool = False,
|
|
47
|
+
):
|
|
42
48
|
super().__init__(message)
|
|
43
49
|
self.failure_metadata = dict(failure_metadata or {})
|
|
50
|
+
self.fallback_eligible = bool(fallback_eligible)
|
|
44
51
|
|
|
45
52
|
|
|
46
53
|
class PipelineStopped(PipelineError):
|
|
@@ -620,6 +627,30 @@ def _normalise_video_route(task: dict[str, Any], settings: dict[str, Any]) -> st
|
|
|
620
627
|
return raw if raw in {"pexels", "pixabay", "local"} else "pexels"
|
|
621
628
|
|
|
622
629
|
|
|
630
|
+
def _material_video_routes(task: dict[str, Any], settings: dict[str, Any]) -> list[str]:
|
|
631
|
+
"""Return stock providers to try, starting with the task's selected source."""
|
|
632
|
+
route = _normalise_video_route(task, settings)
|
|
633
|
+
if route not in {"pexels", "pixabay"}:
|
|
634
|
+
return [route]
|
|
635
|
+
|
|
636
|
+
ordered_providers: list[str] = []
|
|
637
|
+
for card in material_source_cards(settings, enabled_only=True):
|
|
638
|
+
provider = str(card.get("provider") or "").strip().casefold()
|
|
639
|
+
if provider not in {"pexels", "pixabay"} or provider in ordered_providers:
|
|
640
|
+
continue
|
|
641
|
+
if material_api_keys(settings, provider):
|
|
642
|
+
ordered_providers.append(provider)
|
|
643
|
+
|
|
644
|
+
# A selected source is an explicit preference for this task. The remaining
|
|
645
|
+
# configured sources follow their persisted priority and are true fallbacks.
|
|
646
|
+
if route in ordered_providers:
|
|
647
|
+
ordered_providers.remove(route)
|
|
648
|
+
ordered_providers.insert(0, route)
|
|
649
|
+
elif material_api_keys(settings, route):
|
|
650
|
+
ordered_providers.insert(0, route)
|
|
651
|
+
return ordered_providers or [route]
|
|
652
|
+
|
|
653
|
+
|
|
623
654
|
def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None = None) -> int:
|
|
624
655
|
"""Reserve extra bounded time only for long stock-video downloads and assembly."""
|
|
625
656
|
effective_settings = settings if isinstance(settings, dict) else _settings()
|
|
@@ -633,7 +664,9 @@ def _video_timeout_seconds(task: dict[str, Any], settings: dict[str, Any] | None
|
|
|
633
664
|
def _task_stale_timeout_seconds(task: dict[str, Any]) -> int:
|
|
634
665
|
"""Keep stale-task recovery aligned with the actual execution budget."""
|
|
635
666
|
if str(task.get("stage") or "").strip().casefold() == "video":
|
|
636
|
-
|
|
667
|
+
settings = _settings()
|
|
668
|
+
attempts = max(1, len(_material_video_routes(task, settings)))
|
|
669
|
+
return _video_timeout_seconds(task, settings) * attempts + 5 * 60
|
|
637
670
|
return STALE_TASK_SECONDS
|
|
638
671
|
|
|
639
672
|
|
|
@@ -820,7 +853,12 @@ def _moneyprinter_cli_args(task: dict[str, Any], route: str, settings: dict[str,
|
|
|
820
853
|
return args
|
|
821
854
|
|
|
822
855
|
|
|
823
|
-
def
|
|
856
|
+
def _run_video_helper_once(
|
|
857
|
+
task: dict[str, Any],
|
|
858
|
+
*,
|
|
859
|
+
route_override: str = "",
|
|
860
|
+
settings: dict[str, Any] | None = None,
|
|
861
|
+
) -> Path:
|
|
824
862
|
helper_dir = Path(__file__).resolve().parents[1] / "seed" / "skills"
|
|
825
863
|
helper = helper_dir / "mpt_agent.py"
|
|
826
864
|
if not helper.is_file():
|
|
@@ -828,16 +866,23 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
828
866
|
subject = str(task.get("topic") or "").strip()
|
|
829
867
|
if not subject:
|
|
830
868
|
raise PipelineError("A etapa Vídeo não recebeu um tema válido.")
|
|
831
|
-
settings = _settings()
|
|
869
|
+
settings = settings if isinstance(settings, dict) else _settings()
|
|
832
870
|
configured_root = _configured_moneyprinter_root(settings)
|
|
833
871
|
task_id = str(task.get("id") or "").strip()
|
|
834
872
|
if not task_id:
|
|
835
873
|
raise PipelineError("A tarefa de vídeo não tem um identificador válido.")
|
|
836
874
|
env = os.environ.copy()
|
|
875
|
+
for key in (
|
|
876
|
+
"MPT_PEXELS_API_KEY",
|
|
877
|
+
"MPT_PEXELS_API_KEYS",
|
|
878
|
+
"MPT_PIXABAY_API_KEY",
|
|
879
|
+
"MPT_PIXABAY_API_KEYS",
|
|
880
|
+
):
|
|
881
|
+
env.pop(key, None)
|
|
837
882
|
card = active_llm_card(settings)
|
|
838
883
|
provider = str(card.get("provider") or "openai").strip()
|
|
839
884
|
definition = provider_definition(provider)
|
|
840
|
-
route = _normalise_video_route(task, settings)
|
|
885
|
+
route = str(route_override or _normalise_video_route(task, settings)).strip().casefold()
|
|
841
886
|
source_keys = material_api_keys(settings, route) if route in {"pexels", "pixabay"} else []
|
|
842
887
|
if route in {"pexels", "pixabay"} and not source_keys:
|
|
843
888
|
source_label = "Pexels" if route == "pexels" else "Pixabay"
|
|
@@ -987,7 +1032,11 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
987
1032
|
_stop_process(process)
|
|
988
1033
|
message = f"A etapa Vídeo excedeu o limite de {timeout_seconds // 60} minutos e foi encerrada."
|
|
989
1034
|
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
990
|
-
raise PipelineError(
|
|
1035
|
+
raise PipelineError(
|
|
1036
|
+
_failure_message(message, metadata),
|
|
1037
|
+
failure_metadata=metadata,
|
|
1038
|
+
fallback_eligible=True,
|
|
1039
|
+
)
|
|
991
1040
|
if time.monotonic() - last_activity_at >= VIDEO_IDLE_TIMEOUT_SECONDS:
|
|
992
1041
|
_stop_process(process)
|
|
993
1042
|
message = (
|
|
@@ -995,7 +1044,11 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
995
1044
|
f"{VIDEO_IDLE_TIMEOUT_SECONDS // 60} minutos e foi encerrada."
|
|
996
1045
|
)
|
|
997
1046
|
metadata = _failure_attribution(task, settings, "video", error=message)
|
|
998
|
-
raise PipelineError(
|
|
1047
|
+
raise PipelineError(
|
|
1048
|
+
_failure_message(message, metadata),
|
|
1049
|
+
failure_metadata=metadata,
|
|
1050
|
+
fallback_eligible=True,
|
|
1051
|
+
)
|
|
999
1052
|
finally:
|
|
1000
1053
|
reader.join(timeout=2)
|
|
1001
1054
|
_persist_video_diagnostics(task, "\n".join(output_lines))
|
|
@@ -1010,12 +1063,20 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
1010
1063
|
message = "A geração de vídeo precisa de credenciais adicionais do MoneyPrinterTurbo"
|
|
1011
1064
|
if detail:
|
|
1012
1065
|
message += f". Detalhe do helper: {detail}"
|
|
1013
|
-
raise PipelineError(
|
|
1066
|
+
raise PipelineError(
|
|
1067
|
+
_failure_message(message, metadata),
|
|
1068
|
+
failure_metadata=metadata,
|
|
1069
|
+
fallback_eligible=True,
|
|
1070
|
+
)
|
|
1014
1071
|
if result_code != 0:
|
|
1015
1072
|
detail = _terminal_helper_detail(output) or "erro sem detalhes devolvidos pelo helper"
|
|
1016
1073
|
metadata = _failure_attribution(task, settings, "video", error=detail, output=output)
|
|
1017
1074
|
message = f"MoneyPrinterTurbo falhou na etapa Vídeo: {detail}"
|
|
1018
|
-
raise PipelineError(
|
|
1075
|
+
raise PipelineError(
|
|
1076
|
+
_failure_message(message, metadata),
|
|
1077
|
+
failure_metadata=metadata,
|
|
1078
|
+
fallback_eligible=True,
|
|
1079
|
+
)
|
|
1019
1080
|
match = re.search(r"(?m)^VIDEO_FILE=(.+)$", output)
|
|
1020
1081
|
video_path = Path(match.group(1).strip()).expanduser() if match else None
|
|
1021
1082
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
@@ -1030,10 +1091,66 @@ def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
|
1030
1091
|
if not video_path or not video_path.is_file() or video_path.stat().st_size <= 0:
|
|
1031
1092
|
message = "MoneyPrinterTurbo terminou sem devolver um MP4 válido."
|
|
1032
1093
|
metadata = _failure_attribution(task, settings, "video", error=message, output=output)
|
|
1033
|
-
raise PipelineError(
|
|
1094
|
+
raise PipelineError(
|
|
1095
|
+
_failure_message(message, metadata),
|
|
1096
|
+
failure_metadata=metadata,
|
|
1097
|
+
fallback_eligible=True,
|
|
1098
|
+
)
|
|
1034
1099
|
return video_path
|
|
1035
1100
|
|
|
1036
1101
|
|
|
1102
|
+
def _stock_fallback_is_eligible(route: str, error: PipelineError) -> bool:
|
|
1103
|
+
"""Allow fallback only when the failed attempt points to its stock source."""
|
|
1104
|
+
if route not in {"pexels", "pixabay"} or not getattr(error, "fallback_eligible", False):
|
|
1105
|
+
return False
|
|
1106
|
+
metadata = dict(getattr(error, "failure_metadata", {}) or {})
|
|
1107
|
+
providers = {
|
|
1108
|
+
item.strip().casefold()
|
|
1109
|
+
for item in str(metadata.get("failure_provider") or "").split(",")
|
|
1110
|
+
if item.strip()
|
|
1111
|
+
}
|
|
1112
|
+
if providers and providers - {route}:
|
|
1113
|
+
return False
|
|
1114
|
+
fields = {
|
|
1115
|
+
item.strip().casefold()
|
|
1116
|
+
for item in str(metadata.get("failure_config_fields") or "").split(",")
|
|
1117
|
+
if item.strip()
|
|
1118
|
+
}
|
|
1119
|
+
if fields and fields - {f"{route}_api_key", f"{route}_api_keys"}:
|
|
1120
|
+
return False
|
|
1121
|
+
return True
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
def _run_video_helper(task: dict[str, Any]) -> Path:
|
|
1125
|
+
"""Run the stock helper with provider fallback in the configured priority order."""
|
|
1126
|
+
settings = _settings()
|
|
1127
|
+
routes = _material_video_routes(task, settings)
|
|
1128
|
+
if len(routes) <= 1:
|
|
1129
|
+
return _run_video_helper_once(task, route_override=routes[0] if routes else "", settings=settings)
|
|
1130
|
+
|
|
1131
|
+
for index, route in enumerate(routes):
|
|
1132
|
+
attempt_task = dict(task)
|
|
1133
|
+
generation_settings = task.get("generation_settings") if isinstance(task.get("generation_settings"), dict) else {}
|
|
1134
|
+
attempt_task["material_source"] = route
|
|
1135
|
+
attempt_task["generation_settings"] = {**generation_settings, "material_source": route}
|
|
1136
|
+
try:
|
|
1137
|
+
return _run_video_helper_once(attempt_task, route_override=route, settings=settings)
|
|
1138
|
+
except PipelineStopped:
|
|
1139
|
+
raise
|
|
1140
|
+
except PipelineError as exc:
|
|
1141
|
+
if not _stock_fallback_is_eligible(route, exc) or index == len(routes) - 1:
|
|
1142
|
+
metadata = dict(getattr(exc, "failure_metadata", {}) or {})
|
|
1143
|
+
metadata["failure_route"] = route
|
|
1144
|
+
metadata["fallback_attempts"] = " → ".join(_provider_api_label(item) for item in routes[: index + 1])
|
|
1145
|
+
message = (
|
|
1146
|
+
f"Falha no provider {_provider_api_label(route)} após tentar "
|
|
1147
|
+
f"{metadata['fallback_attempts']}. Último erro: {exc}"
|
|
1148
|
+
)
|
|
1149
|
+
raise PipelineError(message, failure_metadata=metadata) from exc
|
|
1150
|
+
|
|
1151
|
+
raise PipelineError("Nenhum provider de vídeo stock configurado.")
|
|
1152
|
+
|
|
1153
|
+
|
|
1037
1154
|
def _read_persisted_script(task: dict[str, Any], channel: dict[str, Any], blueprint: dict[str, Any], topic: str) -> dict[str, Any] | None:
|
|
1038
1155
|
"""Load a previously saved script so retries do not regenerate it."""
|
|
1039
1156
|
artifacts = task.get("artifacts") if isinstance(task.get("artifacts"), dict) else {}
|
package/package.json
CHANGED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Tutorial completo: configurar OAuth do Google para o Thunderbolt
|
|
2
|
+
|
|
3
|
+
Este tutorial explica como criar um projecto no Google Cloud, activar a YouTube Data API v3, configurar a tela de consentimento OAuth, criar as credenciais e ligá-las ao Thunderbolt.
|
|
4
|
+
|
|
5
|
+
## Pré-requisitos
|
|
6
|
+
|
|
7
|
+
Antes de começar, certifique-se de que tem uma conta Google, acesso ao [Google Cloud Console](https://console.cloud.google.com/) e o Thunderbolt instalado e em execução localmente. A aplicação em execução é necessária para confirmar a URI de redireccionamento OAuth utilizada pelo projecto.
|
|
8
|
+
|
|
9
|
+
## Passo 1 — Aceder ao Google Cloud Console
|
|
10
|
+
|
|
11
|
+
Abra o [Google Cloud Console](https://console.cloud.google.com/) e faça login com a sua conta Google. Se for a primeira utilização, aceite os Termos de Serviço apresentados.
|
|
12
|
+
|
|
13
|
+
## Passo 2 — Criar ou seleccionar um projecto
|
|
14
|
+
|
|
15
|
+
No topo da página, clique no selector de projectos, junto a **Google Cloud**. No diálogo apresentado, seleccione **Novo projecto** e preencha os campos seguintes:
|
|
16
|
+
|
|
17
|
+
| Campo | Valor sugerido |
|
|
18
|
+
|---|---|
|
|
19
|
+
| Nome do projecto | `Thunderbolt` |
|
|
20
|
+
| Organização | Deixe em branco ou seleccione a organização predefinida |
|
|
21
|
+
|
|
22
|
+
Clique em **Criar**, aguarde a conclusão e use o selector de projectos para mudar para o projecto criado.
|
|
23
|
+
|
|
24
|
+
## Passo 3 — Activar a YouTube Data API v3
|
|
25
|
+
|
|
26
|
+
No menu lateral, abra **APIs e Serviços → Biblioteca**. Pesquise por **YouTube Data API v3**, abra o resultado correspondente e clique em **Activar**.
|
|
27
|
+
|
|
28
|
+
Se a **Google+ API** ainda aparecer como opção no seu projecto, a sua activação é opcional e pode ser útil para funcionalidades legadas que dependam dela.
|
|
29
|
+
|
|
30
|
+
## Passo 4 — Configurar a tela de consentimento OAuth
|
|
31
|
+
|
|
32
|
+
> **Este é um passo obrigatório.** A tela de consentimento é apresentada ao utilizador quando o Thunderbolt solicita autorização para aceder à conta Google/YouTube.
|
|
33
|
+
|
|
34
|
+
No menu lateral, abra **APIs e Serviços → Tela de consentimento OAuth**. Clique em **Começar** ou **Configurar tela de consentimento**, conforme a opção apresentada.
|
|
35
|
+
|
|
36
|
+
Em **Tipo de utilizador**, seleccione **Externo** e clique em **Criar**. Na secção **Informações do app**, preencha:
|
|
37
|
+
|
|
38
|
+
| Campo | Valor sugerido |
|
|
39
|
+
|---|---|
|
|
40
|
+
| Nome do app | `Thunderbolt` ou o nome pretendido |
|
|
41
|
+
| E-mail para suporte do utilizador | O seu e-mail |
|
|
42
|
+
| Logótipo do app | Opcional; pode deixar em branco |
|
|
43
|
+
| Domínio da página inicial | Deixe em branco ou utilize `localhost` para desenvolvimento local |
|
|
44
|
+
| Política de privacidade | Opcional nesta fase |
|
|
45
|
+
| Termos de serviço | Opcional nesta fase |
|
|
46
|
+
|
|
47
|
+
Clique em **Próxima**. Em **Público**, mantenha a opção **Externo** e avance novamente.
|
|
48
|
+
|
|
49
|
+
Em **Escopos**, clique em **Adicionar ou remover escopos**, pesquise por `youtube` e seleccione:
|
|
50
|
+
|
|
51
|
+
| Escopo | Finalidade |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `https://www.googleapis.com/auth/youtube` | Gerir a sua conta do YouTube |
|
|
54
|
+
| `https://www.googleapis.com/auth/youtube.upload` | Gerir os seus vídeos do YouTube e efectuar uploads |
|
|
55
|
+
| `https://www.googleapis.com/auth/youtube.readonly` | Visualizar a sua conta do YouTube; opcional |
|
|
56
|
+
|
|
57
|
+
Clique em **Actualizar** e depois em **Próxima**. Em **Informações de contacto**, indique o seu e-mail para receber notificações do Google e avance.
|
|
58
|
+
|
|
59
|
+
Na página **Concluir**, leia a Política de Dados do Utilizador, marque a caixa de concordância e clique em **Continuar** e, depois, em **Criar**.
|
|
60
|
+
|
|
61
|
+
> **Importante:** depois de criada, a tela de consentimento não pode ser removida, embora possa ser editada posteriormente.
|
|
62
|
+
|
|
63
|
+
## Passo 5 — Adicionar utilizadores de teste
|
|
64
|
+
|
|
65
|
+
Enquanto a aplicação estiver com o estado **Não verificado**, o Google exige que os utilizadores de teste sejam adicionados manualmente para permitir o login.
|
|
66
|
+
|
|
67
|
+
Na página da tela de consentimento OAuth, abra o separador **Público-alvo** e, na área **Utilizadores de teste**, clique em **Adicionar utilizadores**. Introduza o seu e-mail, clique em **Adicionar** e depois em **Guardar**.
|
|
68
|
+
|
|
69
|
+
## Passo 6 — Criar as credenciais OAuth
|
|
70
|
+
|
|
71
|
+
No menu lateral, abra **APIs e Serviços → Credenciais** e clique em **+ Criar credenciais → ID do cliente OAuth**. Em **Tipo de aplicativo**, seleccione **Aplicativo da Web** e atribua um nome, por exemplo, `Thunderbolt Web Client`.
|
|
72
|
+
|
|
73
|
+
### Origens JavaScript autorizadas
|
|
74
|
+
|
|
75
|
+
Clique em **Adicionar URI** e introduza a origem onde o frontend é executado, por exemplo:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
http://localhost:3000
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Se existir um ambiente de produção, adicione também a origem HTTPS correspondente, por exemplo `https://seudominio.com`.
|
|
82
|
+
|
|
83
|
+
### URIs de redireccionamento autorizadas
|
|
84
|
+
|
|
85
|
+
Esta é a parte mais crítica. Clique em **Adicionar URI** e introduza a URI exacta utilizada pelo Thunderbolt para o callback OAuth. Exemplos de desenvolvimento local:
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
http://localhost:3000/api/auth/google/callback
|
|
89
|
+
http://localhost:5173/oauth2callback
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
> **A URI tem de ser exactamente igual à configurada no código do Thunderbolt.** Uma barra final diferente, o uso de `http` em vez de `https` ou uma porta diferente causa o erro `redirect_uri_mismatch`.
|
|
93
|
+
|
|
94
|
+
Se não souber qual é a URI utilizada, consulte a configuração ou o ficheiro de ambiente do Thunderbolt e confirme também os logs da aplicação.
|
|
95
|
+
|
|
96
|
+
Clique em **Criar**. Na janela apresentada, copie o **ID do cliente (Client ID)** e a **Chave secreta do cliente (Client Secret)** e guarde-os num local seguro.
|
|
97
|
+
|
|
98
|
+
> **Nunca partilhe o Client Secret nem o envie para o controlo de versão Git.**
|
|
99
|
+
|
|
100
|
+
## Passo 7 — Configurar o Thunderbolt
|
|
101
|
+
|
|
102
|
+
O projecto utiliza variáveis de ambiente para as credenciais OAuth. Localize o ficheiro de ambiente do Thunderbolt, normalmente `.env`, `.env.local` ou o ficheiro de configuração equivalente, e adicione:
|
|
103
|
+
|
|
104
|
+
```env
|
|
105
|
+
GOOGLE_CLIENT_ID=seu_client_id_aqui
|
|
106
|
+
GOOGLE_CLIENT_SECRET=sua_client_secret_aqui
|
|
107
|
+
GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Substitua `seu_client_id_aqui` e `sua_client_secret_aqui` pelos valores copiados no passo anterior. Confirme que `GOOGLE_REDIRECT_URI` é exactamente igual à URI registada no Google Cloud Console.
|
|
111
|
+
|
|
112
|
+
## Passo 8 — Testar a autenticação
|
|
113
|
+
|
|
114
|
+
Reinicie o servidor do Thunderbolt para carregar as novas variáveis de ambiente. Abra a interface e tente iniciar sessão com o Google.
|
|
115
|
+
|
|
116
|
+
O fluxo esperado é:
|
|
117
|
+
|
|
118
|
+
1. O Google apresenta a tela de consentimento com o nome do app e os escopos solicitados.
|
|
119
|
+
2. O utilizador autoriza o acesso.
|
|
120
|
+
3. O Google redirecciona para a URI de callback do Thunderbolt.
|
|
121
|
+
4. A autenticação é concluída com sucesso na aplicação.
|
|
122
|
+
|
|
123
|
+
## Solução de problemas
|
|
124
|
+
|
|
125
|
+
### Erro `redirect_uri_mismatch`
|
|
126
|
+
|
|
127
|
+
Este erro significa que a URI enviada pelo Thunderbolt não corresponde exactamente à URI registada no Google Cloud Console.
|
|
128
|
+
|
|
129
|
+
Verifique a URI utilizada nos logs ou no código do Thunderbolt. Depois, abra **Credenciais**, seleccione o ID do cliente e adicione a URI exacta em **URIs de redireccionamento autorizadas**. Aguarde um a dois minutos para a configuração se propagar, limpe o cache do navegador e tente novamente.
|
|
130
|
+
|
|
131
|
+
### Erro `invalid_request` ou `access_denied`
|
|
132
|
+
|
|
133
|
+
Confirme se o seu e-mail foi adicionado como utilizador de teste no passo 5. Verifique também se os escopos necessários estão activos, especialmente `youtube.upload`.
|
|
134
|
+
|
|
135
|
+
### Mensagem “Acesso bloqueado: solicitação inválida”
|
|
136
|
+
|
|
137
|
+
Na maioria dos casos, esta mensagem está relacionada com uma URI de redireccionamento incorrecta. Reveja cuidadosamente os passos 6 e 7, verificando protocolo, domínio, porta, caminho e barras finais.
|
|
138
|
+
|
|
139
|
+
## Escopos importantes para o YouTube
|
|
140
|
+
|
|
141
|
+
Para efectuar uploads de vídeos, o Thunderbolt utiliza principalmente os seguintes escopos OAuth:
|
|
142
|
+
|
|
143
|
+
| Escopo | Descrição |
|
|
144
|
+
|---|---|
|
|
145
|
+
| `https://www.googleapis.com/auth/youtube` | Gerir a sua conta do YouTube |
|
|
146
|
+
| `https://www.googleapis.com/auth/youtube.upload` | Gerir os seus vídeos do YouTube e efectuar uploads |
|
|
147
|
+
| `https://www.googleapis.com/auth/youtube.readonly` | Visualizar a sua conta do YouTube; opcional |
|
|
148
|
+
|
|
149
|
+
> **Boa prática de segurança:** mantenha o Client Secret fora do código, dos ficheiros versionados e de mensagens públicas. Use variáveis de ambiente e restrinja o acesso às credenciais.
|
|
150
|
+
|
|
151
|
+
## Referências
|
|
152
|
+
|
|
153
|
+
- [Google Cloud Console](https://console.cloud.google.com/)
|
|
154
|
+
- [YouTube Data API v3](https://developers.google.com/youtube/v3)
|
|
155
|
+
- [Documentação OAuth 2.0 do Google](https://developers.google.com/identity/protocols/oauth2)
|