@danhachuel/thunderbolt 0.3.35 → 0.3.36

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.
@@ -1,4 +1,5 @@
1
1
  [theme]
2
+ base = "dark"
2
3
  font = "sans serif"
3
4
 
4
5
  [theme.dark]
@@ -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.35
5
+ > **Versão deste manual:** 0.3.36
6
6
  > **Pacote npm:** `@danhachuel/thunderbolt`
7
7
  > **Porta padrão da UI:** `localhost:3030`
8
8
  > **Repositório:** [github.com/DanHachuel/thunderbolt](https://github.com/DanHachuel/thunderbolt)
@@ -100,13 +100,13 @@ Execute:
100
100
  Windows PowerShell ou MobaXterm:
101
101
 
102
102
  ```powershell
103
- npx.cmd --yes @danhachuel/thunderbolt@0.3.35 install
103
+ npx.cmd --yes @danhachuel/thunderbolt@0.3.36 install
104
104
  ```
105
105
 
106
106
  Linux/macOS:
107
107
 
108
108
  ```bash
109
- npx --yes @danhachuel/thunderbolt@0.3.35 install
109
+ npx --yes @danhachuel/thunderbolt@0.3.36 install
110
110
  ```
111
111
 
112
112
  A instalação normal é **segura para actualizações**: preserva `storage`, Blueprints, Brandings, configurações e artefactos do utilizador. Remove apenas `.venv`, o clone técnico do MoneyPrinterTurbo e dependências que serão recriadas. Uma pasta antiga sem dados do utilizador, como `C:\Users\<utilizador>\AppData\Local\hermes` da tentativa incompleta, pode ser removida; uma pasta antiga que contenha Blueprints, Brandings ou storage é preservada e apenas avisada no terminal. Feche processos Python, Node, Streamlit e MobaXterm que estejam a usar as pastas antes de executar.
@@ -357,12 +357,15 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned
357
357
 
358
358
  ## 8. Configuração inicial da UI
359
359
 
360
- Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline**, **Pipeline TikTok**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. O menu expansível **Automação** contém a subaba **Automação Youtube**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **AI Influencers** contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. O **Tutorial Meta** apresenta o guia de configuração de Instagram e credenciais Meta para automações com n8n. O **Tutorial Supabase** apresenta o guia para criar as tabelas `plans` e `posts` e o bucket `instagram-images`, incluindo a ligação para a fonte original no GitHub; as duas frases promocionais da comunidade foram omitidas. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. O menu **Pipeline** contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. O menu **Configurações** contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. Abra **Configurações > Contas Google** para gerir contas Google/YouTube, cartões, documentos de Upload directo, `sessionInfo` por conta, a `INNERTUBE_API_KEY` global, contas adicionais e a configuração global do YouTube. Abra **Configurações > Configuração API** para configurar as restantes API Keys, providers, modelos, serviços e o **Teste de vozes**. A página **Configurações > Notificações** contém as subabas **Geral** e **Telegram**. A subaba Geral mantém o histórico e as preferências dos eventos locais; a subaba Telegram envia esses mesmos eventos para o Chat ID configurado. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** em **Contas Google** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica em **Contas Google**, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
360
+ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**, **Pipeline Vídeos**, **Pipeline Música**, **Automação**, **Edição**, **AI Influencers** e **Configurações**. O menu expansível **Pipeline Música** contém **Criação de Músicas** e **Upload Música**, cuja página tem as subabas **JewelMusic**, **Pushtunes** e **ytmusicapi**. O menu expansível **Pipeline Vídeos** contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. O menu expansível **Automação** contém a subaba **Automação Youtube**. O menu expansível **Edição** contém **Limpador de Metadados**, **Cortes** e **Editor Python**. O menu expansível **AI Influencers** contém **Personagens**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. O **Tutorial Meta** apresenta o guia de configuração de Instagram e credenciais Meta para automações com n8n. O **Tutorial Supabase** apresenta o guia para criar as tabelas `plans` e `posts` e o bucket `instagram-images`, incluindo a ligação para a fonte original no GitHub; as duas frases promocionais da comunidade foram omitidas. O menu expansível **Niche Finder** contém **Niche Finder Kaggle** e **Niche Finder Apify**. A aba **Niche Finder Kaggle** mantém a análise do dataset Kaggle; **Niche Finder Apify** é uma segunda alternativa independente, com actor, dataset, parâmetros, credencial, execução e resultados próprios. O menu **Pipeline Vídeos** contém **Criação de Vídeos**, **Backlog Vídeos**, **Roteiros**, **Thumbnails** e **Upload**. O menu **Pipeline Música** contém **Criação de Músicas** e **Upload Música**. O menu **Configurações** contém **Canais Youtube**, **Blueprints Youtube**, **MCP**, **Contas Google**, **Configuração API** e **Notificações**. Abra **Configurações > Contas Google** para gerir contas Google/YouTube, cartões, documentos de Upload directo, `sessionInfo` por conta, a `INNERTUBE_API_KEY` global, contas adicionais e a configuração global do YouTube. Abra **Configurações > Configuração API** para configurar as restantes API Keys, providers, modelos, serviços e o **Teste de vozes**. A página **Configurações > Notificações** contém as subabas **Geral** e **Telegram**. A subaba Geral mantém o histórico e as preferências dos eventos locais; a subaba Telegram envia esses mesmos eventos para o Chat ID configurado. A página **Pipeline Música > Upload Música** contém as subabas **JewelMusic**, **Pushtunes** e **ytmusicapi**. Para YouTube, preencha primeiro o par **OAuth Client ID + OAuth Client Secret** em **Contas Google** se pretende autorizar uploads. A **YouTube Data API Key** é uma credencial Google Cloud diferente e fica em **Contas Google**, apenas para o método oficial de métricas; Client ID + Client Secret não formam uma API Key.
361
361
 
362
362
  | Configuração | Finalidade |
363
363
  |---|---|
364
364
  | Porta Streamlit | Definir a porta local da UI |
365
365
  | Pasta MoneyPrinterTurbo | Indicar o clone local que será sincronizado com `config.toml` |
366
+ | JewelMusic API Key/Base URL | Configurar o upload de tracks e os metadados de título, artista, álbum, ano e género |
367
+ | Pushtunes | Escolher fonte, destino, operação, perfil/CSV, browser.json, playlist e credenciais Spotify para sincronização de biblioteca |
368
+ | ytmusicapi browser.json | Autenticar uploads directos para YouTube Music através de uma sessão local de browser |
366
369
  | YouTube Data API Key (opcional) | Em **Configurações > Contas Google**, permitir o método oficial Data API para métricas públicas; não é necessária para a página pública nem para OAuth |
367
370
  | YouTube OAuth Client ID | Em **Configurações > Contas Google**, identificar a aplicação OAuth 2.0 do Google para autorizar operações autenticadas, como upload |
368
371
  | YouTube OAuth Client Secret | Secret do mesmo cliente OAuth 2.0; não é uma Data API Key nem um token de acesso |
@@ -383,6 +386,16 @@ Na primeira execução, a barra lateral apresenta **Início**, **Niche Finder**,
383
386
  | Telegram Proxy | Proxy HTTP/HTTPS/SOCKS opcional para ambientes sem acesso directo |
384
387
  | Telegram timeout | Limite de espera de cada envio, entre 5 e 120 segundos |
385
388
 
389
+ ### Upload Música — JewelMusic, Pushtunes e ytmusicapi
390
+
391
+ 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.
392
+
393
+ Em **Pushtunes**, active a integração e seleccione uma fonte, um destino e uma operação (`tracks`, `albums` ou `playlist`). Para uma fonte CSV, carregue o ficheiro na própria subaba; para YouTube Music, indique o `browser.json`; para Spotify, preencha Client ID, Client Secret e Redirect URI ou use a configuração que o Pushtunes espera; para playlists, preencha o nome. Perfis `.toml` podem fornecer parâmetros adicionais. O botão de validação não sincroniza nada. O botão de execução chama o CLI do Pushtunes sem shell, com argumentos separados, e grava o resultado no histórico local. Pushtunes é uma sincronização de biblioteca, não um upload de bytes de um MP3 isolado.
394
+
395
+ Em **ytmusicapi**, carregue um `browser.json` ou indique o caminho de um ficheiro existente, active a integração e seleccione uma música local. O teste consulta a biblioteca de uploads sem escrever; o envio usa `upload_song` e aceita MP3, M4A, WMA, FLAC e OGG com menos de 300 MB. O ficheiro de autenticação fica em `storage/ytmusicapi/browser.json` quando é carregado pela UI e não é enviado para o GitHub. Todas as operações ficam registadas em `uploads.json`, enquanto as credenciais permanecem apenas no storage local.
396
+
397
+ As dependências `ytmusicapi>=1.12,<2` e `pushtunes>=2.15,<3` são instaladas pelo procedimento normal. O adaptador JewelMusic usa directamente 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`, sobretudo a licença AGPL do Pushtunes, antes de redistribuir o runtime.
398
+
386
399
  ### Telegram Gateway
387
400
 
388
401
  Em **Configurações > Notificações > Telegram**, active **Activar notificações Telegram**, introduza o **Telegram Bot Token** criado pelo BotFather e o **Telegram Chat ID** de destino. O botão **Testar chamada API** executa apenas o método read-only `getMe` para validar o bot; não envia uma mensagem de teste. O proxy é opcional e o suporte SOCKS depende das dependências instaladas pelo `requirements.txt`.
@@ -458,7 +471,7 @@ Ao abrir a página, o Thunderbolt não prepara dados públicos, não descarrega
458
471
 
459
472
  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.
460
473
 
461
- As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.35 install`; o instalador detecta e reutiliza o que já estiver válido.
474
+ As dependências adicionais — `scikit-learn`, `mlxtend`, `plotly`, `seaborn`, `matplotlib` e `kagglehub` — são instaladas pelo procedimento normal de `npx`. Em instalações existentes, execute novamente `npx.cmd --yes @danhachuel/thunderbolt@0.3.36 install`; o instalador detecta e reutiliza o que já estiver válido.
462
475
 
463
476
  ### Niche Finder Apify
464
477
 
package/README.md CHANGED
@@ -11,11 +11,13 @@ A primeira versão implementa a camada UI independente com:
11
11
  | Área | Incluído |
12
12
  |---|---|
13
13
  | Início | Resumo de canais, tarefas, backlog, execução e falhas, com as filas do Pipeline inline |
14
- | Pipeline | Menu expansível com Criação de Vídeos, Criação de Músicas, Roteiros e Upload |
14
+ | Pipeline Vídeos | Menu expansível com Criação de Vídeos, Backlog Vídeos, Roteiros, Thumbnails e Upload |
15
+ | Pipeline Música | Menu expansível com Criação de Músicas e Upload Música |
15
16
  | Blueprints Youtube | Leitura da pasta `storage/blueprints/`, upload/validação de JSON e criação a partir de link YouTube |
16
17
  | Brandings | Subaba própria dentro de Blueprints, upload/listagem de Brandings e criação conjunta com Blueprint |
17
18
  | 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 |
18
19
  | 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 |
20
+ | 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` |
19
21
  | 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 |
20
22
  | AI Influencers | Menu expansível com Personagens, Redes Sociais, Tutorial Meta e Tutorial Supabase; conteúdo interno localizado |
21
23
  | Niche Finder | Menu expansível com duas alternativas independentes: Niche Finder Kaggle e Niche Finder Apify, com parâmetros, execução e resultados separados |
@@ -33,6 +35,16 @@ A API key, o username/perfil e a lista inicial de plataformas continuam em **Con
33
35
 
34
36
  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**.
35
37
 
38
+ ## Upload Música — JewelMusic, Pushtunes e ytmusicapi
39
+
40
+ 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.
41
+
42
+ **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.
43
+
44
+ **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.
45
+
46
+ 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.
47
+
36
48
  ## Upload directo — credenciais por conta e por canal
37
49
 
38
50
  O Upload directo baseado no [YouTube-Video-Upload-Frontend-Api](https://github.com/Nojus10/YouTube-Video-Upload-Frontend-Api) usa um único documento JSON por conta Google. Em **Configurações > Contas Google > Contas Google/YouTube — canais em lote**, cada conta aparece como um cartão expansível identificado por **nome — e-mail**. Dentro do cartão existe o uploader **Subir documento de cookies/credenciais** e o único campo técnico permitido na UI, **sessionInfo token desta conta Google**. Cookies, `chunk_size` e `delegated_session_ids` continuam no documento de cada conta; `INNERTUBE_API_KEY` é configurada uma única vez como chave global para todas as contas e para todo o sistema.
@@ -56,15 +68,15 @@ No topo da área principal da aplicação existe o menu nativo de idioma no padr
56
68
 
57
69
  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` define **Dark** como base inicial 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.
58
70
 
59
- ## Navegação da UI 0.3.35
71
+ ## Navegação da UI 0.3.36
60
72
 
61
- 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** é expansível e contém **Criação de Vídeos**, **Criação de Músicas**, **Roteiros** e **Upload**. **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**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **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**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
73
+ 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**, **Redes Sociais**, **Tutorial Meta** e **Tutorial Supabase**, nessa ordem. **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**, **Configuração API** e **Notificações**. O Início reúne o dashboard e as filas do Pipeline, sem botões de acções rápidas.
62
74
 
63
75
  A subaba **Tutorial Supabase** apresenta o guia de criação das tabelas `plans` e `posts` e do bucket `instagram-images`, com ligação para a fonte original no GitHub. As duas frases promocionais da comunidade foram omitidas. Todas as subabas internas também são localizadas pelo helper nativo de tabs:
64
76
 
65
77
  O conteúdo interno das páginas também é localizado, não apenas a navegação: títulos, subtítulos, descrições, labels de campos, placeholders, opções de selectores, botões, avisos, mensagens de sucesso/erro, estados vazios, métricas, expanderes e blocos Markdown/HTML são traduzidos no idioma seleccionado através da camada global de conteúdo. Valores técnicos, chaves de estado, IDs de formulários, nomes de ficheiros, URLs e dados introduzidos pelo utilizador permanecem inalterados.
66
78
 
67
- Todas as subabas internas também são localizadas pelo helper nativo de tabs: **Blueprints/Brandings**, **Pesquisa pública/Cadastro manual/Contas cadastradas**, **Upload/Biblioteca**, **Importar do YouTube/Canais em lote gmail/Cadastro manual**, **Criar vídeo/Vídeos**, **Novo roteiro/letra/Histórico guardado**, as três abas de análise de clusters, as quatro abas de cortes, **Vídeos/Código Python**, as quatro opções de Upload, **API Keys/Teste de vozes**, **Serviços e modelos/Fontes de materiais**, **Client MCP/Servidor MCP/Skill** e **Notificações/Geral/Telegram**. As chaves técnicas e a ordem funcional permanecem inalteradas.
79
+ Todas as subabas internas também são localizadas pelo helper nativo de tabs: **Blueprints/Brandings**, **Pesquisa pública/Cadastro manual/Contas cadastradas**, **Upload/Biblioteca**, **Importar do YouTube/Canais em lote gmail/Cadastro manual**, **Criar vídeo/Vídeos**, **Novo roteiro/letra/Histórico guardado**, as três abas de análise de clusters, as quatro abas de cortes, **Vídeos/Código Python**, as quatro opções de Upload, **JewelMusic/Pushtunes/ytmusicapi**, **API Keys/Teste de vozes**, **Serviços e modelos/Fontes de materiais**, **Client MCP/Servidor MCP/Skill** e **Notificações/Geral/Telegram**. As chaves técnicas e a ordem funcional permanecem inalteradas.
68
80
 
69
81
  Dentro de **Configurações > Contas Google**, a UI contém os cartões expansíveis de contas Google/YouTube, `sessionInfo` por conta, documentos de Upload directo, o formulário **Adicionar outra conta Gmail**, o bloco global de `INNERTUBE_API_KEY` e a configuração global do YouTube (OAuth Client ID, OAuth Client Secret e YouTube Data API Key). A página **Configuração API** contém as restantes credenciais, providers, modelos, serviços, Nano Banana, TikTok e Postiz.
70
82
 
@@ -13,3 +13,15 @@ Thunderbolt adapts the data-analysis ideas and parts of the clustering/tag-assoc
13
13
  ## yt-dlp
14
14
 
15
15
  Thunderbolt depends on and embeds the Python API of [yt-dlp](https://github.com/yt-dlp/yt-dlp) for public media downloads. yt-dlp is distributed under [The Unlicense](https://github.com/yt-dlp/yt-dlp/blob/master/LICENSE), subject to the notices and licensing information maintained by the upstream project. This dependency is separate from Thunderbolt's MIT License.
16
+
17
+ ## ytmusicapi
18
+
19
+ Thunderbolt depends on [sigma67/ytmusicapi](https://github.com/sigma67/ytmusicapi) for the optional YouTube Music browser-authenticated music-upload workflow. Consulte a licença MIT, os avisos e as condições de distribuição mantidos no projecto upstream. Esta dependência é separada da licença MIT do Thunderbolt.
20
+
21
+ ## Pushtunes
22
+
23
+ Thunderbolt depends on [Psy-Q/pushtunes](https://pypi.org/project/pushtunes/) for the optional library-synchronisation workflow between local Subsonic/Jellyfin/CSV sources and Spotify, YouTube Music or Tidal. Pushtunes is distributed under the GNU Affero General Public License v3.0 or later; this notice does not change Thunderbolt's own licence and users should consult the complete upstream licence and notices before redistributing the combined runtime.
24
+
25
+ ## JewelMusic SDK
26
+
27
+ The JewelMusic upload adapter follows the documented HTTP contract and API examples from [jewelmusic/sdk](https://github.com/jewelmusic/sdk), which is published under the MIT License. The Python package `jewelmusic-sdk` was not available on PyPI at implementation time, so Thunderbolt does not bundle that unavailable package; the adapter uses the documented `POST /v1/tracks/upload` request directly and retains attribution to the upstream SDK repository.
package/app/main.py CHANGED
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import hashlib
4
4
  import html
5
5
  import json
6
+ import mimetypes
6
7
  import re
7
8
  from contextlib import nullcontext
8
9
  from datetime import date, datetime
@@ -64,6 +65,7 @@ from integrations.platforms import IntegrationResult, TikTokAdapter, YouTubeAdap
64
65
  from integrations.tiktok_public import fetch_public_tiktok_profile, normalize_tiktok_reference
65
66
  from integrations.postiz import PostizAdapter
66
67
  from integrations.upload_post import UploadPostAdapter, UPLOAD_POST_PLATFORM_OPTIONS, normalize_upload_post_platforms
68
+ from integrations.music_uploads import JewelMusicAdapter, PushtunesAdapter, YTMusicApiAdapter, MUSIC_UPLOAD_EXTENSIONS, PUSHTUNES_OPERATIONS, PUSHTUNES_SOURCES, PUSHTUNES_TARGETS, YT_MUSIC_UPLOAD_EXTENSIONS
67
69
  from integrations.upload_routing import OFFICIAL_DAILY_LIMIT, official_upload_count, upload_with_default_route
68
70
  from integrations.youtube_direct_upload import YouTubeDirectUploader
69
71
  from integrations.youtube_direct_credentials import delete_credentials_document, direct_account_status, document_status, ensure_credentials_document, load_credentials_document, merge_credentials_document, parse_credentials_document, save_credentials_document, update_credentials_document_session_info
@@ -3707,6 +3709,277 @@ def render_upload_direct():
3707
3709
  (st.success if result.ok else st.error)(result.message)
3708
3710
 
3709
3711
 
3712
+ def _persist_music_upload_settings(updates: dict[str, Any]) -> dict[str, Any]:
3713
+ settings = read_json("settings.json", {})
3714
+ settings.update(updates)
3715
+ write_json("settings.json", settings)
3716
+ return settings
3717
+
3718
+
3719
+ def _render_music_source(prefix: str, extensions: set[str]) -> str:
3720
+ source_mode = st.radio(
3721
+ "Origem da música",
3722
+ ["Ficheiro existente", "Carregar ficheiro"],
3723
+ horizontal=True,
3724
+ key=f"{prefix}_music_source_mode",
3725
+ )
3726
+ selected_path = str(st.session_state.get(f"{prefix}_music_path") or "")
3727
+ if source_mode == "Ficheiro existente":
3728
+ existing = [path for path in list_music_files() if path.suffix.lower() in extensions]
3729
+ if existing:
3730
+ selected = st.selectbox(
3731
+ "Música local",
3732
+ existing,
3733
+ index=next((index for index, item in enumerate(existing) if str(item) == selected_path), 0),
3734
+ format_func=lambda item: item.name,
3735
+ key=f"{prefix}_music_existing",
3736
+ )
3737
+ selected_path = str(selected)
3738
+ st.session_state[f"{prefix}_music_path"] = selected_path
3739
+ else:
3740
+ st.info("Ainda não existem ficheiros compatíveis em storage/music. Escolha Carregar ficheiro.")
3741
+ selected_path = ""
3742
+ else:
3743
+ uploaded = st.file_uploader(
3744
+ "Carregar ficheiro de música",
3745
+ type=sorted(extension.lstrip(".") for extension in extensions),
3746
+ key=f"{prefix}_music_file_upload",
3747
+ )
3748
+ if uploaded is not None and st.button("Guardar música no storage local", key=f"{prefix}_music_store", use_container_width=True):
3749
+ try:
3750
+ stored = store_music_file(uploaded.name, uploaded.getvalue())
3751
+ selected_path = str(stored)
3752
+ st.session_state[f"{prefix}_music_path"] = selected_path
3753
+ st.success(f"Música guardada em `{stored}`.")
3754
+ except (OSError, ValueError) as exc:
3755
+ st.error(str(exc))
3756
+ selected_path = str(st.session_state.get(f"{prefix}_music_path") or selected_path)
3757
+ if selected_path and Path(selected_path).is_file():
3758
+ st.caption(f"Ficheiro seleccionado: `{selected_path}`")
3759
+ return selected_path
3760
+ return ""
3761
+
3762
+
3763
+ def _record_music_upload(destination: str, result: IntegrationResult, *, music_path: str = "", target: dict[str, Any] | None = None) -> dict[str, Any]:
3764
+ record = {
3765
+ "id": uuid.uuid4().hex,
3766
+ "destination": destination,
3767
+ "music_path": music_path,
3768
+ "target": target or {},
3769
+ "status": "published" if result.ok else "failed",
3770
+ "message": result.message,
3771
+ "data": result.data,
3772
+ "created_at": now(),
3773
+ }
3774
+ uploads = read_json("uploads.json", [])
3775
+ uploads.append(record)
3776
+ write_json("uploads.json", uploads)
3777
+ reconcile_persisted_notifications()
3778
+ return record
3779
+
3780
+
3781
+ def _render_music_upload_history() -> None:
3782
+ records = [
3783
+ record
3784
+ for record in read_json("uploads.json", [])
3785
+ if isinstance(record, dict)
3786
+ and any(name in str(record.get("destination") or "").lower() for name in ("jewelmusic", "pushtunes", "youtube music", "ytmusicapi"))
3787
+ ]
3788
+ st.divider()
3789
+ st.subheader("Histórico de uploads de música")
3790
+ if not records:
3791
+ st.caption("Ainda não existem uploads de música registados.")
3792
+ return
3793
+ for record in reversed(records[-20:]):
3794
+ status = "Concluído" if record.get("status") == "published" else "Falhou"
3795
+ path = Path(str(record.get("music_path") or ""))
3796
+ with st.container(border=True):
3797
+ st.write(f"**{record.get('destination', 'Upload musical')}** · {status}")
3798
+ st.caption(f"{record.get('created_at', '—')} · {path.name if path.name else record.get('target', {})}")
3799
+ st.write(record.get("message", ""))
3800
+ if path.is_file():
3801
+ st.download_button(
3802
+ "Descarregar cópia local",
3803
+ data=path.read_bytes(),
3804
+ file_name=path.name,
3805
+ mime=mimetypes.guess_type(path.name)[0] or "audio/mpeg",
3806
+ key=f"music_history_download_{record.get('id')}",
3807
+ )
3808
+
3809
+
3810
+ def _render_jewelmusic_upload_tab() -> None:
3811
+ st.subheader("JewelMusic")
3812
+ st.caption("Upload de tracks para o JewelMusic através do endpoint documentado do SDK, com distribuição posterior para as plataformas disponíveis na sua conta.")
3813
+ st.info("A API Key é guardada apenas no settings.json local. O teste consulta /v1/ping e não cria uma track.")
3814
+ settings = read_json("settings.json", {})
3815
+ with st.form("jewelmusic_settings_form"):
3816
+ enabled = st.checkbox("Activar JewelMusic", value=bool(settings.get("jewelmusic_enabled", False)))
3817
+ api_key = st.text_input("JewelMusic API Key", value=str(settings.get("jewelmusic_api_key") or ""), type="password")
3818
+ base_url = st.text_input("Base URL", value=str(settings.get("jewelmusic_base_url") or "https://api.jewelmusic.com"))
3819
+ proxy_url = st.text_input("Proxy opcional", value=str(settings.get("jewelmusic_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
3820
+ timeout_seconds = st.number_input("Timeout (segundos)", min_value=5, max_value=900, value=int(settings.get("jewelmusic_timeout_seconds", 120)), step=5)
3821
+ save = st.form_submit_button("Guardar configuração JewelMusic", type="primary", use_container_width=True)
3822
+ if save:
3823
+ settings = _persist_music_upload_settings({
3824
+ "jewelmusic_enabled": bool(enabled),
3825
+ "jewelmusic_api_key": api_key.strip(),
3826
+ "jewelmusic_base_url": base_url.strip().rstrip("/"),
3827
+ "jewelmusic_proxy_url": proxy_url.strip(),
3828
+ "jewelmusic_timeout_seconds": int(timeout_seconds),
3829
+ })
3830
+ st.success("Configuração JewelMusic guardada no storage local.")
3831
+ adapter = JewelMusicAdapter(settings)
3832
+ if st.button("Testar conexão JewelMusic", key="jewelmusic_test", use_container_width=True):
3833
+ test_result = adapter.test_connection()
3834
+ (st.success if test_result.ok else st.error)(test_result.message)
3835
+ music_path = _render_music_source("jewelmusic", MUSIC_UPLOAD_EXTENSIONS)
3836
+ metadata_cols = st.columns(4)
3837
+ with metadata_cols[0]:
3838
+ title = st.text_input("Título", value=Path(music_path).stem if music_path else "", key="jewelmusic_title")
3839
+ with metadata_cols[1]:
3840
+ artist = st.text_input("Artista", key="jewelmusic_artist")
3841
+ with metadata_cols[2]:
3842
+ album = st.text_input("Álbum", key="jewelmusic_album")
3843
+ with metadata_cols[3]:
3844
+ year = st.text_input("Ano", key="jewelmusic_year")
3845
+ genre = st.text_input("Género", key="jewelmusic_genre")
3846
+ if st.button("Enviar música para JewelMusic", type="primary", key="jewelmusic_upload", use_container_width=True, disabled=not bool(music_path)):
3847
+ result = JewelMusicAdapter(read_json("settings.json", {})).upload_track(music_path, title=title, artist=artist, album=album, year=year, genre=genre)
3848
+ _record_music_upload("JewelMusic", result, music_path=music_path, target={"artist": artist, "title": title})
3849
+ (st.success if result.ok else st.error)(result.message)
3850
+
3851
+
3852
+ def _store_pushtunes_csv(uploaded: Any) -> str:
3853
+ target = STORAGE / "music" / "pushtunes-source.csv"
3854
+ target.parent.mkdir(parents=True, exist_ok=True)
3855
+ target.write_bytes(uploaded.getvalue())
3856
+ return str(target)
3857
+
3858
+
3859
+ def _render_pushtunes_upload_tab() -> None:
3860
+ st.subheader("Pushtunes")
3861
+ st.caption("Sincronização de biblioteca a partir de Subsonic, Jellyfin ou CSV para Spotify, YouTube Music ou Tidal. Pushtunes não transforma um MP3 isolado num upload; para isso use JewelMusic ou ytmusicapi.")
3862
+ st.warning("O Pushtunes usa as credenciais próprias dos serviços. O Thunderbolt apenas inicia o comando local com parâmetros separados e mascara os segredos na saída.")
3863
+ settings = read_json("settings.json", {})
3864
+ with st.form("pushtunes_settings_form"):
3865
+ enabled = st.checkbox("Activar Pushtunes", value=bool(settings.get("pushtunes_enabled", False)))
3866
+ executable = st.text_input("Executável ou módulo", value=str(settings.get("pushtunes_executable") or "pushtunes"), help="Por padrão é usado o comando pushtunes instalado no ambiente Python do Thunderbolt.")
3867
+ source = st.selectbox("Fonte", list(PUSHTUNES_SOURCES), index=list(PUSHTUNES_SOURCES).index(str(settings.get("pushtunes_source") or "csv")) if str(settings.get("pushtunes_source") or "csv") in PUSHTUNES_SOURCES else 0, format_func=lambda value: {"csv": "CSV local", "subsonic": "Subsonic/Navidrome", "jellyfin": "Jellyfin", "spotify": "Spotify", "ytm": "YouTube Music"}.get(value, value))
3868
+ target = st.selectbox("Destino", list(PUSHTUNES_TARGETS), index=list(PUSHTUNES_TARGETS).index(str(settings.get("pushtunes_target") or "ytm")) if str(settings.get("pushtunes_target") or "ytm") in PUSHTUNES_TARGETS else 0, format_func=lambda value: {"spotify": "Spotify", "ytm": "YouTube Music", "tidal": "Tidal", "csv": "CSV"}.get(value, value))
3869
+ operation = st.selectbox("Operação", list(PUSHTUNES_OPERATIONS), index=list(PUSHTUNES_OPERATIONS).index(str(settings.get("pushtunes_operation") or "tracks")) if str(settings.get("pushtunes_operation") or "tracks") in PUSHTUNES_OPERATIONS else 0, format_func=lambda value: {"tracks": "Tracks", "albums": "Álbuns", "playlist": "Playlist"}.get(value, value))
3870
+ profile = st.text_input("Perfil Pushtunes (.toml), opcional", value=str(settings.get("pushtunes_profile") or ""))
3871
+ csv_file = st.text_input("Caminho CSV", value=str(settings.get("pushtunes_csv_file") or ""))
3872
+ ytm_auth_file = st.text_input("Caminho browser.json do YouTube Music", value=str(settings.get("pushtunes_ytm_auth_file") or ""))
3873
+ tidal_session_file = st.text_input("Caminho tidal-session.json do Tidal", value=str(settings.get("pushtunes_tidal_session_file") or ""))
3874
+ playlist_name = st.text_input("Nome da playlist", value=str(settings.get("pushtunes_playlist_name") or ""))
3875
+ similarity = st.slider("Similaridade mínima", min_value=0.0, max_value=1.0, value=float(settings.get("pushtunes_similarity", 0.8)), step=0.05)
3876
+ working_directory = st.text_input("Directório de trabalho", value=str(settings.get("pushtunes_working_directory") or ""))
3877
+ spotify_client_id = st.text_input("Spotify Client ID", value=str(settings.get("pushtunes_spotify_client_id") or ""))
3878
+ spotify_client_secret = st.text_input("Spotify Client Secret", value=str(settings.get("pushtunes_spotify_client_secret") or ""), type="password")
3879
+ spotify_redirect_uri = st.text_input("Spotify Redirect URI", value=str(settings.get("pushtunes_spotify_redirect_uri") or ""))
3880
+ timeout_seconds = st.number_input("Timeout Pushtunes (segundos)", min_value=30, max_value=3600, value=int(settings.get("pushtunes_timeout_seconds", 1800)), step=30)
3881
+ save = st.form_submit_button("Guardar configuração Pushtunes", type="primary", use_container_width=True)
3882
+ if save:
3883
+ settings = _persist_music_upload_settings({
3884
+ "pushtunes_enabled": bool(enabled),
3885
+ "pushtunes_executable": executable.strip() or "pushtunes",
3886
+ "pushtunes_source": source,
3887
+ "pushtunes_target": target,
3888
+ "pushtunes_operation": operation,
3889
+ "pushtunes_profile": profile.strip(),
3890
+ "pushtunes_csv_file": csv_file.strip(),
3891
+ "pushtunes_ytm_auth_file": ytm_auth_file.strip(),
3892
+ "pushtunes_tidal_session_file": tidal_session_file.strip(),
3893
+ "pushtunes_playlist_name": playlist_name.strip(),
3894
+ "pushtunes_similarity": float(similarity),
3895
+ "pushtunes_working_directory": working_directory.strip(),
3896
+ "pushtunes_spotify_client_id": spotify_client_id.strip(),
3897
+ "pushtunes_spotify_client_secret": spotify_client_secret.strip(),
3898
+ "pushtunes_spotify_redirect_uri": spotify_redirect_uri.strip(),
3899
+ "pushtunes_timeout_seconds": int(timeout_seconds),
3900
+ })
3901
+ st.success("Configuração Pushtunes guardada no storage local.")
3902
+ if source == "csv":
3903
+ st.caption("Pode carregar o CSV nesta página e usar o caminho guardado como origem Pushtunes.")
3904
+ csv_upload = st.file_uploader("Carregar CSV de origem Pushtunes", type=["csv"], key="pushtunes_csv_upload")
3905
+ if csv_upload is not None and st.button("Guardar CSV Pushtunes no storage", key="pushtunes_csv_store", use_container_width=True):
3906
+ stored_csv = _store_pushtunes_csv(csv_upload)
3907
+ settings = _persist_music_upload_settings({"pushtunes_csv_file": stored_csv})
3908
+ st.success(f"CSV guardado em `{stored_csv}`.")
3909
+ adapter = PushtunesAdapter(read_json("settings.json", {}))
3910
+ status = adapter.status()
3911
+ (st.success if status.ok else st.warning)(status.message)
3912
+ if st.button("Validar instalação e configuração Pushtunes", key="pushtunes_test", use_container_width=True):
3913
+ check = PushtunesAdapter(read_json("settings.json", {})).status()
3914
+ (st.success if check.ok else st.error)(check.message)
3915
+ if st.button("Executar sincronização Pushtunes", type="primary", key="pushtunes_sync", use_container_width=True, disabled=not status.ok):
3916
+ result = PushtunesAdapter(read_json("settings.json", {})).sync()
3917
+ _record_music_upload(f"Pushtunes ({adapter.source} → {adapter.target})", result, target={"source": adapter.source, "target": adapter.target, "operation": adapter.operation})
3918
+ (st.success if result.ok else st.error)(result.message)
3919
+ if result.data.get("stdout") or result.data.get("stderr"):
3920
+ with st.expander("Saída técnica do Pushtunes"):
3921
+ st.text(result.data.get("stdout", ""))
3922
+ if result.data.get("stderr"):
3923
+ st.text(result.data["stderr"])
3924
+
3925
+
3926
+ def _store_ytmusicapi_auth(uploaded: Any) -> str:
3927
+ target = STORAGE / "ytmusicapi" / "browser.json"
3928
+ target.parent.mkdir(parents=True, exist_ok=True)
3929
+ target.write_bytes(uploaded.getvalue())
3930
+ return str(target)
3931
+
3932
+
3933
+ def _render_ytmusicapi_upload_tab() -> None:
3934
+ st.subheader("ytmusicapi")
3935
+ st.caption("Upload de músicas para YouTube Music através da API não oficial ytmusicapi. O serviço exige autenticação de browser e aceita MP3, M4A, WMA, FLAC ou OGG até 300 MB.")
3936
+ st.warning("Use um browser.json exportado/configurado pelo ytmusicapi. Não cole cookies ou tokens na conversa, no GitHub ou em issues; o ficheiro fica apenas no storage local.")
3937
+ settings = read_json("settings.json", {})
3938
+ with st.form("ytmusicapi_settings_form"):
3939
+ enabled = st.checkbox("Activar ytmusicapi", value=bool(settings.get("ytmusicapi_enabled", False)))
3940
+ auth_file = st.text_input("Caminho do browser.json", value=str(settings.get("ytmusicapi_auth_file") or ""))
3941
+ proxy_url = st.text_input("Proxy opcional", value=str(settings.get("ytmusicapi_proxy_url") or ""), placeholder="http://127.0.0.1:8080")
3942
+ timeout_seconds = st.number_input("Timeout (segundos)", min_value=30, max_value=900, value=int(settings.get("ytmusicapi_timeout_seconds", 240)), step=10)
3943
+ save = st.form_submit_button("Guardar configuração ytmusicapi", type="primary", use_container_width=True)
3944
+ if save:
3945
+ settings = _persist_music_upload_settings({
3946
+ "ytmusicapi_enabled": bool(enabled),
3947
+ "ytmusicapi_auth_file": auth_file.strip(),
3948
+ "ytmusicapi_proxy_url": proxy_url.strip(),
3949
+ "ytmusicapi_timeout_seconds": int(timeout_seconds),
3950
+ })
3951
+ st.success("Configuração ytmusicapi guardada no storage local.")
3952
+ auth_upload = st.file_uploader("Carregar browser.json", type=["json"], key="ytmusicapi_auth_upload")
3953
+ if auth_upload is not None and st.button("Guardar browser.json no storage local", key="ytmusicapi_auth_store", use_container_width=True):
3954
+ stored_auth = _store_ytmusicapi_auth(auth_upload)
3955
+ settings = _persist_music_upload_settings({"ytmusicapi_auth_file": stored_auth, "ytmusicapi_enabled": True})
3956
+ st.success(f"Ficheiro de autenticação guardado em `{stored_auth}`.")
3957
+ adapter = YTMusicApiAdapter(read_json("settings.json", {}))
3958
+ status = adapter.status()
3959
+ (st.success if status.ok else st.warning)(status.message)
3960
+ if st.button("Testar autenticação ytmusicapi", key="ytmusicapi_test", use_container_width=True):
3961
+ result = YTMusicApiAdapter(read_json("settings.json", {})).test_connection()
3962
+ (st.success if result.ok else st.error)(result.message)
3963
+ music_path = _render_music_source("ytmusicapi", YT_MUSIC_UPLOAD_EXTENSIONS)
3964
+ if st.button("Enviar música para YouTube Music", type="primary", key="ytmusicapi_upload", use_container_width=True, disabled=not bool(music_path) or not status.ok):
3965
+ result = YTMusicApiAdapter(read_json("settings.json", {})).upload_song(music_path)
3966
+ _record_music_upload("ytmusicapi / YouTube Music", result, music_path=music_path, target={"service": "youtube_music"})
3967
+ (st.success if result.ok else st.error)(result.message)
3968
+
3969
+
3970
+ def render_music_upload() -> None:
3971
+ st.title("Upload Música")
3972
+ 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.")
3973
+ jewel_tab, pushtunes_tab, ytmusicapi_tab = render_localized_tabs(["JewelMusic", "Pushtunes", "ytmusicapi"])
3974
+ with jewel_tab:
3975
+ _render_jewelmusic_upload_tab()
3976
+ with pushtunes_tab:
3977
+ _render_pushtunes_upload_tab()
3978
+ with ytmusicapi_tab:
3979
+ _render_ytmusicapi_upload_tab()
3980
+ _render_music_upload_history()
3981
+
3982
+
3710
3983
  def render_upload():
3711
3984
  st.title("Upload")
3712
3985
  upload_tab, direct_tab, postiz_tab, upload_post_tab = render_localized_tabs(["Upload convencional", "Upload directo", "Postiz", "Upload-Post"])
@@ -5561,7 +5834,7 @@ def main():
5561
5834
  "Criação de Vídeos": render_new_video,
5562
5835
  "Backlog Vídeos": render_videos,
5563
5836
  "Criação de Músicas": render_music_creation,
5564
- "Upload Música": lambda: render_edit_placeholder("Upload Música", ""),
5837
+ "Upload Música": render_music_upload,
5565
5838
  "Roteiros": render_scripts,
5566
5839
  "Thumbnails": render_thumbnails,
5567
5840
  "Upload": render_upload,
@@ -268,6 +268,31 @@ DEFAULTS: dict[str, Any] = {
268
268
  "suno_api_key": "",
269
269
  "suno_api_base_url": "",
270
270
  "suno_api_endpoint": "/api/generate",
271
+ "jewelmusic_enabled": False,
272
+ "jewelmusic_api_key": "",
273
+ "jewelmusic_base_url": "https://api.jewelmusic.com",
274
+ "jewelmusic_proxy_url": "",
275
+ "jewelmusic_timeout_seconds": 120,
276
+ "pushtunes_enabled": False,
277
+ "pushtunes_executable": "pushtunes",
278
+ "pushtunes_source": "csv",
279
+ "pushtunes_target": "ytm",
280
+ "pushtunes_operation": "tracks",
281
+ "pushtunes_profile": "",
282
+ "pushtunes_csv_file": "",
283
+ "pushtunes_ytm_auth_file": "",
284
+ "pushtunes_tidal_session_file": "",
285
+ "pushtunes_playlist_name": "",
286
+ "pushtunes_similarity": 0.8,
287
+ "pushtunes_working_directory": "",
288
+ "pushtunes_spotify_client_id": "",
289
+ "pushtunes_spotify_client_secret": "",
290
+ "pushtunes_spotify_redirect_uri": "",
291
+ "pushtunes_timeout_seconds": 1800,
292
+ "ytmusicapi_enabled": False,
293
+ "ytmusicapi_auth_file": "",
294
+ "ytmusicapi_proxy_url": "",
295
+ "ytmusicapi_timeout_seconds": 240,
271
296
  "voice_preview_provider": "edge",
272
297
  "voice_preview_rate": "+0%",
273
298
  "direct_cookie_sid": "",
@@ -0,0 +1,381 @@
1
+ """Music upload integrations used by the Thunderbolt Upload Música page.
2
+
3
+ The adapters deliberately keep provider-specific behaviour behind the existing
4
+ IntegrationResult contract. They never log or return secrets and they do not
5
+ perform a remote write from a credential-validation call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import mimetypes
12
+ import os
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ import requests
21
+
22
+ from .platforms import IntegrationResult
23
+
24
+
25
+ MUSIC_UPLOAD_EXTENSIONS = {".mp3", ".m4a", ".wma", ".flac", ".ogg", ".wav", ".aac"}
26
+ YT_MUSIC_UPLOAD_EXTENSIONS = {".mp3", ".m4a", ".wma", ".flac", ".ogg"}
27
+ DEFAULT_JEWELMUSIC_BASE_URL = "https://api.jewelmusic.com"
28
+ DEFAULT_JEWELMUSIC_TIMEOUT = 120
29
+ DEFAULT_YTMUSICAPI_TIMEOUT = 240
30
+ DEFAULT_PUSHTUNES_TIMEOUT = 1800
31
+ PUSHTUNES_SOURCES = ("subsonic", "jellyfin", "csv", "spotify", "ytm")
32
+ PUSHTUNES_TARGETS = ("spotify", "ytm", "tidal", "csv")
33
+ PUSHTUNES_OPERATIONS = ("tracks", "albums", "playlist")
34
+
35
+
36
+ def _bounded_timeout(value: Any, default: int, maximum: int) -> int:
37
+ try:
38
+ return max(5, min(maximum, int(value)))
39
+ except (TypeError, ValueError):
40
+ return default
41
+
42
+
43
+ def _proxy_kwargs(proxy_url: str) -> dict[str, dict[str, str]]:
44
+ proxy = str(proxy_url or "").strip()
45
+ return {"proxies": {"http": proxy, "https": proxy}} if proxy else {}
46
+
47
+
48
+ def _audio_path(value: str | Path, allowed_extensions: set[str] | None = None) -> tuple[Path | None, str | None]:
49
+ path = Path(value).expanduser() if str(value or "").strip() else Path("")
50
+ if not path.is_file():
51
+ return None, f"Ficheiro de música não encontrado: {path}"
52
+ if path.stat().st_size <= 0:
53
+ return None, f"O ficheiro de música está vazio: {path.name}"
54
+ extensions = allowed_extensions or MUSIC_UPLOAD_EXTENSIONS
55
+ if path.suffix.lower() not in extensions:
56
+ allowed = ", ".join(sorted(extensions))
57
+ return None, f"Formato não suportado para este destino: {path.suffix or '(sem extensão)'}. Use {allowed}."
58
+ return path, None
59
+
60
+
61
+ def _response_payload(response: requests.Response) -> Any:
62
+ try:
63
+ return response.json()
64
+ except ValueError:
65
+ return {"text": response.text[:2000]}
66
+
67
+
68
+ def _redact_output(text: str, secrets: list[str]) -> str:
69
+ result = str(text or "")
70
+ for secret in secrets:
71
+ if secret:
72
+ result = result.replace(secret, "[redacted]")
73
+ return result[-8000:]
74
+
75
+
76
+ def _error(message: str, data: dict[str, Any] | None = None) -> IntegrationResult:
77
+ return IntegrationResult(False, message, data or {})
78
+
79
+
80
+ class JewelMusicAdapter:
81
+ """Client for JewelMusic's documented ``POST /v1/tracks/upload`` API.
82
+
83
+ The public GitHub repository currently documents the Python SDK but does
84
+ not publish ``jewelmusic-sdk`` on PyPI. Thunderbolt therefore mirrors the
85
+ SDK's documented HTTP contract with requests instead of adding an
86
+ unavailable dependency.
87
+ """
88
+
89
+ def __init__(self, settings: dict[str, Any] | None = None):
90
+ self.settings = settings or {}
91
+ self.enabled = bool(self.settings.get("jewelmusic_enabled", False))
92
+ self.api_key = str(self.settings.get("jewelmusic_api_key") or "").strip()
93
+ self.base_url = str(self.settings.get("jewelmusic_base_url") or DEFAULT_JEWELMUSIC_BASE_URL).strip().rstrip("/")
94
+ self.timeout = _bounded_timeout(self.settings.get("jewelmusic_timeout_seconds"), DEFAULT_JEWELMUSIC_TIMEOUT, 900)
95
+ self.proxy_url = str(self.settings.get("jewelmusic_proxy_url") or "").strip()
96
+
97
+ def _url(self, path: str) -> str:
98
+ return f"{self.base_url}/v1/{path.lstrip('/')}"
99
+
100
+ def _headers(self) -> dict[str, str]:
101
+ return {
102
+ "Authorization": f"Bearer {self.api_key}",
103
+ "Accept": "application/json",
104
+ "User-Agent": "Thunderbolt-JewelMusic/0.3",
105
+ }
106
+
107
+ def status(self) -> IntegrationResult:
108
+ if not self.enabled:
109
+ return _error("JewelMusic está desactivado nesta subaba.", {"status": "disabled"})
110
+ if not self.api_key:
111
+ return _error("JewelMusic não está configurado: introduza a API Key.", {"status": "missing_api_key"})
112
+ if not self.base_url.startswith(("http://", "https://")):
113
+ return _error("A Base URL JewelMusic deve começar por http:// ou https://.", {"status": "invalid_base_url"})
114
+ return IntegrationResult(True, "JewelMusic configurado.", {"base_url": self.base_url, "api_key_configured": True})
115
+
116
+ def test_connection(self) -> IntegrationResult:
117
+ status = self.status()
118
+ if not status.ok:
119
+ return status
120
+ try:
121
+ response = requests.get(self._url("ping"), headers=self._headers(), timeout=self.timeout, **_proxy_kwargs(self.proxy_url))
122
+ except requests.RequestException as exc:
123
+ return _error(f"Não foi possível contactar o JewelMusic: {exc}", {"status": "network_error"})
124
+ payload = _response_payload(response)
125
+ if response.status_code >= 400:
126
+ return _error(f"JewelMusic rejeitou a validação (HTTP {response.status_code}).", {"status_code": response.status_code, "payload": payload})
127
+ return IntegrationResult(True, "Ligação JewelMusic validada sem criar uma track.", {"status_code": response.status_code, "payload": payload})
128
+
129
+ def upload_track(self, audio_path: str | Path, *, title: str, artist: str, album: str = "", year: str = "", genre: str = "") -> IntegrationResult:
130
+ status = self.status()
131
+ if not status.ok:
132
+ return status
133
+ path, error = _audio_path(audio_path)
134
+ if error or path is None:
135
+ return _error(error or "Ficheiro de música inválido.")
136
+ clean_title = str(title or "").strip() or path.stem
137
+ clean_artist = str(artist or "").strip()
138
+ if not clean_artist:
139
+ return _error("Indique o artista antes de enviar para o JewelMusic.")
140
+ metadata = {
141
+ "title": clean_title,
142
+ "artist": clean_artist,
143
+ "album": str(album or "").strip(),
144
+ "year": str(year or "").strip(),
145
+ "genre": str(genre or "").strip(),
146
+ }
147
+ metadata = {key: value for key, value in metadata.items() if value}
148
+ content_type = mimetypes.guess_type(path.name)[0] or "audio/mpeg"
149
+ try:
150
+ with path.open("rb") as handle:
151
+ response = requests.post(
152
+ self._url("tracks/upload"),
153
+ headers=self._headers(),
154
+ data=metadata,
155
+ files={"file": (path.name, handle, content_type)},
156
+ timeout=self.timeout,
157
+ **_proxy_kwargs(self.proxy_url),
158
+ )
159
+ except requests.RequestException as exc:
160
+ return _error(f"Não foi possível enviar a música para o JewelMusic: {exc}", {"filename": path.name})
161
+ payload = _response_payload(response)
162
+ if response.status_code >= 400:
163
+ return _error(f"JewelMusic rejeitou a música (HTTP {response.status_code}).", {"filename": path.name, "status_code": response.status_code, "payload": payload})
164
+ return IntegrationResult(True, f"Música enviada para o JewelMusic: {clean_title}.", {"filename": path.name, "title": clean_title, "artist": clean_artist, "payload": payload, "status_code": response.status_code})
165
+
166
+
167
+ class YTMusicApiAdapter:
168
+ """Adapter around ytmusicapi's browser-authenticated upload_song method."""
169
+
170
+ def __init__(self, settings: dict[str, Any] | None = None):
171
+ self.settings = settings or {}
172
+ self.enabled = bool(self.settings.get("ytmusicapi_enabled", False))
173
+ self.auth_file = str(self.settings.get("ytmusicapi_auth_file") or "").strip()
174
+ self.proxy_url = str(self.settings.get("ytmusicapi_proxy_url") or "").strip()
175
+ self.timeout = _bounded_timeout(self.settings.get("ytmusicapi_timeout_seconds"), DEFAULT_YTMUSICAPI_TIMEOUT, 900)
176
+
177
+ def _auth_path(self) -> Path:
178
+ return Path(self.auth_file).expanduser()
179
+
180
+ def status(self) -> IntegrationResult:
181
+ if not self.enabled:
182
+ return _error("ytmusicapi está desactivado nesta subaba.", {"status": "disabled"})
183
+ if not self.auth_file:
184
+ return _error("ytmusicapi não está configurado: indique o caminho do browser.json.", {"status": "missing_auth_file"})
185
+ path = self._auth_path()
186
+ if not path.is_file():
187
+ return _error(f"Ficheiro de autenticação ytmusicapi não encontrado: {path}", {"status": "missing_auth_file", "path": str(path)})
188
+ try:
189
+ from ytmusicapi import YTMusic # type: ignore
190
+ except ImportError:
191
+ return _error("A dependência ytmusicapi não está instalada. Execute novamente a instalação do Thunderbolt.", {"status": "dependency_missing"})
192
+ try:
193
+ YTMusic(str(path), proxies=_proxy_kwargs(self.proxy_url).get("proxies"))
194
+ except Exception as exc:
195
+ return _error(f"O browser.json não foi aceite pelo ytmusicapi: {exc}", {"status": "invalid_auth_file", "path": str(path)})
196
+ return IntegrationResult(True, "ytmusicapi pronto com autenticação de browser.", {"auth_file": str(path), "auth_type": "browser"})
197
+
198
+ def _client(self):
199
+ from ytmusicapi import YTMusic # type: ignore
200
+
201
+ return YTMusic(str(self._auth_path()), proxies=_proxy_kwargs(self.proxy_url).get("proxies"))
202
+
203
+ def test_connection(self) -> IntegrationResult:
204
+ status = self.status()
205
+ if not status.ok:
206
+ return status
207
+ try:
208
+ songs = self._client().get_library_upload_songs(limit=1)
209
+ except Exception as exc:
210
+ return _error(f"Não foi possível consultar a biblioteca de uploads do YouTube Music: {exc}", {"status": "network_or_auth_error"})
211
+ count = len(songs) if isinstance(songs, list) else 0
212
+ return IntegrationResult(True, "Autenticação ytmusicapi validada com consulta read-only.", {"sample_count": count})
213
+
214
+ def upload_song(self, audio_path: str | Path) -> IntegrationResult:
215
+ status = self.status()
216
+ if not status.ok:
217
+ return status
218
+ path, error = _audio_path(audio_path, YT_MUSIC_UPLOAD_EXTENSIONS)
219
+ if error or path is None:
220
+ return _error(error or "Ficheiro de música inválido.")
221
+ if path.stat().st_size >= 300 * 1024 * 1024:
222
+ return _error("O YouTube Music não aceita uploads com 300 MB ou mais.", {"filename": path.name})
223
+ try:
224
+ result = self._client().upload_song(str(path))
225
+ except Exception as exc:
226
+ return _error(f"Não foi possível enviar a música para o YouTube Music: {exc}", {"filename": path.name})
227
+ result_name = str(getattr(result, "name", result))
228
+ if "SUCCEEDED" not in result_name.upper():
229
+ data: dict[str, Any] = {"filename": path.name, "result": result_name}
230
+ if hasattr(result, "status_code"):
231
+ data["status_code"] = result.status_code
232
+ return _error(f"O YouTube Music não confirmou o upload de {path.name}.", data)
233
+ return IntegrationResult(True, f"Música enviada para o YouTube Music: {path.stem}.", {"filename": path.name, "result": result_name})
234
+
235
+
236
+ class PushtunesAdapter:
237
+ """Safe wrapper around the Pushtunes CLI library synchronizer.
238
+
239
+ Pushtunes moves library metadata between local sources and Spotify, YTM or
240
+ Tidal. It does not upload arbitrary local MP3 bytes, so the UI exposes the
241
+ supported source/target sync operations instead of mislabelling them as a
242
+ single-file upload.
243
+ """
244
+
245
+ def __init__(self, settings: dict[str, Any] | None = None):
246
+ self.settings = settings or {}
247
+ self.enabled = bool(self.settings.get("pushtunes_enabled", False))
248
+ self.executable = str(self.settings.get("pushtunes_executable") or "pushtunes").strip()
249
+ self.source = str(self.settings.get("pushtunes_source") or "csv").strip().lower()
250
+ self.target = str(self.settings.get("pushtunes_target") or "ytm").strip().lower()
251
+ self.operation = str(self.settings.get("pushtunes_operation") or "tracks").strip().lower()
252
+ self.profile = str(self.settings.get("pushtunes_profile") or "").strip()
253
+ self.csv_file = str(self.settings.get("pushtunes_csv_file") or "").strip()
254
+ self.ytm_auth_file = str(self.settings.get("pushtunes_ytm_auth_file") or "").strip()
255
+ self.tidal_session_file = str(self.settings.get("pushtunes_tidal_session_file") or "").strip()
256
+ self.playlist_name = str(self.settings.get("pushtunes_playlist_name") or "").strip()
257
+ self.similarity = self.settings.get("pushtunes_similarity", 0.8)
258
+ self.working_directory = str(self.settings.get("pushtunes_working_directory") or "").strip()
259
+ self.spotify_client_id = str(self.settings.get("pushtunes_spotify_client_id") or "").strip()
260
+ self.spotify_client_secret = str(self.settings.get("pushtunes_spotify_client_secret") or "").strip()
261
+ self.spotify_redirect_uri = str(self.settings.get("pushtunes_spotify_redirect_uri") or "").strip()
262
+ self.timeout = _bounded_timeout(self.settings.get("pushtunes_timeout_seconds"), DEFAULT_PUSHTUNES_TIMEOUT, 3600)
263
+
264
+ def _command_prefix(self) -> list[str] | None:
265
+ if Path(self.executable).expanduser().is_file() or shutil.which(self.executable):
266
+ return [str(Path(self.executable).expanduser())] if Path(self.executable).expanduser().is_file() else [self.executable]
267
+ if self.executable == "pushtunes":
268
+ try:
269
+ import importlib.util
270
+
271
+ if importlib.util.find_spec("pushtunes") is not None:
272
+ return [sys.executable, "-m", "pushtunes.cli.main"]
273
+ except (ImportError, ValueError):
274
+ pass
275
+ return None
276
+
277
+ def status(self) -> IntegrationResult:
278
+ if not self.enabled:
279
+ return _error("Pushtunes está desactivado nesta subaba.", {"status": "disabled"})
280
+ prefix = self._command_prefix()
281
+ if prefix is None:
282
+ return _error("Pushtunes não está instalado ou o executável não foi encontrado. Execute novamente a instalação do Thunderbolt.", {"status": "executable_missing", "executable": self.executable})
283
+ if self.source not in PUSHTUNES_SOURCES:
284
+ return _error(f"Fonte Pushtunes inválida: {self.source}.", {"status": "invalid_source", "allowed": list(PUSHTUNES_SOURCES)})
285
+ if self.target not in PUSHTUNES_TARGETS:
286
+ return _error(f"Destino Pushtunes inválido: {self.target}.", {"status": "invalid_target", "allowed": list(PUSHTUNES_TARGETS)})
287
+ if self.operation not in PUSHTUNES_OPERATIONS:
288
+ return _error(f"Operação Pushtunes inválida: {self.operation}.", {"status": "invalid_operation", "allowed": list(PUSHTUNES_OPERATIONS)})
289
+ if self.profile and not Path(self.profile).expanduser().is_file():
290
+ return _error(f"Perfil Pushtunes não encontrado: {self.profile}", {"status": "missing_profile"})
291
+ if self.source == "csv" and not self.csv_file:
292
+ return _error("Indique um CSV de origem quando a fonte Pushtunes for csv.", {"status": "missing_csv"})
293
+ if self.csv_file and not Path(self.csv_file).expanduser().is_file() and self.source == "csv":
294
+ return _error(f"CSV de origem Pushtunes não encontrado: {self.csv_file}", {"status": "missing_csv"})
295
+ if self.ytm_auth_file and not Path(self.ytm_auth_file).expanduser().is_file():
296
+ return _error(f"Ficheiro browser.json do YouTube Music não encontrado: {self.ytm_auth_file}", {"status": "missing_ytm_auth"})
297
+ if self.target == "tidal" and not self.tidal_session_file:
298
+ return _error("Indique o ficheiro tidal-session.json para usar o destino Tidal.", {"status": "missing_tidal_session"})
299
+ if self.tidal_session_file and not Path(self.tidal_session_file).expanduser().is_file():
300
+ return _error(f"Ficheiro de sessão Tidal não encontrado: {self.tidal_session_file}", {"status": "missing_tidal_session"})
301
+ if self.target == "csv" and not self.csv_file:
302
+ return _error("Indique um CSV de destino quando o alvo Pushtunes for csv.", {"status": "missing_csv"})
303
+ if self.operation == "playlist" and not self.playlist_name and not self.profile:
304
+ return _error("Indique o nome da playlist ou use um perfil Pushtunes.", {"status": "missing_playlist_name"})
305
+ if self.working_directory and not Path(self.working_directory).expanduser().is_dir():
306
+ return _error(f"Directório de trabalho Pushtunes não encontrado: {self.working_directory}", {"status": "missing_working_directory"})
307
+ return IntegrationResult(True, "Pushtunes pronto para sincronização de biblioteca.", {"command": prefix, "source": self.source, "target": self.target, "operation": self.operation})
308
+
309
+ def _args(self) -> list[str]:
310
+ args = ["push", self.operation, "--from", self.source, "--to", self.target, "--no-color"]
311
+ try:
312
+ args.extend(["--similarity", str(max(0.0, min(1.0, float(self.similarity))))])
313
+ except (TypeError, ValueError):
314
+ args.extend(["--similarity", "0.8"])
315
+ if self.profile:
316
+ args.extend(["--profile", str(Path(self.profile).expanduser().resolve())])
317
+ if self.csv_file:
318
+ args.extend(["--csv-file", str(Path(self.csv_file).expanduser().resolve())])
319
+ if self.ytm_auth_file and self.operation in {"albums", "playlist"}:
320
+ args.extend(["--ytm-auth", str(Path(self.ytm_auth_file).expanduser().resolve())])
321
+ if self.operation == "playlist" and self.playlist_name:
322
+ args.extend(["--playlist-name", self.playlist_name])
323
+ return args
324
+
325
+ def sync(self) -> IntegrationResult:
326
+ status = self.status()
327
+ if not status.ok:
328
+ return status
329
+ prefix = self._command_prefix() or []
330
+ env = os.environ.copy()
331
+ if self.spotify_client_id:
332
+ env["SPOTIFY_CLIENT_ID"] = self.spotify_client_id
333
+ if self.spotify_client_secret:
334
+ env["SPOTIFY_CLIENT_SECRET"] = self.spotify_client_secret
335
+ if self.spotify_redirect_uri:
336
+ env["SPOTIFY_REDIRECT_URI"] = self.spotify_redirect_uri
337
+ command = [*prefix, *self._args()]
338
+ secrets = [self.spotify_client_id, self.spotify_client_secret]
339
+ temporary_cwd: tempfile.TemporaryDirectory[str] | None = None
340
+ execution_cwd = str(Path(self.working_directory).expanduser().resolve()) if self.working_directory else None
341
+ if (self.ytm_auth_file and self.operation == "tracks") or self.tidal_session_file:
342
+ temporary_cwd = tempfile.TemporaryDirectory(prefix="thunderbolt-pushtunes-")
343
+ if self.ytm_auth_file and self.operation == "tracks":
344
+ shutil.copy2(Path(self.ytm_auth_file).expanduser(), Path(temporary_cwd.name) / "browser.json")
345
+ if self.tidal_session_file:
346
+ shutil.copy2(Path(self.tidal_session_file).expanduser(), Path(temporary_cwd.name) / "tidal-session.json")
347
+ execution_cwd = temporary_cwd.name
348
+ try:
349
+ completed = subprocess.run(
350
+ command,
351
+ cwd=execution_cwd,
352
+ env=env,
353
+ capture_output=True,
354
+ text=True,
355
+ timeout=self.timeout,
356
+ check=False,
357
+ )
358
+ except (OSError, subprocess.SubprocessError) as exc:
359
+ return _error(f"Não foi possível iniciar o Pushtunes: {exc}", {"status": "process_error"})
360
+ finally:
361
+ if temporary_cwd is not None:
362
+ temporary_cwd.cleanup()
363
+ stdout = _redact_output(completed.stdout, secrets)
364
+ stderr = _redact_output(completed.stderr, secrets)
365
+ data = {"command": command, "returncode": completed.returncode, "stdout": stdout, "stderr": stderr, "source": self.source, "target": self.target, "operation": self.operation}
366
+ if completed.returncode != 0:
367
+ return _error("O Pushtunes terminou com erro. Consulte a saída técnica abaixo e confirme as credenciais do serviço de origem/destino.", data)
368
+ return IntegrationResult(True, f"Pushtunes concluiu a sincronização {self.source} → {self.target}.", data)
369
+
370
+
371
+ __all__ = [
372
+ "DEFAULT_JEWELMUSIC_BASE_URL",
373
+ "JewelMusicAdapter",
374
+ "MUSIC_UPLOAD_EXTENSIONS",
375
+ "PUSHTUNES_OPERATIONS",
376
+ "PUSHTUNES_SOURCES",
377
+ "PUSHTUNES_TARGETS",
378
+ "PushtunesAdapter",
379
+ "YTMusicApiAdapter",
380
+ "YT_MUSIC_UPLOAD_EXTENSIONS",
381
+ ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danhachuel/thunderbolt",
3
- "version": "0.3.35",
3
+ "version": "0.3.36",
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
@@ -12,5 +12,7 @@ mlxtend>=0.23,<1
12
12
  plotly>=5.18,<7
13
13
  seaborn>=0.13,<1
14
14
  yt-dlp>=2025.1.15,<2027
15
+ ytmusicapi>=1.12,<2
16
+ pushtunes>=2.15,<3
15
17
  matplotlib>=3.7,<4
16
18
  kagglehub>=0.3,<1