@danhachuel/thunderbolt 0.3.71 → 0.3.73
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 +15 -7
- package/README.md +19 -8
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/app/main.py +206 -8
- package/hermes_ui/languages.py +10 -10
- package/integrations/bilibili_upload.py +259 -0
- package/integrations/distrokid_upload.py +227 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
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.3.
|
|
5
|
+
> **Versão deste manual:** 0.3.73
|
|
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)
|
|
@@ -132,13 +132,13 @@ Execute:
|
|
|
132
132
|
Windows PowerShell ou MobaXterm:
|
|
133
133
|
|
|
134
134
|
```powershell
|
|
135
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
135
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.73 install
|
|
136
136
|
```
|
|
137
137
|
|
|
138
138
|
Linux/macOS:
|
|
139
139
|
|
|
140
140
|
```bash
|
|
141
|
-
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
141
|
+
npx --yes --prefer-online @danhachuel/thunderbolt@0.3.73 install
|
|
142
142
|
```
|
|
143
143
|
|
|
144
144
|
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.
|
|
@@ -146,7 +146,7 @@ A instalação normal é **segura para actualizações**: preserva `storage`, Bl
|
|
|
146
146
|
Se quiser apagar absolutamente tudo de forma intencional, use o comando destrutivo separado:
|
|
147
147
|
|
|
148
148
|
```powershell
|
|
149
|
-
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.
|
|
149
|
+
npx.cmd --yes --prefer-online @danhachuel/thunderbolt@0.3.73 install --purge-data
|
|
150
150
|
```
|
|
151
151
|
|
|
152
152
|
O parâmetro `--purge-data` apaga Blueprints, Brandings, configurações, storage e artefactos locais. Não o use numa actualização normal.
|
|
@@ -485,7 +485,7 @@ O **Backlog Vídeos** e a lista **Automação > Automação Youtube > Vídeos ca
|
|
|
485
485
|
|
|
486
486
|
Nos dois cards, o bloco de estado apresenta o valor técnico, o rótulo legível, a barra de progresso e a mensagem de erro quando existir. O bloco **Formato** resolve nesta ordem `format`, `style_wide`, `style` e, por fim, `wide`, mantendo a apresentação de formatos como `wide`, `shorts`, `music` ou `full_ia` mesmo em tarefas antigas. Em **Automação Youtube > Vídeos cadastrados**, o botão **Start** retoma a partir dos artefactos persistidos: reutiliza roteiro, título/keywords, vídeo, prompt e thumbnail prontos e só executa novamente uma etapa cujo resultado não exista. O botão **Apagar** remove o card da fila depois de confirmação e preserva os ficheiros de artefactos; uma tarefa em execução deve ser parada antes da remoção.
|
|
487
487
|
|
|
488
|
-
### Upload Música — JewelMusic, Pushtunes e
|
|
488
|
+
### Upload Música — JewelMusic, Pushtunes, ytmusicapi e DistroKid
|
|
489
489
|
|
|
490
490
|
A área **Pipeline Música > Upload Música** separa três métodos com contratos diferentes. Em **JewelMusic**, active a integração, introduza a API Key fornecida pelo dashboard da JewelMusic e confirme a Base URL oficial `https://api.jewelmusic.com` e, se necessário, configure proxy e timeout. Carregue ou seleccione um ficheiro de música, indique artista e título e clique em **Enviar música para JewelMusic**. O teste de ligação consulta `/v1/ping`; o upload envia `multipart/form-data` para `/v1/tracks/upload` com os metadados preenchidos.
|
|
491
491
|
|
|
@@ -574,7 +574,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
|
|
|
574
574
|
|
|
575
575
|
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.
|
|
576
576
|
|
|
577
|
-
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 --prefer-online @danhachuel/thunderbolt@0.3.
|
|
577
|
+
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 --prefer-online @danhachuel/thunderbolt@0.3.73 install`; o instalador detecta e reutiliza o que já estiver válido.
|
|
578
578
|
|
|
579
579
|
### Niche Finder Apify
|
|
580
580
|
|
|
@@ -626,9 +626,14 @@ Em **Configurações > Contas Google**, cada conta aparece como um expander iden
|
|
|
626
626
|
|
|
627
627
|
Em **Canais Youtube > 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.
|
|
628
628
|
|
|
629
|
+
### API Bilibili — várias contas
|
|
630
|
+
A subaba **Configuração API > API Bilibili** fica entre **API Tiktok** e **AI Influencers**. Use **Adicionar nova API** para criar cards separados. Em cada card, preencha um nome, marque **Conta activa no Upload** quando a conta puder ser usada, introduza `SESSDATA`, `bili_jct` e `BUVID3`, e adicione `BUVID4`, `DedeUserID`, `ac_time_value` ou proxy apenas quando necessário. **Testar chamada API** valida a sessão sem criar upload; **Guardar card** persiste a conta e **Apagar card** remove-a. Os cookies são campos protegidos e não aparecem em mensagens, logs ou `uploads.json`.
|
|
631
|
+
|
|
632
|
+
Em **Upload > Upload convencional**, seleccione **Bilibili** em **Destinos**, escolha a conta activa e preencha os campos apresentados no card do vídeo. O botão **Enviar via bilibili-api (Python)** executa o upload do MP4/MOV/MKV/WEBM, gera uma capa de primeiro frame quando a thumbnail não existe e guarda o resultado localmente. O pacote Python é opcional no import, mas é instalado pelo requirements normal; se a dependência ou a sessão estiverem indisponíveis, o erro identifica a API sem bloquear as restantes redes.
|
|
633
|
+
|
|
629
634
|
### Upload-Post
|
|
630
635
|
|
|
631
|
-
A subaba **Upload-Post** usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para publicar vídeos prontos em uma ou mais plataformas ligadas ao perfil configurado. Em **Configuração API > API Keys > Serviços e modelos > Publicação através do Upload-Post**, active a integração, guarde a API key, o username/perfil e as plataformas padrão. Depois abra **Upload > Upload-Post**, confirme ou altere as plataformas, escreva o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
|
|
636
|
+
A subaba **Upload-Post** usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para publicar vídeos prontos em uma ou mais plataformas ligadas ao perfil configurado. Em **Configuração API > API Keys > Serviços e modelos > Publicação através do Upload-Post**, active a integração, guarde a API key, o username/perfil e as plataformas padrão. **Plataformas Upload-Post** é um campo textual; escreva os slugs separados por vírgulas, por exemplo `youtube,tiktok`, exactamente como os destinos seleccionáveis em **Upload > Upload convencional**. Depois abra **Upload > Upload-Post**, confirme ou altere as plataformas, escreva o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
|
|
632
637
|
|
|
633
638
|
A credencial não é duplicada na página de Upload. A integração envia o ficheiro local como `video` em `multipart/form-data`, usa `platform[]` repetido para cada destino e autentica com `Authorization: Apikey ...`. **Processar em segundo plano** envia `async_upload=true`; quando a API devolver `request_id`, este fica visível e é guardado em `uploads.json`. O histórico local também alimenta a notificação **Upload-Post concluído**. O Upload-Post é distinto do Postiz: não usa os endpoints nem a selecção de integrações do Postiz.
|
|
634
639
|
|
|
@@ -795,3 +800,6 @@ A publicação YouTube segue primeiro a lógica adaptada do `PublishingSchedulin
|
|
|
795
800
|
[2]: https://www.npmjs.com/package/@danhachuel/thunderbolt — Pacote npm publicado.
|
|
796
801
|
|
|
797
802
|
[3]: https://github.com/DanHachuel/thunderbolt — Repositório GitHub do Thunderbolt UI.
|
|
803
|
+
|
|
804
|
+
### Growth — novas áreas reservadas
|
|
805
|
+
O grupo **Growth** inclui agora as abas **Analista Facebook Pages** e **Analista Bilibili**. Ambas são páginas vazias nesta versão e não executam operações nem pedem credenciais.
|
package/README.md
CHANGED
|
@@ -18,12 +18,12 @@ A primeira versão implementa a camada UI independente com:
|
|
|
18
18
|
| Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
|
|
19
19
|
| Canais Youtube | Subabas de importação pública sem API Key, Canais em lote gmail por conta Google/YouTube via OAuth e cadastro manual independente; cartões com botão Editar, nicho/referências visível, Prompts do Canal/Blueprint, Narrador/voz e gestão dos últimos 10 vídeos |
|
|
20
20
|
| Criação de Vídeos / Criação de Músicas | Subabas Criar vídeo e Vídeos; idiomas históricos preservados e dez códigos MoneyPrinterTurbo com bandeiras; Pexels/Pixabay, full IA com Estilo IA e Apenas Música com agente musical; a segunda página reutiliza o mesmo fluxo com título próprio |
|
|
21
|
-
| Upload Música | Subabas **JewelMusic**, **Pushtunes** e **
|
|
21
|
+
| Upload Música | Subabas **JewelMusic**, **Pushtunes**, **ytmusicapi** e **DistroKid** para upload/sincronização musical com credenciais e histórico locais |
|
|
22
22
|
| Automação | Menu expansível com a subaba **Automação Youtube**, onde ficam os vídeos e canais, selectores editáveis de Blueprint/voz padrão, Automação ON, horário diário HH:MM e worker local baseado no relógio do computador |
|
|
23
23
|
| AI Influencers | Menu expansível com Personagens, Geração de Conteúdo IA, Motion Control, UGC Products e Redes Sociais; Personagens aceita múltiplas imagens e documentos `.md`/`.json`, e os dois workflows standalone guardam os resultados localmente sem Telegram ou publicação social |
|
|
24
24
|
| Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
|
|
25
25
|
| Edição | Menu expansível abaixo de Automação com Limpador de Metadados, Clip Generator local em Cortes e Editor Python inspirado no PYEdit para vídeos e scripts locais |
|
|
26
|
-
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz,
|
|
26
|
+
| Upload | YouTube via `youtube-automation-agent` adaptado internamente, fallback ordenado API Oficial → Upload directo → Postiz, Upload-Post textual e destinos locais Bilibili/TikTok/Instagram/Facebook Pages |
|
|
27
27
|
| MCP | Catálogo local opcional de Short Video Maker, AutoVio, OpenMontage e OpenCut, com portas editáveis e activação |
|
|
28
28
|
| Configurações | **Contas Google**, **Configuração API** e **Notificações**; Notificações contém as subabas **Geral** e **Telegram Gateway**, com envio das mesmas operações locais para um Chat ID; API Keys divididas entre **Serviços e modelos**, **API Tiktok** e **Fontes de materiais**, com várias chaves independentes por fonte, além de contas Google/YouTube por cartão, `INNERTUBE_API_KEY` global para todas as contas, providers, TTS, Nano Banana, Postiz, TikTok e Upload-Post |
|
|
29
29
|
| Launcher | Execução via `npx`, instalação assistida, diagnóstico e preparação para distribuição |
|
|
@@ -54,7 +54,7 @@ Na subaba **Configuração API > API Keys > Imagem e Video**, o selector **Provi
|
|
|
54
54
|
|
|
55
55
|
## API Tiktok — múltiplas aplicações TikTok
|
|
56
56
|
|
|
57
|
-
A subaba **Configuração API > API Tiktok** fica entre **Contas Google** e **
|
|
57
|
+
A subaba **Configuração API > API Tiktok** fica entre **Contas Google** e **API Bilibili**. Cada aplicação TikTok é apresentada num card separado e contém apenas **TikTok Client ID** e **TikTok Client Secret**, ambos guardados no storage local. O botão **Adicionar nova API** cria cards adicionais; cada card dispõe de **Guardar card**, **Testar chamada API** e **Apagar card**. A autorização OAuth, os scopes e os tokens de publicação continuam fora desta subaba, no fluxo próprio do TikTok for Developers.
|
|
58
58
|
|
|
59
59
|
As credenciais antigas dos campos únicos `tiktok_client_key` e `tiktok_client_secret` são migradas automaticamente para o primeiro card sem serem eliminadas. Para compatibilidade com integrações existentes, o adapter TikTok utiliza o primeiro card completo e mantém fallback para os campos legados; a UI não altera os campos pedidos nem mistura Client ID/Secret com tokens, redirect URI ou scopes.
|
|
60
60
|
|
|
@@ -62,19 +62,27 @@ As credenciais antigas dos campos únicos `tiktok_client_key` e `tiktok_client_s
|
|
|
62
62
|
|
|
63
63
|
A página **Upload** contém quatro subabas: **Upload convencional**, **Upload directo**, **Postiz** e **Upload-Post**. A quarta alternativa usa a API oficial do [Upload-Post](https://docs.upload-post.com/) para enviar um vídeo local para uma ou mais plataformas ligadas ao perfil configurado. Seleccione as plataformas na própria subaba, confirme o título e a descrição e clique em **Enviar vídeo pelo Upload-Post**.
|
|
64
64
|
|
|
65
|
-
A API key, o username/perfil e a lista inicial de plataformas continuam em **Configuração API > API Keys > Serviços e modelos**, no expander **Publicação através do Upload-Post**. A subaba não pede novamente a credencial. O cliente envia `multipart/form-data` para `https://api.upload-post.com/api/upload`, repete `platform[]` para cada destino e guarda a resposta, o `request_id` e o resultado no histórico local de uploads. A opção **Processar em segundo plano** usa `async_upload=true` quando a API estiver configurada para processamento assíncrono.
|
|
65
|
+
A API key, o username/perfil e a lista inicial de plataformas continuam em **Configuração API > API Keys > Serviços e modelos**, no expander **Publicação através do Upload-Post**. **Plataformas Upload-Post** é um campo textual: escreva os slugs separados por vírgulas, como `youtube,tiktok`, tal como os destinos da página Upload. A subaba não pede novamente a credencial. O cliente envia `multipart/form-data` para `https://api.upload-post.com/api/upload`, repete `platform[]` para cada destino e guarda a resposta, o `request_id` e o resultado no histórico local de uploads. A opção **Processar em segundo plano** usa `async_upload=true` quando a API estiver configurada para processamento assíncrono.
|
|
66
66
|
|
|
67
67
|
O Upload-Post é independente do Postiz: Postiz continua a usar o seu fluxo próprio de asset + post, enquanto Upload-Post publica directamente nas plataformas ligadas ao username configurado. Uma publicação aceite pelo Upload-Post também é reconciliada no centro de **Notificações**.
|
|
68
68
|
|
|
69
|
-
##
|
|
69
|
+
## Bilibili — upload via bilibili-api (Python)
|
|
70
|
+
A opção **Bilibili** está disponível em **Upload > Upload convencional > Destinos**. Seleccione uma conta Bilibili activa, reveja título, descrição, tags e ID da secção, e clique em **Enviar via bilibili-api (Python)**. O adapter usa `bilibili-api-python` 17.4.2, que expõe `Credential` com `SESSDATA`, `bili_jct` e `BUVID3` e executa o `VideoUploader` assíncrono a partir da UI síncrona. A capa usa a thumbnail do vídeo quando existe; caso contrário, é extraído localmente um primeiro frame com FFmpeg.
|
|
70
71
|
|
|
71
|
-
A
|
|
72
|
+
A subaba **Configuração API > API Bilibili**, colocada entre **API Tiktok** e **AI Influencers**, permite criar, editar, testar e apagar vários cards. Cada card guarda label, estado activo, `SESSDATA`, `bili_jct`, `BUVID3` e campos opcionais `BUVID4`, `DedeUserID`, `ac_time_value` e proxy. Os cookies são campos protegidos, não entram no histórico de uploads nem em logs, e o teste de chamada é read-only. O repositório upstream foi arquivado e encerrado em 2026; por isso a integração é opcional e os erros de disponibilidade do SDK são apresentados sem falhar o arranque do Thunderbolt.
|
|
73
|
+
|
|
74
|
+
## Upload Música — JewelMusic, Pushtunes, ytmusicapi e DistroKid
|
|
75
|
+
|
|
76
|
+
A página **Upload Música** contém quatro subabas independentes. **JewelMusic** usa a API documentada do [JewelMusic SDK](https://github.com/jewelmusic/sdk) para enviar tracks com metadados de título, artista, álbum, ano e género; a API key, a Base URL, o proxy opcional e o timeout ficam no storage local. O teste de ligação é read-only e não cria uma track.
|
|
72
77
|
|
|
73
78
|
**Pushtunes** usa o pacote [Pushtunes](https://pypi.org/project/pushtunes/) instalado pelo Thunderbolt para sincronizar tracks, álbuns ou playlists entre uma fonte Subsonic/Jellyfin/CSV/Spotify/YouTube Music e um destino Spotify/YouTube Music/Tidal/CSV. A UI aceita perfil `.toml`, CSV, `browser.json`, `tidal-session.json`, playlist, similaridade, directório de trabalho e credenciais Spotify; a execução chama o CLI sem shell e mascara segredos na saída. Pushtunes é uma sincronização de biblioteca, não um upload de bytes de um MP3 isolado.
|
|
74
79
|
|
|
75
80
|
**ytmusicapi** adapta o [ytmusicapi](https://github.com/sigma67/ytmusicapi) para upload directo de MP3, M4A, WMA, FLAC ou OGG para YouTube Music. O método exige um `browser.json` configurado pelo utilizador, que pode ser carregado na subaba e guardado em `storage/ytmusicapi/`; o teste consulta a biblioteca de uploads sem escrever. O histórico de cada operação é guardado localmente em `uploads.json` e não inclui o conteúdo dos segredos.
|
|
76
81
|
|
|
77
|
-
|
|
82
|
+
**DistroKid** adapta somente a etapa de upload do [musikai](https://github.com/igolaizola/musikai): recebe várias faixas e uma capa local, abre o formulário de novo lançamento no browser, preenche artista, título, record label, género e títulos das faixas, e carrega os ficheiros. O cookie de sessão é guardado localmente e nunca é mostrado nos resultados. A submissão final permanece manual no browser; o Thunderbolt não clica automaticamente em Submit. O browser pode usar o Chrome instalado ou um executável indicado na configuração.
|
|
83
|
+
|
|
84
|
+
As credenciais destes quatro métodos são opcionais e independentes das credenciais de vídeo. O instalador acrescenta `ytmusicapi`, `pushtunes`, `bilibili-api-python` e `playwright`; o adaptador JewelMusic usa o contrato HTTP documentado porque o SDK Python upstream não estava publicado no PyPI no momento da implementação. Consulte `THIRD-PARTY-NOTICES.md` antes de redistribuir o runtime.
|
|
85
|
+
|
|
78
86
|
|
|
79
87
|
## Upload directo — credenciais por conta e por canal
|
|
80
88
|
|
|
@@ -99,7 +107,7 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
|
|
|
99
107
|
|
|
100
108
|
A UI suporta os temas **Dark** e **Light** através do menu nativo de três pontos do Streamlit, no local original do toolbar. Não existe um selector Theme adicional dentro da página. A configuração distribuída em `.streamlit/config.toml` disponibiliza as variantes nomeadas **Dark** e **Light**, e o menu nativo continua responsável por alternar entre os modos, seguindo o padrão do [MoneyPrinterTurbo](https://github.com/harry0703/MoneyPrinterTurbo). O CSS próprio do Thunderbolt usa cores semânticas, `currentColor` e `color-mix` para acompanhar o tema activo, sem alterar a posição nem a funcionalidade do toolbar, do botão Deploy e do menu principal.
|
|
101
109
|
|
|
102
|
-
## Navegação da UI 0.3.
|
|
110
|
+
## Navegação da UI 0.3.73
|
|
103
111
|
|
|
104
112
|
A barra lateral mantém os níveis principais, nesta ordem: **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. **Pipeline Vídeos** é expansível e contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. **Pipeline Música** é expansível e contém **Criação de Músicas** e **Upload Música**. **Automação** também é expansível e contém **Automação Youtube**. **Edição** é expansível e contém **Limpador de Metadados**, **Cortes**, **Editor Python** e **Download Mídia**, nessa ordem. **AI Influencers** é expansível e contém **Personagens**, **Geração de Conteúdo IA**, **Motion Control**, **UGC Products**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. A criação de Motion Control e UGC Products ocorre nas páginas standalone e guarda os artefactos localmente; não existem tabs duplicadas dentro de Geração de Conteúdo IA.
|
|
105
113
|
**Niche Finder** é expansível e contém **Niche Finder Kaggle** e **Niche Finder Apify**. **Configurações** é expansível e contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Notificações**, **Logs** e **Configuração API**, nessa ordem. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
|
|
@@ -407,3 +415,6 @@ As credenciais do TikTok ficam em **Configuração API > API Keys > Serviços e
|
|
|
407
415
|
## Segurança
|
|
408
416
|
|
|
409
417
|
Não coloque chaves, cookies, tokens YouTube ou segredos TikTok no Git. Use a configuração local, variáveis de ambiente ou um ficheiro fora do repositório. O `.gitignore` exclui o storage de estado real, ambientes virtuais e ficheiros de segredo. O adaptador interno foi baseado na lógica publicada sob licença MIT do [youtube-automation-agent](https://github.com/darkzOGx/youtube-automation-agent).
|
|
418
|
+
|
|
419
|
+
## Growth
|
|
420
|
+
As páginas **Analista Facebook Pages** e **Analista Bilibili** foram adicionadas ao grupo **Growth** como áreas reservadas e permanecem vazias nesta versão, prontas para futuras implementações específicas.
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -29,3 +29,12 @@ The JewelMusic upload adapter follows the documented HTTP contract and API examp
|
|
|
29
29
|
## supabase-py
|
|
30
30
|
|
|
31
31
|
Thunderbolt optionally depends on [supabase-py](https://github.com/supabase/supabase-py) for the AI Influencers Supabase backend. The package is distributed under the MIT License. The SQLite backend uses Python's standard library and does not require a remote service.
|
|
32
|
+
|
|
33
|
+
## bilibili-api-python
|
|
34
|
+
Thunderbolt depends optionally on [bilibili-api-python](https://pypi.org/project/bilibili-api-python/) for the Bilibili video-upload adapter. The package is distributed under the GNU General Public License v3 or later and its upstream repository [Nemo2011/bilibili-api](https://github.com/Nemo2011/bilibili-api) was archived and announced as closed on 2026-07-06. This dependency is separate from Thunderbolt's MIT License; consult the complete upstream licence and use the integration only with accounts and content you are authorised to operate.
|
|
35
|
+
|
|
36
|
+
## Playwright
|
|
37
|
+
Thunderbolt optionally depends on [Playwright for Python](https://playwright.dev/python/) to open the DistroKid upload form and load local tracks. The adapter does not submit the final form automatically. Playwright's own licence and browser distribution terms remain applicable.
|
|
38
|
+
|
|
39
|
+
## musikai
|
|
40
|
+
The DistroKid workflow is conceptually adapted from [igolaizola/musikai](https://github.com/igolaizola/musikai), an MIT-licensed Go project. Thunderbolt does not bundle musikai or its Go binaries; only the documented browser-assisted upload concept and metadata flow are adapted.
|
package/app/main.py
CHANGED
|
@@ -70,6 +70,8 @@ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdap
|
|
|
70
70
|
from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
|
|
71
71
|
from integrations.postiz import PostizAdapter
|
|
72
72
|
from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
|
|
73
|
+
from integrations.bilibili_upload import BilibiliApiAdapter, BILIBILI_DEFAULT_TID, BILIBILI_VIDEO_EXTENSIONS, normalise_bilibili_api_cards
|
|
74
|
+
from integrations.distrokid_upload import DistroKidAdapter, DISTROKID_AUDIO_EXTENSIONS, DISTROKID_COVER_EXTENSIONS, close_distrokid_session
|
|
73
75
|
from integrations.music_uploads import JewelMusicAdapter, PushtunesAdapter, YTMusicApiAdapter, MUSIC_UPLOAD_EXTENSIONS, PUSHTUNES_OPERATIONS, PUSHTUNES_SOURCES, PUSHTUNES_TARGETS, YT_MUSIC_UPLOAD_EXTENSIONS
|
|
74
76
|
from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
|
|
75
77
|
from integrations.youtube_direct_upload import YouTubeDirectUploader
|
|
@@ -4099,7 +4101,7 @@ def _render_music_upload_history() -> None:
|
|
|
4099
4101
|
record
|
|
4100
4102
|
for record in read_json("uploads.json", [])
|
|
4101
4103
|
if isinstance(record, dict)
|
|
4102
|
-
and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi"))
|
|
4104
|
+
and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi", "distrokid"))
|
|
4103
4105
|
]
|
|
4104
4106
|
st.divider()
|
|
4105
4107
|
st.subheader("Histórico de uploads de música")
|
|
@@ -4283,16 +4285,104 @@ def _render_ytmusicapi_upload_tab() -> None:
|
|
|
4283
4285
|
(st.success if result.ok else st.error)(result.message)
|
|
4284
4286
|
|
|
4285
4287
|
|
|
4288
|
+
def _store_distrokid_cover(uploaded: Any) -> str:
|
|
4289
|
+
target_dir = STORAGE / "distrokid"
|
|
4290
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
4291
|
+
safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", Path(uploaded.name).name).strip("._") or "cover.jpg"
|
|
4292
|
+
target = target_dir / f"{uuid.uuid4().hex[:10]}_{safe_name}"
|
|
4293
|
+
target.write_bytes(uploaded.getvalue())
|
|
4294
|
+
return str(target)
|
|
4295
|
+
|
|
4296
|
+
def _render_distrokid_upload_tab() -> None:
|
|
4297
|
+
st.subheader("DistroKid")
|
|
4298
|
+
st.caption("Upload assistido baseado no fluxo de publish do musikai. O browser preenche o novo lançamento e carrega as faixas; a submissão final fica sempre manual no DistroKid.")
|
|
4299
|
+
settings = read_json("settings.json", {})
|
|
4300
|
+
with st.form("distrokid_settings_form"):
|
|
4301
|
+
enabled = st.checkbox("Activar DistroKid", value=bool(settings.get("distrokid_enabled", False)))
|
|
4302
|
+
cookie = st.text_input("Cookie de sessão DistroKid", value=str(settings.get("distrokid_cookie") or ""), type="password", help="Cole o cabeçalho Cookie da sua sessão DistroKid. O valor é guardado apenas no storage local.")
|
|
4303
|
+
account = st.text_input("Nome da conta DistroKid", value=str(settings.get("distrokid_account") or ""))
|
|
4304
|
+
browser_path = st.text_input("Executável Chrome/Chromium (opcional)", value=str(settings.get("distrokid_browser_path") or ""), help="Deixe vazio para usar Google Chrome; também pode definir THUNDERBOLT_CHROME_PATH.")
|
|
4305
|
+
settings_cols = st.columns(2)
|
|
4306
|
+
with settings_cols[0]:
|
|
4307
|
+
first_name = st.text_input("Primeiro nome", value=str(settings.get("distrokid_first_name") or ""))
|
|
4308
|
+
artist = st.text_input("Artista", value=str(settings.get("distrokid_artist") or ""))
|
|
4309
|
+
release_title = st.text_input("Título do lançamento", value=str(settings.get("distrokid_release_title") or ""))
|
|
4310
|
+
with settings_cols[1]:
|
|
4311
|
+
last_name = st.text_input("Apelido", value=str(settings.get("distrokid_last_name") or ""))
|
|
4312
|
+
record_label = st.text_input("Record label", value=str(settings.get("distrokid_record_label") or ""))
|
|
4313
|
+
genre = st.text_input("Género principal", value=str(settings.get("distrokid_genre") or ""), help="O género deve corresponder a uma opção aceite pelo formulário DistroKid.")
|
|
4314
|
+
save = st.form_submit_button("Guardar configuração DistroKid", type="primary", use_container_width=True)
|
|
4315
|
+
if save:
|
|
4316
|
+
_persist_music_upload_settings({
|
|
4317
|
+
"distrokid_enabled": bool(enabled), "distrokid_cookie": cookie.strip(), "distrokid_account": account.strip(),
|
|
4318
|
+
"distrokid_browser_path": browser_path.strip(), "distrokid_first_name": first_name.strip(), "distrokid_last_name": last_name.strip(),
|
|
4319
|
+
"distrokid_artist": artist.strip(), "distrokid_release_title": release_title.strip(), "distrokid_record_label": record_label.strip(), "distrokid_genre": genre.strip(),
|
|
4320
|
+
})
|
|
4321
|
+
st.success("Configuração DistroKid guardada no storage local.")
|
|
4322
|
+
settings = read_json("settings.json", {})
|
|
4323
|
+
adapter = DistroKidAdapter(settings)
|
|
4324
|
+
status = adapter.status()
|
|
4325
|
+
(st.success if status.ok else st.warning)(status.message)
|
|
4326
|
+
if st.button("Testar sessão DistroKid", key="distrokid_test", use_container_width=True):
|
|
4327
|
+
result = DistroKidAdapter(read_json("settings.json", {})).test_connection()
|
|
4328
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4329
|
+
uploaded_tracks = st.file_uploader("Faixas para o lançamento", type=sorted(extension.lstrip(".") for extension in DISTROKID_AUDIO_EXTENSIONS), accept_multiple_files=True, key="distrokid_track_upload")
|
|
4330
|
+
if uploaded_tracks and st.button("Guardar faixas no storage local", key="distrokid_store_tracks", use_container_width=True):
|
|
4331
|
+
stored_paths: list[str] = []
|
|
4332
|
+
for uploaded in uploaded_tracks:
|
|
4333
|
+
try:
|
|
4334
|
+
stored_paths.append(str(store_music_file(f"distrokid_{uuid.uuid4().hex[:8]}_{uploaded.name}", uploaded.getvalue())))
|
|
4335
|
+
except (OSError, ValueError) as exc:
|
|
4336
|
+
st.error(str(exc))
|
|
4337
|
+
if stored_paths:
|
|
4338
|
+
st.session_state["distrokid_track_paths"] = stored_paths
|
|
4339
|
+
st.success(f"{len(stored_paths)} faixa(s) guardada(s) no storage local.")
|
|
4340
|
+
selected_paths = [str(path) for path in st.session_state.get("distrokid_track_paths", []) if Path(str(path)).is_file()]
|
|
4341
|
+
track_rows: list[dict[str, Any]] = []
|
|
4342
|
+
if selected_paths:
|
|
4343
|
+
st.markdown("**Faixas seleccionadas**")
|
|
4344
|
+
for index, path in enumerate(selected_paths, start=1):
|
|
4345
|
+
track_title = st.text_input(f"Título da faixa {index}", value=Path(path).stem, key=f"distrokid_track_title_{index}")
|
|
4346
|
+
instrumental = st.checkbox("Instrumental", value=False, key=f"distrokid_track_instrumental_{index}")
|
|
4347
|
+
track_rows.append({"path": path, "title": track_title.strip() or Path(path).stem, "instrumental": instrumental})
|
|
4348
|
+
cover_upload = st.file_uploader("Capa do lançamento (opcional)", type=sorted(extension.lstrip(".") for extension in DISTROKID_COVER_EXTENSIONS), key="distrokid_cover_upload")
|
|
4349
|
+
if cover_upload and st.button("Guardar capa no storage local", key="distrokid_store_cover", use_container_width=True):
|
|
4350
|
+
try:
|
|
4351
|
+
st.session_state["distrokid_cover_path"] = _store_distrokid_cover(cover_upload)
|
|
4352
|
+
st.success("Capa guardada no storage local.")
|
|
4353
|
+
except OSError as exc:
|
|
4354
|
+
st.error(f"Não foi possível guardar a capa: {exc}")
|
|
4355
|
+
cover_path = str(st.session_state.get("distrokid_cover_path") or "")
|
|
4356
|
+
if cover_path and Path(cover_path).is_file():
|
|
4357
|
+
st.caption(f"Capa seleccionada: `{cover_path}`")
|
|
4358
|
+
session_id = str(st.session_state.get("distrokid_session_id") or "")
|
|
4359
|
+
if session_id and st.button("Fechar browser DistroKid", key="distrokid_close_browser", use_container_width=True):
|
|
4360
|
+
result = close_distrokid_session(session_id)
|
|
4361
|
+
(st.success if result.ok else st.warning)(result.message)
|
|
4362
|
+
st.session_state.pop("distrokid_session_id", None)
|
|
4363
|
+
if st.button("Abrir formulário e carregar para DistroKid", type="primary", key="distrokid_prepare_upload", use_container_width=True, disabled=not status.ok or not track_rows):
|
|
4364
|
+
current = read_json("settings.json", {})
|
|
4365
|
+
result = DistroKidAdapter(current).prepare_upload(
|
|
4366
|
+
track_rows, artist=str(current.get("distrokid_artist") or ""), release_title=str(current.get("distrokid_release_title") or ""),
|
|
4367
|
+
record_label=str(current.get("distrokid_record_label") or ""), cover_path=cover_path or None, genre=str(current.get("distrokid_genre") or ""),
|
|
4368
|
+
)
|
|
4369
|
+
_record_music_upload("DistroKid", result, music_path=selected_paths[0] if selected_paths else "", target={"service": "DistroKid", "account": str(current.get("distrokid_account") or "")})
|
|
4370
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4371
|
+
if result.ok and result.data.get("session_id"):
|
|
4372
|
+
st.session_state["distrokid_session_id"] = result.data["session_id"]
|
|
4373
|
+
|
|
4286
4374
|
def render_music_upload() -> None:
|
|
4287
4375
|
st.title("Upload Música")
|
|
4288
|
-
st.caption("Carregue
|
|
4289
|
-
jewel_tab, pushtunes_tab, ytmusicapi_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi"])
|
|
4376
|
+
st.caption("Carregue músicas por JewelMusic, sincronize bibliotecas com Pushtunes, envie para YouTube Music com ytmusicapi ou prepare um lançamento DistroKid. As credenciais, browser e histórico permanecem locais.")
|
|
4377
|
+
jewel_tab, pushtunes_tab, ytmusicapi_tab, distrokid_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi", "DistroKid"])
|
|
4290
4378
|
with jewel_tab:
|
|
4291
4379
|
_render_jewelmusic_upload_tab()
|
|
4292
4380
|
with pushtunes_tab:
|
|
4293
4381
|
_render_pushtunes_upload_tab()
|
|
4294
4382
|
with ytmusicapi_tab:
|
|
4295
4383
|
_render_ytmusicapi_upload_tab()
|
|
4384
|
+
with distrokid_tab:
|
|
4385
|
+
_render_distrokid_upload_tab()
|
|
4296
4386
|
_render_music_upload_history()
|
|
4297
4387
|
|
|
4298
4388
|
|
|
@@ -4460,6 +4550,7 @@ def render_upload_post():
|
|
|
4460
4550
|
|
|
4461
4551
|
UPLOAD_DESTINATION_TARGET_KEYS = {
|
|
4462
4552
|
"TikTok": "tiktok_accounts",
|
|
4553
|
+
"Bilibili": "bilibili_api_cards",
|
|
4463
4554
|
"Instagram": "instagram_profiles",
|
|
4464
4555
|
"Facebook Pages": "facebook_pages",
|
|
4465
4556
|
}
|
|
@@ -4491,6 +4582,8 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
|
|
|
4491
4582
|
configured_targets = settings.get(setting_key, [])
|
|
4492
4583
|
if destination == "TikTok" and (not isinstance(configured_targets, list) or not configured_targets):
|
|
4493
4584
|
configured_targets = settings.get("tiktok_profiles", [])
|
|
4585
|
+
if destination == "Bilibili" and isinstance(configured_targets, list):
|
|
4586
|
+
configured_targets = [item for item in configured_targets if isinstance(item, dict) and bool(item.get("active", True))]
|
|
4494
4587
|
if not isinstance(configured_targets, list):
|
|
4495
4588
|
return []
|
|
4496
4589
|
targets: list[Any] = []
|
|
@@ -4505,14 +4598,16 @@ def upload_targets_for_destination(destination: str, channels: list[dict[str, An
|
|
|
4505
4598
|
def render_upload_destination_target(destination: str, channels: list[dict[str, Any]], settings: dict[str, Any]) -> Any | None:
|
|
4506
4599
|
options = upload_targets_for_destination(destination, channels, settings)
|
|
4507
4600
|
destination_key = re.sub(r"[^a-z0-9]+", "_", destination.lower()).strip("_")
|
|
4508
|
-
select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else "Perfil / página")
|
|
4509
|
-
empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else f"Nenhum {destination} configurado")
|
|
4601
|
+
select_label = "Canal" if destination == "YouTube" else ("Conta TikTok" if destination == "TikTok" else ("Conta Bilibili" if destination == "Bilibili" else "Perfil / página"))
|
|
4602
|
+
empty_label = "Nenhum canal YouTube cadastrado" if destination == "YouTube" else ("Nenhuma conta TikTok cadastrada" if destination == "TikTok" else ("Nenhuma conta Bilibili activa" if destination == "Bilibili" else f"Nenhum {destination} configurado"))
|
|
4510
4603
|
if not options:
|
|
4511
4604
|
st.selectbox(select_label, [empty_label], disabled=True, key=f"upload_target_{destination_key}")
|
|
4512
4605
|
if destination == "YouTube":
|
|
4513
4606
|
st.caption("Cadastre ou liste pelo menos um canal YouTube antes de escolher o destino de envio.")
|
|
4514
4607
|
elif destination == "TikTok":
|
|
4515
4608
|
st.caption("Cadastre uma conta em Pipeline TikTok > Contas TikTok antes de escolher o destino de envio.")
|
|
4609
|
+
elif destination == "Bilibili":
|
|
4610
|
+
st.caption("Configure e active uma conta em Configuração API > API Bilibili antes de escolher o destino de envio.")
|
|
4516
4611
|
else:
|
|
4517
4612
|
st.caption(f"A lista de {destination} será ligada numa etapa própria de credenciais/API.")
|
|
4518
4613
|
return None
|
|
@@ -4533,7 +4628,7 @@ def render_upload_conventional():
|
|
|
4533
4628
|
direct_accounts = {str(account.get("id")): account for account in settings.get("youtube_batch_accounts", []) if isinstance(account, dict) and account.get("id")}
|
|
4534
4629
|
postiz = PostizAdapter(settings)
|
|
4535
4630
|
tasks = [t for t in read_json("tasks.json", []) if t.get("state") == "done" or t.get("artifacts", {}).get("video")]
|
|
4536
|
-
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
|
|
4631
|
+
destination = st.multiselect("Destinos", ["YouTube", "TikTok", "Bilibili", "Instagram", "Facebook Pages"], default=["YouTube"], key="upload_destinations", placeholder="Seleccione os destinos")
|
|
4537
4632
|
upload_targets: dict[str, Any | None] = {}
|
|
4538
4633
|
if destination:
|
|
4539
4634
|
st.markdown("**Onde enviar**")
|
|
@@ -4654,6 +4749,31 @@ def render_upload_conventional():
|
|
|
4654
4749
|
write_json("uploads.json", uploads)
|
|
4655
4750
|
reconcile_persisted_notifications()
|
|
4656
4751
|
(st.success if result.ok else st.warning)(result.message)
|
|
4752
|
+
bilibili_target = upload_targets.get("Bilibili") if "Bilibili" in destination else None
|
|
4753
|
+
if "Bilibili" in destination:
|
|
4754
|
+
bilibili_title = st.text_input("Título Bilibili", value=task.get("title") or task.get("topic", "Vídeo Thunderbolt"), key=f"bilibili_title_{task['id']}")
|
|
4755
|
+
bilibili_description = st.text_area("Descrição Bilibili", value=task.get("description", ""), key=f"bilibili_description_{task['id']}", height=90)
|
|
4756
|
+
bilibili_tags = st.text_input("Tags Bilibili separadas por vírgulas", value=task.get("tags", "") if isinstance(task.get("tags", ""), str) else ", ".join(task.get("tags", [])), key=f"bilibili_tags_{task['id']}")
|
|
4757
|
+
bilibili_tid = st.number_input("ID da secção Bilibili", min_value=1, max_value=9999, value=BILIBILI_DEFAULT_TID, step=1, key=f"bilibili_tid_{task['id']}")
|
|
4758
|
+
if st.button("Enviar via bilibili-api (Python)", type="primary", key=f"upload_bilibili_{task['id']}", disabled=not bilibili_target, help="Configure e active uma conta Bilibili em Configuração API > API Bilibili." if not bilibili_target else None):
|
|
4759
|
+
result = BilibiliApiAdapter(bilibili_target, settings).upload_video(
|
|
4760
|
+
video_path, title=bilibili_title, description=bilibili_description, tags=bilibili_tags, tid=int(bilibili_tid),
|
|
4761
|
+
cover_path=thumbnail_path or None,
|
|
4762
|
+
)
|
|
4763
|
+
record = {
|
|
4764
|
+
"task_id": task.get("id"),
|
|
4765
|
+
"destination": "Bilibili",
|
|
4766
|
+
"target": upload_target_reference(bilibili_target),
|
|
4767
|
+
"status": "published" if result.ok else "failed",
|
|
4768
|
+
"message": result.message,
|
|
4769
|
+
"data": result.data,
|
|
4770
|
+
"created_at": now(),
|
|
4771
|
+
}
|
|
4772
|
+
uploads = read_json("uploads.json", [])
|
|
4773
|
+
uploads.append(record)
|
|
4774
|
+
write_json("uploads.json", uploads)
|
|
4775
|
+
reconcile_persisted_notifications()
|
|
4776
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4657
4777
|
if "Instagram" in destination:
|
|
4658
4778
|
st.button("Preparar Instagram", key=f"upload_instagram_{task['id']}", disabled=True, help="UI preparada; publicação Instagram ainda não está activa.")
|
|
4659
4779
|
if "Facebook Pages" in destination:
|
|
@@ -4757,6 +4877,78 @@ def render_tiktok_api_cards(settings: dict[str, Any]) -> None:
|
|
|
4757
4877
|
st.rerun()
|
|
4758
4878
|
|
|
4759
4879
|
|
|
4880
|
+
def _persist_bilibili_api_cards(settings: dict[str, Any], cards: list[dict[str, Any]]) -> None:
|
|
4881
|
+
clean_cards: list[dict[str, Any]] = []
|
|
4882
|
+
for item in cards:
|
|
4883
|
+
normalised, _ = normalise_bilibili_api_cards({"bilibili_api_cards": [item]})
|
|
4884
|
+
if normalised:
|
|
4885
|
+
clean_cards.append(normalised[0])
|
|
4886
|
+
settings["bilibili_api_cards"] = clean_cards
|
|
4887
|
+
first = next((card for card in clean_cards if card.get("active") and card.get("sessdata") and card.get("bili_jct") and card.get("buvid3")), clean_cards[0] if clean_cards else {})
|
|
4888
|
+
for field in ("sessdata", "bili_jct", "buvid3", "buvid4", "dedeuserid", "ac_time_value", "proxy"):
|
|
4889
|
+
settings[f"bilibili_{field}"] = str(first.get(field) or "")
|
|
4890
|
+
write_json("settings.json", settings)
|
|
4891
|
+
|
|
4892
|
+
def render_bilibili_api_cards(settings: dict[str, Any]) -> None:
|
|
4893
|
+
st.title("API Bilibili")
|
|
4894
|
+
st.caption("Configure várias contas Bilibili em cards separados. O upload usa bilibili-api-python; os cookies permanecem locais e nunca são mostrados em logs, históricos ou metadados.")
|
|
4895
|
+
st.warning("O pacote bilibili-api-python é uma integração opcional e depende da sessão do browser Bilibili. Use apenas contas e conteúdos que tenha autorização para operar.")
|
|
4896
|
+
cards, changed = normalise_bilibili_api_cards(settings)
|
|
4897
|
+
if changed and cards:
|
|
4898
|
+
_persist_bilibili_api_cards(settings, cards)
|
|
4899
|
+
if not cards:
|
|
4900
|
+
st.info("Ainda não existe nenhuma conta Bilibili configurada. Use o botão abaixo para criar o primeiro card.")
|
|
4901
|
+
for index, card in enumerate(cards):
|
|
4902
|
+
card_id = str(card["id"])
|
|
4903
|
+
with st.container(border=True):
|
|
4904
|
+
header_cols = st.columns([3.2, 1.2])
|
|
4905
|
+
with header_cols[0]:
|
|
4906
|
+
st.subheader(str(card.get("label") or f"Conta Bilibili {index + 1}"))
|
|
4907
|
+
with header_cols[1]:
|
|
4908
|
+
configured = bool(card.get("active", True) and card.get("sessdata") and card.get("bili_jct") and card.get("buvid3"))
|
|
4909
|
+
_api_status_badge("Configured" if configured else "Missing configuration", "ready" if configured else "missing")
|
|
4910
|
+
with st.form(f"bilibili_api_card_form_{card_id}"):
|
|
4911
|
+
label = st.text_input("Nome da conta", value=str(card.get("label") or f"Conta Bilibili {index + 1}"), key=f"bilibili_api_{card_id}_label")
|
|
4912
|
+
active = st.checkbox("Conta activa no Upload", value=bool(card.get("active", True)), key=f"bilibili_api_{card_id}_active")
|
|
4913
|
+
credential_cols = st.columns(2)
|
|
4914
|
+
with credential_cols[0]:
|
|
4915
|
+
sessdata = st.text_input("SESSDATA", value=str(card.get("sessdata") or ""), type="password", key=f"bilibili_api_{card_id}_sessdata")
|
|
4916
|
+
bili_jct = st.text_input("bili_jct", value=str(card.get("bili_jct") or ""), type="password", key=f"bilibili_api_{card_id}_bili_jct")
|
|
4917
|
+
buvid3 = st.text_input("BUVID3", value=str(card.get("buvid3") or ""), type="password", key=f"bilibili_api_{card_id}_buvid3")
|
|
4918
|
+
buvid4 = st.text_input("BUVID4 (opcional)", value=str(card.get("buvid4") or ""), type="password", key=f"bilibili_api_{card_id}_buvid4")
|
|
4919
|
+
with credential_cols[1]:
|
|
4920
|
+
dedeuserid = st.text_input("DedeUserID (opcional)", value=str(card.get("dedeuserid") or ""), type="password", key=f"bilibili_api_{card_id}_dedeuserid")
|
|
4921
|
+
ac_time_value = st.text_input("ac_time_value (opcional)", value=str(card.get("ac_time_value") or ""), type="password", key=f"bilibili_api_{card_id}_ac_time_value")
|
|
4922
|
+
proxy = st.text_input("Proxy opcional", value=str(card.get("proxy") or ""), key=f"bilibili_api_{card_id}_proxy", placeholder="http://127.0.0.1:8080")
|
|
4923
|
+
action_cols = st.columns(3)
|
|
4924
|
+
with action_cols[0]:
|
|
4925
|
+
test_clicked = st.form_submit_button("Testar chamada API", use_container_width=True)
|
|
4926
|
+
with action_cols[1]:
|
|
4927
|
+
save_clicked = st.form_submit_button("Guardar card", type="primary", use_container_width=True)
|
|
4928
|
+
with action_cols[2]:
|
|
4929
|
+
delete_clicked = st.form_submit_button("Apagar card", use_container_width=True)
|
|
4930
|
+
edited = {"id": card_id, "label": label.strip() or f"Conta Bilibili {index + 1}", "active": bool(active), "sessdata": sessdata.strip(), "bili_jct": bili_jct.strip(), "buvid3": buvid3.strip(), "buvid4": buvid4.strip(), "dedeuserid": dedeuserid.strip(), "ac_time_value": ac_time_value.strip(), "proxy": proxy.strip()}
|
|
4931
|
+
if test_clicked:
|
|
4932
|
+
cards[index] = edited
|
|
4933
|
+
_persist_bilibili_api_cards(settings, cards)
|
|
4934
|
+
result = BilibiliApiAdapter(edited, settings).test_connection()
|
|
4935
|
+
(st.success if result.ok else st.error)(result.message)
|
|
4936
|
+
elif save_clicked:
|
|
4937
|
+
cards[index] = edited
|
|
4938
|
+
_persist_bilibili_api_cards(settings, cards)
|
|
4939
|
+
st.success("Card Bilibili guardado.")
|
|
4940
|
+
st.rerun()
|
|
4941
|
+
elif delete_clicked:
|
|
4942
|
+
cards = [item for item in cards if str(item.get("id")) != card_id]
|
|
4943
|
+
_persist_bilibili_api_cards(settings, cards)
|
|
4944
|
+
st.success("Card Bilibili apagado.")
|
|
4945
|
+
st.rerun()
|
|
4946
|
+
if st.button("Adicionar nova API", type="primary", use_container_width=True, key="add_bilibili_api_card"):
|
|
4947
|
+
cards.append({"id": f"bilibili-api-{uuid.uuid4().hex[:10]}", "label": f"Conta Bilibili {len(cards) + 1}", "active": True, "sessdata": "", "bili_jct": "", "buvid3": "", "buvid4": "", "dedeuserid": "", "ac_time_value": "", "proxy": ""})
|
|
4948
|
+
_persist_bilibili_api_cards(settings, cards)
|
|
4949
|
+
st.success("Novo card Bilibili criado.")
|
|
4950
|
+
st.rerun()
|
|
4951
|
+
|
|
4760
4952
|
def render_google_accounts():
|
|
4761
4953
|
st.title("Contas Google")
|
|
4762
4954
|
st.caption("Gestão das contas Google/YouTube, documentos de credenciais, canais em lote e credenciais globais do YouTube.")
|
|
@@ -5539,7 +5731,7 @@ def render_settings():
|
|
|
5539
5731
|
key=f"settings_{key}",
|
|
5540
5732
|
)
|
|
5541
5733
|
|
|
5542
|
-
api_keys_tab, google_accounts_tab, tiktok_api_tab, ai_influencers_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "API Tiktok", "AI Influencers", "Teste de Voz"])
|
|
5734
|
+
api_keys_tab, google_accounts_tab, tiktok_api_tab, bilibili_api_tab, ai_influencers_tab, voice_test_tab = render_localized_tabs(["API Keys", "Contas Google", "API Tiktok", "API Bilibili", "AI Influencers", "Teste de Voz"])
|
|
5543
5735
|
|
|
5544
5736
|
with api_keys_tab:
|
|
5545
5737
|
with st.container(border=True):
|
|
@@ -5697,7 +5889,7 @@ def render_settings():
|
|
|
5697
5889
|
upload_post_api_key = text_setting("Upload-Post API key", "upload_post_api_key", secret=True)
|
|
5698
5890
|
_render_credential_status(upload_post_api_key)
|
|
5699
5891
|
upload_post_username = text_setting("Upload-Post username", "upload_post_username")
|
|
5700
|
-
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms")
|
|
5892
|
+
upload_post_platforms = text_setting("Plataformas Upload-Post", "upload_post_platforms", help_text="Escreva as plataformas separadas por vírgulas, como em Pipeline Vídeos > Upload > Destino. Ex.: youtube,tiktok")
|
|
5701
5893
|
upload_post_auto_upload = st.checkbox("Publicar automaticamente após gerar", bool(settings.get("upload_post_auto_upload", False)))
|
|
5702
5894
|
_render_api_test_control(
|
|
5703
5895
|
settings,
|
|
@@ -5762,6 +5954,8 @@ def render_settings():
|
|
|
5762
5954
|
with tiktok_api_tab:
|
|
5763
5955
|
render_tiktok_api_cards(settings)
|
|
5764
5956
|
|
|
5957
|
+
with bilibili_api_tab:
|
|
5958
|
+
render_bilibili_api_cards(settings)
|
|
5765
5959
|
with ai_influencers_tab:
|
|
5766
5960
|
st.subheader("AI Influencers")
|
|
5767
5961
|
st.caption("Estado do backend usado por Personagens e Geração de Conteúdo IA. O selector e as credenciais são editados nesta aba, em Banco de Dados Influencers.")
|
|
@@ -6405,6 +6599,8 @@ def main():
|
|
|
6405
6599
|
("Analista Growth Youtube", ":material/analytics:", "Analista Growth Youtube"),
|
|
6406
6600
|
("Analista Growth Tiktok", ":material/analytics:", "Analista Growth Tiktok"),
|
|
6407
6601
|
("Analista Growth Instagram", ":material/analytics:", "Analista Growth Instagram"),
|
|
6602
|
+
("Analista Facebook Pages", ":material/analytics:", "Analista Facebook Pages"),
|
|
6603
|
+
("Analista Bilibili", ":material/analytics:", "Analista Bilibili"),
|
|
6408
6604
|
]
|
|
6409
6605
|
documentation_items = [
|
|
6410
6606
|
("Tutorial Meta", ":material/menu_book:", "Tutorial Meta"),
|
|
@@ -6545,6 +6741,8 @@ def main():
|
|
|
6545
6741
|
"Analista Growth Youtube": lambda: render_edit_placeholder("Analista Growth Youtube", ""),
|
|
6546
6742
|
"Analista Growth Tiktok": lambda: render_edit_placeholder("Analista Growth Tiktok", ""),
|
|
6547
6743
|
"Analista Growth Instagram": lambda: render_edit_placeholder("Analista Growth Instagram", ""),
|
|
6744
|
+
"Analista Facebook Pages": lambda: render_edit_placeholder("Analista Facebook Pages", ""),
|
|
6745
|
+
"Analista Bilibili": lambda: render_edit_placeholder("Analista Bilibili", ""),
|
|
6548
6746
|
"Documentação": lambda: render_edit_placeholder("Documentação", "Seleccione um tutorial no menu expansível."),
|
|
6549
6747
|
"Tutorial Meta": render_models_ai_tutorial,
|
|
6550
6748
|
"Tutorial Supabase": render_supabase_tutorial,
|
package/hermes_ui/languages.py
CHANGED
|
@@ -252,34 +252,34 @@ for _language_code in UI_TRANSLATIONS:
|
|
|
252
252
|
# translation path as the legacy pages.
|
|
253
253
|
UI_NAV_TRANSLATIONS: dict[str, dict[str, str]] = {
|
|
254
254
|
"pt": {
|
|
255
|
-
"Arquivos Base": "Arquivos Base", "Pipeline Vídeos": "Pipeline Vídeos", "Canais e Perfis de Vídeos": "Canais e Perfis de Vídeos", "Canais YouTube": "Canais YouTube", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Backlog Vídeos", "Thumbnails": "Thumbnails", "Música": "Música", "Upload Música": "Upload Música", "Geração de Conteúdo IA": "Geração de Conteúdo IA", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "Analista Growth Youtube", "Analista Growth Tiktok": "Analista Growth Tiktok", "Analista Growth Instagram": "Analista Growth Instagram", "Documentação": "Documentação", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline Música", "módulos disponíveis": "módulos disponíveis",
|
|
255
|
+
"Arquivos Base": "Arquivos Base", "Pipeline Vídeos": "Pipeline Vídeos", "Canais e Perfis de Vídeos": "Canais e Perfis de Vídeos", "Canais YouTube": "Canais YouTube", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Backlog Vídeos", "Thumbnails": "Thumbnails", "Música": "Música", "Upload Música": "Upload Música", "Geração de Conteúdo IA": "Geração de Conteúdo IA", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "Analista Growth Youtube", "Analista Growth Tiktok": "Analista Growth Tiktok", "Analista Growth Instagram": "Analista Growth Instagram", "Analista Facebook Pages": "Analista Facebook Pages", "Analista Bilibili": "Analista Bilibili", "Documentação": "Documentação", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline Música", "módulos disponíveis": "módulos disponíveis",
|
|
256
256
|
},
|
|
257
257
|
"en": {
|
|
258
|
-
"Arquivos Base": "Base Files", "Pipeline Vídeos": "Video Pipeline", "Canais e Perfis de Vídeos": "Video Channels & Profiles", "Canais YouTube": "YouTube Channels", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video Backlog", "Thumbnails": "Thumbnails", "Música": "Music", "Upload Música": "Music Upload", "Geração de Conteúdo IA": "AI Content Generation", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "YouTube Growth Analyst", "Analista Growth Tiktok": "TikTok Growth Analyst", "Analista Growth Instagram": "Instagram Growth Analyst", "Documentação": "Documentation", "Tutorial Kaggle": "Kaggle Tutorial", "Tutorial Apify": "Apify Tutorial", "Pipeline Música": "Music Pipeline", "módulos disponíveis": "available modules",
|
|
258
|
+
"Arquivos Base": "Base Files", "Pipeline Vídeos": "Video Pipeline", "Canais e Perfis de Vídeos": "Video Channels & Profiles", "Canais YouTube": "YouTube Channels", "Facebook Pages": "Facebook Pages", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video Backlog", "Thumbnails": "Thumbnails", "Música": "Music", "Upload Música": "Music Upload", "Geração de Conteúdo IA": "AI Content Generation", "Motion Control": "Motion Control", "UGC Products": "UGC Products", "Growth": "Growth", "Analista Growth Youtube": "YouTube Growth Analyst", "Analista Growth Tiktok": "TikTok Growth Analyst", "Analista Growth Instagram": "Instagram Growth Analyst", "Analista Facebook Pages": "Facebook Pages Analyst", "Analista Bilibili": "Bilibili Analyst", "Documentação": "Documentation", "Tutorial Kaggle": "Kaggle Tutorial", "Tutorial Apify": "Apify Tutorial", "Pipeline Música": "Music Pipeline", "módulos disponíveis": "available modules",
|
|
259
259
|
},
|
|
260
260
|
"zh": {
|
|
261
|
-
"Arquivos Base": "基础文件", "Pipeline Vídeos": "视频流程", "Canais e Perfis de Vídeos": "视频频道与资料", "Canais YouTube": "YouTube 频道", "Facebook Pages": "Facebook 页面", "Prompt Masters": "主提示词", "Backlog Vídeos": "视频待办", "Thumbnails": "缩略图", "Música": "音乐", "Upload Música": "音乐上传", "Geração de Conteúdo IA": "AI 内容生成", "Motion Control": "动作控制", "UGC Products": "UGC 产品", "Growth": "增长", "Analista Growth Youtube": "YouTube 增长分析师", "Analista Growth Tiktok": "TikTok 增长分析师", "Analista Growth Instagram": "Instagram 增长分析师", "Documentação": "文档", "Tutorial Kaggle": "Kaggle 教程", "Tutorial Apify": "Apify 教程", "Pipeline Música": "音乐流程", "módulos disponíveis": "可用模块",
|
|
261
|
+
"Arquivos Base": "基础文件", "Pipeline Vídeos": "视频流程", "Canais e Perfis de Vídeos": "视频频道与资料", "Canais YouTube": "YouTube 频道", "Facebook Pages": "Facebook 页面", "Prompt Masters": "主提示词", "Backlog Vídeos": "视频待办", "Thumbnails": "缩略图", "Música": "音乐", "Upload Música": "音乐上传", "Geração de Conteúdo IA": "AI 内容生成", "Motion Control": "动作控制", "UGC Products": "UGC 产品", "Growth": "增长", "Analista Growth Youtube": "YouTube 增长分析师", "Analista Growth Tiktok": "TikTok 增长分析师", "Analista Growth Instagram": "Instagram 增长分析师", "Analista Facebook Pages": "Facebook Pages 分析师", "Analista Bilibili": "Bilibili 分析师", "Documentação": "文档", "Tutorial Kaggle": "Kaggle 教程", "Tutorial Apify": "Apify 教程", "Pipeline Música": "音乐流程", "módulos disponíveis": "可用模块",
|
|
262
262
|
},
|
|
263
263
|
"de": {
|
|
264
|
-
"Arquivos Base": "Basisdateien", "Pipeline Vídeos": "Video-Pipeline", "Canais e Perfis de Vídeos": "Videokanäle und Profile", "Canais YouTube": "YouTube-Kanäle", "Facebook Pages": "Facebook-Seiten", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video-Backlog", "Thumbnails": "Thumbnails", "Música": "Musik", "Upload Música": "Musik-Upload", "Geração de Conteúdo IA": "KI-Inhaltserstellung", "Motion Control": "Motion Control", "UGC Products": "UGC-Produkte", "Growth": "Wachstum", "Analista Growth Youtube": "YouTube-Wachstumsanalyst", "Analista Growth Tiktok": "TikTok-Wachstumsanalyst", "Analista Growth Instagram": "Instagram-Wachstumsanalyst", "Documentação": "Dokumentation", "Tutorial Kaggle": "Kaggle-Tutorial", "Tutorial Apify": "Apify-Tutorial", "Pipeline Música": "Musik-Pipeline", "módulos disponíveis": "verfügbare Module",
|
|
264
|
+
"Arquivos Base": "Basisdateien", "Pipeline Vídeos": "Video-Pipeline", "Canais e Perfis de Vídeos": "Videokanäle und Profile", "Canais YouTube": "YouTube-Kanäle", "Facebook Pages": "Facebook-Seiten", "Prompt Masters": "Prompt Masters", "Backlog Vídeos": "Video-Backlog", "Thumbnails": "Thumbnails", "Música": "Musik", "Upload Música": "Musik-Upload", "Geração de Conteúdo IA": "KI-Inhaltserstellung", "Motion Control": "Motion Control", "UGC Products": "UGC-Produkte", "Growth": "Wachstum", "Analista Growth Youtube": "YouTube-Wachstumsanalyst", "Analista Growth Tiktok": "TikTok-Wachstumsanalyst", "Analista Growth Instagram": "Instagram-Wachstumsanalyst", "Analista Facebook Pages": "Facebook-Pages-Analyst", "Analista Bilibili": "Bilibili-Analyst", "Documentação": "Dokumentation", "Tutorial Kaggle": "Kaggle-Tutorial", "Tutorial Apify": "Apify-Tutorial", "Pipeline Música": "Musik-Pipeline", "módulos disponíveis": "verfügbare Module",
|
|
265
265
|
},
|
|
266
266
|
"vi": {
|
|
267
|
-
"Arquivos Base": "Tệp cơ sở", "Pipeline Vídeos": "Quy trình video", "Canais e Perfis de Vídeos": "Kênh và hồ sơ video", "Canais YouTube": "Kênh YouTube", "Facebook Pages": "Trang Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Danh sách video", "Thumbnails": "Ảnh thu nhỏ", "Música": "Âm nhạc", "Upload Música": "Tải nhạc lên", "Geração de Conteúdo IA": "Tạo nội dung AI", "Motion Control": "Điều khiển chuyển động", "UGC Products": "Sản phẩm UGC", "Growth": "Tăng trưởng", "Analista Growth Youtube": "Chuyên viên phân tích tăng trưởng YouTube", "Analista Growth Tiktok": "Chuyên viên phân tích tăng trưởng TikTok", "Analista Growth Instagram": "Chuyên viên phân tích tăng trưởng Instagram", "Documentação": "Tài liệu", "Tutorial Kaggle": "Hướng dẫn Kaggle", "Tutorial Apify": "Hướng dẫn Apify", "Pipeline Música": "Quy trình âm nhạc", "módulos disponíveis": "mô-đun khả dụng",
|
|
267
|
+
"Arquivos Base": "Tệp cơ sở", "Pipeline Vídeos": "Quy trình video", "Canais e Perfis de Vídeos": "Kênh và hồ sơ video", "Canais YouTube": "Kênh YouTube", "Facebook Pages": "Trang Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Danh sách video", "Thumbnails": "Ảnh thu nhỏ", "Música": "Âm nhạc", "Upload Música": "Tải nhạc lên", "Geração de Conteúdo IA": "Tạo nội dung AI", "Motion Control": "Điều khiển chuyển động", "UGC Products": "Sản phẩm UGC", "Growth": "Tăng trưởng", "Analista Growth Youtube": "Chuyên viên phân tích tăng trưởng YouTube", "Analista Growth Tiktok": "Chuyên viên phân tích tăng trưởng TikTok", "Analista Growth Instagram": "Chuyên viên phân tích tăng trưởng Instagram", "Analista Facebook Pages": "Chuyên viên phân tích Facebook Pages", "Analista Bilibili": "Chuyên viên phân tích Bilibili", "Documentação": "Tài liệu", "Tutorial Kaggle": "Hướng dẫn Kaggle", "Tutorial Apify": "Hướng dẫn Apify", "Pipeline Música": "Quy trình âm nhạc", "módulos disponíveis": "mô-đun khả dụng",
|
|
268
268
|
},
|
|
269
269
|
"tr": {
|
|
270
|
-
"Arquivos Base": "Temel Dosyalar", "Pipeline Vídeos": "Video Akışı", "Canais e Perfis de Vídeos": "Video Kanalları ve Profilleri", "Canais YouTube": "YouTube Kanalları", "Facebook Pages": "Facebook Sayfaları", "Prompt Masters": "Prompt Master'lar", "Backlog Vídeos": "Video Bekleme Listesi", "Thumbnails": "Küçük resimler", "Música": "Müzik", "Upload Música": "Müzik Yükleme", "Geração de Conteúdo IA": "Yapay Zekâ İçeriği Oluşturma", "Motion Control": "Hareket Kontrolü", "UGC Products": "UGC Ürünleri", "Growth": "Büyüme", "Analista Growth Youtube": "YouTube Büyüme Analisti", "Analista Growth Tiktok": "TikTok Büyüme Analisti", "Analista Growth Instagram": "Instagram Büyüme Analisti", "Documentação": "Dokümantasyon", "Tutorial Kaggle": "Kaggle Eğitimi", "Tutorial Apify": "Apify Eğitimi", "Pipeline Música": "Müzik Akışı", "módulos disponíveis": "kullanılabilir modüller",
|
|
270
|
+
"Arquivos Base": "Temel Dosyalar", "Pipeline Vídeos": "Video Akışı", "Canais e Perfis de Vídeos": "Video Kanalları ve Profilleri", "Canais YouTube": "YouTube Kanalları", "Facebook Pages": "Facebook Sayfaları", "Prompt Masters": "Prompt Master'lar", "Backlog Vídeos": "Video Bekleme Listesi", "Thumbnails": "Küçük resimler", "Música": "Müzik", "Upload Música": "Müzik Yükleme", "Geração de Conteúdo IA": "Yapay Zekâ İçeriği Oluşturma", "Motion Control": "Hareket Kontrolü", "UGC Products": "UGC Ürünleri", "Growth": "Büyüme", "Analista Growth Youtube": "YouTube Büyüme Analisti", "Analista Growth Tiktok": "TikTok Büyüme Analisti", "Analista Growth Instagram": "Instagram Büyüme Analisti", "Analista Facebook Pages": "Facebook Pages Analisti", "Analista Bilibili": "Bilibili Analisti", "Documentação": "Dokümantasyon", "Tutorial Kaggle": "Kaggle Eğitimi", "Tutorial Apify": "Apify Eğitimi", "Pipeline Música": "Müzik Akışı", "módulos disponíveis": "kullanılabilir modüller",
|
|
271
271
|
},
|
|
272
272
|
"ru": {
|
|
273
|
-
"Arquivos Base": "Базовые файлы", "Pipeline Vídeos": "Конвейер видео", "Canais e Perfis de Vídeos": "Каналы и профили видео", "Canais YouTube": "Каналы YouTube", "Facebook Pages": "Страницы Facebook", "Prompt Masters": "Мастер-промпты", "Backlog Vídeos": "Очередь видео", "Thumbnails": "Миниатюры", "Música": "Музыка", "Upload Música": "Загрузка музыки", "Geração de Conteúdo IA": "Генерация контента ИИ", "Motion Control": "Управление движением", "UGC Products": "UGC-продукты", "Growth": "Рост", "Analista Growth Youtube": "Аналитик роста YouTube", "Analista Growth Tiktok": "Аналитик роста TikTok", "Analista Growth Instagram": "Аналитик роста Instagram", "Documentação": "Документация", "Tutorial Kaggle": "Руководство Kaggle", "Tutorial Apify": "Руководство Apify", "Pipeline Música": "Конвейер музыки", "módulos disponíveis": "доступные модули",
|
|
273
|
+
"Arquivos Base": "Базовые файлы", "Pipeline Vídeos": "Конвейер видео", "Canais e Perfis de Vídeos": "Каналы и профили видео", "Canais YouTube": "Каналы YouTube", "Facebook Pages": "Страницы Facebook", "Prompt Masters": "Мастер-промпты", "Backlog Vídeos": "Очередь видео", "Thumbnails": "Миниатюры", "Música": "Музыка", "Upload Música": "Загрузка музыки", "Geração de Conteúdo IA": "Генерация контента ИИ", "Motion Control": "Управление движением", "UGC Products": "UGC-продукты", "Growth": "Рост", "Analista Growth Youtube": "Аналитик роста YouTube", "Analista Growth Tiktok": "Аналитик роста TikTok", "Analista Growth Instagram": "Аналитик роста Instagram", "Analista Facebook Pages": "Аналитик Facebook Pages", "Analista Bilibili": "Аналитик Bilibili", "Documentação": "Документация", "Tutorial Kaggle": "Руководство Kaggle", "Tutorial Apify": "Руководство Apify", "Pipeline Música": "Конвейер музыки", "módulos disponíveis": "доступные модули",
|
|
274
274
|
},
|
|
275
275
|
"es": {
|
|
276
|
-
"Arquivos Base": "Archivos base", "Pipeline Vídeos": "Flujo de vídeos", "Canais e Perfis de Vídeos": "Canales y perfiles de vídeo", "Canais YouTube": "Canales de YouTube", "Facebook Pages": "Páginas de Facebook", "Prompt Masters": "Prompts maestros", "Backlog Vídeos": "Cola de vídeos", "Thumbnails": "Miniaturas", "Música": "Música", "Upload Música": "Subir música", "Geração de Conteúdo IA": "Generación de contenido con IA", "Motion Control": "Control de movimiento", "UGC Products": "Productos UGC", "Growth": "Crecimiento", "Analista Growth Youtube": "Analista de crecimiento de YouTube", "Analista Growth Tiktok": "Analista de crecimiento de TikTok", "Analista Growth Instagram": "Analista de crecimiento de Instagram", "Documentação": "Documentación", "Tutorial Kaggle": "Tutorial de Kaggle", "Tutorial Apify": "Tutorial de Apify", "Pipeline Música": "Flujo de música", "módulos disponíveis": "módulos disponibles",
|
|
276
|
+
"Arquivos Base": "Archivos base", "Pipeline Vídeos": "Flujo de vídeos", "Canais e Perfis de Vídeos": "Canales y perfiles de vídeo", "Canais YouTube": "Canales de YouTube", "Facebook Pages": "Páginas de Facebook", "Prompt Masters": "Prompts maestros", "Backlog Vídeos": "Cola de vídeos", "Thumbnails": "Miniaturas", "Música": "Música", "Upload Música": "Subir música", "Geração de Conteúdo IA": "Generación de contenido con IA", "Motion Control": "Control de movimiento", "UGC Products": "Productos UGC", "Growth": "Crecimiento", "Analista Growth Youtube": "Analista de crecimiento de YouTube", "Analista Growth Tiktok": "Analista de crecimiento de TikTok", "Analista Growth Instagram": "Analista de crecimiento de Instagram", "Analista Facebook Pages": "Analista de Facebook Pages", "Analista Bilibili": "Analista de Bilibili", "Documentação": "Documentación", "Tutorial Kaggle": "Tutorial de Kaggle", "Tutorial Apify": "Tutorial de Apify", "Pipeline Música": "Flujo de música", "módulos disponíveis": "módulos disponibles",
|
|
277
277
|
},
|
|
278
278
|
"id": {
|
|
279
|
-
"Arquivos Base": "File Dasar", "Pipeline Vídeos": "Alur Video", "Canais e Perfis de Vídeos": "Kanal dan Profil Video", "Canais YouTube": "Kanal YouTube", "Facebook Pages": "Halaman Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Antrean Video", "Thumbnails": "Thumbnail", "Música": "Musik", "Upload Música": "Unggah Musik", "Geração de Conteúdo IA": "Pembuatan Konten AI", "Motion Control": "Kontrol Gerakan", "UGC Products": "Produk UGC", "Growth": "Pertumbuhan", "Analista Growth Youtube": "Analis Pertumbuhan YouTube", "Analista Growth Tiktok": "Analis Pertumbuhan TikTok", "Analista Growth Instagram": "Analis Pertumbuhan Instagram", "Documentação": "Dokumentasi", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Alur Musik", "módulos disponíveis": "modul tersedia",
|
|
279
|
+
"Arquivos Base": "File Dasar", "Pipeline Vídeos": "Alur Video", "Canais e Perfis de Vídeos": "Kanal dan Profil Video", "Canais YouTube": "Kanal YouTube", "Facebook Pages": "Halaman Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Antrean Video", "Thumbnails": "Thumbnail", "Música": "Musik", "Upload Música": "Unggah Musik", "Geração de Conteúdo IA": "Pembuatan Konten AI", "Motion Control": "Kontrol Gerakan", "UGC Products": "Produk UGC", "Growth": "Pertumbuhan", "Analista Growth Youtube": "Analis Pertumbuhan YouTube", "Analista Growth Tiktok": "Analis Pertumbuhan TikTok", "Analista Growth Instagram": "Analis Pertumbuhan Instagram", "Analista Facebook Pages": "Analis Facebook Pages", "Analista Bilibili": "Analis Bilibili", "Documentação": "Dokumentasi", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Alur Musik", "módulos disponíveis": "modul tersedia",
|
|
280
280
|
},
|
|
281
281
|
"it": {
|
|
282
|
-
"Arquivos Base": "File di base", "Pipeline Vídeos": "Pipeline video", "Canais e Perfis de Vídeos": "Canali e profili video", "Canais YouTube": "Canali YouTube", "Facebook Pages": "Pagine Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Coda video", "Thumbnails": "Miniature", "Música": "Musica", "Upload Música": "Caricamento musica", "Geração de Conteúdo IA": "Generazione di contenuti IA", "Motion Control": "Controllo del movimento", "UGC Products": "Prodotti UGC", "Growth": "Crescita", "Analista Growth Youtube": "Analista della crescita YouTube", "Analista Growth Tiktok": "Analista della crescita TikTok", "Analista Growth Instagram": "Analista della crescita Instagram", "Documentação": "Documentazione", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline musicale", "módulos disponíveis": "moduli disponibili",
|
|
282
|
+
"Arquivos Base": "File di base", "Pipeline Vídeos": "Pipeline video", "Canais e Perfis de Vídeos": "Canali e profili video", "Canais YouTube": "Canali YouTube", "Facebook Pages": "Pagine Facebook", "Prompt Masters": "Prompt Master", "Backlog Vídeos": "Coda video", "Thumbnails": "Miniature", "Música": "Musica", "Upload Música": "Caricamento musica", "Geração de Conteúdo IA": "Generazione di contenuti IA", "Motion Control": "Controllo del movimento", "UGC Products": "Prodotti UGC", "Growth": "Crescita", "Analista Growth Youtube": "Analista della crescita YouTube", "Analista Growth Tiktok": "Analista della crescita TikTok", "Analista Growth Instagram": "Analista della crescita Instagram", "Analista Facebook Pages": "Analista Facebook Pages", "Analista Bilibili": "Analista Bilibili", "Documentação": "Documentazione", "Tutorial Kaggle": "Tutorial Kaggle", "Tutorial Apify": "Tutorial Apify", "Pipeline Música": "Pipeline musicale", "módulos disponíveis": "moduli disponibili",
|
|
283
283
|
},
|
|
284
284
|
}
|
|
285
285
|
for _language_code, _navigation_values in UI_NAV_TRANSLATIONS.items():
|
|
@@ -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
|
+
]
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Preparação de uploads DistroKid por browser, inspirada no fluxo do musikai.
|
|
2
|
+
|
|
3
|
+
O adapter executa apenas a parte de upload/preenchimento do formulário. A
|
|
4
|
+
submissão final fica sempre manual no browser para evitar publicação acidental.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import http.cookies
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import uuid
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from .platforms import IntegrationResult
|
|
18
|
+
|
|
19
|
+
DISTROKID_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg", ".aac"}
|
|
20
|
+
DISTROKID_COVER_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
|
21
|
+
DEFAULT_DISTROKID_URL = "https://distrokid.com/new/"
|
|
22
|
+
_LIVE_SESSIONS: dict[str, Any] = {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cookie_header_to_context_cookies(cookie_header: str) -> list[dict[str, str]]:
|
|
26
|
+
jar = http.cookies.SimpleCookie()
|
|
27
|
+
jar.load(str(cookie_header or ""))
|
|
28
|
+
return [
|
|
29
|
+
{"name": morsel.key, "value": morsel.value, "domain": ".distrokid.com", "path": "/"}
|
|
30
|
+
for morsel in jar.values()
|
|
31
|
+
if morsel.key and morsel.value
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _safe_path(value: str | Path | None, allowed: set[str], label: str) -> Path | None:
|
|
36
|
+
if not value:
|
|
37
|
+
return None
|
|
38
|
+
path = Path(value).expanduser()
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
raise ValueError(f"{label} não encontrado: {path}")
|
|
41
|
+
if path.suffix.lower() not in allowed:
|
|
42
|
+
raise ValueError(f"Formato de {label.lower()} não suportado: {path.suffix}")
|
|
43
|
+
return path.resolve()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _text(value: Any, maximum: int = 500) -> str:
|
|
47
|
+
return str(value or "").strip()[:maximum]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DistroKidAdapter:
|
|
51
|
+
"""Preenche a página de novo lançamento DistroKid e carrega as tracks localmente."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, settings: dict[str, Any] | None = None):
|
|
54
|
+
self.settings = settings or {}
|
|
55
|
+
self.enabled = bool(self.settings.get("distrokid_enabled", False))
|
|
56
|
+
self.cookie = str(self.settings.get("distrokid_cookie") or "").strip()
|
|
57
|
+
self.account = _text(self.settings.get("distrokid_account"), 160)
|
|
58
|
+
self.first_name = _text(self.settings.get("distrokid_first_name"), 120)
|
|
59
|
+
self.last_name = _text(self.settings.get("distrokid_last_name"), 120)
|
|
60
|
+
self.record_label = _text(self.settings.get("distrokid_record_label"), 160)
|
|
61
|
+
self.browser_path = str(self.settings.get("distrokid_browser_path") or os.getenv("THUNDERBOLT_CHROME_PATH") or "").strip()
|
|
62
|
+
self.base_url = str(self.settings.get("distrokid_url") or DEFAULT_DISTROKID_URL).strip() or DEFAULT_DISTROKID_URL
|
|
63
|
+
|
|
64
|
+
def status(self) -> IntegrationResult:
|
|
65
|
+
if not self.enabled:
|
|
66
|
+
return IntegrationResult(False, "DistroKid está desactivado nesta configuração.", {})
|
|
67
|
+
if not self.cookie:
|
|
68
|
+
return IntegrationResult(False, "Configure o cookie de sessão DistroKid para preencher o formulário de upload.", {"missing_fields": ["distrokid_cookie"]})
|
|
69
|
+
cookies = _cookie_header_to_context_cookies(self.cookie)
|
|
70
|
+
if not cookies:
|
|
71
|
+
return IntegrationResult(False, "O cookie DistroKid não tem um formato válido de cabeçalho Cookie.", {"missing_fields": ["distrokid_cookie"]})
|
|
72
|
+
return IntegrationResult(True, "DistroKid configurado para upload manual assistido.", {"account": self.account or "Conta DistroKid", "cookies": len(cookies), "manual_submit": True})
|
|
73
|
+
|
|
74
|
+
def test_connection(self) -> IntegrationResult:
|
|
75
|
+
status = self.status()
|
|
76
|
+
if not status.ok:
|
|
77
|
+
return status
|
|
78
|
+
cookie_header = self.cookie
|
|
79
|
+
try:
|
|
80
|
+
response = requests.get(
|
|
81
|
+
self.base_url,
|
|
82
|
+
headers={"Cookie": cookie_header, "User-Agent": "Mozilla/5.0 Thunderbolt"},
|
|
83
|
+
timeout=30,
|
|
84
|
+
allow_redirects=True,
|
|
85
|
+
)
|
|
86
|
+
except requests.RequestException as exc:
|
|
87
|
+
return IntegrationResult(False, f"Não foi possível contactar o DistroKid: {exc}", {"api": "DistroKid web", "account": self.account or "Conta DistroKid"})
|
|
88
|
+
final_url = str(getattr(response, "url", "") or "")
|
|
89
|
+
authenticated = response.status_code < 400 and "/login" not in final_url.lower() and "login" not in (response.text or "")[:2000].lower()
|
|
90
|
+
if not authenticated:
|
|
91
|
+
return IntegrationResult(False, "A sessão DistroKid foi rejeitada ou expirou. Actualize o cookie no browser e guarde-o novamente.", {"api": "DistroKid web", "status_code": response.status_code, "authenticated": False})
|
|
92
|
+
return IntegrationResult(True, "Chamada DistroKid concluída; a sessão parece válida.", {"api": "DistroKid web", "status_code": response.status_code, "authenticated": True})
|
|
93
|
+
|
|
94
|
+
def prepare_upload(
|
|
95
|
+
self,
|
|
96
|
+
tracks: list[dict[str, Any]],
|
|
97
|
+
*,
|
|
98
|
+
artist: str,
|
|
99
|
+
release_title: str,
|
|
100
|
+
record_label: str = "",
|
|
101
|
+
cover_path: str | Path | None = None,
|
|
102
|
+
genre: str = "",
|
|
103
|
+
) -> IntegrationResult:
|
|
104
|
+
"""Open DistroKid's form, fill metadata and upload tracks; never submit automatically."""
|
|
105
|
+
status = self.status()
|
|
106
|
+
if not status.ok:
|
|
107
|
+
return status
|
|
108
|
+
if not tracks:
|
|
109
|
+
return IntegrationResult(False, "Seleccione pelo menos uma faixa para o upload DistroKid.", {})
|
|
110
|
+
clean_artist = _text(artist, 160)
|
|
111
|
+
clean_release_title = _text(release_title, 160)
|
|
112
|
+
clean_label = _text(record_label or self.record_label, 160)
|
|
113
|
+
if not clean_artist or not clean_release_title:
|
|
114
|
+
return IntegrationResult(False, "Artista e título do lançamento são obrigatórios para o upload DistroKid.", {})
|
|
115
|
+
prepared_tracks: list[dict[str, Any]] = []
|
|
116
|
+
try:
|
|
117
|
+
for item in tracks:
|
|
118
|
+
path = _safe_path(item.get("path"), DISTROKID_AUDIO_EXTENSIONS, "ficheiro de áudio")
|
|
119
|
+
if path is None:
|
|
120
|
+
raise ValueError("Cada faixa DistroKid precisa de um ficheiro de áudio local.")
|
|
121
|
+
prepared_tracks.append({
|
|
122
|
+
"path": path,
|
|
123
|
+
"title": _text(item.get("title") or path.stem, 160),
|
|
124
|
+
"instrumental": bool(item.get("instrumental", False)),
|
|
125
|
+
})
|
|
126
|
+
cover = _safe_path(cover_path, DISTROKID_COVER_EXTENSIONS, "capa")
|
|
127
|
+
except ValueError as exc:
|
|
128
|
+
return IntegrationResult(False, str(exc), {"api": "DistroKid web"})
|
|
129
|
+
try:
|
|
130
|
+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
|
131
|
+
from playwright.sync_api import sync_playwright
|
|
132
|
+
except ImportError as exc:
|
|
133
|
+
return IntegrationResult(False, "A dependência Playwright não está instalada; actualize o Thunderbolt para activar o browser DistroKid.", {"api": "DistroKid web"})
|
|
134
|
+
session_id = f"distrokid-{uuid.uuid4().hex[:12]}"
|
|
135
|
+
try:
|
|
136
|
+
playwright = sync_playwright().start()
|
|
137
|
+
launch_kwargs: dict[str, Any] = {"headless": False}
|
|
138
|
+
if self.browser_path:
|
|
139
|
+
launch_kwargs["executable_path"] = self.browser_path
|
|
140
|
+
else:
|
|
141
|
+
launch_kwargs["channel"] = "chrome"
|
|
142
|
+
browser = playwright.chromium.launch(**launch_kwargs)
|
|
143
|
+
context = browser.new_context(locale="en-US", accept_downloads=True)
|
|
144
|
+
context.add_cookies(_cookie_header_to_context_cookies(self.cookie))
|
|
145
|
+
page = context.new_page()
|
|
146
|
+
page.goto(self.base_url, wait_until="domcontentloaded", timeout=60000)
|
|
147
|
+
page.wait_for_selector("body", state="visible", timeout=30000)
|
|
148
|
+
if "/login" in page.url.lower():
|
|
149
|
+
raise RuntimeError("A sessão DistroKid foi redireccionada para login.")
|
|
150
|
+
self._fill_form(page, prepared_tracks, clean_artist, clean_release_title, clean_label, cover, _text(genre, 120))
|
|
151
|
+
_LIVE_SESSIONS[session_id] = {"playwright": playwright, "browser": browser, "context": context, "page": page}
|
|
152
|
+
return IntegrationResult(True, "Formulário DistroKid aberto e ficheiros carregados. Reveja todos os dados no browser e clique manualmente em Submit.", {"session_id": session_id, "account": self.account or "Conta DistroKid", "tracks": len(prepared_tracks), "manual_submit": True})
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
try:
|
|
155
|
+
playwright.stop() # type: ignore[name-defined]
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
if exc.__class__.__name__ == "Error" or "executable" in str(exc).lower() or "browser" in str(exc).lower():
|
|
159
|
+
return IntegrationResult(False, f"Não foi possível abrir o browser DistroKid: {exc}", {"api": "DistroKid web"})
|
|
160
|
+
if "Timeout" in exc.__class__.__name__ or "timeout" in str(exc).lower():
|
|
161
|
+
return IntegrationResult(False, f"O DistroKid não carregou o formulário a tempo: {exc}", {"api": "DistroKid web"})
|
|
162
|
+
return IntegrationResult(False, f"Upload DistroKid falhou: {exc}", {"api": "DistroKid web"})
|
|
163
|
+
|
|
164
|
+
@staticmethod
|
|
165
|
+
def _fill_form(page: Any, tracks: list[dict[str, Any]], artist: str, release_title: str, record_label: str, cover: Path | None, genre: str) -> None:
|
|
166
|
+
try:
|
|
167
|
+
page.locator("#sitetran_select").select_option("en", timeout=5000)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
page.locator("#artistName").fill(artist)
|
|
171
|
+
if record_label:
|
|
172
|
+
try:
|
|
173
|
+
page.locator("#recordLabel").select_option(label=record_label, timeout=8000)
|
|
174
|
+
except Exception:
|
|
175
|
+
try:
|
|
176
|
+
page.locator("#recordLabel").select_option(record_label, timeout=5000)
|
|
177
|
+
except Exception:
|
|
178
|
+
pass
|
|
179
|
+
try:
|
|
180
|
+
page.locator("#howManySongsOnThisAlbum").select_option(str(len(tracks)), timeout=15000)
|
|
181
|
+
except Exception:
|
|
182
|
+
pass
|
|
183
|
+
page.wait_for_timeout(1000)
|
|
184
|
+
if cover is not None:
|
|
185
|
+
page.locator("#artwork").set_input_files(str(cover), timeout=15000)
|
|
186
|
+
try:
|
|
187
|
+
page.locator("img.artworkPreview").wait_for(state="visible", timeout=30000)
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
track_nodes = page.locator("input[name^='tracknum_']")
|
|
191
|
+
count = track_nodes.count()
|
|
192
|
+
if count < len(tracks):
|
|
193
|
+
raise RuntimeError(f"O DistroKid apresentou {count} campos de faixa, mas foram seleccionadas {len(tracks)}.")
|
|
194
|
+
for index, track in enumerate(tracks, start=1):
|
|
195
|
+
track_id = track_nodes.nth(index - 1).get_attribute("id") or ""
|
|
196
|
+
track_id = re.sub(r"^tracknum_", "", track_id)
|
|
197
|
+
if not track_id:
|
|
198
|
+
raise RuntimeError(f"Não foi possível encontrar o identificador da faixa {index}.")
|
|
199
|
+
page.locator(f"#title_{track_id}").fill(track["title"])
|
|
200
|
+
page.locator(f"#js-track-upload-{index}").set_input_files(str(track["path"]), timeout=30000)
|
|
201
|
+
page.locator(f"#showFilename_{index}").wait_for(state="visible", timeout=120000)
|
|
202
|
+
if len(tracks) > 1:
|
|
203
|
+
try:
|
|
204
|
+
page.locator("#albumTitleInput").fill(release_title)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
if genre:
|
|
208
|
+
try:
|
|
209
|
+
page.locator("#genrePrimary").select_option(label=genre, timeout=5000)
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def close_distrokid_session(session_id: str) -> IntegrationResult:
|
|
215
|
+
session = _LIVE_SESSIONS.pop(str(session_id), None)
|
|
216
|
+
if not session:
|
|
217
|
+
return IntegrationResult(False, "Sessão DistroKid não encontrada ou já encerrada.", {})
|
|
218
|
+
try:
|
|
219
|
+
session["context"].close()
|
|
220
|
+
session["browser"].close()
|
|
221
|
+
session["playwright"].stop()
|
|
222
|
+
except Exception as exc:
|
|
223
|
+
return IntegrationResult(False, f"A sessão DistroKid foi fechada com aviso: {exc}", {"session_id": session_id})
|
|
224
|
+
return IntegrationResult(True, "Sessão DistroKid fechada.", {"session_id": session_id})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
__all__ = ["DEFAULT_DISTROKID_URL", "DISTROKID_AUDIO_EXTENSIONS", "DISTROKID_COVER_EXTENSIONS", "DistroKidAdapter", "close_distrokid_session"]
|
package/package.json
CHANGED