@danhachuel/thunderbolt 0.3.71 → 0.3.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MANUAL-INSTALACAO.md +12 -7
- package/README.md +16 -8
- package/THIRD-PARTY-NOTICES.md +9 -0
- package/app/main.py +202 -8
- 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.72
|
|
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.72 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.72 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.72 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.72 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
|
|
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.72
|
|
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.
|
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.")
|
|
@@ -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