@danhachuel/thunderbolt 0.2.51 → 0.2.52
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/MANUAL-INSTALACAO.md +7 -7
- package/README.md +8 -6
- package/app/main.py +87 -81
- package/integrations/youtube_direct_credentials.py +55 -5
- package/package.json +1 -1
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.2.
|
|
5
|
+
> **Versão deste manual:** 0.2.52
|
|
6
6
|
> **Pacote npm:** `@danhachuel/thunderbolt`
|
|
7
7
|
> **Porta padrão da UI:** `localhost:3030`
|
|
8
8
|
> **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
|
|
@@ -100,13 +100,13 @@ Execute:
|
|
|
100
100
|
Windows PowerShell ou MobaXterm:
|
|
101
101
|
|
|
102
102
|
```powershell
|
|
103
|
-
npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
103
|
+
npx.cmd --yes @danhachuel/thunderbolt@0.2.52 install
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Linux/macOS:
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
npx --yes @danhachuel/thunderbolt@0.2.
|
|
109
|
+
npx --yes @danhachuel/thunderbolt@0.2.52 install
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
|
|
@@ -428,7 +428,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
428
428
|
|
|
429
429
|
Os parâmetros da UI são número de clusters entre 2 e 10, suporte mínimo entre 0,01 e 0,50, país, engagement, intervalo de datas e tags, todos dentro da área principal da aba. O núcleo normaliza os dados, calcula engagement, aplica filtros, faz transformação logarítmica e standardização, executa K-Means e calcula itemsets/regras com FP-Growth. Não são apresentados resultados até ao primeiro clique em **Analisar Nichos**; o mesmo botão aplica alterações posteriores aos filtros. Os resultados são DataFrames de clusters, itemsets frequentes, regras de associação e dados analisados; o gráfico de dispersão é criado nativamente com Plotly.
|
|
430
430
|
|
|
431
|
-
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.
|
|
431
|
+
As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.2.52 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
432
432
|
|
|
433
433
|
### Niche Finder Apify
|
|
434
434
|
|
|
@@ -466,13 +466,13 @@ A área **Teste de vozes**, dentro de **Configurações > Configurações Técni
|
|
|
466
466
|
|
|
467
467
|
## Upload directo
|
|
468
468
|
|
|
469
|
-
Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada
|
|
469
|
+
Em **Configurações > Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um expander identificado por nome e e-mail. Dentro dele existe o uploader **Subir documento de cookies/credenciais** e o input **sessionInfo token desta conta Google**. O documento JSON único contém cookies, INNERTUBE_API_KEY, chunk_size e o mapa de IDs delegados por canal; o sessionInfo preenchido na conta é sincronizado no mesmo ficheiro, guardado em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`.
|
|
470
470
|
|
|
471
471
|
Em **Canais > Canais cadastrados**, a secção **Upload directo — documento da conta deste canal** mostra apenas a conta Google associada e confirma se o documento contém o ID delegado desse canal. O `DELEGATED_SESSION_ID` permanece exclusivamente no mapa do documento; o upload bloqueia a operação se faltar qualquer elemento técnico.
|
|
472
472
|
|
|
473
|
-
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O
|
|
473
|
+
A subaba **Upload directo** adapta o [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api). Cada conta Google tem um único `credentials.json`, criado automaticamente ao adicionar a conta, com cookies, sessionInfo, INNERTUBE_API_KEY, chunk_size e `delegated_session_ids` por canal. O uploader aceita um documento JSON completo ou parcial; o merge actualiza somente os valores presentes e preserva os restantes. O documento é guardado fora de `storage/state/`, com permissões locais restritas.
|
|
474
474
|
|
|
475
|
-
Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
|
|
475
|
+
Associe cada canal à conta Google correcta em **Canais**; não introduza o ID delegado na UI. A associação do canal é permitida mesmo que o documento esteja incompleto. No momento do upload, o Thunderbolt lê o documento da conta, encontra o `DELEGATED_SESSION_ID` pela chave do canal e bloqueia apenas a operação se faltar conta, documento válido, cookies, sessionInfo, INNERTUBE_API_KEY, ID delegado ou vídeo elegível. O tamanho de chunk é normalizado para múltiplos de 262144 bytes. O método é uma integração não oficial de sessão do YouTube; não extraia cookies automaticamente, não os coloque no repositório e não os partilhe.
|
|
476
476
|
|
|
477
477
|
## MCP e integrações externas
|
|
478
478
|
|
package/README.md
CHANGED
|
@@ -22,25 +22,27 @@ A primeira versão implementa a camada UI independente com:
|
|
|
22
22
|
| Edição | Menu expansível abaixo de Automação com Limpador de Metadados funcional, Cortes reservado e Editor Python inspirado no PYEdit para vídeos e scripts locais |
|
|
23
23
|
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, OAuth directo de redundância, Upload directo via sessão Frontend API com cookies/sessionInfo por conta e DELEGATED_SESSION_ID por canal, TikTok, Instagram e Facebook Pages no front end |
|
|
24
24
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
25
|
-
| Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta,
|
|
25
|
+
| Configurações Técnicas | Provedores LLM, OpenAI/NVIDIA NIM com API key, Base URL e selector de modelos, TTS/voz, preview de vozes, Suno, materiais, Whisper, FFmpeg, OAuth YouTube, contas Google/YouTube para canais em lote com e-mail, Client ID, Client Secret e sessionInfo por conta, cartões expansíveis por nome/e-mail, documento credentials.json criado automaticamente e eliminação individual, cookies SID/SSID/HSID/APISID/SAPISID no documento JSON, Data API Key opcional, Kaggle Username/API Key, Apify API Token/Actor ID/limites, Upload directo, TikTok Client ID/Secret e Upload-Post |
|
|
26
26
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
27
27
|
|
|
28
28
|
## Upload directo — credenciais por conta e por canal
|
|
29
29
|
|
|
30
|
-
O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada
|
|
30
|
+
O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações Técnicas > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` continuam exclusivamente no documento.
|
|
31
31
|
|
|
32
|
-
O
|
|
32
|
+
O botão **Adicionar outra conta Gmail** fica abaixo de uma divisória, fora dos cartões existentes. Ao criar uma conta, o Thunderbolt cria imediatamente `credentials.json` com placeholders vazios. Um upload completo ou parcial é incorporado por merge: os cookies enviados actualizam apenas os cookies fornecidos e valores como `sessionInfo`, `INNERTUBE_API_KEY`, `chunk_size` e `delegated_session_ids` existentes não são apagados. O alerta **Documento incompleto: SID, SSID, HSID, APISID, SAPISID, sessionInfo, INNERTUBE_API_KEY** permanece visível por conta quando necessário.
|
|
33
|
+
|
|
34
|
+
O documento é guardado por Gmail em `storage/youtube_direct_accounts/<id-da-conta>/credentials.json`, com permissões locais restritas. Em **Canais > Canais cadastrados**, a UI apenas associa o canal à conta Google; essa associação continua permitida mesmo quando o documento está incompleto. O uploader procura o ID do canal dentro do mapa `delegated_session_ids` do documento e usa-o como `pageId`/`onBehalfOfUser`; só a operação de Upload directo é bloqueada quando faltarem os dados técnicos. Não existem campos separados de cookies, INNERTUBE_API_KEY, chunk_size ou DELEGATED_SESSION_ID na UI.
|
|
33
35
|
|
|
34
36
|
Os dados são segredos de sessão. Os valores não aparecem em tabelas ou logs, o ficheiro é escrito com permissões locais restritas e não é incluído no Git. O Thunderbolt não extrai cookies automaticamente do browser e não envia as credenciais para o GitHub. O método é não oficial e pode deixar de funcionar se o YouTube alterar os endpoints internos.
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
Os adaptadores do MoneyPrinterTurbo e de publicação nas plataformas são ligados pelas configurações locais e pelos pontos de integração em `integrations/`. A UI não inventa dados quando um serviço externo ou credencial não está disponível.
|
|
38
40
|
|
|
39
|
-
## Navegação da UI 0.2.
|
|
41
|
+
## Navegação da UI 0.2.52
|
|
40
42
|
|
|
41
43
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Automação**, **Edição**, **Models AI** e **Configurações**. **Pipeline** é expansível e contém **Criação de Vídeos**, **Criação de Músicas** e **Upload**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes** e **Editor Python**, nessa ordem. **Models AI** é expansível e contém **Personagens** e **Redes Sociais**, nessa ordem. **Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais**, **Blueprints**, **MCP** e **Configurações Técnicas**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
42
44
|
|
|
43
|
-
Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém contas Google, sessionInfo e documentos de Upload directo; **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
|
|
45
|
+
Dentro de **Configurações Técnicas**, a UI está organizada em três subabas, sem numeração visível: **Contas Google/YouTube — canais em lote**, **API Keys** e **Teste de vozes**. A primeira contém cartões expansíveis de contas Google, sessionInfo e documentos de Upload directo; o formulário **Adicionar outra conta Gmail** fica fora dos cartões. **API Keys** reúne execução local, OAuth, LLM, TTS, materiais e restantes credenciais; **Teste de vozes** contém exclusivamente o preview de áudio.
|
|
44
46
|
|
|
45
47
|
## Canais — edição por cartão e vídeos recentes
|
|
46
48
|
|
|
@@ -72,7 +74,7 @@ A consulta só é executada quando o utilizador carrega no botão; a aplicação
|
|
|
72
74
|
|
|
73
75
|
Para autorizar uma conta, use no Google Cloud um cliente OAuth do tipo **Desktop app**. O Thunderbolt usa o loopback local determinístico `http://127.0.0.1:8765/`, abre o browser e guarda o token por conta. Se estiver a usar um cliente do tipo Web application, adicione exactamente essa URI em **Google Cloud > APIs e serviços > Credenciais > URIs de redireccionamento autorizados**; a URI deve incluir a porta e a barra final. O erro `400: redirect_uri_mismatch` significa que a URI registada no cliente Google não coincide exactamente com a enviada pela aplicação.
|
|
74
76
|
|
|
75
|
-
A área **Contas Google/YouTube — canais em lote** permite manter várias contas. Cada cartão tem
|
|
77
|
+
A área **Contas Google/YouTube — canais em lote** permite manter várias contas em cartões expansíveis com nome e e-mail. Cada cartão tem OAuth Client ID, OAuth Client Secret, sessionInfo e documento de Upload directo; o formulário separado **Adicionar outra conta Gmail** cria o documento padrão automaticamente. Use **Apagar conta** para remover a conta, os tokens e os dados directos associados. O sessionInfo é sincronizado com o documento JSON da própria conta; cookies e os restantes parâmetros de Upload directo permanecem apenas nesse documento.
|
|
76
78
|
|
|
77
79
|
## Instalação
|
|
78
80
|
|
package/app/main.py
CHANGED
|
@@ -37,7 +37,7 @@ from hermes_ui.voice_preview import DEFAULT_SAMPLE, load_preview_file, synthesiz
|
|
|
37
37
|
from hermes_ui.creative_generation import CreativeGenerationError, generate_creative_package, generate_topic_for_channel
|
|
38
38
|
from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdapter, fetch_channel_videos_public
|
|
39
39
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
40
|
-
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
40
|
+
from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
|
|
41
41
|
from integrations.youtube_batch import account_key as youtube_batch_account_key, account_status as youtube_batch_account_status, authorize_account as authorize_youtube_batch_account, delete_account_token as delete_youtube_batch_token, list_my_channels as list_youtube_batch_channels, loopback_redirect_uri
|
|
42
42
|
from integrations.local_runtime import MoneyPrinterRuntime
|
|
43
43
|
from integrations.moneyprinter_config import sync_moneyprinter_config
|
|
@@ -1914,53 +1914,60 @@ def render_settings():
|
|
|
1914
1914
|
|
|
1915
1915
|
with google_accounts_tab:
|
|
1916
1916
|
st.subheader("Contas Google/YouTube — canais em lote")
|
|
1917
|
-
st.caption("
|
|
1917
|
+
st.caption("Abra uma conta para ver e editar os detalhes. O e-mail identifica a conta Google; esta área não lê a caixa Gmail.")
|
|
1918
1918
|
batch_accounts = [account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")]
|
|
1919
|
-
|
|
1920
|
-
|
|
1919
|
+
channel_state = read_json("channels.json", [])
|
|
1920
|
+
youtube_accounts_missing_document: list[str] = []
|
|
1921
|
+
|
|
1921
1922
|
for batch_account in batch_accounts:
|
|
1922
1923
|
account_id = str(batch_account["id"])
|
|
1923
|
-
|
|
1924
|
+
account_email_snapshot = str(batch_account.get("email") or "sem e-mail")
|
|
1925
|
+
account_label_snapshot = str(batch_account.get("label") or "Canais YouTube")
|
|
1926
|
+
ensure_credentials_document(STORAGE, batch_account, settings, channel_state)
|
|
1927
|
+
direct_status = direct_account_status(STORAGE, batch_account)
|
|
1928
|
+
missing_document_parts = list(direct_status.get("missing_cookies", []))
|
|
1929
|
+
if not direct_status.get("has_session_info"):
|
|
1930
|
+
missing_document_parts.append("sessionInfo")
|
|
1931
|
+
if not direct_status.get("has_innertube_api_key"):
|
|
1932
|
+
missing_document_parts.append("INNERTUBE_API_KEY")
|
|
1933
|
+
if missing_document_parts:
|
|
1934
|
+
youtube_accounts_missing_document.append(account_email_snapshot)
|
|
1935
|
+
|
|
1936
|
+
with st.expander(f"{account_label_snapshot} — {account_email_snapshot}", expanded=False):
|
|
1924
1937
|
with st.form(f"batch_account_form_{account_id}"):
|
|
1925
|
-
account_cols = st.columns(
|
|
1938
|
+
account_cols = st.columns(2)
|
|
1926
1939
|
with account_cols[0]:
|
|
1927
|
-
account_label = st.text_input("Nome da conta", value=
|
|
1928
|
-
|
|
1929
|
-
account_email = st.text_input("E-mail/Gmail da conta", value=str(batch_account.get("email", "")), key=f"batch_email_{account_id}")
|
|
1930
|
-
with account_cols[2]:
|
|
1940
|
+
account_label = st.text_input("Nome da conta", value=account_label_snapshot, key=f"batch_label_{account_id}")
|
|
1941
|
+
account_email = st.text_input("E-mail/Gmail da conta", value=account_email_snapshot if account_email_snapshot != "sem e-mail" else "", key=f"batch_email_{account_id}")
|
|
1931
1942
|
account_client_id = st.text_input("OAuth Client ID", value=str(batch_account.get("client_id", "")), key=f"batch_client_id_{account_id}")
|
|
1932
|
-
with account_cols[
|
|
1943
|
+
with account_cols[1]:
|
|
1933
1944
|
account_client_secret = st.text_input("OAuth Client Secret", value=str(batch_account.get("client_secret", "")), type="password", key=f"batch_client_secret_{account_id}")
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1945
|
+
account_session_info = st.text_input(
|
|
1946
|
+
"sessionInfo token desta conta Google",
|
|
1947
|
+
value=str(batch_account.get("sessionInfo") or batch_account.get("session_info") or batch_account.get("direct_session_info", "")),
|
|
1948
|
+
type="password",
|
|
1949
|
+
key=f"batch_session_info_{account_id}",
|
|
1950
|
+
help="Token sessionInfo usado pelo Upload directo. É guardado por conta e sincronizado no credentials.json; os cookies e restantes valores continuam exclusivamente no documento.",
|
|
1951
|
+
)
|
|
1941
1952
|
save_account = st.form_submit_button("Guardar dados da conta Google", type="primary", use_container_width=True)
|
|
1942
|
-
|
|
1943
|
-
st.markdown("**
|
|
1944
|
-
st.caption("
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
else:
|
|
1959
|
-
st.warning("Sem documento de credenciais")
|
|
1960
|
-
st.caption("Documento guardado em: storage/youtube_direct_accounts/<id-da-conta>/credentials.json")
|
|
1961
|
-
direct_account_save_buttons[account_id] = st.button("Guardar documento de Upload directo desta conta", type="secondary", use_container_width=True, key=f"save_direct_account_{account_id}")
|
|
1953
|
+
|
|
1954
|
+
st.markdown("**Documento de cookies/credenciais desta conta Google**")
|
|
1955
|
+
st.caption("O documento padrão é criado automaticamente. Suba um JSON completo ou apenas o documento de cookies; os valores preenchidos são incorporados e mantidos em credentials.json.")
|
|
1956
|
+
document_upload = st.file_uploader(
|
|
1957
|
+
"Subir documento de cookies/credenciais",
|
|
1958
|
+
type=["json"],
|
|
1959
|
+
key=f"direct_credentials_document_{account_id}",
|
|
1960
|
+
help="Aceita o JSON do YouTube-Video-Upload-Frontend-Api. Um documento parcial de cookies também é incorporado sem apagar os restantes campos.",
|
|
1961
|
+
)
|
|
1962
|
+
if missing_document_parts:
|
|
1963
|
+
st.warning(f"Documento incompleto: {', '.join(missing_document_parts)}")
|
|
1964
|
+
else:
|
|
1965
|
+
st.success("Documento completo para a conta Google")
|
|
1966
|
+
st.caption(f"Documento guardado em: {direct_status['document_file']}")
|
|
1967
|
+
document_save = st.button("Guardar documento nesta conta", key=f"save_direct_account_{account_id}", use_container_width=True)
|
|
1968
|
+
|
|
1962
1969
|
account_status = youtube_batch_account_status(batch_account, STORAGE)
|
|
1963
|
-
status_cols = st.columns(
|
|
1970
|
+
status_cols = st.columns(3)
|
|
1964
1971
|
with status_cols[0]:
|
|
1965
1972
|
(st.success if account_status.ok else st.warning)(account_status.message)
|
|
1966
1973
|
with status_cols[1]:
|
|
@@ -1977,23 +1984,13 @@ def render_settings():
|
|
|
1977
1984
|
settings["youtube_batch_accounts"] = remaining_accounts
|
|
1978
1985
|
if settings.get("youtube_batch_selected_account_id") == account_id:
|
|
1979
1986
|
settings["youtube_batch_selected_account_id"] = str(remaining_accounts[0].get("id")) if remaining_accounts else ""
|
|
1980
|
-
|
|
1981
|
-
channels_changed = False
|
|
1982
|
-
for channel in channels:
|
|
1987
|
+
for channel in channel_state:
|
|
1983
1988
|
if str(channel.get("google_account_id") or "") == account_id:
|
|
1984
1989
|
channel.update({"google_account_id": "", "google_account_email": ""})
|
|
1985
|
-
|
|
1986
|
-
if channels_changed:
|
|
1987
|
-
write_json("channels.json", channels)
|
|
1990
|
+
write_json("channels.json", channel_state)
|
|
1988
1991
|
write_json("settings.json", settings)
|
|
1989
1992
|
st.rerun()
|
|
1990
|
-
|
|
1991
|
-
st.session_state["new_batch_account_label"] = account_label
|
|
1992
|
-
st.session_state["new_batch_account_email"] = account_email
|
|
1993
|
-
st.session_state["new_batch_account_client_id"] = account_client_id
|
|
1994
|
-
st.session_state["new_batch_account_client_secret"] = account_client_secret
|
|
1995
|
-
st.session_state["new_batch_account_session_info"] = account_session_info
|
|
1996
|
-
st.rerun()
|
|
1993
|
+
|
|
1997
1994
|
if save_account:
|
|
1998
1995
|
if "@" not in account_email.strip():
|
|
1999
1996
|
st.error("Informe um e-mail Google válido.")
|
|
@@ -2007,40 +2004,47 @@ def render_settings():
|
|
|
2007
2004
|
delete_youtube_batch_token(existing, STORAGE)
|
|
2008
2005
|
existing.update({"label": account_label.strip() or "Canais YouTube", "email": account_email.strip(), "client_id": account_client_id.strip(), "client_secret": account_client_secret.strip(), "sessionInfo": account_session_info.strip()})
|
|
2009
2006
|
update_credentials_document_session_info(STORAGE, existing, account_session_info.strip())
|
|
2007
|
+
ensure_credentials_document(STORAGE, existing, settings, channel_state)
|
|
2010
2008
|
settings["youtube_batch_accounts"] = batch_accounts
|
|
2011
2009
|
write_json("settings.json", settings)
|
|
2012
2010
|
st.success("Conta Google/YouTube guardada.")
|
|
2013
2011
|
st.rerun()
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
if
|
|
2017
|
-
st.error("Seleccione
|
|
2012
|
+
|
|
2013
|
+
if document_save:
|
|
2014
|
+
if document_upload is None:
|
|
2015
|
+
st.error("Seleccione um documento JSON de cookies/credenciais antes de guardar.")
|
|
2018
2016
|
else:
|
|
2019
2017
|
try:
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2018
|
+
merge_credentials_document(
|
|
2019
|
+
STORAGE,
|
|
2020
|
+
batch_account,
|
|
2021
|
+
document_upload.getvalue(),
|
|
2022
|
+
document_upload.name,
|
|
2023
|
+
session_info_override=str(batch_account.get("sessionInfo") or ""),
|
|
2024
|
+
channels=channel_state,
|
|
2025
|
+
)
|
|
2026
|
+
st.success("Documento incorporado e guardado nesta conta Google.")
|
|
2025
2027
|
st.rerun()
|
|
2026
2028
|
except ValueError as exc:
|
|
2027
2029
|
st.error(str(exc))
|
|
2030
|
+
|
|
2031
|
+
if youtube_accounts_missing_document:
|
|
2032
|
+
st.info("Contas que ainda precisam de dados no documento: " + ", ".join(youtube_accounts_missing_document))
|
|
2033
|
+
|
|
2034
|
+
st.divider()
|
|
2035
|
+
st.markdown("### Adicionar outra conta Gmail")
|
|
2036
|
+
st.caption("Este formulário fica fora dos cartões das contas existentes. A associação de canais não depende da completude deste documento; ela apenas ficará pendente para Upload directo até os campos serem preenchidos.")
|
|
2028
2037
|
with st.form("add_batch_account_form"):
|
|
2029
|
-
st.
|
|
2030
|
-
st.caption("Estes campos pertencem a este Gmail. O ficheiro será guardado numa pasta exclusiva da conta e nunca será uma configuração global.")
|
|
2031
|
-
add_cols = st.columns(4)
|
|
2038
|
+
add_cols = st.columns(2)
|
|
2032
2039
|
with add_cols[0]:
|
|
2033
2040
|
new_account_label = st.text_input("Nome da nova conta", value="Canais YouTube", key="new_batch_account_label")
|
|
2034
|
-
with add_cols[1]:
|
|
2035
2041
|
new_account_email = st.text_input("E-mail/Gmail", key="new_batch_account_email")
|
|
2036
|
-
with add_cols[2]:
|
|
2037
2042
|
new_account_client_id = st.text_input("OAuth Client ID", key="new_batch_account_client_id")
|
|
2038
|
-
with add_cols[
|
|
2043
|
+
with add_cols[1]:
|
|
2039
2044
|
new_account_client_secret = st.text_input("OAuth Client Secret", type="password", key="new_batch_account_client_secret")
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
st.
|
|
2043
|
-
add_account = st.form_submit_button("Adicionar conta Google/YouTube com documento de Upload directo", use_container_width=True)
|
|
2045
|
+
new_account_session_info = st.text_input("sessionInfo token desta conta Google", type="password", key="new_batch_account_session_info", help="Token sessionInfo desta conta. Os cookies, INNERTUBE_API_KEY, chunk_size e delegated_session_ids continuam apenas no documento.")
|
|
2046
|
+
new_account_document = st.file_uploader("Documento de cookies/credenciais opcional", type=["json"], key="new_batch_account_credentials_document", help="Pode subir agora um JSON completo ou apenas o documento de cookies. Se não subir, será criado um credentials.json padrão vazio.")
|
|
2047
|
+
add_account = st.form_submit_button("Adicionar conta Google/YouTube", type="primary", use_container_width=True)
|
|
2044
2048
|
if add_account:
|
|
2045
2049
|
document_error = ""
|
|
2046
2050
|
if "@" not in new_account_email.strip():
|
|
@@ -2049,25 +2053,27 @@ def render_settings():
|
|
|
2049
2053
|
st.error("Informe o Client ID e o Client Secret da nova conta.")
|
|
2050
2054
|
elif new_account_document is not None:
|
|
2051
2055
|
try:
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2056
|
+
raw_document = new_account_document.getvalue()
|
|
2057
|
+
json.loads(raw_document.decode("utf-8-sig", errors="replace"))
|
|
2058
|
+
except (UnicodeDecodeError, json.JSONDecodeError, AttributeError) as exc:
|
|
2059
|
+
document_error = f"O documento {new_account_document.name} deve ser JSON válido."
|
|
2055
2060
|
st.error(document_error)
|
|
2056
2061
|
if "@" in new_account_email.strip() and new_account_client_id.strip() and new_account_client_secret.strip() and not document_error:
|
|
2057
2062
|
new_account = {"id": f"google_batch_{uuid.uuid4().hex[:12]}", "label": new_account_label.strip() or "Canais YouTube", "email": new_account_email.strip(), "client_id": new_account_client_id.strip(), "client_secret": new_account_client_secret.strip(), "sessionInfo": new_account_session_info.strip()}
|
|
2058
|
-
if new_account_document is not None:
|
|
2059
|
-
document = parse_credentials_document(new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip())
|
|
2060
|
-
document["account_id"] = new_account["id"]
|
|
2061
|
-
document["email"] = new_account["email"]
|
|
2062
|
-
save_credentials_document(STORAGE, new_account, document)
|
|
2063
2063
|
batch_accounts.append(new_account)
|
|
2064
2064
|
settings["youtube_batch_accounts"] = batch_accounts
|
|
2065
2065
|
settings["youtube_batch_selected_account_id"] = new_account["id"]
|
|
2066
2066
|
write_json("settings.json", settings)
|
|
2067
|
+
ensure_credentials_document(STORAGE, new_account, settings, channel_state)
|
|
2067
2068
|
if new_account_document is not None:
|
|
2068
|
-
|
|
2069
|
+
try:
|
|
2070
|
+
merge_credentials_document(STORAGE, new_account, new_account_document.getvalue(), new_account_document.name, session_info_override=new_account_session_info.strip(), channels=channel_state)
|
|
2071
|
+
except ValueError as exc:
|
|
2072
|
+
st.error(str(exc))
|
|
2073
|
+
st.stop()
|
|
2074
|
+
st.success(f"Conta {new_account['email']} adicionada e documento incorporado.")
|
|
2069
2075
|
else:
|
|
2070
|
-
st.warning(f"Conta {new_account['email']} adicionada.
|
|
2076
|
+
st.warning(f"Conta {new_account['email']} adicionada. Foi criado um credentials.json padrão; suba o documento de cookies nesta conta quando estiver pronto.")
|
|
2071
2077
|
st.rerun()
|
|
2072
2078
|
|
|
2073
2079
|
with api_keys_tab:
|
|
@@ -32,6 +32,13 @@ def cookie_file_path(storage_root: Path, account: dict[str, Any]) -> Path:
|
|
|
32
32
|
return account_directory(storage_root, account) / "cookies.json"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _placeholder_to_empty(value: Any) -> str:
|
|
36
|
+
text = str(value or "").strip()
|
|
37
|
+
if text in {"...", "…", "<preencher>", "<valor>"}:
|
|
38
|
+
return ""
|
|
39
|
+
return text
|
|
40
|
+
|
|
41
|
+
|
|
35
42
|
def _normalise_pairs(value: Any) -> dict[str, str]:
|
|
36
43
|
pairs: dict[str, str] = {}
|
|
37
44
|
if isinstance(value, dict):
|
|
@@ -40,13 +47,13 @@ def _normalise_pairs(value: Any) -> dict[str, str]:
|
|
|
40
47
|
for key, item in value.items():
|
|
41
48
|
key_text = str(key).strip()
|
|
42
49
|
if key_text in COOKIE_KEYS:
|
|
43
|
-
pairs[key_text] =
|
|
50
|
+
pairs[key_text] = _placeholder_to_empty(item)
|
|
44
51
|
return pairs
|
|
45
52
|
if isinstance(value, list):
|
|
46
53
|
for item in value:
|
|
47
54
|
if isinstance(item, dict):
|
|
48
55
|
name = str(item.get("name") or item.get("key") or "").strip()
|
|
49
|
-
content =
|
|
56
|
+
content = _placeholder_to_empty(item.get("value"))
|
|
50
57
|
if name in COOKIE_KEYS:
|
|
51
58
|
pairs[name] = content
|
|
52
59
|
return pairs
|
|
@@ -130,15 +137,15 @@ def _normalise_document(raw: Any, account: dict[str, Any]) -> dict[str, Any]:
|
|
|
130
137
|
for key, value in delegated_raw.items():
|
|
131
138
|
if isinstance(value, dict):
|
|
132
139
|
value = value.get("DELEGATED_SESSION_ID") or value.get("delegated_session_id") or ""
|
|
133
|
-
value =
|
|
140
|
+
value = _placeholder_to_empty(value)
|
|
134
141
|
if value:
|
|
135
142
|
delegated[str(key)] = value
|
|
136
143
|
return {
|
|
137
144
|
"account_id": str(raw.get("account_id") or account.get("id") or "").strip(),
|
|
138
145
|
"email": str(raw.get("email") or account.get("email") or "").strip(),
|
|
139
|
-
"sessionInfo":
|
|
146
|
+
"sessionInfo": _placeholder_to_empty(raw.get("sessionInfo") or raw.get("session_info") or raw.get("direct_session_info") or _account_session_info(account)),
|
|
140
147
|
"cookies": {key: cookies.get(key, "") for key in COOKIE_KEYS},
|
|
141
|
-
"INNERTUBE_API_KEY":
|
|
148
|
+
"INNERTUBE_API_KEY": _placeholder_to_empty(raw.get("INNERTUBE_API_KEY") or raw.get("innertube_api_key") or raw.get("direct_innertube_api_key") or ""),
|
|
142
149
|
"chunk_size": _safe_chunk_size(raw.get("chunk_size", raw.get("direct_chunk_size", DEFAULT_CHUNK_SIZE))),
|
|
143
150
|
"delegated_session_ids": delegated,
|
|
144
151
|
}
|
|
@@ -257,6 +264,49 @@ def ensure_credentials_document(storage_root: Path, account: dict[str, Any], set
|
|
|
257
264
|
return credentials_document_path(storage_root, account)
|
|
258
265
|
|
|
259
266
|
|
|
267
|
+
def merge_credentials_document(
|
|
268
|
+
storage_root: Path,
|
|
269
|
+
account: dict[str, Any],
|
|
270
|
+
content: bytes,
|
|
271
|
+
filename: str = DIRECT_DOCUMENT_NAME,
|
|
272
|
+
*,
|
|
273
|
+
session_info_override: str = "",
|
|
274
|
+
channels: list[dict[str, Any]] | None = None,
|
|
275
|
+
) -> Path:
|
|
276
|
+
"""Merge a complete or partial credentials JSON into the account document.
|
|
277
|
+
|
|
278
|
+
The upload may contain only cookies or the full Frontend API document. Existing
|
|
279
|
+
non-empty values are preserved, so uploading cookies never discards sessionInfo,
|
|
280
|
+
INNERTUBE_API_KEY, chunk_size or delegated channel IDs.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
raw = json.loads(content.decode("utf-8-sig", errors="replace"))
|
|
284
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
285
|
+
raise ValueError(f"O documento de credenciais {filename} deve ser JSON válido.") from exc
|
|
286
|
+
if not isinstance(raw, dict):
|
|
287
|
+
raise ValueError(f"O documento de credenciais {filename} deve conter um objecto JSON.")
|
|
288
|
+
|
|
289
|
+
current = load_credentials_document(storage_root, account, channels=channels, create=True)
|
|
290
|
+
incoming = _normalise_document(raw, {**account, "sessionInfo": session_info_override})
|
|
291
|
+
merged = _normalise_document(current, account)
|
|
292
|
+
for key in COOKIE_KEYS:
|
|
293
|
+
if incoming["cookies"].get(key):
|
|
294
|
+
merged["cookies"][key] = incoming["cookies"][key]
|
|
295
|
+
incoming_session = _placeholder_to_empty(session_info_override) or incoming.get("sessionInfo", "")
|
|
296
|
+
if incoming_session:
|
|
297
|
+
merged["sessionInfo"] = incoming_session
|
|
298
|
+
if incoming.get("INNERTUBE_API_KEY"):
|
|
299
|
+
merged["INNERTUBE_API_KEY"] = incoming["INNERTUBE_API_KEY"]
|
|
300
|
+
if "chunk_size" in raw:
|
|
301
|
+
merged["chunk_size"] = incoming["chunk_size"]
|
|
302
|
+
for key, value in incoming.get("delegated_session_ids", {}).items():
|
|
303
|
+
if value:
|
|
304
|
+
merged["delegated_session_ids"][str(key)] = value
|
|
305
|
+
merged["account_id"] = str(account.get("id") or merged.get("account_id") or "")
|
|
306
|
+
merged["email"] = str(account.get("email") or merged.get("email") or "")
|
|
307
|
+
return save_credentials_document(storage_root, account, merged)
|
|
308
|
+
|
|
309
|
+
|
|
260
310
|
def delegated_session_id(document: dict[str, Any], channel: dict[str, Any]) -> str:
|
|
261
311
|
mapping = document.get("delegated_session_ids", {}) if isinstance(document, dict) else {}
|
|
262
312
|
if not isinstance(mapping, dict):
|
package/package.json
CHANGED