@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.
@@ -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.71
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.70 install
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.70 install
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.70 install --purge-data
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 ytmusicapi
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.70 install`; o instalador detecta e reutiliza o que já estiver válido.
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 **ytmusicapi** para upload de ficheiros, sincronização de bibliotecas e envio autenticado para YouTube Music; credenciais locais e histórico em `uploads.json` |
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, upload de MP4 para Postiz via API key/MCP configurável, TikTok, Instagram e Facebook Pages no front end |
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 **Fontes de Materiais**. 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.
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
- ## Upload Música JewelMusic, Pushtunes e ytmusicapi
69
+ ## Bilibiliupload 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 página **Upload Música** contém três 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
+ 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
- As credenciais destes três métodos são opcionais e independentes das credenciais de vídeo. O instalador acrescenta `ytmusicapi` e `pushtunes`; 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.
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.66
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.
@@ -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 e encaminhe músicas por JewelMusic, sincronize bibliotecas com Pushtunes ou envie directamente para YouTube Music com ytmusicapi. As credenciais e o histórico permanecem locais.")
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.71",
3
+ "version": "0.3.72",
4
4
  "description": "Thunderbolt — interface local para operação de canais faceless e motor MoneyPrinterTurbo",
5
5
  "license": "MIT",
6
6
  "main": "scripts/cli.mjs",
package/requirements.txt CHANGED
@@ -17,3 +17,5 @@ pushtunes>=2.15,<3
17
17
  matplotlib>=3.7,<4
18
18
  kagglehub>=0.3,<1
19
19
  supabase>=2.4,<3
20
+ bilibili-api-python>=17.4,<18
21
+ playwright>=1.45,<2