@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.
- package/.streamlit/config.toml +1 -0
- package/MANUAL-INSTALACAO.md +18 -5
- package/README.md +16 -4
- package/THIRD-PARTY-NOTICES.md +12 -0
- package/app/main.py +274 -1
- package/hermes_ui/storage.py +25 -0
- package/integrations/music_uploads.py +381 -0
- package/package.json +1 -1
- package/requirements.txt +2 -0
package/.streamlit/config.toml
CHANGED
package/MANUAL-INSTALACAO.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Este manual descreve a instalação local da UI Thunderbolt, baseada no MoneyPrinterTurbo, utilizando o pacote npm `@danhachuel/thunderbolt`. O fluxo recomendado instala automaticamente o ambiente Python, as dependências da aplicação, as dependências do MoneyPrinterTurbo, o Streamlit e o suporte FFmpeg através de `imageio-ffmpeg`.
|
|
4
4
|
|
|
5
|
-
> **Versão deste manual:** 0.3.
|
|
5
|
+
> **Versão deste manual:** 0.3.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.
|
|
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.
|
|
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
|
|
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.
|
|
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,
|
|
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.
|
|
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**, **
|
|
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
|
|
package/THIRD-PARTY-NOTICES.md
CHANGED
|
@@ -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":
|
|
5837
|
+
"Upload Música": render_music_upload,
|
|
5565
5838
|
"Roteiros": render_scripts,
|
|
5566
5839
|
"Thumbnails": render_thumbnails,
|
|
5567
5840
|
"Upload": render_upload,
|
package/hermes_ui/storage.py
CHANGED
|
@@ -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