Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1437e8e24 | ||
|
|
acbd9c0b1f | ||
|
|
f226c3fa8e | ||
|
|
85f7ec0993 | ||
|
|
9dbdd74b2f | ||
|
|
8c73651312 | ||
|
|
1fa49fabc4 | ||
|
|
83c47df29c | ||
|
|
9ea14eb81c | ||
|
|
91cfc173d4 | ||
|
|
67e48e54f7 | ||
|
|
0949df031a | ||
|
|
2771fa8075 | ||
|
|
252d76b907 | ||
|
|
58d4896ffe | ||
|
|
91aa1bd6a3 | ||
|
|
48a6e15c7a | ||
|
|
f850e86a88 | ||
|
|
6bff24ff7d | ||
|
|
faf74dfb11 | ||
|
|
d2f3ebe361 | ||
|
|
33c6bc7703 | ||
|
|
be4f7e1024 | ||
|
|
1c52d52986 | ||
|
|
cc5716e87d | ||
|
|
3f31e240be | ||
|
|
13c8e60712 | ||
|
|
f1e57905c1 | ||
|
|
4221a5992b | ||
|
|
70cdfe8983 | ||
|
|
e1aaa83fe9 | ||
|
|
c037da4901 | ||
|
|
21ef3a5699 | ||
|
|
e4bfd76a79 | ||
|
|
e2ac1ca275 | ||
|
|
dc6ae1fbe1 | ||
|
|
0e14d161d0 | ||
|
|
7dd793253b | ||
|
|
5771d631fd | ||
|
|
b74a0a53ec | ||
|
|
415175ec4d | ||
|
|
d8349fb79f | ||
|
|
1414c5199c | ||
|
|
6e96e8c6b7 | ||
|
|
c2599a72a1 | ||
|
|
2cc6c5e9c6 | ||
|
|
891562dc88 | ||
|
|
9d21ca051f | ||
|
|
05ea4c6181 | ||
|
|
30be62af78 | ||
|
|
a82f6f420c | ||
|
|
61459099f9 | ||
|
|
bcd4f22b1e | ||
|
|
8272985459 | ||
|
|
f339197861 | ||
|
|
2d8290579b | ||
|
|
339dd4f3cc | ||
|
|
4e917fd9ef | ||
|
|
729f3cd8a0 | ||
|
|
4ebfbd472e | ||
|
|
5202b2c586 | ||
|
|
05a2a58ace | ||
|
|
8610f4d95c | ||
|
|
b47fdd1ab3 | ||
|
|
e56a975b77 | ||
|
|
52002f5184 | ||
|
|
878e024c13 | ||
|
|
07794052c8 | ||
|
|
850d3f4b6c | ||
|
|
6f5c32165a | ||
|
|
39c92f44f4 | ||
|
|
9f37b7eb94 | ||
|
|
5041d3e14c | ||
|
|
97a440819a | ||
|
|
1d7832e246 | ||
|
|
61879c42d3 | ||
|
|
246ff1b0e5 | ||
|
|
3dcbdfa489 | ||
|
|
946452ef03 | ||
|
|
d8423ec215 | ||
|
|
4b1524e5f7 | ||
|
|
e5158d382f |
No files matched your search
@@ -1,4 +1,4 @@
|
||||
name: Build MusicDownload
|
||||
name: Build MusicTools
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -33,17 +33,38 @@ jobs:
|
||||
- name: Build app
|
||||
run: python build_macos.py
|
||||
|
||||
- name: Package .app -> zip
|
||||
- name: Create DMG
|
||||
run: |
|
||||
cd dist
|
||||
ditto -c -k --sequesterRsrc --keepParent MusicDownload.app ../MusicDownload-macOS.zip
|
||||
ls -lh ../MusicDownload-macOS.zip
|
||||
STAGING="dmg_staging"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING"
|
||||
cp -R dist/MusicTools.app "$STAGING/"
|
||||
cp "assets/Fix Gatekeeper.command" "$STAGING/"
|
||||
chmod +x "$STAGING/Fix Gatekeeper.command"
|
||||
ln -s /Applications "$STAGING/Applications"
|
||||
cat > "$STAGING/LEGGIMI.txt" <<'EOF'
|
||||
INSTALLAZIONE MusicTools
|
||||
========================
|
||||
|
||||
1) Trascina MusicTools.app sulla cartella "Applicazioni"
|
||||
(l'alias e' qui dentro nel DMG)
|
||||
|
||||
2) Doppio click su "Fix Gatekeeper.command"
|
||||
Rimuove la quarantena macOS Gatekeeper (una sola volta).
|
||||
|
||||
3) Apri MusicTools dalle Applicazioni e attiva con email + chiave.
|
||||
|
||||
Problemi? scrivici a info@djluza.com
|
||||
EOF
|
||||
hdiutil create -volname "MusicTools" -srcfolder "$STAGING" \
|
||||
-ov -format UDZO MusicTools-macOS.dmg
|
||||
ls -lh MusicTools-macOS.dmg
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-macOS
|
||||
path: MusicDownload-macOS.zip
|
||||
name: MusicTools-macOS
|
||||
path: MusicTools-macOS.dmg
|
||||
retention-days: 14
|
||||
|
||||
# =========================================================
|
||||
@@ -66,17 +87,25 @@ jobs:
|
||||
- name: Build exe
|
||||
run: python build_windows.py
|
||||
|
||||
- name: Unblock files (rimuovi MOTW pre-zip)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Per ridurre l'incidenza del bug "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize" causato da Mark of the Web.
|
||||
Get-ChildItem -Path dist/MusicTools -Recurse -Include *.dll,*.exe,*.pyd `
|
||||
| Unblock-File
|
||||
|
||||
- name: Package -> zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path dist/MusicDownload/* -DestinationPath MusicDownload-Windows.zip -CompressionLevel Optimal
|
||||
Get-Item MusicDownload-Windows.zip
|
||||
Compress-Archive -Path dist/MusicTools/* -DestinationPath MusicTools-Windows.zip -CompressionLevel Optimal
|
||||
Get-Item MusicTools-Windows.zip
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-Windows
|
||||
path: MusicDownload-Windows.zip
|
||||
name: MusicTools-Windows
|
||||
path: MusicTools-Windows.zip
|
||||
retention-days: 14
|
||||
|
||||
# =========================================================
|
||||
@@ -90,22 +119,22 @@ jobs:
|
||||
- name: Download macOS artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-macOS
|
||||
name: MusicTools-macOS
|
||||
path: artifacts/macos
|
||||
|
||||
- name: Download Windows artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: MusicDownload-Windows
|
||||
name: MusicTools-Windows
|
||||
path: artifacts/windows
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: MusicDownload ${{ github.ref_name }}
|
||||
name: MusicTools ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos/MusicDownload-macOS.zip
|
||||
artifacts/windows/MusicDownload-Windows.zip
|
||||
artifacts/macos/MusicTools-macOS.dmg
|
||||
artifacts/windows/MusicTools-Windows.zip
|
||||
@@ -34,3 +34,18 @@ MUSICA/
|
||||
# Claude
|
||||
.claude/
|
||||
Apri con claude-social.command
|
||||
|
||||
# Playlist personali (non vanno nel repo)
|
||||
Vibes_List.txt
|
||||
Beatport Top 100*.txt
|
||||
PlayList_2026.txt
|
||||
PlayList_da_scaricare.txt
|
||||
|
||||
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
|
||||
assets/icon.iconset/
|
||||
|
||||
# Backend (server/) build artifacts e secrets
|
||||
server/node_modules/
|
||||
server/.wrangler/
|
||||
server/.dev.vars
|
||||
server/dist/
|
||||
@@ -1,7 +0,0 @@
|
||||
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
|
||||
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
|
||||
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
|
||||
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
|
||||
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
|
||||
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
|
||||
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
|
||||
@@ -1,18 +0,0 @@
|
||||
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
|
||||
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
|
||||
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
|
||||
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
|
||||
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
|
||||
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
|
||||
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
|
||||
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
|
||||
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
|
||||
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
|
||||
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
|
||||
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
|
||||
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
|
||||
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
|
||||
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
|
||||
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
|
||||
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
|
||||
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
|
||||
@@ -1,98 +1,180 @@
|
||||
# MusicDownload
|
||||
# MusicTools
|
||||
|
||||
Applicazione desktop per scaricare musica da Spotify, YouTube, SoundCloud e qualsiasi sorgente supportata da yt-dlp, con possibilita di migliorare la qualita audio dei file esistenti.
|
||||
Applicazione desktop per **scaricare musica e video**, **modificare i metadati** dei file audio e **registrare l'audio** da qualsiasi ingresso del computer.
|
||||
|
||||
UI moderna basata su **pywebview** (WebKit su macOS, EdgeChromium su Windows) — frontend HTML/CSS/JS, backend Python.
|
||||
|
||||
Sviluppata da **LuZa**.
|
||||
|
||||
## Funzionalita
|
||||
Repo: <https://github.com/luzadev/musicdownload>
|
||||
|
||||
- **Download da Spotify** — Incolla un link a playlist, album o brano singolo: l'app risolve i brani via API Spotify e li scarica da YouTube in MP3
|
||||
- **Download diretto** — Incolla un URL YouTube, SoundCloud o qualsiasi sorgente yt-dlp e scarica direttamente in MP3 (singoli video e playlist)
|
||||
- **Upgrade qualita** — Scansiona una cartella di file audio e riscarica quelli a bassa qualita da YouTube
|
||||
- **Aggiornamento app** — Controlla la disponibilita di nuove versioni direttamente dalle Impostazioni
|
||||
- **Gestione impostazioni** — Credenziali Spotify, bitrate, soglia HQ, percorsi, tema
|
||||
> ℹ️ Il repo Git conserva il nome storico **`musicdownload`** (l'app prima si chiamava così); URL stabili per release già pubblicate e installazioni esistenti.
|
||||
|
||||
## Funzionalità
|
||||
|
||||
| Tab | Cosa fa |
|
||||
|---|---|
|
||||
| **⬇ Scarica** | Spotify (playlist/album/brano) · YouTube · SoundCloud · lista URL `.txt` · tracklist `.txt` con titoli (es. Beatport Top 100) |
|
||||
| **🎬 Video** | YouTube · TikTok · Instagram · Facebook (e 1000+ siti yt-dlp) in MP4, qualità best/1080p/720p/480p |
|
||||
| **⚡ Upgrade** | Scansiona una cartella audio e riscarica i file sotto la soglia kbps impostata |
|
||||
| **🏷 Metadati** | Editor tag ID3 / MP4 / Vorbis (MP3, M4A, FLAC, WAV) — titolo, artista, album, anno, copertina, BPM, key, commento, origine (macOS) |
|
||||
| **● Registra** | Cattura audio in MP3 320 kbps da qualsiasi ingresso (mixer, scheda audio, microfono, BlackHole/loopback) |
|
||||
| **⚙ Impostazioni** | Credenziali Spotify, bitrate, soglia HQ, percorsi, tema, controllo aggiornamenti |
|
||||
|
||||
### Auto-skip dei duplicati
|
||||
|
||||
Tracking persistente (`.downloaded_tracks` / `.downloaded_videos`) + scan filename token-based: re-eseguire la stessa playlist salta i brani già presenti, anche se il filename ha `Official Video` / `Lyrics` / ecc.
|
||||
|
||||
### Sottocartella automatica per le tracklist
|
||||
|
||||
Se carichi un file `.txt` con titoli (es. `Beatport Top 100 Melodic House & Techno.txt`), l'app crea automaticamente una sottocartella `<output_dir>/Beatport Top 100 Melodic House & Techno/` per non mischiare brani di playlist diverse.
|
||||
|
||||
## Requisiti
|
||||
|
||||
- Python 3.8+
|
||||
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) installato nel sistema (`brew install yt-dlp` su macOS)
|
||||
- [ffmpeg](https://ffmpeg.org/) installato nel sistema (`brew install ffmpeg` su macOS)
|
||||
- Credenziali Spotify API (gratuite, vedi sotto) — necessarie solo per URL Spotify
|
||||
- `ffmpeg` + `ffprobe` (solo per build/sviluppo locale: `brew install ffmpeg` su macOS)
|
||||
- Credenziali Spotify API gratuite — solo se si usano URL Spotify
|
||||
|
||||
## Installazione
|
||||
Nelle build distribuite (`.app` / `.exe`) **tutto è incluso** — non serve installare nulla.
|
||||
|
||||
## Installazione (sviluppo)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/luzadev/musicdownload.git
|
||||
cd musicdownload
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Dipendenze Python:
|
||||
|
||||
| Pacchetto | Utilizzo |
|
||||
|---|---|
|
||||
| `customtkinter` | GUI moderna basata su Tkinter |
|
||||
| `requests` | Chiamate API Spotify |
|
||||
| `yt-dlp` | Download audio da YouTube |
|
||||
| `Pillow` | Gestione immagini (copertine) |
|
||||
|
||||
## Avvio
|
||||
|
||||
```bash
|
||||
python3 main.py
|
||||
```
|
||||
|
||||
Dipendenze principali:
|
||||
|
||||
| Pacchetto | Utilizzo |
|
||||
|---|---|
|
||||
| `pywebview` | Finestra nativa con WebView (WebKit / EdgeChromium) |
|
||||
| `requests` | Chiamate API Spotify e GitHub |
|
||||
| `yt-dlp` | Download audio/video da YouTube e oltre 1000 siti |
|
||||
| `mutagen` | Lettura/scrittura tag ID3, MP4, FLAC, WAV |
|
||||
| `pyobjc-framework-WebKit` (macOS) | Backend WebKit di pywebview |
|
||||
| `pythonnet` (Windows) | Backend EdgeChromium di pywebview |
|
||||
|
||||
## Credenziali Spotify API
|
||||
|
||||
1. Vai su https://developer.spotify.com/dashboard e accedi (anche account gratuito)
|
||||
Necessarie solo per scaricare da URL `open.spotify.com/...`.
|
||||
|
||||
1. Vai su <https://developer.spotify.com/dashboard> e accedi (anche account gratuito)
|
||||
2. Clicca **Create app**
|
||||
3. Compila: nome a piacere, Redirect URI `http://localhost:8888/callback`, seleziona **Web API**
|
||||
4. Nella pagina dell'app, clicca **Settings**
|
||||
4. Apri la app e clicca **Settings**
|
||||
5. Copia **Client ID** e **Client Secret** nelle Impostazioni dell'app
|
||||
|
||||
Le credenziali sono gratuite e non richiedono Spotify Premium.
|
||||
Le credenziali sono gratuite, non servono Spotify Premium.
|
||||
|
||||
## Cookies per contenuti privati
|
||||
|
||||
Per scaricare **Instagram / Facebook privati** o aggirare il rate-limit di YouTube serve un file `cookies.txt` (formato Netscape). Esportalo dal browser con un'estensione tipo "Get cookies.txt LOCALLY" e imposta il percorso in **Impostazioni → Percorsi**.
|
||||
|
||||
## Guida rapida: registrazione audio di sistema (macOS)
|
||||
|
||||
macOS non espone nativamente l'output audio del sistema. Per registrare quello che esce dagli speaker (es. uno stream Spotify/YouTube), serve un driver virtuale di **loopback**. Il più diffuso è **BlackHole**, gratuito e open source.
|
||||
|
||||
### 1. Installazione
|
||||
|
||||
```bash
|
||||
brew install blackhole-2ch
|
||||
```
|
||||
|
||||
> Subito dopo l'installazione, se non vedi BlackHole nella tab Registra, riavvia il servizio audio:
|
||||
> ```bash
|
||||
> sudo killall coreaudiod
|
||||
> ```
|
||||
> Si rilancia da solo in pochi secondi, nessun danno. In alternativa, logout/login o riavvio del Mac.
|
||||
|
||||
### 2. Permesso Microfono
|
||||
|
||||
Anche se non è un microfono fisico, macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation.
|
||||
|
||||
Vai su **Preferenze di Sistema → Privacy e sicurezza → Microfono** e abilita l'app da cui lanci MusicTools (Terminal/iTerm in sviluppo, oppure MusicTools.app per la build distribuita). Se non vedi nessuna voce, comparirà un popup la prima volta che premi REC — accetta.
|
||||
|
||||
### 3. Setup A — minimo (registri ma non senti l'audio durante)
|
||||
|
||||
1. Click sull'icona altoparlante nella barra menu → **Output → BlackHole 2ch**
|
||||
*Oppure: Preferenze di Sistema → Audio → Uscita → BlackHole 2ch*
|
||||
2. Fai partire lo stream (Spotify/YouTube): non sentirai nulla — è normale, l'audio va a BlackHole
|
||||
3. App MusicTools → tab **● Registra** → dispositivo **🔄 BlackHole 2ch (loopback)** → **REC**
|
||||
4. **Stop** e riascolta il file MP3 generato
|
||||
|
||||
### 4. Setup B — completo (registri E senti l'audio contemporaneamente, consigliato)
|
||||
|
||||
1. Apri **Configurazione MIDI Audio** (`Applicazioni → Utility → Configurazione MIDI Audio`)
|
||||
2. In basso a sinistra: **+ → Crea dispositivo aggregato a uscita multipla** (Multi-Output Device)
|
||||
3. Nella colonna a destra, spunta sia **BlackHole 2ch** sia i tuoi **Altoparlanti integrati** (o le cuffie)
|
||||
4. Imposta **BlackHole 2ch** come **Master Device** (menù a tendina in alto)
|
||||
5. Abilita **Drift Correction** sulla riga degli altoparlanti
|
||||
6. (Opzionale) Rinomina il dispositivo aggregato in `Speakers + BlackHole`
|
||||
7. **Preferenze di Sistema → Audio → Uscita** → seleziona `Speakers + BlackHole`
|
||||
8. Nella tab Registra dell'app scegli **🔄 BlackHole 2ch (loopback)** — **NON il dispositivo aggregato**, quello è solo per riprodurre
|
||||
|
||||
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
| Sintomo | Causa probabile | Fix |
|
||||
|---|---|---|
|
||||
| BlackHole non compare nel dropdown | CoreAudio non l'ha ancora caricato dopo l'install | `sudo killall coreaudiod` poi premi **↻ Aggiorna** |
|
||||
| Errore `ffmpeg exit -6` o `permesso Microfono mancante` | macOS nega l'accesso al device | Privacy e sicurezza → Microfono → abilita Terminal/MusicTools |
|
||||
| File MP3 muto | Output di sistema non passa per BlackHole | Cambia output su BlackHole o Multi-Output Device (vedi Setup A/B) |
|
||||
| Audio doppio/eco nel file | Stai registrando da BlackHole *e* da microfono fisico simultaneamente | Verifica che nel dropdown ci sia solo `BlackHole 2ch` |
|
||||
| Audio crackly/distorto | Sample rate non allineato | In Audio MIDI Setup, BlackHole + altoparlanti tutti a 48 kHz |
|
||||
|
||||
## Struttura progetto
|
||||
|
||||
```
|
||||
MusicDownload/
|
||||
├── main.py # Entry point
|
||||
├── requirements.txt # Dipendenze Python
|
||||
MusicTools/
|
||||
├── main.py # Entry point — bootstrap pywebview
|
||||
├── requirements.txt
|
||||
├── build_macos.py # Script build macOS (.app)
|
||||
├── build_windows.py # Script build Windows (.exe)
|
||||
├── .github/workflows/
|
||||
│ └── build.yml # CI: build macOS + Windows + Release automatica su tag
|
||||
│
|
||||
├── core/ # Logica backend
|
||||
├── core/ # Logica backend (riusabile, no UI)
|
||||
│ ├── config.py # Configurazione persistente (JSON)
|
||||
│ ├── paths.py # Ricerca binari (yt-dlp, ffmpeg)
|
||||
│ ├── spotify_client.py # Autenticazione e API Spotify
|
||||
│ ├── downloader.py # Download brani (Spotify via YouTube + URL diretti yt-dlp)
|
||||
│ └── upgrader.py # Upgrade qualita file esistenti
|
||||
│ ├── spotify_client.py # API Spotify (Client Credentials)
|
||||
│ ├── downloader.py # Download audio + video (Spotify, yt-dlp diretto)
|
||||
│ ├── upgrader.py # Upgrade qualità file esistenti
|
||||
│ ├── metadata.py # Lettura/scrittura tag ID3/MP4/Vorbis/WAV
|
||||
│ └── recorder.py # Registrazione audio via ffmpeg
|
||||
│
|
||||
└── gui/ # Interfaccia grafica
|
||||
├── app.py # Finestra principale con sidebar
|
||||
├── download_tab.py # Pagina download
|
||||
├── upgrade_tab.py # Pagina upgrade
|
||||
└── settings_tab.py # Pagina impostazioni
|
||||
├── api/
|
||||
│ └── bridge.py # Ponte JS <-> Python (pywebview.js_api)
|
||||
│
|
||||
└── webui/ # Frontend
|
||||
├── index.html # Single-page, 6 view
|
||||
├── css/style.css # Tema scuro Spotify-like, gradient hero, animazioni
|
||||
└── js/app.js # Routing, eventi, chiamate API
|
||||
```
|
||||
|
||||
## Configurazione
|
||||
|
||||
Il file `config.json` viene creato automaticamente:
|
||||
Il file `config.json` viene creato automaticamente al primo avvio:
|
||||
|
||||
| Chiave | Default | Descrizione |
|
||||
|---|---|---|
|
||||
| `client_id` | `""` | Spotify Client ID |
|
||||
| `client_secret` | `""` | Spotify Client Secret |
|
||||
| `bitrate` | `"320K"` | Bitrate download (128K/192K/256K/320K) |
|
||||
| `bitrate` | `"320K"` | Bitrate download audio (128K/192K/256K/320K) |
|
||||
| `hq_threshold` | `310` | Soglia kbps per considerare un file HQ |
|
||||
| `cookies_path` | `"cookies.txt"` | Path al file cookies YouTube (opzionale) |
|
||||
| `output_dir` | `"MUSICA/"` | Cartella output predefinita |
|
||||
| `theme` | `"dark"` | Tema interfaccia (dark/light/system) |
|
||||
| `cookies_path` | `""` | Path al file `cookies.txt` (opzionale) |
|
||||
| `output_dir` | `""` | Cartella output predefinita |
|
||||
| `theme` | `"dark"` | Tema interfaccia |
|
||||
|
||||
Posizione del file:
|
||||
Posizione:
|
||||
- **Sviluppo**: directory del progetto
|
||||
- **macOS bundle**: `~/Library/Application Support/MusicDownload/`
|
||||
- **Windows bundle**: `%APPDATA%/MusicDownload/`
|
||||
- **macOS bundle**: `~/Library/Application Support/MusicTools/`
|
||||
- **Windows bundle**: `%APPDATA%/MusicTools/`
|
||||
|
||||
## Build
|
||||
## Build locale
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -102,7 +184,7 @@ pip install pyinstaller
|
||||
python3 build_macos.py
|
||||
```
|
||||
|
||||
Produce `dist/MusicDownload.app` con yt-dlp, ffmpeg e ffprobe inclusi.
|
||||
Produce `dist/MusicTools.app` con yt-dlp, ffmpeg, ffprobe e tutte le dylib bundled (rpath patchati con `install_name_tool`).
|
||||
|
||||
### Windows
|
||||
|
||||
@@ -111,21 +193,36 @@ pip install pyinstaller
|
||||
python build_windows.py
|
||||
```
|
||||
|
||||
Produce `dist/MusicDownload/MusicDownload.exe` con tutti i binari inclusi.
|
||||
Produce `dist/MusicTools/MusicTools.exe`. Lo script scarica automaticamente `yt-dlp.exe` e `ffmpeg.exe`/`ffprobe.exe` static build.
|
||||
|
||||
## Build & Release automatiche (GitHub Actions)
|
||||
|
||||
Ogni push di un tag `v*` triggera la pipeline `.github/workflows/build.yml`:
|
||||
|
||||
1. **`build-macos`** (runner `macos-latest`) → `MusicTools-macOS.zip` (la `.app` zippata con `ditto`)
|
||||
2. **`build-windows`** (runner `windows-latest`) → `MusicTools-Windows.zip`
|
||||
3. **`release`** → crea automaticamente una GitHub Release con entrambi gli asset allegati
|
||||
|
||||
Per pubblicare una nuova versione:
|
||||
|
||||
```bash
|
||||
git tag v1.3.0
|
||||
git push origin v1.3.0
|
||||
```
|
||||
|
||||
Per fare solo una build di test (senza release):
|
||||
|
||||
```bash
|
||||
gh workflow run "Build MusicTools" --ref main
|
||||
```
|
||||
|
||||
oppure dalla UI: **Actions → Build MusicTools → Run workflow**.
|
||||
|
||||
Le release ufficiali sono disponibili su <https://github.com/luzadev/musicdownload/releases>.
|
||||
|
||||
## Aggiornamento app
|
||||
|
||||
L'app verifica la disponibilita di nuove versioni tramite un file `version.json` remoto. Il formato atteso:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "v1.1",
|
||||
"download_url": "https://www.djluza.com/musicdownload/MusicDownload.dmg",
|
||||
"notes": "Descrizione delle novita"
|
||||
}
|
||||
```
|
||||
|
||||
L'URL di controllo e configurabile nella costante `UPDATE_URL` in `gui/settings_tab.py`.
|
||||
L'app verifica nuove versioni interrogando l'API GitHub Releases (`api/bridge.py` → `check_update`). Per funzionare end-to-end serve che il repo sia pubblico, altrimenti l'API restituisce 404.
|
||||
|
||||
## Licenza
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
|
||||
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
|
||||
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
|
||||
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
|
||||
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
|
||||
@@ -4,12 +4,19 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from core.config import load_config, save_config, VERSION
|
||||
import requests
|
||||
|
||||
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
||||
from core import beatport, spotify_client
|
||||
from core import license as license_mod
|
||||
from core.downloader import (
|
||||
download_playlist,
|
||||
download_direct_url,
|
||||
@@ -18,7 +25,20 @@ from core.downloader import (
|
||||
reset_stop as reset_download_stop,
|
||||
is_stopped as download_is_stopped,
|
||||
)
|
||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
||||
from core.spotify_client import (
|
||||
get_access_token,
|
||||
resolve_spotify_url,
|
||||
resolve_spotify_track_no_auth,
|
||||
SpotifyAuthRequired,
|
||||
)
|
||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||
from core.recorder import (
|
||||
list_input_devices,
|
||||
start_recording,
|
||||
stop_recording as recorder_stop,
|
||||
is_recording,
|
||||
get_last_stderr as recorder_last_stderr,
|
||||
)
|
||||
from core.upgrader import (
|
||||
upgrade_folder,
|
||||
request_stop as request_upgrade_stop,
|
||||
@@ -27,10 +47,18 @@ from core.upgrader import (
|
||||
|
||||
|
||||
SPOTIFY_GUIDE_TEXT = """\
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
||||
NOTE: Spotify oggi consente di creare le chiavi API SOLO con un account
|
||||
Spotify Premium attivo. Senza Premium puoi comunque scaricare:
|
||||
- YouTube, SoundCloud, TikTok, Instagram, Facebook (senza setup)
|
||||
- Brani Spotify SINGOLI (incolla il link e basta, l'app fa il resto)
|
||||
Le chiavi Spotify servono solo per playlist e album da Spotify.
|
||||
|
||||
Procedura (se hai Spotify Premium):
|
||||
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi.
|
||||
2) Clicca "Create app".
|
||||
3) Compila:
|
||||
- App name: MusicDownload (o quello che vuoi)
|
||||
- App name: MusicTools (o quello che vuoi)
|
||||
- Description: a piacere
|
||||
- Redirect URI: http://localhost:8888/callback (obbligatorio ma non usato)
|
||||
- Seleziona "Web API"
|
||||
@@ -40,7 +68,7 @@ SPOTIFY_GUIDE_TEXT = """\
|
||||
- Client Secret (clicca "View client secret")
|
||||
5) Incollali qui sotto e premi "Salva".
|
||||
|
||||
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
|
||||
Limite tecnico Spotify: 100 richieste/minuto.
|
||||
"""
|
||||
|
||||
|
||||
@@ -49,6 +77,30 @@ def _js_safe(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
_NUM_PREFIX = re.compile(r"^\s*\d+\s*[\.\)\:\-]\s*")
|
||||
_DURATION_TAIL = re.compile(r"\s*\(\s*\d{1,2}:\d{2}\s*\)\s*$")
|
||||
# Tutti i tipi di trattini: hyphen, en-dash, em-dash, horizontal bar, minus
|
||||
_DASH_SPLIT = re.compile(r"\s+[‐‑‒–—―−\-]\s+")
|
||||
|
||||
|
||||
def _parse_track_line(line: str) -> Optional[dict]:
|
||||
"""Parse 'N. Artista - Titolo (variant) (durata)' -> {name, artist}.
|
||||
Ritorna None se la riga non e parseabile."""
|
||||
s = line.strip()
|
||||
if not s:
|
||||
return None
|
||||
s = _NUM_PREFIX.sub("", s)
|
||||
s = _DURATION_TAIL.sub("", s).strip()
|
||||
parts = _DASH_SPLIT.split(s, maxsplit=1)
|
||||
if len(parts) < 2:
|
||||
return None
|
||||
artist = parts[0].strip()
|
||||
name = parts[1].strip()
|
||||
if not artist or not name:
|
||||
return None
|
||||
return {"name": name, "artist": artist}
|
||||
|
||||
|
||||
class Api:
|
||||
"""Espone i metodi a JavaScript tramite pywebview js_api."""
|
||||
|
||||
@@ -81,10 +133,16 @@ class Api:
|
||||
def get_init_data(self) -> dict:
|
||||
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
||||
cfg = load_config()
|
||||
# Non spediamo il token raw al frontend (e' sensibile e non serve in UI)
|
||||
safe_cfg = {k: v for k, v in cfg.items() if k != "license_token"}
|
||||
license_status = license_mod.get_status(cfg)
|
||||
return {
|
||||
"version": VERSION,
|
||||
"config": cfg,
|
||||
"config": safe_cfg,
|
||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||
"license": license_status,
|
||||
"plan": license_mod.get_plan(cfg),
|
||||
"purchase_url": "https://musictools.djluza.com",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -96,7 +154,10 @@ class Api:
|
||||
except (TypeError, ValueError):
|
||||
threshold = 310
|
||||
|
||||
config = {
|
||||
# Merge sul config esistente per preservare i campi licenza
|
||||
# (license_key/token/email/...), device_id, ecc.
|
||||
config = load_config()
|
||||
config.update({
|
||||
"client_id": (payload.get("client_id") or "").strip(),
|
||||
"client_secret": (payload.get("client_secret") or "").strip(),
|
||||
"bitrate": payload.get("bitrate", "320K"),
|
||||
@@ -104,28 +165,149 @@ class Api:
|
||||
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
||||
"output_dir": (payload.get("output_dir") or "").strip(),
|
||||
"theme": payload.get("theme", "dark"),
|
||||
}
|
||||
})
|
||||
save_config(config)
|
||||
return {"ok": True}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Licenza
|
||||
# ------------------------------------------------------------------
|
||||
def get_license_status(self) -> dict:
|
||||
return license_mod.get_status()
|
||||
|
||||
def activate_license(self, payload: dict) -> dict:
|
||||
try:
|
||||
status = license_mod.activate(
|
||||
payload.get("key") or "",
|
||||
payload.get("email") or "",
|
||||
)
|
||||
return {"ok": True, "license": status}
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
return {"ok": False, "error": f"Impossibile contattare il server: {e}"}
|
||||
except license_mod.LicenseError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def deactivate_license(self) -> dict:
|
||||
status = license_mod.deactivate(release_remote=True)
|
||||
return {"ok": True, "license": status}
|
||||
|
||||
def revalidate_license(self) -> dict:
|
||||
"""Revalidate in foreground (chiamabile dalla UI 'Verifica ora')."""
|
||||
status = license_mod.validate()
|
||||
return {"ok": True, "license": status}
|
||||
|
||||
def open_purchase_page(self) -> dict:
|
||||
webbrowser.open("https://musictools.djluza.com")
|
||||
return {"ok": True}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quota giornaliera
|
||||
# ------------------------------------------------------------------
|
||||
def get_quota_status(self) -> dict:
|
||||
"""Ritorna lo stato quota corrente (lettura senza incremento)."""
|
||||
try:
|
||||
data = license_mod.get_quota_status()
|
||||
return {"ok": True, "quota": data}
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
return {"ok": False, "offline": True, "error": str(e)}
|
||||
except license_mod.LicenseError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _gate(self, feature: str) -> Optional[dict]:
|
||||
"""Verifica feature + quota prima di un'azione.
|
||||
|
||||
Ritorna None se l'azione e' permessa; altrimenti un dict di
|
||||
errore pronto per la UI (con eventuale info quota per il modal
|
||||
"limite raggiunto").
|
||||
"""
|
||||
# 1) Feature gate locale (dai claims JWT in config)
|
||||
if not license_mod.has_feature(feature):
|
||||
plan = license_mod.get_plan()
|
||||
plan_name = plan.get("name") or "il tuo piano"
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "feature_not_in_plan",
|
||||
"feature": feature,
|
||||
"error": (
|
||||
f"Questa funzione non e' inclusa in {plan_name}. "
|
||||
"Passa a un piano superiore per sbloccarla."
|
||||
),
|
||||
"plan": plan,
|
||||
}
|
||||
# 2) Quota gate server-side: skippato per i piani senza limite
|
||||
# giornaliero (Annual) — evita una chiamata HTTP inutile e fa
|
||||
# funzionare l'app anche offline per quei piani.
|
||||
plan = license_mod.get_plan()
|
||||
if plan.get("daily_limit") is None:
|
||||
return None
|
||||
try:
|
||||
res = license_mod.consume_quota(feature)
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
return {
|
||||
"ok": False, "reason": "offline", "offline": True,
|
||||
"error": f"Impossibile contattare il server: {e}",
|
||||
}
|
||||
except license_mod.LicenseError as e:
|
||||
return {"ok": False, "reason": "license_invalid", "error": str(e)}
|
||||
|
||||
if not res.get("allowed", True):
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "quota_exceeded",
|
||||
"feature": feature,
|
||||
"error": res.get("error") or "Limite giornaliero raggiunto.",
|
||||
"quota": res,
|
||||
}
|
||||
# OK: notifico la UI dello stato quota aggiornato
|
||||
self._emit("quota:update", res)
|
||||
return None
|
||||
|
||||
def check_update(self) -> dict:
|
||||
"""Controlla aggiornamenti chiamando version.json remoto."""
|
||||
"""Controlla aggiornamenti interrogando l'API djluza.com.
|
||||
|
||||
Il server identifica la piattaforma e restituisce un URL di
|
||||
download firmato a tempo. Senza un token di licenza valido
|
||||
ritorna comunque la versione corrente ma con download_url vuoto.
|
||||
"""
|
||||
import platform
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
UPDATE_URL = "https://www.djluza.com/musicdownload/version.json"
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
is_macos = platform.system() == "Darwin"
|
||||
plat = "macos" if is_macos else "windows"
|
||||
|
||||
url = f"{LICENSE_API_URL.rstrip('/')}/api/latest?platform={plat}¤t={VERSION}"
|
||||
headers = {
|
||||
"User-Agent": f"MusicTools/{VERSION}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||
try:
|
||||
req = urllib.request.Request(UPDATE_URL, headers={"User-Agent": "MusicDownload"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
remote = data.get("version", "")
|
||||
notes = data.get("notes", "") or ""
|
||||
download_url = data.get("download_url", "") or ""
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"current": VERSION,
|
||||
"remote": remote,
|
||||
"is_new": bool(remote and remote != VERSION),
|
||||
"download_url": data.get("download_url", ""),
|
||||
"notes": data.get("notes", ""),
|
||||
"download_url": download_url,
|
||||
"notes": notes[:500] if notes else "",
|
||||
"requires_license": not bool(download_url) and not token,
|
||||
}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"ok": False, "error": f"HTTP {e.code}: {e.reason}", "current": VERSION}
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||
return {"ok": False, "error": f"Connessione fallita: {e}", "current": VERSION}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "current": VERSION}
|
||||
|
||||
@@ -167,16 +349,128 @@ class Api:
|
||||
return ""
|
||||
|
||||
def load_url_list(self, path: str) -> dict:
|
||||
"""Carica un file .txt. Riconosce automaticamente se contiene URL
|
||||
oppure una tracklist 'Artista - Titolo'."""
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
urls = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
||||
return {"ok": True, "urls": urls, "count": len(urls)}
|
||||
lines = [line.strip() for line in f if line.strip() and not line.strip().startswith("#")]
|
||||
if not lines:
|
||||
return {"ok": True, "kind": "urls", "urls": [], "count": 0}
|
||||
|
||||
# Detect: se la prima riga e un URL trattiamo tutto come URL list
|
||||
if lines[0].lower().startswith(("http://", "https://")):
|
||||
return {"ok": True, "kind": "urls", "urls": lines, "count": len(lines)}
|
||||
|
||||
# Altrimenti parse come tracklist
|
||||
tracks = []
|
||||
for line in lines:
|
||||
t = _parse_track_line(line)
|
||||
if t:
|
||||
tracks.append(t)
|
||||
name = os.path.splitext(os.path.basename(path))[0]
|
||||
return {
|
||||
"ok": True, "kind": "tracks", "tracks": tracks,
|
||||
"count": len(tracks), "name": name,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def open_external_url(self, url: str) -> None:
|
||||
webbrowser.open(url)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Metadata editor
|
||||
# ------------------------------------------------------------------
|
||||
def pick_audio_file(self) -> str:
|
||||
return self.browse_file([
|
||||
"Audio (*.mp3;*.m4a;*.mp4;*.aac;*.flac;*.wav)",
|
||||
"All files (*.*)",
|
||||
])
|
||||
|
||||
def pick_image_file(self) -> str:
|
||||
return self.browse_file([
|
||||
"Immagini (*.jpg;*.jpeg;*.png)",
|
||||
"All files (*.*)",
|
||||
])
|
||||
|
||||
def read_metadata(self, path: str) -> dict:
|
||||
try:
|
||||
data = read_metadata(path)
|
||||
return {"ok": True, "data": data}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def save_metadata(self, payload: dict) -> dict:
|
||||
path = (payload.get("path") or "").strip()
|
||||
if not path:
|
||||
return {"ok": False, "error": "Path mancante"}
|
||||
if not os.path.exists(path):
|
||||
return {"ok": False, "error": "File non trovato"}
|
||||
|
||||
gate = self._gate("metadata")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
data = payload.get("data") or {}
|
||||
cover_path = (payload.get("cover_path") or "").strip() or None
|
||||
remove_cover = bool(payload.get("remove_cover", False))
|
||||
|
||||
try:
|
||||
write_metadata(path, data, cover_path, remove_cover)
|
||||
return {"ok": True}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Recorder
|
||||
# ------------------------------------------------------------------
|
||||
def list_audio_inputs(self) -> dict:
|
||||
try:
|
||||
return {"ok": True, "devices": list_input_devices()}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e), "devices": []}
|
||||
|
||||
def start_audio_recording(self, payload: dict) -> dict:
|
||||
device_id = str(payload.get("device_id", "")).strip()
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
filename = (payload.get("filename") or "").strip()
|
||||
bitrate = (payload.get("bitrate") or "320k").strip()
|
||||
|
||||
if not device_id:
|
||||
return {"ok": False, "error": "Dispositivo non selezionato"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
if not filename:
|
||||
filename = f"Registrazione_{time.strftime('%Y%m%d_%H%M%S')}.mp3"
|
||||
if not filename.lower().endswith(".mp3"):
|
||||
filename += ".mp3"
|
||||
|
||||
gate = self._gate("record")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
out_path = os.path.join(output_dir, filename)
|
||||
|
||||
def cb(status, payload_evt):
|
||||
payload_evt = dict(payload_evt or {})
|
||||
payload_evt["status"] = status
|
||||
self._emit("recording:event", payload_evt)
|
||||
|
||||
res = start_recording(device_id, out_path, bitrate=bitrate,
|
||||
progress_callback=cb)
|
||||
return res
|
||||
|
||||
def stop_audio_recording(self) -> dict:
|
||||
return recorder_stop()
|
||||
|
||||
def is_audio_recording(self) -> dict:
|
||||
return {"recording": is_recording()}
|
||||
|
||||
def get_recorder_log(self) -> dict:
|
||||
"""Ritorna le ultime righe di stderr di ffmpeg dell'ultima sessione
|
||||
di registrazione. Utile per diagnosticare un errore."""
|
||||
return {"ok": True, "lines": recorder_last_stderr(80)}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Audio folder scan (Upgrade tab)
|
||||
# ------------------------------------------------------------------
|
||||
@@ -207,6 +501,10 @@ class Api:
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._download_worker,
|
||||
args=(list(urls), output_dir),
|
||||
@@ -215,11 +513,177 @@ class Api:
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def start_tracks_download(self, payload: dict) -> dict:
|
||||
"""Avvia il download di una tracklist gia parsata
|
||||
(lista di {name, artist}). Se 'subfolder' e presente,
|
||||
scarica in output_dir/subfolder."""
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
tracks = payload.get("tracks") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
if not tracks:
|
||||
return {"ok": False, "error": "Nessuna traccia fornita"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
if subfolder:
|
||||
# Sanitizza: rimuovi separatori di path
|
||||
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||
if safe:
|
||||
output_dir = os.path.join(output_dir, safe)
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._tracks_worker,
|
||||
args=(list(tracks), output_dir),
|
||||
daemon=True,
|
||||
)
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def start_urls_download(self, payload: dict) -> dict:
|
||||
"""Analogo a start_tracks_download ma accetta URL YouTube gia noti
|
||||
(bypass search). Usato dal flow del tab 'YouTube Search'."""
|
||||
if self._any_job_running():
|
||||
return {"ok": False, "error": "Un download gia in corso"}
|
||||
|
||||
urls = payload.get("urls") or []
|
||||
titles = payload.get("titles") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
subfolder = (payload.get("subfolder") or "").strip()
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessuna URL fornita"}
|
||||
if len(urls) != len(titles):
|
||||
return {"ok": False, "error": "urls e titles devono avere stessa lunghezza"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
if subfolder:
|
||||
safe = subfolder.replace("/", "_").replace("\\", "_").strip()
|
||||
if safe:
|
||||
output_dir = os.path.join(output_dir, safe)
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._urls_worker,
|
||||
args=(list(urls), list(titles), output_dir),
|
||||
daemon=True,
|
||||
)
|
||||
self._download_thread.start()
|
||||
return {"ok": True}
|
||||
|
||||
def stop_download(self) -> dict:
|
||||
request_download_stop()
|
||||
self._log("download", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def _tracks_worker(self, tracks: list, output_dir: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
view = "download"
|
||||
|
||||
self._log(view, f"[INFO] Tracklist: {len(tracks)} brani da cercare su YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
|
||||
def progress_cb(idx, total, track_name, status, pct):
|
||||
if status == "downloading":
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE:
|
||||
return
|
||||
_last[0] = now
|
||||
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "track": track_name,
|
||||
"status": status, "pct": pct,
|
||||
"url_idx": 0, "url_total": 1,
|
||||
}
|
||||
|
||||
if status == "searching":
|
||||
self._log(view, f"[CERCA] {track_name}")
|
||||
elif status == "skipped":
|
||||
self._log(view, f"[SKIP] {track_name} (gia scaricato)")
|
||||
track_progress = (idx + 1) / total if total > 0 else 1
|
||||
payload_evt["overall"] = min(track_progress, 1.0)
|
||||
elif status == "downloading":
|
||||
track_progress = (idx / total) + (pct / 100 / total) if total > 0 else 0
|
||||
payload_evt["overall"] = min(track_progress, 1.0)
|
||||
elif status == "done":
|
||||
self._log(view, f"[OK] {track_name}")
|
||||
track_progress = (idx + 1) / total if total > 0 else 1
|
||||
payload_evt["overall"] = min(track_progress, 1.0)
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
elif status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Download completato!")
|
||||
elif status.startswith("error"):
|
||||
self._log(view, f"[ERRORE] {track_name}: {status}")
|
||||
|
||||
self._emit("download:progress", payload_evt)
|
||||
|
||||
download_playlist(tracks, output_dir, bitrate, cookies_path, progress_cb)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _urls_worker(self, urls: list, titles: list, output_dir: str) -> None:
|
||||
from core.downloader import download_urls
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
view = "download"
|
||||
|
||||
self._log(view, f"[INFO] URL list: {len(urls)} da scaricare da YouTube")
|
||||
self._log(view, f"[INFO] Destinazione: {output_dir}")
|
||||
|
||||
_last = [0.0]
|
||||
_THROTTLE = 0.10
|
||||
|
||||
def progress_cb(idx, total, title, status, pct):
|
||||
if status == "downloading":
|
||||
now = time.monotonic()
|
||||
if now - _last[0] < _THROTTLE:
|
||||
return
|
||||
_last[0] = now
|
||||
|
||||
payload_evt = {
|
||||
"idx": idx, "total": total, "track": title,
|
||||
"status": status, "pct": pct,
|
||||
"url_idx": 0, "url_total": 1,
|
||||
}
|
||||
if status == "skipped":
|
||||
self._log(view, f"[SKIP] {title} (gia scaricato)")
|
||||
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||
elif status == "downloading":
|
||||
payload_evt["overall"] = min((idx / total) + (pct / 100 / total), 1.0) if total else 0
|
||||
elif status == "done":
|
||||
self._log(view, f"[OK] {title}")
|
||||
payload_evt["overall"] = min((idx + 1) / total, 1.0) if total else 1
|
||||
elif status == "stopped":
|
||||
self._log(view, "[INFO] Download interrotto.")
|
||||
elif status == "completed":
|
||||
payload_evt["overall"] = 1.0
|
||||
self._log(view, "[INFO] Download completato!")
|
||||
elif status.startswith("error"):
|
||||
self._log(view, f"[ERRORE] {title}: {status}")
|
||||
|
||||
self._emit("download:progress", payload_evt)
|
||||
|
||||
download_urls(urls, titles, output_dir, bitrate, cookies_path, progress_cb)
|
||||
self._emit("download:done", {"ok": True})
|
||||
|
||||
def _download_worker(self, urls: list, output_dir: str) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
@@ -230,14 +694,11 @@ class Api:
|
||||
view = "download"
|
||||
|
||||
token = None
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
has_credentials = bool(client_id and client_secret)
|
||||
has_spotify = any("spotify.com" in u for u in urls)
|
||||
if has_spotify:
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
if not client_id or not client_secret:
|
||||
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
if has_spotify and has_credentials:
|
||||
self._log(view, "[INFO] Autenticazione Spotify...")
|
||||
try:
|
||||
token = get_access_token(client_id, client_secret)
|
||||
@@ -245,6 +706,13 @@ class Api:
|
||||
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
elif has_spotify:
|
||||
self._log(
|
||||
view,
|
||||
"[INFO] Spotify API non configurate: i singoli brani "
|
||||
"verranno comunque scaricati via metadata pubblico. "
|
||||
"Per album/playlist servono le credenziali (gratuite, 2 min)."
|
||||
)
|
||||
|
||||
for url_idx, url in enumerate(urls):
|
||||
if download_is_stopped():
|
||||
@@ -304,12 +772,19 @@ class Api:
|
||||
if is_spotify:
|
||||
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
||||
try:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
if token:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
else:
|
||||
# Senza API keys: fallback solo per singoli brani.
|
||||
label, name, tracks = resolve_spotify_track_no_auth(url)
|
||||
except SpotifyAuthRequired as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
||||
dest_dir = os.path.join(output_dir, name)
|
||||
dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir
|
||||
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
||||
else:
|
||||
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
||||
@@ -341,6 +816,10 @@ class Api:
|
||||
if not directory:
|
||||
return {"ok": False, "error": "Cartella non impostata"}
|
||||
|
||||
gate = self._gate("upgrade")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
|
||||
@@ -407,15 +886,21 @@ class Api:
|
||||
urls = payload.get("urls") or []
|
||||
output_dir = (payload.get("output_dir") or "").strip()
|
||||
quality = payload.get("quality") or "1080p"
|
||||
audio_only = bool(payload.get("audio_only", False))
|
||||
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL fornito"}
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
# Anche "audio_only" video count come video: usa codec/yt-dlp diversi
|
||||
gate = self._gate("video")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._video_thread = threading.Thread(
|
||||
target=self._video_worker,
|
||||
args=(list(urls), output_dir, quality),
|
||||
args=(list(urls), output_dir, quality, audio_only),
|
||||
daemon=True,
|
||||
)
|
||||
self._video_thread.start()
|
||||
@@ -426,10 +911,12 @@ class Api:
|
||||
self._log("video", "[INFO] Interruzione richiesta...")
|
||||
return {"ok": True}
|
||||
|
||||
def _video_worker(self, urls: list, output_dir: str, quality: str) -> None:
|
||||
def _video_worker(self, urls: list, output_dir: str, quality: str,
|
||||
audio_only: bool = False) -> None:
|
||||
reset_download_stop()
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
bitrate = cfg.get("bitrate", "320K")
|
||||
total_urls = len(urls)
|
||||
multi = total_urls > 1
|
||||
view = "video"
|
||||
@@ -485,9 +972,11 @@ class Api:
|
||||
|
||||
self._emit("video:progress", payload_evt)
|
||||
|
||||
self._log(view, f"[INFO] Video: {url}")
|
||||
mode_lbl = f"Solo audio MP3 {bitrate}" if audio_only else f"Video {quality}"
|
||||
self._log(view, f"[INFO] {mode_lbl}: {url}")
|
||||
try:
|
||||
download_video(url, output_dir, quality, cookies_path, progress_cb)
|
||||
download_video(url, output_dir, quality, cookies_path, progress_cb,
|
||||
audio_only=audio_only, audio_bitrate=bitrate)
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
@@ -500,3 +989,284 @@ class Api:
|
||||
})
|
||||
|
||||
self._emit("video:done", {"ok": True})
|
||||
|
||||
# ================================================================
|
||||
# Beatport charts
|
||||
# ================================================================
|
||||
def beatport_genres(self) -> list:
|
||||
"""Lista dei generi disponibili per il dropdown UI."""
|
||||
return beatport.list_genres()
|
||||
|
||||
def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
"""Fetches la Top 100 per il genere. Salva anche l'ultimo genere in config.
|
||||
Ritorna {ok: True, tracks: [...]} oppure {ok: False, error, message}."""
|
||||
# Memoria: ricorda l'ultimo genere usato
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["beatport_last_genre"] = slug
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
tracks = beatport.fetch_top100(slug, force_refresh=force_refresh)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except beatport.BeatportUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except beatport.BeatportParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
|
||||
def _beatport_output_dir(self, out_root: str, genre_name: str) -> Path:
|
||||
"""Calcola la cartella dove finiscono i file Beatport.
|
||||
Coerente col comportamento di `start_tracks_download`, che sanitizza
|
||||
gli slash nel subfolder (`/` -> `_`), quindi `Beatport/<genre>`
|
||||
diventa flat: `Beatport_<genre>`."""
|
||||
safe_genre = genre_name.replace("/", "_").replace("\\", "_").strip()
|
||||
subfolder = f"Beatport_{safe_genre}" if safe_genre else "Beatport"
|
||||
return Path(out_root) / subfolder
|
||||
|
||||
def beatport_check_existing(self, tracks: list, genre_name: str) -> list:
|
||||
"""Per ogni track ritorna True se il file esiste gia' nella cartella
|
||||
di output Beatport (stessa dove `beatport_download_selected` scrive).
|
||||
Match euristico: nel filename (senza extension) devono comparire sia
|
||||
il titolo che il primo artista (case-insensitive)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
|
||||
out_dir = self._beatport_output_dir(out_root, genre_name)
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].split("&")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
found = any(
|
||||
(title in stem and first_artist in stem)
|
||||
for stem in existing_stems
|
||||
)
|
||||
result.append(found)
|
||||
return result
|
||||
|
||||
def beatport_download_selected(self, tracks: list, genre_name: str) -> dict:
|
||||
"""Converte i BeatportTrack in tracklist compatibile con start_tracks_download
|
||||
e lancia il download riusando l'infrastruttura esistente (license gate,
|
||||
skip file gia' scaricati, log su canale 'download', ecc.)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
# Sfrutta lo stesso computed path del check_existing per garantire
|
||||
# che i due metodi restino allineati (senza affidarsi a start_tracks_download
|
||||
# per la sanitizzazione).
|
||||
target_dir = self._beatport_output_dir(out_root, genre_name)
|
||||
subfolder = target_dir.name # es. "Beatport_melodic-house-techno"
|
||||
|
||||
converted = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
|
||||
# ================================================================
|
||||
# Music Search — Spotify + YouTube
|
||||
# ================================================================
|
||||
|
||||
def _music_output_dir(self, out_root: str, source: str) -> Path:
|
||||
"""Cartella target per Spotify/YouTube search. Coerente col pattern
|
||||
di start_tracks_download (subfolder singolo, no nested)."""
|
||||
safe = source.replace("/", "_").replace("\\", "_").strip()
|
||||
return Path(out_root) / safe
|
||||
|
||||
# ---- Spotify ----
|
||||
|
||||
def spotify_search(self, query: str, artist_mode: bool = False) -> dict:
|
||||
"""Cerca su Spotify. Free-form (limit 50) o artist-mode (discografia)."""
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||
|
||||
# Salva stato
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["spotify_search_last_query"] = query
|
||||
cfg["spotify_search_artist_mode"] = bool(artist_mode)
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cfg = load_config()
|
||||
cid = (cfg.get("client_id") or "").strip()
|
||||
secret = (cfg.get("client_secret") or "").strip()
|
||||
if not cid or not secret:
|
||||
return {"ok": False, "error": "no_creds", "message": "Credenziali Spotify mancanti"}
|
||||
|
||||
try:
|
||||
token = spotify_client.get_access_token(cid, secret)
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "auth", "message": str(e)}
|
||||
|
||||
try:
|
||||
if artist_mode:
|
||||
tracks = spotify_client.search_artist_discography(token, query)
|
||||
else:
|
||||
tracks = spotify_client.search_tracks(token, query, limit=50)
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "artist_not_found", "message": str(e)}
|
||||
except requests.HTTPError as e:
|
||||
code = e.response.status_code if e.response is not None else 0
|
||||
if code == 429:
|
||||
return {"ok": False, "error": "rate_limit", "message": "Spotify limitante - attendi qualche secondo"}
|
||||
return {"ok": False, "error": "server", "message": f"Spotify HTTP {code}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": tracks}
|
||||
|
||||
def spotify_check_existing(self, tracks: list) -> list:
|
||||
"""True per ogni track già presente in output_dir/Spotify/."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
out_dir = self._music_output_dir(out_root, "Spotify")
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("name") or "").lower().strip()
|
||||
artists = (t.get("artists") or "")
|
||||
first_artist = artists.split(",")[0].strip().lower()
|
||||
if not title or not first_artist:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any((title in stem and first_artist in stem) for stem in existing_stems))
|
||||
return result
|
||||
|
||||
def spotify_search_download(self, tracks: list) -> dict:
|
||||
"""Scarica i track Spotify selezionati (name+artist → YouTube search)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._music_output_dir(out_root, "Spotify")
|
||||
subfolder = target.name
|
||||
|
||||
converted = []
|
||||
for t in tracks:
|
||||
title = (t.get("name") or "").strip()
|
||||
artists = (t.get("artists") or "").strip()
|
||||
if not title:
|
||||
continue
|
||||
converted.append({"name": title, "artist": artists})
|
||||
|
||||
if not converted:
|
||||
return {"ok": False, "error": "Nessun brano valido"}
|
||||
|
||||
return self.start_tracks_download({
|
||||
"tracks": converted,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
|
||||
# ---- YouTube ----
|
||||
|
||||
def youtube_search(self, query: str) -> dict:
|
||||
"""Cerca 50 risultati su YouTube via yt-dlp."""
|
||||
from core import youtube_search as yts
|
||||
|
||||
query = (query or "").strip()
|
||||
if not query:
|
||||
return {"ok": False, "error": "empty_query", "message": "Query vuota"}
|
||||
|
||||
try:
|
||||
cfg = load_config()
|
||||
cfg["youtube_search_last_query"] = query
|
||||
save_config(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
results = yts.search_youtube(query, limit=50)
|
||||
except RuntimeError as e:
|
||||
return {"ok": False, "error": "ytdlp", "message": str(e)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": "unknown", "message": str(e)}
|
||||
|
||||
return {"ok": True, "tracks": results}
|
||||
|
||||
def youtube_check_existing(self, tracks: list) -> list:
|
||||
"""True per ogni track già presente in output_dir/YouTube/. Match sul titolo video."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return [False] * len(tracks)
|
||||
out_dir = self._music_output_dir(out_root, "YouTube")
|
||||
if not out_dir.exists():
|
||||
return [False] * len(tracks)
|
||||
existing_stems = [p.stem.lower() for p in out_dir.glob("*.mp3")]
|
||||
result = []
|
||||
for t in tracks:
|
||||
title = (t.get("title") or "").lower().strip()
|
||||
if not title:
|
||||
result.append(False)
|
||||
continue
|
||||
result.append(any(
|
||||
(title in stem or stem in title)
|
||||
for stem in existing_stems
|
||||
))
|
||||
return result
|
||||
|
||||
def youtube_search_download(self, tracks: list) -> dict:
|
||||
"""Scarica direttamente gli URL YouTube selezionati (no re-search)."""
|
||||
cfg = load_config()
|
||||
out_root = (cfg.get("output_dir") or "").strip()
|
||||
if not out_root:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
target = self._music_output_dir(out_root, "YouTube")
|
||||
subfolder = target.name
|
||||
|
||||
urls: list = []
|
||||
titles: list = []
|
||||
for t in tracks:
|
||||
url = (t.get("url") or "").strip()
|
||||
title = (t.get("title") or "").strip()
|
||||
if not url or not title:
|
||||
continue
|
||||
urls.append(url)
|
||||
titles.append(title)
|
||||
|
||||
if not urls:
|
||||
return {"ok": False, "error": "Nessun URL valido"}
|
||||
|
||||
return self.start_urls_download({
|
||||
"urls": urls,
|
||||
"titles": titles,
|
||||
"output_dir": out_root,
|
||||
"subfolder": subfolder,
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Rimuove la quarantena Gatekeeper da MusicTools.app
|
||||
# Doppio click in macOS apre Terminal ed esegue questo script.
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="MusicTools.app"
|
||||
|
||||
# Cerca l'app in /Applications, poi nella stessa cartella del DMG montato.
|
||||
if [ -d "/Applications/${APP_NAME}" ]; then
|
||||
APP="/Applications/${APP_NAME}"
|
||||
elif [ -d "$(dirname "$0")/${APP_NAME}" ]; then
|
||||
APP="$(dirname "$0")/${APP_NAME}"
|
||||
else
|
||||
osascript -e "display alert \"${APP_NAME} non trovata\" message \"Trascina MusicTools.app nella cartella Applicazioni prima di eseguire questo script.\" as critical"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Rimuovo la quarantena da: ${APP}"
|
||||
xattr -dr com.apple.quarantine "${APP}" 2>/dev/null || true
|
||||
|
||||
# Notifica visuale
|
||||
osascript -e "display notification \"MusicTools puo' essere aperta normalmente con doppio click.\" with title \"Fix completato\""
|
||||
|
||||
echo ""
|
||||
echo "Fatto. Ora puoi aprire MusicTools normalmente con doppio click."
|
||||
echo "Puoi chiudere questa finestra."
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Genera l'icona dell'app (PNG/ICNS/ICO) in modo riproducibile.
|
||||
|
||||
Stile: squircle (Apple-style rounded rect) con gradient verde Spotify
|
||||
verso nero, nota musicale bianca al centro con leggero glow, highlight
|
||||
sottile in alto.
|
||||
|
||||
Uso:
|
||||
python3 assets/generate_icon.py
|
||||
|
||||
Produce:
|
||||
assets/icon.png (1024x1024, master)
|
||||
assets/icon.icns (macOS, generato via iconutil)
|
||||
assets/icon.ico (Windows, multi-size)
|
||||
assets/icon.iconset/... (intermedio macOS)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
|
||||
SIZE = 1024
|
||||
RADIUS_RATIO = 0.225 # ~22% del lato, Apple iOS-like squircle
|
||||
PADDING_RATIO = 0.085 # margine fra bordo immagine e squircle
|
||||
|
||||
# Palette (coerente col tema della UI)
|
||||
GREEN_TOP = (30, 215, 96) # verde Spotify lucido
|
||||
GREEN_BOTTOM = (15, 59, 34) # verde scuro profondo
|
||||
WHITE = (255, 255, 255, 255)
|
||||
HIGHLIGHT = (255, 255, 255, 38) # bianco semitrasparente per il riflesso
|
||||
|
||||
|
||||
def vertical_gradient(size: int, top: Tuple[int, int, int],
|
||||
bottom: Tuple[int, int, int]) -> Image.Image:
|
||||
"""Crea un gradient verticale top -> bottom."""
|
||||
img = Image.new("RGB", (size, size), top)
|
||||
px = img.load()
|
||||
for y in range(size):
|
||||
t = y / (size - 1)
|
||||
# ease (quad) per ammorbidire la transizione
|
||||
t = t * t
|
||||
r = int(top[0] + (bottom[0] - top[0]) * t)
|
||||
g = int(top[1] + (bottom[1] - top[1]) * t)
|
||||
b = int(top[2] + (bottom[2] - top[2]) * t)
|
||||
for x in range(size):
|
||||
px[x, y] = (r, g, b)
|
||||
return img
|
||||
|
||||
|
||||
def squircle_mask(size: int, radius: int, padding: int) -> Image.Image:
|
||||
"""Maschera alpha per un rounded rect centrato con padding."""
|
||||
mask = Image.new("L", (size, size), 0)
|
||||
draw = ImageDraw.Draw(mask)
|
||||
draw.rounded_rectangle(
|
||||
(padding, padding, size - padding, size - padding),
|
||||
radius=radius, fill=255,
|
||||
)
|
||||
return mask
|
||||
|
||||
|
||||
def find_music_font() -> str:
|
||||
"""Trova un font che contenga il glifo della nota musicale (♫)."""
|
||||
candidates = [
|
||||
"/System/Library/Fonts/Apple Symbols.ttf",
|
||||
"/System/Library/Fonts/Symbol.ttf",
|
||||
"/System/Library/Fonts/SFNS.ttf",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
]
|
||||
for f in candidates:
|
||||
if Path(f).exists():
|
||||
return f
|
||||
raise RuntimeError("Nessun font Unicode con simboli trovato")
|
||||
|
||||
|
||||
def draw_note(img: Image.Image, glyph: str = "♫") -> None:
|
||||
"""Stampa la nota musicale centrata, in bianco con leggero glow."""
|
||||
font_path = find_music_font()
|
||||
target_pt = int(SIZE * 0.62)
|
||||
font = ImageFont.truetype(font_path, target_pt)
|
||||
|
||||
cx, cy = SIZE // 2, SIZE // 2 - int(SIZE * 0.01)
|
||||
|
||||
# Glow: stessa nota disegnata sfocata sotto in bianco semitrasparente
|
||||
glow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||
glow_draw = ImageDraw.Draw(glow_layer)
|
||||
glow_draw.text((cx, cy), glyph, font=font, fill=(255, 255, 255, 110),
|
||||
anchor="mm")
|
||||
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=24))
|
||||
img.alpha_composite(glow_layer)
|
||||
|
||||
# Testo nitido sopra
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.text((cx, cy), glyph, font=font, fill=WHITE, anchor="mm")
|
||||
|
||||
|
||||
def make_master() -> Image.Image:
|
||||
"""Genera l'immagine master 1024x1024 RGBA con tutto."""
|
||||
padding = int(SIZE * PADDING_RATIO)
|
||||
radius = int(SIZE * RADIUS_RATIO)
|
||||
|
||||
# Background gradient verde
|
||||
gradient = vertical_gradient(SIZE, GREEN_TOP, GREEN_BOTTOM)
|
||||
|
||||
# Squircle: prendiamo il gradient mascherato come forma
|
||||
mask = squircle_mask(SIZE, radius, padding)
|
||||
base = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
base.paste(gradient, (0, 0), mask)
|
||||
|
||||
# Highlight in alto: banda sottile (tipo "shine" iOS), molto trasparente
|
||||
hl_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
hl_draw = ImageDraw.Draw(hl_layer)
|
||||
hl_top = padding + int(SIZE * 0.04)
|
||||
hl_bot = padding + int(SIZE * 0.16)
|
||||
hl_left = padding + int(SIZE * 0.10)
|
||||
hl_right = SIZE - padding - int(SIZE * 0.10)
|
||||
hl_draw.ellipse(
|
||||
(hl_left, hl_top, hl_right, hl_bot),
|
||||
fill=(255, 255, 255, 30),
|
||||
)
|
||||
hl_layer = hl_layer.filter(ImageFilter.GaussianBlur(radius=18))
|
||||
masked = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
masked.paste(hl_layer, (0, 0), mask)
|
||||
base.alpha_composite(masked)
|
||||
|
||||
# Bordo interno sottile per dare definizione (stroke da 0.5%)
|
||||
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
ring_draw = ImageDraw.Draw(ring)
|
||||
ring_draw.rounded_rectangle(
|
||||
(padding, padding, SIZE - padding, SIZE - padding),
|
||||
radius=radius,
|
||||
outline=(255, 255, 255, 28),
|
||||
width=max(2, int(SIZE * 0.004)),
|
||||
)
|
||||
base.alpha_composite(ring)
|
||||
|
||||
# Nota musicale al centro
|
||||
draw_note(base)
|
||||
|
||||
# Lieve ombra interna in basso per profondita (vignette)
|
||||
shade = Image.new("L", (SIZE, SIZE), 0)
|
||||
sd = ImageDraw.Draw(shade)
|
||||
sd.rounded_rectangle(
|
||||
(padding, padding, SIZE - padding, SIZE - padding),
|
||||
radius=radius, outline=255, width=int(SIZE * 0.025),
|
||||
)
|
||||
shade = shade.filter(ImageFilter.GaussianBlur(radius=int(SIZE * 0.025)))
|
||||
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||
overlay.paste((0, 0, 0, 60), mask=shade)
|
||||
overlay = Image.new("RGBA", overlay.size, (0, 0, 0, 0)).convert("RGBA")
|
||||
# Skip vignette complicata — base e' gia' coerente
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def export_iconset(master: Image.Image, outdir: Path) -> None:
|
||||
"""Esporta le dimensioni standard macOS in outdir."""
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
sizes = [
|
||||
(16, "icon_16x16.png"),
|
||||
(32, "icon_16x16@2x.png"),
|
||||
(32, "icon_32x32.png"),
|
||||
(64, "icon_32x32@2x.png"),
|
||||
(128, "icon_128x128.png"),
|
||||
(256, "icon_128x128@2x.png"),
|
||||
(256, "icon_256x256.png"),
|
||||
(512, "icon_256x256@2x.png"),
|
||||
(512, "icon_512x512.png"),
|
||||
(1024, "icon_512x512@2x.png"),
|
||||
]
|
||||
for px, name in sizes:
|
||||
img = master.resize((px, px), Image.LANCZOS)
|
||||
img.save(outdir / name, "PNG")
|
||||
|
||||
|
||||
def build_icns(iconset_dir: Path, output_icns: Path) -> bool:
|
||||
"""Usa iconutil di macOS per generare il .icns. Ritorna True se ok."""
|
||||
if not shutil.which("iconutil"):
|
||||
return False
|
||||
try:
|
||||
subprocess.run(
|
||||
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(output_icns)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def build_ico(master: Image.Image, output_ico: Path) -> None:
|
||||
"""Genera il .ico multi-size per Windows."""
|
||||
sizes = [(16, 16), (24, 24), (32, 32), (48, 48),
|
||||
(64, 64), (128, 128), (256, 256)]
|
||||
master.save(output_ico, format="ICO",
|
||||
sizes=sizes, append_images=[])
|
||||
|
||||
|
||||
def main():
|
||||
print(f">>> Genero icona master {SIZE}x{SIZE}...")
|
||||
master = make_master()
|
||||
master_path = HERE / "icon.png"
|
||||
master.save(master_path, "PNG")
|
||||
print(f" {master_path}")
|
||||
|
||||
iconset_dir = HERE / "icon.iconset"
|
||||
if iconset_dir.exists():
|
||||
shutil.rmtree(iconset_dir)
|
||||
print(">>> Esporto iconset macOS...")
|
||||
export_iconset(master, iconset_dir)
|
||||
|
||||
icns_path = HERE / "icon.icns"
|
||||
if build_icns(iconset_dir, icns_path):
|
||||
print(f" {icns_path}")
|
||||
else:
|
||||
print(" SKIP icns (iconutil non disponibile)")
|
||||
|
||||
print(">>> Genero icon.ico per Windows...")
|
||||
ico_path = HERE / "icon.ico"
|
||||
build_ico(master, ico_path)
|
||||
print(f" {ico_path}")
|
||||
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Genera i loghi brand MusicTools partendo dall'icona master.
|
||||
|
||||
Produce:
|
||||
- logo_mark.png 1024x1024 solo squircle + nota, bg trasparente
|
||||
- logo_horizontal_dark.png 2400x720 mark + "MusicTools" testo bianco (per bg scuri)
|
||||
- logo_horizontal_light.png 2400x720 mark + "MusicTools" testo nero (per bg chiari)
|
||||
- logo_stacked.png 1280x1600 mark sopra + testo sotto, bianco (per bg scuri)
|
||||
|
||||
Lo squircle riuso quello generato da generate_icon.py (assets/icon.png),
|
||||
cosi' resta coerente con l'icona dell'app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ICON_PATH = HERE / "icon.png"
|
||||
|
||||
ARIAL_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
||||
ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
|
||||
WHITE = (255, 255, 255)
|
||||
BLACK = (10, 10, 10)
|
||||
|
||||
|
||||
def font(size: int, *, black: bool = True) -> ImageFont.FreeTypeFont:
|
||||
path = ARIAL_BLACK if black else ARIAL_BOLD
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def load_mark(target_size: int) -> Image.Image:
|
||||
"""Carica l'icona master e la riscala alla dimensione richiesta.
|
||||
|
||||
L'icona originale ha sfondo trasparente fuori dallo squircle
|
||||
(vedi generate_icon.py), quindi va bene per overlay su qualunque bg.
|
||||
"""
|
||||
if not ICON_PATH.exists():
|
||||
raise FileNotFoundError(
|
||||
f"icon.png non trovata: rilancia prima 'python3 assets/generate_icon.py'"
|
||||
)
|
||||
icon = Image.open(ICON_PATH).convert("RGBA")
|
||||
if icon.size != (target_size, target_size):
|
||||
icon = icon.resize((target_size, target_size), Image.LANCZOS)
|
||||
return icon
|
||||
|
||||
|
||||
def measure_text(text: str, f: ImageFont.FreeTypeFont) -> tuple[int, int]:
|
||||
"""Ritorna (larghezza, altezza) effettiva del testo."""
|
||||
bbox = f.getbbox(text)
|
||||
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
|
||||
|
||||
def draw_text_top_left(canvas: Image.Image, text: str, top_left: tuple[int, int],
|
||||
f: ImageFont.FreeTypeFont, color: tuple) -> None:
|
||||
"""Disegna testo con angolo top-left = top_left (compensa font bbox)."""
|
||||
draw = ImageDraw.Draw(canvas, "RGBA")
|
||||
bbox = f.getbbox(text)
|
||||
x = top_left[0] - bbox[0]
|
||||
y = top_left[1] - bbox[1]
|
||||
draw.text((x, y), text, font=f, fill=(*color, 255))
|
||||
|
||||
|
||||
def make_mark(path: Path) -> None:
|
||||
mark = load_mark(1024)
|
||||
mark.save(path, "PNG", optimize=True)
|
||||
print(f"[ok] mark -> {path}")
|
||||
|
||||
|
||||
def make_horizontal(path: Path, text_color: tuple) -> None:
|
||||
"""Layout: mark a sinistra + testo. Canvas si autodimensiona al testo."""
|
||||
H = 720
|
||||
mark_size = int(H * 0.92)
|
||||
pad_left = 60
|
||||
gap = 70
|
||||
pad_right = 80
|
||||
|
||||
text = "MusicTools"
|
||||
f = font(260, black=True)
|
||||
tw, th = measure_text(text, f)
|
||||
|
||||
W = pad_left + mark_size + gap + tw + pad_right
|
||||
|
||||
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
|
||||
# Mark a sinistra, centrato verticalmente
|
||||
mark = load_mark(mark_size)
|
||||
mark_y = (H - mark_size) // 2
|
||||
canvas.alpha_composite(mark, (pad_left, mark_y))
|
||||
|
||||
# Testo allineato top-left calcolato in modo che la sua baseline
|
||||
# sia visivamente centrata sull'altezza dello squircle.
|
||||
text_x = pad_left + mark_size + gap
|
||||
text_y = (H - th) // 2
|
||||
draw_text_top_left(canvas, text, (text_x, text_y), f, text_color)
|
||||
|
||||
canvas.save(path, "PNG", optimize=True)
|
||||
name = "dark" if text_color == WHITE else "light"
|
||||
print(f"[ok] horizontal ({name}) -> {path} [{W}x{H}]")
|
||||
|
||||
|
||||
def make_stacked(path: Path, text_color: tuple) -> None:
|
||||
"""Mark sopra + 'MusicTools' sotto. Canvas autodimensionato al testo."""
|
||||
mark_size = 1024
|
||||
f = font(180, black=True)
|
||||
text = "MusicTools"
|
||||
tw, th = measure_text(text, f)
|
||||
|
||||
pad = 80
|
||||
gap = 50
|
||||
W = max(mark_size, tw) + 2 * pad
|
||||
H = pad + mark_size + gap + th + pad
|
||||
|
||||
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||
|
||||
mark = load_mark(mark_size)
|
||||
mark_x = (W - mark_size) // 2
|
||||
canvas.alpha_composite(mark, (mark_x, pad))
|
||||
|
||||
text_x = (W - tw) // 2
|
||||
text_y = pad + mark_size + gap
|
||||
draw_text_top_left(canvas, text, (text_x, text_y), f, text_color)
|
||||
|
||||
canvas.save(path, "PNG", optimize=True)
|
||||
name = "dark" if text_color == WHITE else "light"
|
||||
print(f"[ok] stacked ({name}) -> {path} [{W}x{H}]")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
make_mark(HERE / "logo_mark.png")
|
||||
make_horizontal(HERE / "logo_horizontal_dark.png", WHITE) # per sfondi scuri
|
||||
make_horizontal(HERE / "logo_horizontal_light.png", BLACK) # per sfondi chiari
|
||||
make_stacked(HERE / "logo_stacked_dark.png", WHITE) # per sfondi scuri
|
||||
make_stacked(HERE / "logo_stacked_light.png", BLACK) # per sfondi chiari
|
||||
# rimuovo eventuale vecchio file senza suffisso
|
||||
old = HERE / "logo_stacked.png"
|
||||
if old.exists():
|
||||
old.unlink()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,296 @@
|
||||
"""Genera le immagini prodotto per Lemon Squeezy.
|
||||
|
||||
Produce:
|
||||
- ls_thumbnail.png 1024x1024 (thumbnail prodotto + checkout)
|
||||
- ls_cover.png 1920x1080 (cover/hero per la pagina prodotto)
|
||||
- ls_og.png 1200x630 (Open Graph per social share)
|
||||
|
||||
La nota musicale e' disegnata geometricamente (no glyph Unicode)
|
||||
per essere indipendente dai font installati sul sistema.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
OUT_DIR = Path(__file__).resolve().parent
|
||||
|
||||
GREEN_LIGHT = (29, 185, 84)
|
||||
GREEN_DARK = (19, 122, 55)
|
||||
BG_TOP = (10, 50, 26)
|
||||
BG_BOTTOM = (6, 6, 6)
|
||||
WHITE = (255, 255, 255)
|
||||
MUTED = (190, 190, 190)
|
||||
|
||||
ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
ARIAL_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
|
||||
ARIAL_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
|
||||
|
||||
def font(size: int, *, black: bool = False, bold: bool = True) -> ImageFont.FreeTypeFont:
|
||||
path = ARIAL_BLACK if black else (ARIAL_BOLD if bold else ARIAL_REG)
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except OSError:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def vgradient(size: tuple[int, int], top: tuple, bottom: tuple) -> Image.Image:
|
||||
"""Gradient verticale tra due colori (RGB)."""
|
||||
w, h = size
|
||||
img = Image.new("RGB", size, top)
|
||||
px = img.load()
|
||||
for y in range(h):
|
||||
t = y / max(h - 1, 1)
|
||||
r = int(top[0] + (bottom[0] - top[0]) * t)
|
||||
g = int(top[1] + (bottom[1] - top[1]) * t)
|
||||
b = int(top[2] + (bottom[2] - top[2]) * t)
|
||||
for x in range(w):
|
||||
px[x, y] = (r, g, b)
|
||||
return img
|
||||
|
||||
|
||||
def radial_glow(size: tuple[int, int], center: tuple[int, int],
|
||||
radius: int, color: tuple, opacity: int = 90) -> Image.Image:
|
||||
"""Glow radiale soft (RGBA)."""
|
||||
glow = Image.new("RGBA", size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(glow)
|
||||
cx, cy = center
|
||||
draw.ellipse(
|
||||
[cx - radius, cy - radius, cx + radius, cy + radius],
|
||||
fill=(*color, opacity),
|
||||
)
|
||||
return glow.filter(ImageFilter.GaussianBlur(radius // 3))
|
||||
|
||||
|
||||
def draw_text_centered(canvas: Image.Image, text: str, center: tuple[int, int],
|
||||
size_px: int, color=WHITE, *, black: bool = True,
|
||||
bold: bool = True) -> None:
|
||||
draw = ImageDraw.Draw(canvas, "RGBA")
|
||||
f = font(size_px, black=black, bold=bold)
|
||||
bbox = draw.textbbox((0, 0), text, font=f)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
x = center[0] - tw // 2 - bbox[0]
|
||||
y = center[1] - th // 2 - bbox[1]
|
||||
draw.text((x, y), text, font=f, fill=(*color, 255))
|
||||
|
||||
|
||||
def draw_text_left(canvas: Image.Image, text: str, anchor: tuple[int, int],
|
||||
size_px: int, color=WHITE, *, black: bool = True,
|
||||
bold: bool = True) -> None:
|
||||
"""Disegna testo allineato a sinistra (anchor = baseline-left)."""
|
||||
draw = ImageDraw.Draw(canvas, "RGBA")
|
||||
f = font(size_px, black=black, bold=bold)
|
||||
bbox = draw.textbbox(anchor, text, font=f)
|
||||
draw.text(anchor, text, font=f, fill=(*color, 255))
|
||||
|
||||
|
||||
def draw_pill(canvas: Image.Image, center: tuple[int, int], text: str,
|
||||
pad_x: int, pad_y: int, font_size: int,
|
||||
fill=(255, 255, 255, 30), text_color=WHITE,
|
||||
*, bold: bool = True) -> None:
|
||||
draw = ImageDraw.Draw(canvas, "RGBA")
|
||||
f = font(font_size, bold=bold, black=False)
|
||||
bbox = draw.textbbox((0, 0), text, font=f)
|
||||
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
pill_w = tw + 2 * pad_x
|
||||
pill_h = th + 2 * pad_y
|
||||
x0 = center[0] - pill_w // 2
|
||||
y0 = center[1] - pill_h // 2
|
||||
draw.rounded_rectangle(
|
||||
[x0, y0, x0 + pill_w, y0 + pill_h],
|
||||
radius=pill_h // 2,
|
||||
fill=fill,
|
||||
)
|
||||
tx = center[0] - tw // 2 - bbox[0]
|
||||
ty = center[1] - th // 2 - bbox[1]
|
||||
draw.text((tx, ty), text, font=f, fill=(*text_color, 255))
|
||||
|
||||
|
||||
def draw_music_note(canvas: Image.Image, center: tuple[int, int],
|
||||
size_px: int, color=WHITE) -> None:
|
||||
"""Disegna una nota musicale stilizzata (due crome connesse).
|
||||
|
||||
Geometria approssimativa:
|
||||
- 2 teste ellittiche (sinistra e destra)
|
||||
- 2 aste verticali sottili
|
||||
- barra orizzontale curva in alto che le connette
|
||||
Tutto scalato in funzione di size_px (larghezza disegno).
|
||||
"""
|
||||
cx, cy = center
|
||||
W = size_px
|
||||
head_w = int(W * 0.30) # larghezza testa
|
||||
head_h = int(head_w * 0.74) # altezza testa (ovale schiacciata)
|
||||
stem_w = max(int(W * 0.035), 6)
|
||||
stem_h = int(W * 0.78)
|
||||
spacing = int(W * 0.40) # distanza tra le 2 aste
|
||||
|
||||
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
|
||||
d = ImageDraw.Draw(layer)
|
||||
|
||||
# Posizioni delle 2 aste
|
||||
left_stem_x = cx - spacing // 2
|
||||
right_stem_x = cx + spacing // 2
|
||||
|
||||
top_y = cy - stem_h // 2
|
||||
bot_y = cy + stem_h // 2
|
||||
|
||||
# Aste (rettangoli verticali)
|
||||
d.rounded_rectangle(
|
||||
[left_stem_x - stem_w // 2, top_y, left_stem_x + stem_w // 2, bot_y],
|
||||
radius=stem_w // 2, fill=color + (255,),
|
||||
)
|
||||
d.rounded_rectangle(
|
||||
[right_stem_x - stem_w // 2, top_y, right_stem_x + stem_w // 2, bot_y],
|
||||
radius=stem_w // 2, fill=color + (255,),
|
||||
)
|
||||
|
||||
# Barra orizzontale spessa in alto che unisce le aste (ramo)
|
||||
bar_thick = max(int(W * 0.09), 14)
|
||||
d.rounded_rectangle(
|
||||
[left_stem_x - stem_w // 2 - 4, top_y,
|
||||
right_stem_x + stem_w // 2 + 4, top_y + bar_thick],
|
||||
radius=bar_thick // 3, fill=color + (255,),
|
||||
)
|
||||
# Seconda barra subito sotto (la nota doppia ha sempre 2 ramificazioni)
|
||||
second_bar_y = top_y + int(bar_thick * 1.6)
|
||||
d.rounded_rectangle(
|
||||
[left_stem_x - stem_w // 2 - 4, second_bar_y,
|
||||
right_stem_x + stem_w // 2 + 4, second_bar_y + int(bar_thick * 0.7)],
|
||||
radius=bar_thick // 3, fill=color + (255,),
|
||||
)
|
||||
|
||||
# Teste ellittiche: in basso, leggermente a sinistra dell'asta (inclinate)
|
||||
def head_at(stem_x: int) -> None:
|
||||
# Centro testa: poco sotto al fondo dell'asta, leggermente a sinistra
|
||||
hx = stem_x - int(head_w * 0.42)
|
||||
hy = bot_y - int(head_h * 0.20)
|
||||
# Disegnata su layer rotato per dare l'inclinazione
|
||||
tmp = Image.new("RGBA", (head_w + 40, head_h + 40), (0, 0, 0, 0))
|
||||
td = ImageDraw.Draw(tmp)
|
||||
td.ellipse([10, 10, 10 + head_w, 10 + head_h], fill=color + (255,))
|
||||
rotated = tmp.rotate(-22, resample=Image.BICUBIC, expand=True)
|
||||
rw, rh = rotated.size
|
||||
layer.alpha_composite(rotated, (hx - rw // 2 + head_w // 2, hy - rh // 2 + head_h // 2))
|
||||
|
||||
head_at(left_stem_x)
|
||||
head_at(right_stem_x)
|
||||
|
||||
# Glow morbido sotto la nota per profondita'
|
||||
glow_src = layer.filter(ImageFilter.GaussianBlur(W // 22))
|
||||
# Riduco un po' l'alpha del glow
|
||||
alpha = glow_src.split()[3].point(lambda a: int(a * 0.45))
|
||||
glow = Image.merge("RGBA", (*glow_src.split()[:3], alpha))
|
||||
canvas.alpha_composite(glow)
|
||||
canvas.alpha_composite(layer)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Thumbnail 1024x1024
|
||||
# ============================================================
|
||||
def make_thumbnail(path: Path) -> None:
|
||||
W = H = 1024
|
||||
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
|
||||
img.alpha_composite(radial_glow((W, H), (W // 2, H // 2 - 80), 430, GREEN_LIGHT, 100))
|
||||
|
||||
# Eyebrow in cima (testo semplice, no pill)
|
||||
draw_text_centered(img, "DESKTOP APP - MAC e WINDOWS", (W // 2, 165),
|
||||
size_px=30, color=GREEN_LIGHT, black=False, bold=True)
|
||||
|
||||
# Nota geometrica centrale
|
||||
draw_music_note(img, (W // 2, H // 2 - 40), 360, color=WHITE)
|
||||
|
||||
# Titolo grande
|
||||
draw_text_centered(img, "MusicTools", (W // 2, H - 280),
|
||||
size_px=110, color=WHITE, black=True)
|
||||
|
||||
# Tagline
|
||||
draw_text_centered(img, "Scarica - Modifica - Registra", (W // 2, H - 195),
|
||||
size_px=38, color=MUTED, black=False, bold=True)
|
||||
|
||||
# Pill prezzo in basso
|
||||
draw_pill(img, (W // 2, H - 100), "Licenza a vita 39,90 EUR",
|
||||
pad_x=26, pad_y=14, font_size=30,
|
||||
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
|
||||
|
||||
img.convert("RGB").save(path, "PNG", optimize=True)
|
||||
print(f"[ok] thumbnail -> {path}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Cover/Hero 1920x1080
|
||||
# ============================================================
|
||||
def make_cover(path: Path) -> None:
|
||||
W, H = 1920, 1080
|
||||
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
|
||||
# Glow grosso a sinistra (zona del testo)
|
||||
img.alpha_composite(radial_glow((W, H), (W // 3, H // 2), 640, GREEN_LIGHT, 85))
|
||||
# Glow secondario a destra (zona nota)
|
||||
img.alpha_composite(radial_glow((W, H), (int(W * 0.78), H // 2), 380, GREEN_LIGHT, 55))
|
||||
|
||||
LEFT_X = 140
|
||||
# Eyebrow testo semplice
|
||||
draw_text_left(img, "DESKTOP APP", (LEFT_X, 220),
|
||||
size_px=30, color=GREEN_LIGHT, black=False, bold=True)
|
||||
|
||||
# Heading 2 righe (sinistra)
|
||||
draw_text_left(img, "La tua musica.", (LEFT_X, 310),
|
||||
size_px=120, color=WHITE, black=True)
|
||||
draw_text_left(img, "In un'unica app.", (LEFT_X, 460),
|
||||
size_px=120, color=GREEN_LIGHT, black=True)
|
||||
|
||||
# Sub-tagline
|
||||
draw_text_left(img, "Scarica da Spotify e YouTube.", (LEFT_X, 640),
|
||||
size_px=42, color=MUTED, black=False, bold=True)
|
||||
draw_text_left(img, "Registra audio. Modifica metadati.", (LEFT_X, 700),
|
||||
size_px=42, color=MUTED, black=False, bold=True)
|
||||
|
||||
# Pill prezzo (sinistra, basso)
|
||||
draw_pill(img, (LEFT_X + 320, H - 160), "Licenza a vita - 39,90 EUR",
|
||||
pad_x=30, pad_y=18, font_size=40,
|
||||
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
|
||||
|
||||
# Nota grande a destra
|
||||
draw_music_note(img, (int(W * 0.78), H // 2 - 20), 540, color=WHITE)
|
||||
|
||||
img.convert("RGB").save(path, "PNG", optimize=True)
|
||||
print(f"[ok] cover -> {path}")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# OG image 1200x630
|
||||
# ============================================================
|
||||
def make_og(path: Path) -> None:
|
||||
W, H = 1200, 630
|
||||
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
|
||||
img.alpha_composite(radial_glow((W, H), (W // 2, H // 2 - 50), 420, GREEN_LIGHT, 95))
|
||||
|
||||
# Nota in alto centro
|
||||
draw_music_note(img, (W // 2, 180), 180, color=WHITE)
|
||||
|
||||
# Titolo
|
||||
draw_text_centered(img, "MusicTools", (W // 2, 360),
|
||||
size_px=92, color=WHITE, black=True)
|
||||
# Tagline
|
||||
draw_text_centered(img, "Scarica - Modifica - Registra", (W // 2, 440),
|
||||
size_px=32, color=MUTED, black=False, bold=True)
|
||||
|
||||
# Pill prezzo in basso
|
||||
draw_pill(img, (W // 2, H - 80), "Licenza a vita 39,90 EUR - Mac e Windows",
|
||||
pad_x=24, pad_y=13, font_size=28,
|
||||
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
|
||||
|
||||
img.convert("RGB").save(path, "PNG", optimize=True)
|
||||
print(f"[ok] og -> {path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
make_thumbnail(OUT_DIR / "ls_thumbnail.png")
|
||||
make_cover(OUT_DIR / "ls_cover.png")
|
||||
make_og(OUT_DIR / "ls_og.png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build script per creare MusicDownload.app su macOS.
|
||||
Build script per creare MusicTools.app su macOS.
|
||||
|
||||
Uso:
|
||||
python3 build_macos.py
|
||||
@@ -8,7 +8,7 @@ Uso:
|
||||
Cosa fa:
|
||||
1. Scarica il binario standalone di yt-dlp per macOS
|
||||
2. Raccoglie ffmpeg + ffprobe + tutte le loro dylib da Homebrew
|
||||
3. Esegue PyInstaller per creare MusicDownload.app
|
||||
3. Esegue PyInstaller per creare MusicTools.app
|
||||
|
||||
Requisiti:
|
||||
pip3 install pyinstaller
|
||||
@@ -208,47 +208,291 @@ def run_pyinstaller():
|
||||
if f.is_file():
|
||||
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
||||
|
||||
icon_path = ROOT / "assets" / "icon.icns"
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "MusicDownload",
|
||||
"--name", "MusicTools",
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(["--icon", str(icon_path)])
|
||||
cmd.extend([
|
||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||
"--add-data", f"{ROOT / 'webui'}:webui",
|
||||
# Hidden imports per pywebview (WebKit su macOS)
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.cocoa",
|
||||
"--hidden-import", "requests",
|
||||
"--collect-all", "mutagen",
|
||||
# Binari bundled (yt-dlp, ffmpeg, ffprobe + dylibs)
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
str(ROOT / "main.py"),
|
||||
]
|
||||
])
|
||||
|
||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||
|
||||
app_path = DIST_DIR / "MusicDownload.app"
|
||||
app_path = DIST_DIR / "MusicTools.app"
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
patch_info_plist(app_path)
|
||||
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
|
||||
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
|
||||
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
|
||||
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
|
||||
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
|
||||
# wrap_subprocess_in_bundle(app_path)
|
||||
adhoc_codesign(app_path)
|
||||
# Mostra dimensione
|
||||
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||
else:
|
||||
log("ATTENZIONE: .app non trovata, controlla l'output di PyInstaller")
|
||||
# Potrebbe essere in dist/MusicDownload/ (non .app)
|
||||
alt = DIST_DIR / "MusicDownload"
|
||||
# Potrebbe essere in dist/MusicTools/ (non .app)
|
||||
alt = DIST_DIR / "MusicTools"
|
||||
if alt.exists():
|
||||
log(f"Trovata directory: {alt}")
|
||||
|
||||
|
||||
def patch_info_plist(app_path):
|
||||
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
|
||||
|
||||
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
|
||||
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
|
||||
|
||||
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
|
||||
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
|
||||
"""
|
||||
import plistlib
|
||||
|
||||
info_plist = app_path / "Contents" / "Info.plist"
|
||||
if not info_plist.exists():
|
||||
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
|
||||
return
|
||||
|
||||
log("Patching Info.plist con le chiavi TCC...")
|
||||
with open(info_plist, "rb") as f:
|
||||
data = plistlib.load(f)
|
||||
|
||||
data["NSMicrophoneUsageDescription"] = (
|
||||
"MusicTools usa il microfono per registrare l'audio del sistema "
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
|
||||
)
|
||||
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
|
||||
data["CFBundleIdentifier"] = "com.djluza.musictools"
|
||||
data["LSApplicationCategoryType"] = "public.app-category.music"
|
||||
|
||||
with open(info_plist, "wb") as f:
|
||||
plistlib.dump(data, f)
|
||||
|
||||
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
|
||||
# Verifica
|
||||
with open(info_plist, "rb") as f:
|
||||
verify = plistlib.load(f)
|
||||
if "NSMicrophoneUsageDescription" not in verify:
|
||||
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
|
||||
else:
|
||||
log("Verifica OK: NSMicrophoneUsageDescription presente.")
|
||||
|
||||
|
||||
_BUNDLE_ID = "com.djluza.musictools"
|
||||
|
||||
|
||||
def _subprocess_info_plist(executable, bundle_id):
|
||||
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
|
||||
|
||||
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
|
||||
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
|
||||
device audio, anche se il main MusicTools ha il proprio TCC concesso.
|
||||
"""
|
||||
return (
|
||||
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
||||
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
||||
'<plist version="1.0">\n'
|
||||
'<dict>\n'
|
||||
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
|
||||
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
|
||||
f' <key>CFBundleName</key><string>{executable}</string>\n'
|
||||
' <key>CFBundlePackageType</key><string>APPL</string>\n'
|
||||
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
|
||||
' <key>CFBundleVersion</key><string>1</string>\n'
|
||||
' <key>LSBackgroundOnly</key><true/>\n'
|
||||
' <key>LSUIElement</key><true/>\n'
|
||||
' <key>NSMicrophoneUsageDescription</key>\n'
|
||||
' <string>MusicTools usa il microfono per registrare l\'audio del '
|
||||
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
|
||||
'</dict>\n'
|
||||
'</plist>\n'
|
||||
)
|
||||
|
||||
|
||||
def wrap_subprocess_in_bundle(app_path):
|
||||
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
|
||||
|
||||
macOS 14+ ENFORCE il fatto che ogni binario che apre device
|
||||
privacy-sensitive (microfono, camera, ...) debba avere un proprio
|
||||
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
|
||||
ffmpeg viene lanciato come binario standalone (anche se figlio di
|
||||
MusicTools), non c'e' Info.plist associato -> SIGABRT.
|
||||
|
||||
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
|
||||
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
|
||||
Stesso per ffprobe.
|
||||
"""
|
||||
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
|
||||
fw = app_path / "Contents" / "Frameworks"
|
||||
if not fw.exists():
|
||||
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
|
||||
return
|
||||
|
||||
for name in ("ffmpeg", "ffprobe"):
|
||||
src = fw / name
|
||||
if not src.exists() or not src.is_file():
|
||||
log(f" skip {name}: non trovato in Frameworks/")
|
||||
continue
|
||||
wrap = fw / f"{name}.app"
|
||||
if wrap.exists():
|
||||
shutil.rmtree(wrap)
|
||||
macos = wrap / "Contents" / "MacOS"
|
||||
macos.mkdir(parents=True, exist_ok=True)
|
||||
dest = macos / name
|
||||
shutil.move(str(src), str(dest))
|
||||
dest.chmod(0o755)
|
||||
|
||||
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
|
||||
# stanno le dylib del bundle MusicTools).
|
||||
# @executable_path = ffmpeg.app/Contents/MacOS/
|
||||
# ../../../ = MusicTools.app/Contents/Frameworks/
|
||||
subprocess.run(
|
||||
["install_name_tool", "-add_rpath",
|
||||
"@executable_path/../../../", str(dest)],
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Crea Info.plist con NSMicrophoneUsageDescription.
|
||||
info = wrap / "Contents" / "Info.plist"
|
||||
bundle_id = f"com.djluza.musictools.{name}"
|
||||
info.write_text(_subprocess_info_plist(name, bundle_id))
|
||||
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
|
||||
|
||||
|
||||
def adhoc_codesign(app_path):
|
||||
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
|
||||
|
||||
macOS tratta ogni binario Mach-O firmato come una "app" distinta per
|
||||
le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep`
|
||||
standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno
|
||||
un identifier auto-generato del tipo `<nome>-<hash>`, diverso da
|
||||
quello del bundle principale -> TCC NEGA al subprocess.
|
||||
|
||||
Soluzione: firmiamo PRIMA ogni binario interno con `--identifier
|
||||
com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede
|
||||
tutti i Mach-O come parte dello stesso "team" e propaga il permesso
|
||||
del main ai subprocess.
|
||||
"""
|
||||
log("Ad-hoc codesign del bundle (identifier coerente per TCC)...")
|
||||
try:
|
||||
# 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
|
||||
subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
|
||||
|
||||
# 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib).
|
||||
# Li firmiamo uno a uno con identifier coerente, dal piu' profondo
|
||||
# al piu' esterno (richiesta da codesign).
|
||||
nested_machos = []
|
||||
for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
|
||||
if not p.is_file():
|
||||
continue
|
||||
# Skip risorse non eseguibili
|
||||
if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg",
|
||||
".html", ".css", ".js", ".json", ".md", ".txt"):
|
||||
continue
|
||||
try:
|
||||
with open(p, "rb") as f:
|
||||
magic = f.read(4)
|
||||
except Exception:
|
||||
continue
|
||||
# Mach-O magic numbers (32/64-bit, big/little endian, fat)
|
||||
if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe",
|
||||
b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
|
||||
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"):
|
||||
nested_machos.append(p)
|
||||
|
||||
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
|
||||
|
||||
for p in nested_machos:
|
||||
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
|
||||
# del SUB-bundle (TCC li tratta come app separate con Info.plist
|
||||
# proprio). Per gli altri binari nested (dylib, framework), usa
|
||||
# l'identifier del bundle principale.
|
||||
ident = _BUNDLE_ID
|
||||
parts = p.relative_to(app_path).parts
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
if sub in parts:
|
||||
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
break
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", ident,
|
||||
"--timestamp=none",
|
||||
str(p)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
|
||||
|
||||
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_path = app_path / "Contents" / "Frameworks" / sub
|
||||
if not sub_path.exists():
|
||||
continue
|
||||
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", sub_ident,
|
||||
"--timestamp=none",
|
||||
str(sub_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
|
||||
|
||||
# 3) Firma del bundle .app principale (l'identifier corretto viene
|
||||
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
|
||||
r = subprocess.run(
|
||||
["codesign", "--force", "--sign", "-",
|
||||
"--identifier", _BUNDLE_ID,
|
||||
"--timestamp=none",
|
||||
str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
|
||||
else:
|
||||
log("Codesign ad-hoc completato.")
|
||||
|
||||
# 4) Verifica finale
|
||||
verify = subprocess.run(
|
||||
["codesign", "--verify", "--deep", "--strict", str(app_path)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if verify.returncode == 0:
|
||||
log("Verifica firma OK.")
|
||||
else:
|
||||
log(f"NOTA verifica firma: {verify.stderr.strip()}")
|
||||
except FileNotFoundError:
|
||||
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" MusicDownload — Build macOS")
|
||||
print(" MusicTools — Build macOS")
|
||||
print("=" * 50)
|
||||
|
||||
# Verifica pyinstaller
|
||||
@@ -271,12 +515,12 @@ def main():
|
||||
print("\n" + "=" * 50)
|
||||
print(" DONE!")
|
||||
print("=" * 50)
|
||||
app = DIST_DIR / "MusicDownload.app"
|
||||
app = DIST_DIR / "MusicTools.app"
|
||||
if app.exists():
|
||||
print(f"\n L'app e in: {app}")
|
||||
print(f" Per testarla: open '{app}'")
|
||||
print(f"\n Per distribuirla, comprimi la cartella:")
|
||||
print(f" zip -r MusicDownload.zip dist/MusicDownload.app")
|
||||
print(f" zip -r MusicTools.zip dist/MusicTools.app")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Build script per creare MusicDownload.exe su Windows.
|
||||
Build script per creare MusicTools.exe su Windows.
|
||||
|
||||
Uso:
|
||||
python build_windows.py
|
||||
@@ -7,7 +7,7 @@ Uso:
|
||||
Cosa fa:
|
||||
1. Scarica il binario standalone di yt-dlp per Windows
|
||||
2. Scarica ffmpeg static build per Windows
|
||||
3. Esegue PyInstaller per creare MusicDownload.exe
|
||||
3. Esegue PyInstaller per creare MusicTools.exe
|
||||
|
||||
Requisiti:
|
||||
pip install pyinstaller
|
||||
@@ -99,37 +99,61 @@ def run_pyinstaller():
|
||||
if f.is_file() and f.suffix in (".exe", ""):
|
||||
add_binaries.extend(["--add-binary", f"{f};."])
|
||||
|
||||
icon_path = ROOT / "assets" / "icon.ico"
|
||||
cmd = [
|
||||
sys.executable, "-m", "PyInstaller",
|
||||
"--name", "MusicDownload",
|
||||
"--name", "MusicTools",
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
# Icona (se presente)
|
||||
# "--icon", str(ROOT / "icon.ico"),
|
||||
# Forza rebuild pulita (senza, residui di build vecchi possono
|
||||
# confondere il bundling di pythonnet/clr_loader).
|
||||
"--clean",
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(["--icon", str(icon_path)])
|
||||
cmd.extend([
|
||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||
"--add-data", f"{ROOT / 'webui'};webui",
|
||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.edgechromium",
|
||||
"--hidden-import", "webview.platforms.winforms",
|
||||
"--hidden-import", "clr",
|
||||
"--hidden-import", "clr_loader",
|
||||
"--hidden-import", "clr_loader.netfx",
|
||||
"--hidden-import", "clr_loader.types",
|
||||
"--hidden-import", "pythonnet",
|
||||
"--hidden-import", "requests",
|
||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
||||
# Collegamenti completi: webview, pythonnet, clr_loader (porta
|
||||
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
|
||||
# altrimenti winforms.py fallisce con "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize ...")
|
||||
"--collect-all", "webview",
|
||||
"--collect-all", "pythonnet",
|
||||
"--collect-all", "clr_loader",
|
||||
"--collect-all", "mutagen",
|
||||
# collect-data prende anche i file .json/.dll non-Python che
|
||||
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
|
||||
# in particolare).
|
||||
"--collect-data", "pythonnet",
|
||||
"--collect-data", "clr_loader",
|
||||
"--copy-metadata", "pythonnet",
|
||||
"--copy-metadata", "clr_loader",
|
||||
# Binari bundled
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
str(ROOT / "main.py"),
|
||||
]
|
||||
])
|
||||
|
||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||
|
||||
exe_path = DIST_DIR / "MusicDownload" / "MusicDownload.exe"
|
||||
exe_path = DIST_DIR / "MusicTools" / "MusicTools.exe"
|
||||
if exe_path.exists():
|
||||
log(f"Build completata: {exe_path}")
|
||||
else:
|
||||
log("ATTENZIONE: MusicDownload.exe non trovato, controlla l'output")
|
||||
log("ATTENZIONE: MusicTools.exe non trovato, controlla l'output")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -137,7 +161,7 @@ def run_pyinstaller():
|
||||
# =========================================================================
|
||||
def main():
|
||||
print("=" * 50)
|
||||
print(" MusicDownload - Build Windows")
|
||||
print(" MusicTools - Build Windows")
|
||||
print("=" * 50)
|
||||
|
||||
if sys.platform != "win32":
|
||||
@@ -166,12 +190,12 @@ def main():
|
||||
print("\n" + "=" * 50)
|
||||
print(" DONE!")
|
||||
print("=" * 50)
|
||||
dist = DIST_DIR / "MusicDownload"
|
||||
dist = DIST_DIR / "MusicTools"
|
||||
if dist.exists():
|
||||
print(f"\n L'app e in: {dist}")
|
||||
print(f" Eseguibile: {dist / 'MusicDownload.exe'}")
|
||||
print(f" Eseguibile: {dist / 'MusicTools.exe'}")
|
||||
print(f"\n Per distribuirla, comprimi la cartella:")
|
||||
print(f" Comprimi {dist} -> MusicDownload_Windows.zip")
|
||||
print(f" Comprimi {dist} -> MusicTools_Windows.zip")
|
||||
print()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Fetch Top 100 Beatport per genere.
|
||||
|
||||
Approccio: estrai il JSON `__NEXT_DATA__` dal HTML della pagina Next.js.
|
||||
Bypass Cloudflare via curl_cffi (TLS impersonation).
|
||||
Vedi docs/superpowers/specs/2026-07-14-beatport-charts-design.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
from curl_cffi import requests as _cffi_requests
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeatportTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str # es. "Extended Mix", "Original Mix", ""
|
||||
artists: str # es. "A, B & C" già formattato
|
||||
duration_sec: int
|
||||
beatport_id: int
|
||||
image_url: str = "" # URL cover art (default vuoto per retrocompatibilità test)
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""Formato testo compatibile coi file .txt curati a mano:
|
||||
'Artista – Titolo (Mix) (M:SS)'."""
|
||||
m, s = divmod(self.duration_sec, 60)
|
||||
return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})"
|
||||
|
||||
@property
|
||||
def spotify_query(self) -> str:
|
||||
"""Query pura per la search Spotify (senza mix name, che disturba
|
||||
il matching su titoli tipo 'Extended Mix')."""
|
||||
return f"{self.artists} {self.title}"
|
||||
|
||||
|
||||
class BeatportError(Exception):
|
||||
"""Base per errori Beatport."""
|
||||
|
||||
|
||||
class BeatportUnreachableError(BeatportError):
|
||||
"""Rete o server Beatport non raggiungibile / 5xx."""
|
||||
|
||||
|
||||
class BeatportParseError(BeatportError):
|
||||
"""HTML/JSON ricevuto ma non conforme allo schema atteso."""
|
||||
|
||||
|
||||
_NEXT_DATA_RE = re.compile(
|
||||
r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def _extract_next_data(html: str) -> dict:
|
||||
"""Estrae il payload JSON dallo script <__NEXT_DATA__> di Next.js."""
|
||||
m = _NEXT_DATA_RE.search(html)
|
||||
if not m:
|
||||
raise BeatportParseError("__NEXT_DATA__ non trovato nella pagina Beatport")
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except json.JSONDecodeError as e:
|
||||
raise BeatportParseError(f"__NEXT_DATA__ JSON malformato: {e}") from e
|
||||
|
||||
|
||||
# Mappa slug URL Beatport -> (numeric_id, display_name)
|
||||
# Enumerata via scripts/refresh_beatport_genres.py (Task 1).
|
||||
# Copia dell'output di /tmp/genres_output.txt (31 generi).
|
||||
GENRES: dict = {
|
||||
"drum-bass": (1, "Drum & Bass"),
|
||||
"hard-techno": (2, "Hard Techno"),
|
||||
"electronica": (3, "Electronica"),
|
||||
"house": (5, "House"),
|
||||
"techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"),
|
||||
"trance-main-floor": (7, "Trance (Main Floor)"),
|
||||
"hard-dance-hardcore-neo-rave": (8, "Hard Dance / Hardcore / Neo Rave"),
|
||||
"breaks-breakbeat-uk-bass": (9, "Breaks / Breakbeat / UK Bass"),
|
||||
"tech-house": (11, "Tech House"),
|
||||
"deep-house": (12, "Deep House"),
|
||||
"psy-trance": (13, "Psy-Trance"),
|
||||
"minimal-deep-tech": (14, "Minimal / Deep Tech"),
|
||||
"progressive-house": (15, "Progressive House"),
|
||||
"dubstep": (18, "Dubstep"),
|
||||
"indie-dance": (37, "Indie Dance"),
|
||||
"trap-future-bass": (38, "Trap / Future Bass"),
|
||||
"dance-pop": (39, "Dance / Pop"),
|
||||
"nu-disco-disco": (50, "Nu Disco / Disco"),
|
||||
"funky-house": (81, "Funky House"),
|
||||
"bass-club": (85, "Bass / Club"),
|
||||
"uk-garage-bassline": (86, "UK Garage / Bassline"),
|
||||
"afro-house": (89, "Afro House"),
|
||||
"melodic-house-techno": (90, "Melodic House & Techno"),
|
||||
"bass-house": (91, "Bass House"),
|
||||
"techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"organic-house": (93, "Organic House"),
|
||||
"electro-classic-detroit-modern": (94, "Electro (Classic / Detroit / Modern)"),
|
||||
"140-deep-dubstep-grime": (95, "140 / Deep Dubstep / Grime"),
|
||||
"mainstage": (96, "Mainstage"),
|
||||
"jackin-house": (97, "Jackin House"),
|
||||
"trance-raw-deep-hypnotic": (99, "Trance (Raw / Deep / Hypnotic)"),
|
||||
}
|
||||
|
||||
|
||||
def list_genres() -> list:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
result = [
|
||||
{"slug": slug, "id": gid, "name": name}
|
||||
for slug, (gid, name) in GENRES.items()
|
||||
]
|
||||
result.sort(key=lambda g: g["name"].casefold())
|
||||
return result
|
||||
|
||||
|
||||
def _find_tracks_results(data: dict) -> list:
|
||||
"""Cerca dentro le queries dehydrated il primo `results` che ha almeno 50 elementi
|
||||
e la shape di una track (chiave `id` presente)."""
|
||||
try:
|
||||
queries = data["props"]["pageProps"]["dehydratedState"]["queries"]
|
||||
except (KeyError, TypeError) as e:
|
||||
raise BeatportParseError(
|
||||
f"schema JSON inatteso, `results` non localizzabile (chiave mancante: {e})"
|
||||
) from e
|
||||
|
||||
for q in queries:
|
||||
state_data = (q or {}).get("state", {}).get("data")
|
||||
if not isinstance(state_data, dict):
|
||||
continue
|
||||
results = state_data.get("results")
|
||||
if isinstance(results, list) and len(results) >= 50:
|
||||
if results and isinstance(results[0], dict) and "id" in results[0]:
|
||||
return results
|
||||
raise BeatportParseError("nessun `results` di 50+ track trovato in __NEXT_DATA__")
|
||||
|
||||
|
||||
def _format_artists(artists_field: object) -> str:
|
||||
"""Beatport ritorna artists come lista di dict {name, ...}.
|
||||
Formatta come 'A, B & C' (& prima dell'ultimo)."""
|
||||
if not isinstance(artists_field, list) or not artists_field:
|
||||
return ""
|
||||
names = [a.get("name", "") for a in artists_field if isinstance(a, dict)]
|
||||
names = [n for n in names if n]
|
||||
if not names:
|
||||
return ""
|
||||
if len(names) == 1:
|
||||
return names[0]
|
||||
return ", ".join(names[:-1]) + " & " + names[-1]
|
||||
|
||||
|
||||
def _extract_image_url(image_field: object, size: int = 95) -> str:
|
||||
"""Estrae URL cover art dall'oggetto `image` di Beatport.
|
||||
Preferisce `dynamic_uri` sostituendo {w}x{h}, fallback a `uri` fisso."""
|
||||
if not isinstance(image_field, dict):
|
||||
return ""
|
||||
dyn = image_field.get("dynamic_uri") or ""
|
||||
if isinstance(dyn, str) and "{w}" in dyn and "{h}" in dyn:
|
||||
return dyn.replace("{w}", str(size)).replace("{h}", str(size))
|
||||
return image_field.get("uri") or ""
|
||||
|
||||
|
||||
def _parse_tracks(data: dict) -> list:
|
||||
"""Trasforma i track dict di Beatport in BeatportTrack ordinati per posizione."""
|
||||
raw = _find_tracks_results(data)
|
||||
out: list = []
|
||||
for i, item in enumerate(raw, 1):
|
||||
try:
|
||||
length_ms = int(item.get("length_ms") or 0)
|
||||
track = BeatportTrack(
|
||||
position=i,
|
||||
title=str(item.get("name") or "").strip(),
|
||||
mix=str(item.get("mix_name") or "").strip(),
|
||||
artists=_format_artists(item.get("artists")),
|
||||
duration_sec=length_ms // 1000,
|
||||
beatport_id=int(item.get("id") or 0),
|
||||
image_url=_extract_image_url(item.get("image")),
|
||||
)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise BeatportParseError(f"track[{i}] shape inattesa: {e}") from e
|
||||
out.append(track)
|
||||
return out
|
||||
|
||||
|
||||
_IMPERSONATE = "chrome131" # aggiorna se CF rompe il fingerprint
|
||||
_REQUEST_TIMEOUT = 15
|
||||
_MAX_ATTEMPTS = 3
|
||||
_BACKOFF_SEC = [1, 3] # attese fra tentativi
|
||||
_CACHE_TTL_SEC = 15 * 60
|
||||
|
||||
# Cache in-memory: slug → (timestamp_epoch, list[BeatportTrack])
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def _url_for(slug: str) -> str:
|
||||
gid, _ = GENRES[slug]
|
||||
return f"https://www.beatport.com/genre/{slug}/{gid}/top-100"
|
||||
|
||||
|
||||
def _do_get(url: str) -> str:
|
||||
"""GET con retry e backoff. Solleva BeatportUnreachableError su fallimento definitivo."""
|
||||
last_exc = None
|
||||
for attempt in range(_MAX_ATTEMPTS):
|
||||
try:
|
||||
resp = _cffi_requests.get(
|
||||
url,
|
||||
impersonate=_IMPERSONATE,
|
||||
timeout=_REQUEST_TIMEOUT,
|
||||
)
|
||||
if resp.status_code >= 500 or resp.status_code == 403:
|
||||
raise Exception(f"HTTP {resp.status_code}")
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
if attempt < _MAX_ATTEMPTS - 1:
|
||||
time.sleep(_BACKOFF_SEC[attempt])
|
||||
raise BeatportUnreachableError(f"Beatport irraggiungibile dopo {_MAX_ATTEMPTS} tentativi: {last_exc}")
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list:
|
||||
"""Fetches la Top 100 Beatport per il genere dato.
|
||||
|
||||
Cache in-memory 15 min. `force_refresh=True` bypassa la cache.
|
||||
|
||||
Raises:
|
||||
ValueError: se slug non è in GENRES.
|
||||
BeatportUnreachableError: rete/5xx dopo i retry.
|
||||
BeatportParseError: HTML/JSON non conforme allo schema.
|
||||
"""
|
||||
if slug not in GENRES:
|
||||
raise ValueError(f"slug genere non valido: {slug!r}")
|
||||
|
||||
now = time.time()
|
||||
if not force_refresh:
|
||||
cached = _cache.get(slug)
|
||||
if cached and (now - cached[0]) < _CACHE_TTL_SEC:
|
||||
return cached[1]
|
||||
|
||||
html = _do_get(_url_for(slug))
|
||||
data = _extract_next_data(html)
|
||||
tracks = _parse_tracks(data)
|
||||
_cache[slug] = (now, tracks)
|
||||
return tracks
|
||||
@@ -5,24 +5,53 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.0"
|
||||
VERSION = "v1.8.3"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
_LEGACY_NAME = "MusicDownload"
|
||||
|
||||
# Endpoint dell'API di licenza/aggiornamenti. Cambialo qui per puntare
|
||||
# a un ambiente di staging.
|
||||
LICENSE_API_URL = "https://musictools.djluza.com"
|
||||
|
||||
# Quanti giorni puo' restare l'app offline prima di richiedere una
|
||||
# nuova validazione contro il server.
|
||||
LICENSE_GRACE_DAYS = 14
|
||||
|
||||
# Ogni quanti giorni l'app rivalida la licenza in background quando online.
|
||||
LICENSE_REVALIDATE_DAYS = 7
|
||||
|
||||
|
||||
def _get_config_dir() -> Path:
|
||||
"""Ritorna la directory per config.json.
|
||||
|
||||
Frozen (bundle PyInstaller):
|
||||
- macOS: ~/Library/Application Support/MusicDownload/
|
||||
- Windows: %APPDATA%/MusicDownload/
|
||||
- macOS: ~/Library/Application Support/MusicTools/
|
||||
- Windows: %APPDATA%/MusicTools/
|
||||
Dev (non frozen):
|
||||
- directory del progetto
|
||||
|
||||
Se la cartella nuova non esiste ma quella legacy 'MusicDownload' si',
|
||||
viene migrata (copia di config.json) cosi' l'utente non perde le
|
||||
impostazioni dopo il rename.
|
||||
"""
|
||||
if getattr(sys, "frozen", False):
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ.get("APPDATA", Path.home()))
|
||||
else:
|
||||
base = Path.home() / "Library" / "Application Support"
|
||||
config_dir = base / "MusicDownload"
|
||||
config_dir = base / APP_NAME
|
||||
legacy_dir = base / _LEGACY_NAME
|
||||
# Migrazione one-shot: se non esiste la nuova ma esiste la vecchia
|
||||
if not config_dir.exists() and legacy_dir.exists():
|
||||
try:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
legacy_cfg = legacy_dir / "config.json"
|
||||
if legacy_cfg.exists():
|
||||
(config_dir / "config.json").write_bytes(legacy_cfg.read_bytes())
|
||||
except OSError:
|
||||
pass
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir
|
||||
return Path(__file__).resolve().parent.parent
|
||||
@@ -41,6 +70,27 @@ DEFAULTS = {
|
||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||
"output_dir": str(_project_dir / "MUSICA"),
|
||||
"theme": "dark",
|
||||
# ---- Beatport ----
|
||||
"beatport_last_genre": "melodic-house-techno", # ultimo genere Top 100 caricato
|
||||
# ---- Music Search (Spotify + YouTube) ----
|
||||
"spotify_search_last_query": "",
|
||||
"spotify_search_artist_mode": False,
|
||||
"youtube_search_last_query": "",
|
||||
# ---- Licenza ----
|
||||
"license_key": "", # chiave fornita all'utente via email
|
||||
"license_email": "", # email associata all'acquisto
|
||||
"license_token": "", # JWT firmato dal server (claims offline)
|
||||
"license_activated_at": 0, # epoch della prima attivazione
|
||||
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
|
||||
"device_id": "", # UUID generato al primo avvio
|
||||
# ---- Piano (snapshot dei claims JWT) ----
|
||||
"plan_code": "", # "basic" | "pro" | "premium" | "annual"
|
||||
"plan_name": "", # nome user-facing
|
||||
"plan_features": [], # ["audio", "video", "record", "metadata", "upgrade"]
|
||||
"plan_daily_limit": None, # None = unlimited (annual), altrimenti int
|
||||
"plan_is_subscription": False,
|
||||
"plan_expires_at": 0, # solo per annual one-time
|
||||
"plan_period_end": 0, # solo per subscription mensili
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
|
||||
|
||||
|
||||
# Flag globale per interruzione
|
||||
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||
if result.returncode != 0:
|
||||
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
||||
|
||||
@@ -146,8 +146,10 @@ def download_playlist(
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
query = f"{track['name']} - {track['artist']}"
|
||||
track_only = track['name']
|
||||
artist = (track.get('artist') or "").strip()
|
||||
name = (track.get('name') or "").strip()
|
||||
query = f"{name} - {artist}" if artist else name
|
||||
track_only = name
|
||||
|
||||
# Salta brani gia scaricati in precedenza (file di tracking)
|
||||
if query in done_set:
|
||||
@@ -204,6 +206,7 @@ def download_playlist(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
@@ -278,7 +281,7 @@ def download_direct_url(
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||
if result.returncode != 0:
|
||||
raise ValueError(result.stderr.strip())
|
||||
except Exception as e:
|
||||
@@ -356,6 +359,7 @@ def download_direct_url(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
@@ -395,6 +399,125 @@ def download_direct_url(
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def download_urls(
|
||||
urls: list,
|
||||
titles: list,
|
||||
output_dir: str,
|
||||
bitrate: str = "320K",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> None:
|
||||
"""Scarica direttamente da URL YouTube (bypassa search).
|
||||
|
||||
Usa gli stessi flag yt-dlp di download_playlist. La differenza e che
|
||||
salta lo step di ricerca perche l'URL e gia noto (usato dal flow del
|
||||
tab "YouTube Search" dopo che l'utente ha selezionato i risultati).
|
||||
|
||||
Args:
|
||||
urls: lista URL YouTube (parallela a titles)
|
||||
titles: lista titoli video (per logging/dedupe)
|
||||
output_dir: cartella destinazione
|
||||
bitrate: qualita audio (default 320K)
|
||||
cookies_path: file cookies opzionale
|
||||
progress_callback: callback(idx, total, title, status, percent)
|
||||
"""
|
||||
global _current_process
|
||||
reset_stop()
|
||||
total = len(urls)
|
||||
out_path = Path(output_dir)
|
||||
out_path.mkdir(parents=True, exist_ok=True)
|
||||
done_file = out_path / ".downloaded_tracks"
|
||||
done_set = _load_done_set(done_file)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
ytdlp = find_ytdlp()
|
||||
|
||||
for i, (url, title) in enumerate(zip(urls, titles)):
|
||||
if is_stopped():
|
||||
if progress_callback:
|
||||
progress_callback(i, total, "", "stopped", 0)
|
||||
return
|
||||
|
||||
key = title or url
|
||||
|
||||
# Salta se gia scaricato (file di tracking)
|
||||
if key in done_set:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "skipped", 100)
|
||||
continue
|
||||
|
||||
# Salta se esiste gia un file corrispondente nella cartella
|
||||
if title and _file_exists_for_query(title, existing_files):
|
||||
_mark_done(done_file, key)
|
||||
done_set.add(key)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "skipped", 100)
|
||||
continue
|
||||
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "downloading", 0)
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s.%(ext)s"),
|
||||
url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
if is_stopped():
|
||||
_current_process.terminate()
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "stopped", 0)
|
||||
return
|
||||
|
||||
pct_match = re.search(r"(\d+(?:\.\d+)?)%", line)
|
||||
if pct_match and progress_callback:
|
||||
pct = int(float(pct_match.group(1)))
|
||||
progress_callback(i, total, key, "downloading", pct)
|
||||
|
||||
return_code = _current_process.wait()
|
||||
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
|
||||
if return_code == 0:
|
||||
_mark_done(done_file, key)
|
||||
done_set.add(key)
|
||||
existing_files = _scan_existing_files(out_path)
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, "done", 100)
|
||||
else:
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, f"error: yt-dlp exit {return_code}", 0)
|
||||
|
||||
except Exception as e:
|
||||
with _process_lock:
|
||||
_current_process = None
|
||||
if progress_callback:
|
||||
progress_callback(i, total, key, f"error: {e}", 0)
|
||||
|
||||
if progress_callback and not is_stopped():
|
||||
progress_callback(total, total, "", "completed", 100)
|
||||
|
||||
|
||||
def _video_format(quality: str) -> str:
|
||||
"""Costruisce la format string yt-dlp per la qualita richiesta."""
|
||||
if not quality or quality == "best":
|
||||
@@ -418,10 +541,14 @@ def download_video(
|
||||
quality: str = "1080p",
|
||||
cookies_path: Optional[str] = None,
|
||||
progress_callback: Optional[Callable] = None,
|
||||
audio_only: bool = False,
|
||||
audio_bitrate: str = "320K",
|
||||
) -> None:
|
||||
"""Scarica video (con audio) da YouTube/TikTok/Instagram/Facebook/etc.
|
||||
"""Scarica video (con audio) o solo audio da YouTube/TikTok/Instagram/Facebook/etc.
|
||||
|
||||
quality: "best" | "1080p" | "720p" | "480p"
|
||||
quality: "best" | "1080p" | "720p" | "480p" (ignorato se audio_only=True)
|
||||
audio_only: True -> estrae solo audio in MP3
|
||||
audio_bitrate: bitrate MP3 quando audio_only=True
|
||||
Per Instagram/Facebook privati servono cookies validi.
|
||||
"""
|
||||
global _current_process
|
||||
@@ -439,7 +566,7 @@ def download_video(
|
||||
probe_cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
try:
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||
if result.returncode != 0:
|
||||
raise ValueError(result.stderr.strip())
|
||||
except Exception as e:
|
||||
@@ -461,7 +588,7 @@ def download_video(
|
||||
return
|
||||
|
||||
total = len(entries)
|
||||
fmt = _video_format(quality)
|
||||
fmt = _video_format(quality) if not audio_only else "bestaudio/best"
|
||||
|
||||
for i, entry in enumerate(entries):
|
||||
if is_stopped():
|
||||
@@ -483,16 +610,31 @@ def download_video(
|
||||
if progress_callback:
|
||||
progress_callback(i, total, title, "downloading", 0)
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"-f", fmt,
|
||||
"--merge-output-format", "mp4",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
|
||||
entry_url,
|
||||
]
|
||||
if audio_only:
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"-f", fmt,
|
||||
"--extract-audio",
|
||||
"--audio-format", "mp3",
|
||||
"--audio-quality", audio_bitrate.rstrip("Kk"),
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
|
||||
entry_url,
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"-f", fmt,
|
||||
"--merge-output-format", "mp4",
|
||||
"--add-metadata",
|
||||
"--no-check-certificates",
|
||||
"--newline",
|
||||
"--output", str(Path(output_dir) / "%(title)s [%(id)s].%(ext)s"),
|
||||
entry_url,
|
||||
]
|
||||
ffmpeg_dir = find_ffmpeg_dir()
|
||||
if ffmpeg_dir:
|
||||
cmd.extend(["--ffmpeg-location", ffmpeg_dir])
|
||||
@@ -503,6 +645,7 @@ def download_video(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Gestione licenze MusicTools.
|
||||
|
||||
Flusso:
|
||||
1. L'utente compra su musictools.djluza.com -> riceve license_key via email.
|
||||
2. Al primo avvio l'app mostra schermata bloccante: chiede email + key.
|
||||
3. activate() chiama POST {LICENSE_API_URL}/api/license/activate
|
||||
passando key, email, device_id (+ device_name). Il server:
|
||||
- verifica che la key esista e non abbia superato max attivazioni;
|
||||
- registra il device_id come attivo;
|
||||
- ritorna un JWT con claims {sub: license_id, exp, email, key_id}.
|
||||
4. Il token viene salvato in config.json. Il client lo considera valido
|
||||
per LICENSE_REVALIDATE_DAYS senza ricontrollare il server; oltre
|
||||
LICENSE_GRACE_DAYS chiede sempre nuova validazione online.
|
||||
5. validate() chiama POST /api/license/validate con {token, device_id}
|
||||
in background. Il server puo' revocare (refund, abuse) ritornando
|
||||
401 -> il client cancella il token e torna a schermata attivazione.
|
||||
|
||||
Nota: non verifichiamo la firma JWT lato client (servirebbe la public
|
||||
key). Ci fidiamo del token perche' e' uscito dal server al momento
|
||||
dell'attivazione, e ogni N giorni lo facciamo rivalidare. Per i refund
|
||||
non immediati basta il check periodico.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import platform
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from core.config import (
|
||||
LICENSE_API_URL,
|
||||
LICENSE_GRACE_DAYS,
|
||||
LICENSE_REVALIDATE_DAYS,
|
||||
VERSION,
|
||||
load_config,
|
||||
save_config,
|
||||
)
|
||||
|
||||
|
||||
_HTTP_TIMEOUT = 15 # secondi
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Errori
|
||||
# ============================================================
|
||||
class LicenseError(Exception):
|
||||
"""Errore di attivazione/validazione licenza."""
|
||||
|
||||
|
||||
class LicenseNetworkError(LicenseError):
|
||||
"""Impossibile raggiungere il server (offline, DNS, ecc.)."""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Utility
|
||||
# ============================================================
|
||||
def _now() -> int:
|
||||
return int(time.time())
|
||||
|
||||
|
||||
def _ensure_device_id(config: dict) -> str:
|
||||
"""Garantisce che config abbia un device_id stabile e univoco."""
|
||||
did = (config.get("device_id") or "").strip()
|
||||
if not did:
|
||||
did = str(uuid.uuid4())
|
||||
config["device_id"] = did
|
||||
save_config(config)
|
||||
return did
|
||||
|
||||
|
||||
def _device_name() -> str:
|
||||
"""Nome leggibile per identificare il device lato server (UI utente)."""
|
||||
try:
|
||||
return f"{platform.node() or 'device'} ({platform.system()})"
|
||||
except Exception:
|
||||
return "device"
|
||||
|
||||
|
||||
def _decode_jwt_claims(token: str) -> dict:
|
||||
"""Decodifica i claims di un JWT senza verificare la firma.
|
||||
|
||||
Ritorna {} se il token e' malformato. La verifica vera e' fatta
|
||||
dal server quando rivalidiamo online; qui ci serve solo leggere
|
||||
exp/email per la UI.
|
||||
"""
|
||||
try:
|
||||
parts = token.split(".")
|
||||
if len(parts) != 3:
|
||||
return {}
|
||||
payload = parts[1]
|
||||
# base64url -> base64 standard (padding)
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _http_post(path: str, body: dict) -> dict:
|
||||
"""POST JSON verso LICENSE_API_URL{path}. Ritorna il JSON di risposta.
|
||||
|
||||
Solleva LicenseNetworkError per problemi di rete e LicenseError
|
||||
per risposte HTTP 4xx/5xx (con messaggio dal server quando disponibile).
|
||||
"""
|
||||
url = LICENSE_API_URL.rstrip("/") + path
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": f"MusicTools/{VERSION}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err_body = e.read().decode("utf-8")
|
||||
err_data = json.loads(err_body)
|
||||
msg = err_data.get("error") or err_data.get("message") or e.reason
|
||||
except Exception:
|
||||
msg = e.reason or f"HTTP {e.code}"
|
||||
raise LicenseError(str(msg))
|
||||
except urllib.error.URLError as e:
|
||||
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
|
||||
except (TimeoutError, OSError) as e:
|
||||
raise LicenseNetworkError(str(e))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API pubblica
|
||||
# ============================================================
|
||||
def _plan_snapshot(cfg: dict) -> dict:
|
||||
"""Estrae i dati del piano dal config (popolati da activate/validate)."""
|
||||
code = (cfg.get("plan_code") or "").strip()
|
||||
if not code:
|
||||
return {}
|
||||
return {
|
||||
"code": code,
|
||||
"name": (cfg.get("plan_name") or "").strip() or code.title(),
|
||||
"features": list(cfg.get("plan_features") or []),
|
||||
"daily_limit": cfg.get("plan_daily_limit"), # puo' essere None
|
||||
"is_subscription": bool(cfg.get("plan_is_subscription")),
|
||||
"expires_at": int(cfg.get("plan_expires_at") or 0),
|
||||
"period_end": int(cfg.get("plan_period_end") or 0),
|
||||
}
|
||||
|
||||
|
||||
def has_feature(name: str, config: Optional[dict] = None) -> bool:
|
||||
"""True se il piano corrente include la feature richiesta."""
|
||||
cfg = config or load_config()
|
||||
plan = _plan_snapshot(cfg)
|
||||
if not plan:
|
||||
return False
|
||||
return name in (plan.get("features") or [])
|
||||
|
||||
|
||||
def get_plan(config: Optional[dict] = None) -> dict:
|
||||
"""Ritorna il dict del piano corrente, oppure {} se non noto."""
|
||||
return _plan_snapshot(config or load_config())
|
||||
|
||||
|
||||
def get_status(config: Optional[dict] = None) -> dict:
|
||||
"""Ritorna lo stato corrente della licenza per la UI.
|
||||
|
||||
Chiavi: licensed (bool), reason (str), email, key, activated_at,
|
||||
last_validated_at, days_since_validation, expires_soon (bool),
|
||||
needs_revalidation (bool), plan (dict).
|
||||
"""
|
||||
cfg = config or load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
key = (cfg.get("license_key") or "").strip()
|
||||
email = (cfg.get("license_email") or "").strip()
|
||||
activated = int(cfg.get("license_activated_at") or 0)
|
||||
last_val = int(cfg.get("last_validated_at") or 0)
|
||||
plan = _plan_snapshot(cfg)
|
||||
|
||||
if not token or not key:
|
||||
return {
|
||||
"licensed": False,
|
||||
"reason": "not_activated",
|
||||
"email": "",
|
||||
"key": "",
|
||||
"activated_at": 0,
|
||||
"last_validated_at": 0,
|
||||
"days_since_validation": 0,
|
||||
"needs_revalidation": False,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
# Scadenza locale derivata dai claims (best-effort: la verita' e' sul server).
|
||||
t = _now()
|
||||
expires_at = plan.get("expires_at") or 0
|
||||
period_end = plan.get("period_end") or 0
|
||||
if expires_at and expires_at < t:
|
||||
return {
|
||||
"licensed": False, "reason": "plan_expired", "email": email, "key": key,
|
||||
"activated_at": activated, "last_validated_at": last_val,
|
||||
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
|
||||
}
|
||||
if period_end and period_end < t:
|
||||
return {
|
||||
"licensed": False, "reason": "subscription_expired", "email": email, "key": key,
|
||||
"activated_at": activated, "last_validated_at": last_val,
|
||||
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
|
||||
}
|
||||
|
||||
days_since = (t - last_val) // 86400 if last_val else 999
|
||||
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
|
||||
grace_expired = days_since >= LICENSE_GRACE_DAYS
|
||||
|
||||
if grace_expired:
|
||||
return {
|
||||
"licensed": False,
|
||||
"reason": "grace_expired",
|
||||
"email": email,
|
||||
"key": key,
|
||||
"activated_at": activated,
|
||||
"last_validated_at": last_val,
|
||||
"days_since_validation": days_since,
|
||||
"needs_revalidation": True,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
return {
|
||||
"licensed": True,
|
||||
"reason": "ok",
|
||||
"email": email,
|
||||
"key": key,
|
||||
"activated_at": activated,
|
||||
"last_validated_at": last_val,
|
||||
"days_since_validation": days_since,
|
||||
"needs_revalidation": needs_reval,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
|
||||
def is_licensed() -> bool:
|
||||
"""Helper rapido per gating delle azioni."""
|
||||
return get_status()["licensed"]
|
||||
|
||||
|
||||
def _store_plan_from_response(cfg: dict, resp: dict, token: str) -> None:
|
||||
"""Aggiorna il config con i campi del piano estratti dalla risposta API.
|
||||
|
||||
Server risposta puo' contenere:
|
||||
- plan: {code, name, daily_limit, features, is_subscription}
|
||||
- expires_at: epoch (annual)
|
||||
- period_end: epoch (subscription)
|
||||
Come fallback decodifica i claims dal JWT.
|
||||
"""
|
||||
plan = resp.get("plan") if isinstance(resp.get("plan"), dict) else None
|
||||
claims = _decode_jwt_claims(token) if token else {}
|
||||
|
||||
code = ""
|
||||
name = ""
|
||||
features = []
|
||||
daily_limit = None
|
||||
is_sub = False
|
||||
if plan:
|
||||
code = (plan.get("code") or "").strip()
|
||||
name = (plan.get("name") or "").strip()
|
||||
features = list(plan.get("features") or [])
|
||||
daily_limit = plan.get("daily_limit")
|
||||
is_sub = bool(plan.get("is_subscription"))
|
||||
else:
|
||||
code = (claims.get("plan") or "").strip()
|
||||
name = (claims.get("plan_name") or "").strip()
|
||||
features = list(claims.get("features") or [])
|
||||
daily_limit = claims.get("daily_limit")
|
||||
is_sub = bool(claims.get("is_subscription"))
|
||||
|
||||
cfg["plan_code"] = code
|
||||
cfg["plan_name"] = name
|
||||
cfg["plan_features"] = features
|
||||
cfg["plan_daily_limit"] = daily_limit # None ammesso (unlimited)
|
||||
cfg["plan_is_subscription"] = is_sub
|
||||
cfg["plan_expires_at"] = int(resp.get("expires_at") or claims.get("expires_at") or 0)
|
||||
cfg["plan_period_end"] = int(resp.get("period_end") or claims.get("period_end") or 0)
|
||||
|
||||
|
||||
def _clear_plan(cfg: dict) -> None:
|
||||
cfg["plan_code"] = ""
|
||||
cfg["plan_name"] = ""
|
||||
cfg["plan_features"] = []
|
||||
cfg["plan_daily_limit"] = None
|
||||
cfg["plan_is_subscription"] = False
|
||||
cfg["plan_expires_at"] = 0
|
||||
cfg["plan_period_end"] = 0
|
||||
|
||||
|
||||
def activate(license_key: str, email: str) -> dict:
|
||||
"""Attiva una licenza contro il server. Salva token su success.
|
||||
|
||||
Ritorna dict con le chiavi di get_status(). Solleva LicenseError
|
||||
o LicenseNetworkError in caso di fallimento.
|
||||
"""
|
||||
key = (license_key or "").strip()
|
||||
mail = (email or "").strip().lower()
|
||||
if not key or not mail:
|
||||
raise LicenseError("Inserisci email e chiave di licenza.")
|
||||
|
||||
cfg = load_config()
|
||||
device_id = _ensure_device_id(cfg)
|
||||
|
||||
resp = _http_post("/api/license/activate", {
|
||||
"key": key,
|
||||
"email": mail,
|
||||
"device_id": device_id,
|
||||
"device_name": _device_name(),
|
||||
"app_version": VERSION,
|
||||
})
|
||||
|
||||
token = (resp.get("token") or "").strip()
|
||||
if not token:
|
||||
raise LicenseError(resp.get("error") or "Risposta server non valida.")
|
||||
|
||||
now = _now()
|
||||
cfg["license_key"] = key
|
||||
cfg["license_email"] = mail
|
||||
cfg["license_token"] = token
|
||||
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
|
||||
cfg["last_validated_at"] = now
|
||||
_store_plan_from_response(cfg, resp, token)
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
|
||||
def validate() -> dict:
|
||||
"""Rivalida il token corrente contro il server (background).
|
||||
|
||||
Aggiorna last_validated_at su success. Su 401 (revoca/refund)
|
||||
azzera il token cosi' la prossima get_status ritorna not_activated.
|
||||
Su errori di rete non fa nulla (resta valido fino al grace).
|
||||
"""
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
if not token:
|
||||
return get_status(cfg)
|
||||
|
||||
device_id = _ensure_device_id(cfg)
|
||||
try:
|
||||
resp = _http_post("/api/license/validate", {
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
"app_version": VERSION,
|
||||
})
|
||||
except LicenseNetworkError:
|
||||
return get_status(cfg)
|
||||
except LicenseError:
|
||||
# Server ha risposto 4xx -> token non piu valido
|
||||
cfg["license_token"] = ""
|
||||
cfg["last_validated_at"] = 0
|
||||
_clear_plan(cfg)
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
# Server puo' inviare un token rinnovato (rotazione)
|
||||
new_token = (resp.get("token") or "").strip()
|
||||
if new_token:
|
||||
cfg["license_token"] = new_token
|
||||
cfg["last_validated_at"] = _now()
|
||||
_store_plan_from_response(cfg, resp, cfg.get("license_token", ""))
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
|
||||
def deactivate(release_remote: bool = True) -> dict:
|
||||
"""Disattiva la licenza su questo device.
|
||||
|
||||
Se release_remote, notifica il server cosi' libera lo slot
|
||||
di attivazione (utile per spostare l'app su un altro device).
|
||||
Sempre azzera i campi licenza locali, anche se il server e' offline.
|
||||
"""
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
device_id = (cfg.get("device_id") or "").strip()
|
||||
|
||||
if release_remote and token and device_id:
|
||||
try:
|
||||
_http_post("/api/license/deactivate", {
|
||||
"token": token,
|
||||
"device_id": device_id,
|
||||
})
|
||||
except (LicenseError, LicenseNetworkError):
|
||||
# Lo facciamo local-only se il server non risponde.
|
||||
pass
|
||||
|
||||
cfg["license_key"] = ""
|
||||
cfg["license_email"] = ""
|
||||
cfg["license_token"] = ""
|
||||
cfg["license_activated_at"] = 0
|
||||
cfg["last_validated_at"] = 0
|
||||
_clear_plan(cfg)
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Quota giornaliera
|
||||
# ============================================================
|
||||
def _http_json(method: str, path: str, body: Optional[dict] = None,
|
||||
token: Optional[str] = None, timeout: int = 10) -> dict:
|
||||
"""Helper unificato per GET/POST con Authorization Bearer."""
|
||||
url = LICENSE_API_URL.rstrip("/") + path
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers = {
|
||||
"User-Agent": f"MusicTools/{VERSION}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err_body = e.read().decode("utf-8")
|
||||
err_data = json.loads(err_body) if err_body else {}
|
||||
except Exception:
|
||||
err_data = {}
|
||||
err_data.setdefault("error", e.reason or f"HTTP {e.code}")
|
||||
err_data["_status"] = e.code
|
||||
return err_data
|
||||
except urllib.error.URLError as e:
|
||||
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
|
||||
except (TimeoutError, OSError) as e:
|
||||
raise LicenseNetworkError(str(e))
|
||||
|
||||
|
||||
def get_quota_status() -> dict:
|
||||
"""Legge lo stato quota dal server. Ritorna dict con plan/used/limit/remaining.
|
||||
|
||||
Solleva LicenseError se il server rifiuta (401/403); LicenseNetworkError
|
||||
in caso di problemi di rete.
|
||||
"""
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
if not token:
|
||||
raise LicenseError("Licenza non attivata.")
|
||||
resp = _http_json("GET", "/api/usage/status", token=token)
|
||||
if resp.get("_status") and resp["_status"] >= 400:
|
||||
raise LicenseError(resp.get("error") or "Errore stato quota.")
|
||||
return resp
|
||||
|
||||
|
||||
def consume_quota(feature: str = "") -> dict:
|
||||
"""Incrementa la quota giornaliera prima di un download.
|
||||
|
||||
Ritorna {allowed, used, limit, remaining, plan, day}.
|
||||
Se il server risponde 429 -> allowed=False (l'utente ha raggiunto il limite).
|
||||
Per i piani unlimited (annual), allowed=True sempre.
|
||||
Solleva LicenseError per 401/403; LicenseNetworkError per problemi di rete.
|
||||
"""
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
if not token:
|
||||
raise LicenseError("Licenza non attivata.")
|
||||
body = {"feature": feature} if feature else {}
|
||||
resp = _http_json("POST", "/api/usage/consume", body=body, token=token)
|
||||
status_code = resp.pop("_status", 0)
|
||||
if status_code == 401 or status_code == 403:
|
||||
raise LicenseError(resp.get("error") or "Licenza non valida.")
|
||||
if status_code == 429:
|
||||
# Limite raggiunto, ma e' una risposta strutturata: ritorniamo
|
||||
# il dict cosi' la UI puo' mostrare i numeri.
|
||||
resp.setdefault("allowed", False)
|
||||
return resp
|
||||
if status_code and status_code >= 400:
|
||||
raise LicenseError(resp.get("error") or f"Errore quota ({status_code}).")
|
||||
resp.setdefault("allowed", True)
|
||||
return resp
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Lettura e scrittura metadati audio (ID3 per MP3, MP4 per M4A, Vorbis per FLAC)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import plistlib
|
||||
import subprocess
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import (
|
||||
ID3, ID3NoHeaderError,
|
||||
APIC, TIT2, TPE1, TPE2, TALB, TDRC, TRCK, TCON, COMM, TBPM, TKEY,
|
||||
)
|
||||
from mutagen.mp4 import MP4, MP4Cover
|
||||
from mutagen.flac import FLAC, Picture
|
||||
from mutagen.wave import WAVE
|
||||
|
||||
|
||||
SUPPORTED_EXTS = (".mp3", ".m4a", ".mp4", ".aac", ".flac", ".wav")
|
||||
|
||||
_WHERE_FROM_ATTR = "com.apple.metadata:kMDItemWhereFroms"
|
||||
|
||||
|
||||
def _read_where_from(path: str) -> list:
|
||||
"""Legge l'xattr macOS 'kMDItemWhereFroms' (lista di URL/origini).
|
||||
Ritorna [] se non e macOS o se l'attributo non esiste."""
|
||||
if sys.platform != "darwin":
|
||||
return []
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["xattr", "-px", _WHERE_FROM_ATTR, path],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
if res.returncode != 0:
|
||||
return []
|
||||
hex_str = "".join(res.stdout.split())
|
||||
if not hex_str:
|
||||
return []
|
||||
raw = bytes.fromhex(hex_str)
|
||||
items = plistlib.loads(raw)
|
||||
if isinstance(items, list):
|
||||
return [str(x) for x in items]
|
||||
return [str(items)]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _write_where_from(path: str, urls: list) -> None:
|
||||
"""Scrive l'xattr macOS 'kMDItemWhereFroms'. Lista vuota -> rimuove."""
|
||||
if sys.platform != "darwin":
|
||||
return
|
||||
try:
|
||||
clean = [u.strip() for u in (urls or []) if u and u.strip()]
|
||||
if not clean:
|
||||
subprocess.run(
|
||||
["xattr", "-d", _WHERE_FROM_ATTR, path],
|
||||
capture_output=True, timeout=5,
|
||||
)
|
||||
return
|
||||
data = plistlib.dumps(clean, fmt=plistlib.FMT_BINARY)
|
||||
subprocess.run(
|
||||
["xattr", "-wx", _WHERE_FROM_ATTR, data.hex(), path],
|
||||
capture_output=True, timeout=5, check=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _text(value) -> str:
|
||||
"""Estrai testo da un frame mutagen."""
|
||||
if value is None:
|
||||
return ""
|
||||
if hasattr(value, "text"):
|
||||
t = value.text
|
||||
if isinstance(t, list) and t:
|
||||
return str(t[0])
|
||||
return str(t)
|
||||
if isinstance(value, list) and value:
|
||||
return str(value[0])
|
||||
return str(value)
|
||||
|
||||
|
||||
def _read_mp3(path: str, result: dict) -> dict:
|
||||
result["format"] = "MP3"
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
return result
|
||||
|
||||
result["title"] = _text(tags.get("TIT2"))
|
||||
result["artist"] = _text(tags.get("TPE1"))
|
||||
result["album_artist"] = _text(tags.get("TPE2"))
|
||||
result["album"] = _text(tags.get("TALB"))
|
||||
result["year"] = _text(tags.get("TDRC"))
|
||||
result["track"] = _text(tags.get("TRCK"))
|
||||
result["genre"] = _text(tags.get("TCON"))
|
||||
result["bpm"] = _text(tags.get("TBPM"))
|
||||
result["key"] = _text(tags.get("TKEY"))
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("COMM"):
|
||||
result["comment"] = _text(tags[k])
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("APIC"):
|
||||
apic = tags[k]
|
||||
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
|
||||
result["cover_mime"] = apic.mime or "image/jpeg"
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_mp4(path: str, result: dict) -> dict:
|
||||
result["format"] = "M4A/MP4"
|
||||
m = MP4(path)
|
||||
tags = m.tags or {}
|
||||
result["title"] = _text(tags.get("\xa9nam"))
|
||||
result["artist"] = _text(tags.get("\xa9ART"))
|
||||
result["album_artist"] = _text(tags.get("aART"))
|
||||
result["album"] = _text(tags.get("\xa9alb"))
|
||||
result["year"] = _text(tags.get("\xa9day"))
|
||||
track_pair = tags.get("trkn")
|
||||
if track_pair and isinstance(track_pair, list) and track_pair:
|
||||
num, tot = track_pair[0] if isinstance(track_pair[0], tuple) else (track_pair[0], 0)
|
||||
result["track"] = f"{num}/{tot}" if tot else str(num)
|
||||
result["genre"] = _text(tags.get("\xa9gen"))
|
||||
result["comment"] = _text(tags.get("\xa9cmt"))
|
||||
result["bpm"] = _text(tags.get("tmpo"))
|
||||
|
||||
covers = tags.get("covr")
|
||||
if covers:
|
||||
c = covers[0]
|
||||
result["cover_base64"] = base64.b64encode(bytes(c)).decode("ascii")
|
||||
result["cover_mime"] = "image/jpeg" if c.imageformat == MP4Cover.FORMAT_JPEG else "image/png"
|
||||
return result
|
||||
|
||||
|
||||
_WAV_INFO_MAP = {
|
||||
"INAM": "title",
|
||||
"IART": "artist",
|
||||
"IPRD": "album",
|
||||
"ICRD": "year",
|
||||
"IYER": "year",
|
||||
"IGNR": "genre",
|
||||
"ICMT": "comment",
|
||||
"ITRK": "track",
|
||||
"IPRT": "track",
|
||||
"TRCK": "track",
|
||||
"IBPM": "bpm",
|
||||
"TBPM": "bpm",
|
||||
"TKEY": "key",
|
||||
}
|
||||
|
||||
|
||||
def _read_wav_info_chunk(path: str) -> dict:
|
||||
"""Legge il chunk RIFF LIST/INFO di un file WAV.
|
||||
Ritorna un dict con i campi noti. Usato come fallback quando
|
||||
il WAV non contiene ID3 embedded."""
|
||||
out: dict = {}
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
header = f.read(12)
|
||||
if len(header) < 12 or header[:4] != b"RIFF" or header[8:12] != b"WAVE":
|
||||
return out
|
||||
while True:
|
||||
hdr = f.read(8)
|
||||
if len(hdr) < 8:
|
||||
break
|
||||
chunk_id, chunk_size = struct.unpack("<4sI", hdr)
|
||||
if chunk_id == b"LIST":
|
||||
list_type = f.read(4)
|
||||
list_end = f.tell() + (chunk_size - 4)
|
||||
if list_type == b"INFO":
|
||||
while f.tell() < list_end:
|
||||
sub = f.read(8)
|
||||
if len(sub) < 8:
|
||||
break
|
||||
sub_id, sub_size = struct.unpack("<4sI", sub)
|
||||
data = f.read(sub_size).rstrip(b"\x00")
|
||||
if sub_size % 2 == 1:
|
||||
f.read(1) # padding word-aligned
|
||||
key = _WAV_INFO_MAP.get(sub_id.decode("ascii", errors="ignore"))
|
||||
if not key or not data:
|
||||
continue
|
||||
for enc in ("utf-8", "latin-1"):
|
||||
try:
|
||||
out[key] = data.decode(enc).strip()
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
else:
|
||||
f.seek(list_end)
|
||||
else:
|
||||
skip = chunk_size + (chunk_size % 2)
|
||||
f.seek(skip, 1)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
def _read_wav(path: str, result: dict) -> dict:
|
||||
result["format"] = "WAV"
|
||||
|
||||
# Prima fallback: leggi il chunk INFO (formato RIFF originale)
|
||||
info = _read_wav_info_chunk(path)
|
||||
for k, v in info.items():
|
||||
if v:
|
||||
result[k] = v
|
||||
|
||||
# Poi leggi ID3 se presente; ha priorita sui valori INFO
|
||||
w = WAVE(path)
|
||||
tags = w.tags
|
||||
if tags is None:
|
||||
return result
|
||||
|
||||
def setif(key, frame_key):
|
||||
v = _text(tags.get(frame_key))
|
||||
if v:
|
||||
result[key] = v
|
||||
|
||||
setif("title", "TIT2")
|
||||
setif("artist", "TPE1")
|
||||
setif("album_artist", "TPE2")
|
||||
setif("album", "TALB")
|
||||
setif("year", "TDRC")
|
||||
setif("track", "TRCK")
|
||||
setif("genre", "TCON")
|
||||
setif("bpm", "TBPM")
|
||||
setif("key", "TKEY")
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("COMM"):
|
||||
v = _text(tags[k])
|
||||
if v:
|
||||
result["comment"] = v
|
||||
break
|
||||
|
||||
for k in tags.keys():
|
||||
if k.startswith("APIC"):
|
||||
apic = tags[k]
|
||||
result["cover_base64"] = base64.b64encode(apic.data).decode("ascii")
|
||||
result["cover_mime"] = apic.mime or "image/jpeg"
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _read_flac(path: str, result: dict) -> dict:
|
||||
result["format"] = "FLAC"
|
||||
f = FLAC(path)
|
||||
g = lambda k: (f.get(k) or [""])[0] if f.get(k) else ""
|
||||
result["title"] = g("title")
|
||||
result["artist"] = g("artist")
|
||||
result["album_artist"] = g("albumartist")
|
||||
result["album"] = g("album")
|
||||
result["year"] = g("date")
|
||||
result["track"] = g("tracknumber")
|
||||
result["genre"] = g("genre")
|
||||
result["bpm"] = g("bpm")
|
||||
result["key"] = g("initialkey")
|
||||
result["comment"] = g("comment")
|
||||
if f.pictures:
|
||||
pic = f.pictures[0]
|
||||
result["cover_base64"] = base64.b64encode(pic.data).decode("ascii")
|
||||
result["cover_mime"] = pic.mime or "image/jpeg"
|
||||
return result
|
||||
|
||||
|
||||
def read_metadata(path: str) -> dict:
|
||||
"""Ritorna un dict con tutti i metadati leggibili dal file."""
|
||||
p = Path(path)
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
ext = p.suffix.lower()
|
||||
if ext not in SUPPORTED_EXTS:
|
||||
raise ValueError(f"Formato non supportato: {ext}")
|
||||
|
||||
result = {
|
||||
"path": str(p),
|
||||
"filename": p.name,
|
||||
"format": "",
|
||||
"title": "", "artist": "", "album_artist": "",
|
||||
"album": "", "year": "", "track": "",
|
||||
"genre": "", "comment": "",
|
||||
"bpm": "", "key": "",
|
||||
"duration": 0, "bitrate": 0,
|
||||
"cover_base64": "", "cover_mime": "",
|
||||
"where_from": _read_where_from(str(p)),
|
||||
"is_macos": sys.platform == "darwin",
|
||||
}
|
||||
|
||||
audio = MutagenFile(str(p))
|
||||
if audio is None:
|
||||
raise ValueError(f"Impossibile leggere: {ext}")
|
||||
|
||||
info = getattr(audio, "info", None)
|
||||
if info:
|
||||
result["duration"] = int(getattr(info, "length", 0) or 0)
|
||||
result["bitrate"] = int((getattr(info, "bitrate", 0) or 0) // 1000)
|
||||
|
||||
if ext == ".mp3":
|
||||
return _read_mp3(str(p), result)
|
||||
if ext in (".m4a", ".mp4", ".aac"):
|
||||
return _read_mp4(str(p), result)
|
||||
if ext == ".flac":
|
||||
return _read_flac(str(p), result)
|
||||
if ext == ".wav":
|
||||
return _read_wav(str(p), result)
|
||||
return result
|
||||
|
||||
|
||||
def _write_mp3(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
try:
|
||||
tags = ID3(path)
|
||||
except ID3NoHeaderError:
|
||||
tags = ID3()
|
||||
|
||||
def set_or_del(key, frame_cls, value):
|
||||
if value:
|
||||
tags[key] = frame_cls(encoding=3, text=value)
|
||||
elif key in tags:
|
||||
del tags[key]
|
||||
|
||||
set_or_del("TIT2", TIT2, data.get("title", ""))
|
||||
set_or_del("TPE1", TPE1, data.get("artist", ""))
|
||||
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
|
||||
set_or_del("TALB", TALB, data.get("album", ""))
|
||||
set_or_del("TDRC", TDRC, data.get("year", ""))
|
||||
set_or_del("TRCK", TRCK, data.get("track", ""))
|
||||
set_or_del("TCON", TCON, data.get("genre", ""))
|
||||
set_or_del("TBPM", TBPM, data.get("bpm", ""))
|
||||
set_or_del("TKEY", TKEY, data.get("key", ""))
|
||||
|
||||
# Rimuovi tutti i COMM esistenti
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("COMM"):
|
||||
del tags[k]
|
||||
comment = data.get("comment", "")
|
||||
if comment:
|
||||
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
|
||||
|
||||
# Cover
|
||||
if remove_cover or cover_path:
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("APIC"):
|
||||
del tags[k]
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext = cp.suffix.lower()
|
||||
mime = "image/png" if ext == ".png" else "image/jpeg"
|
||||
with open(cp, "rb") as f:
|
||||
cover_data = f.read()
|
||||
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
|
||||
|
||||
tags.save(path, v2_version=3)
|
||||
|
||||
|
||||
def _write_mp4(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
m = MP4(path)
|
||||
|
||||
def set_or_del(key, value):
|
||||
if value:
|
||||
m[key] = value
|
||||
elif key in m:
|
||||
del m[key]
|
||||
|
||||
set_or_del("\xa9nam", data.get("title", ""))
|
||||
set_or_del("\xa9ART", data.get("artist", ""))
|
||||
set_or_del("aART", data.get("album_artist", ""))
|
||||
set_or_del("\xa9alb", data.get("album", ""))
|
||||
set_or_del("\xa9day", data.get("year", ""))
|
||||
set_or_del("\xa9gen", data.get("genre", ""))
|
||||
set_or_del("\xa9cmt", data.get("comment", ""))
|
||||
|
||||
track = data.get("track", "")
|
||||
if track:
|
||||
try:
|
||||
parts = track.split("/")
|
||||
num = int(parts[0])
|
||||
tot = int(parts[1]) if len(parts) > 1 else 0
|
||||
m["trkn"] = [(num, tot)]
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
elif "trkn" in m:
|
||||
del m["trkn"]
|
||||
|
||||
bpm = data.get("bpm", "")
|
||||
if bpm:
|
||||
try:
|
||||
m["tmpo"] = [int(bpm)]
|
||||
except ValueError:
|
||||
pass
|
||||
elif "tmpo" in m:
|
||||
del m["tmpo"]
|
||||
|
||||
if remove_cover or cover_path:
|
||||
if "covr" in m:
|
||||
del m["covr"]
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext = cp.suffix.lower()
|
||||
fmt = MP4Cover.FORMAT_PNG if ext == ".png" else MP4Cover.FORMAT_JPEG
|
||||
with open(cp, "rb") as f:
|
||||
cover_data = f.read()
|
||||
m["covr"] = [MP4Cover(cover_data, imageformat=fmt)]
|
||||
|
||||
m.save()
|
||||
|
||||
|
||||
def _write_flac(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
f = FLAC(path)
|
||||
|
||||
def set_or_del(key, value):
|
||||
if value:
|
||||
f[key] = value
|
||||
elif key in f:
|
||||
del f[key]
|
||||
|
||||
set_or_del("title", data.get("title", ""))
|
||||
set_or_del("artist", data.get("artist", ""))
|
||||
set_or_del("albumartist", data.get("album_artist", ""))
|
||||
set_or_del("album", data.get("album", ""))
|
||||
set_or_del("date", data.get("year", ""))
|
||||
set_or_del("tracknumber", data.get("track", ""))
|
||||
set_or_del("genre", data.get("genre", ""))
|
||||
set_or_del("bpm", data.get("bpm", ""))
|
||||
set_or_del("initialkey", data.get("key", ""))
|
||||
set_or_del("comment", data.get("comment", ""))
|
||||
|
||||
if remove_cover or cover_path:
|
||||
f.clear_pictures()
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext = cp.suffix.lower()
|
||||
pic = Picture()
|
||||
with open(cp, "rb") as fh:
|
||||
pic.data = fh.read()
|
||||
pic.type = 3
|
||||
pic.mime = "image/png" if ext == ".png" else "image/jpeg"
|
||||
f.add_picture(pic)
|
||||
|
||||
f.save()
|
||||
|
||||
|
||||
def _write_wav(path: str, data: dict, cover_path: Optional[str], remove_cover: bool) -> None:
|
||||
w = WAVE(path)
|
||||
if w.tags is None:
|
||||
w.add_tags()
|
||||
tags = w.tags
|
||||
|
||||
def set_or_del(key, frame_cls, value):
|
||||
if value:
|
||||
tags[key] = frame_cls(encoding=3, text=value)
|
||||
elif key in tags:
|
||||
del tags[key]
|
||||
|
||||
set_or_del("TIT2", TIT2, data.get("title", ""))
|
||||
set_or_del("TPE1", TPE1, data.get("artist", ""))
|
||||
set_or_del("TPE2", TPE2, data.get("album_artist", ""))
|
||||
set_or_del("TALB", TALB, data.get("album", ""))
|
||||
set_or_del("TDRC", TDRC, data.get("year", ""))
|
||||
set_or_del("TRCK", TRCK, data.get("track", ""))
|
||||
set_or_del("TCON", TCON, data.get("genre", ""))
|
||||
set_or_del("TBPM", TBPM, data.get("bpm", ""))
|
||||
set_or_del("TKEY", TKEY, data.get("key", ""))
|
||||
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("COMM"):
|
||||
del tags[k]
|
||||
comment = data.get("comment", "")
|
||||
if comment:
|
||||
tags.add(COMM(encoding=3, lang="ita", desc="", text=comment))
|
||||
|
||||
if remove_cover or cover_path:
|
||||
for k in list(tags.keys()):
|
||||
if k.startswith("APIC"):
|
||||
del tags[k]
|
||||
if cover_path:
|
||||
cp = Path(cover_path)
|
||||
if cp.exists():
|
||||
ext_c = cp.suffix.lower()
|
||||
mime = "image/png" if ext_c == ".png" else "image/jpeg"
|
||||
with open(cp, "rb") as f:
|
||||
cover_data = f.read()
|
||||
tags.add(APIC(encoding=3, mime=mime, type=3, desc="Cover", data=cover_data))
|
||||
|
||||
w.save()
|
||||
|
||||
|
||||
def write_metadata(path: str, data: dict, cover_path: Optional[str] = None,
|
||||
remove_cover: bool = False) -> None:
|
||||
"""Salva i metadati. Se remove_cover=True rimuove la copertina esistente
|
||||
senza sostituirla. Se cover_path e fornito, la sostituisce.
|
||||
Su macOS aggiorna anche l'xattr kMDItemWhereFroms se data['where_from']
|
||||
e fornito (lista di stringhe)."""
|
||||
ext = Path(path).suffix.lower()
|
||||
if ext == ".mp3":
|
||||
_write_mp3(path, data, cover_path, remove_cover)
|
||||
elif ext in (".m4a", ".mp4", ".aac"):
|
||||
_write_mp4(path, data, cover_path, remove_cover)
|
||||
elif ext == ".flac":
|
||||
_write_flac(path, data, cover_path, remove_cover)
|
||||
elif ext == ".wav":
|
||||
_write_wav(path, data, cover_path, remove_cover)
|
||||
else:
|
||||
raise ValueError(f"Formato non supportato: {ext}")
|
||||
|
||||
# macOS extended attribute: kMDItemWhereFroms (lista URL/origini)
|
||||
if "where_from" in data:
|
||||
_write_where_from(path, data.get("where_from") or [])
|
||||
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
|
||||
# Directory dell'eseguibile
|
||||
exe_dir = Path(sys.executable).parent
|
||||
dirs.append(exe_dir)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
|
||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
|
||||
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
|
||||
if not _IS_WINDOWS:
|
||||
frameworks = exe_dir.parent / "Frameworks"
|
||||
if frameworks.exists():
|
||||
dirs.append(frameworks)
|
||||
# Mini-bundle .app per i subprocess (vedi build_macos.py
|
||||
# wrap_subprocess_in_bundle).
|
||||
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||
if sub_macos.exists():
|
||||
dirs.append(sub_macos)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -48,6 +55,19 @@ def _exe(name: str) -> str:
|
||||
return name
|
||||
|
||||
|
||||
def subprocess_flags() -> dict:
|
||||
"""Kwargs per subprocess.run/Popen che nascondono la console su Windows.
|
||||
|
||||
Senza CREATE_NO_WINDOW, ogni subprocess (yt-dlp, ffmpeg, ffprobe) lanciato
|
||||
da un'app GUI PyInstaller apre una finestra cmd nera per la durata del
|
||||
processo. Su macOS/Linux il flag non esiste, quindi ritorniamo dict vuoto.
|
||||
"""
|
||||
if _IS_WINDOWS:
|
||||
# 0x08000000 == CREATE_NO_WINDOW
|
||||
return {"creationflags": 0x08000000}
|
||||
return {}
|
||||
|
||||
|
||||
def find_ytdlp() -> str:
|
||||
"""Trova il path di yt-dlp.
|
||||
|
||||
@@ -83,55 +103,45 @@ def find_ytdlp() -> str:
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Trova la directory contenente ffmpeg e ffprobe.
|
||||
|
||||
Ordine di ricerca:
|
||||
1. Bundle PyInstaller
|
||||
2. Percorsi noti per OS
|
||||
3. Qualsiasi posizione nel PATH
|
||||
"""
|
||||
ffmpeg_name = _exe("ffmpeg")
|
||||
|
||||
# 1. Bundle
|
||||
def _find_binary(name: str) -> Optional[str]:
|
||||
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
|
||||
exe_name = _exe(name)
|
||||
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
|
||||
for d in _bundle_dirs():
|
||||
if (d / ffmpeg_name).exists():
|
||||
return str(d)
|
||||
|
||||
# 2. Percorsi noti
|
||||
candidate = d / exe_name
|
||||
if candidate.exists():
|
||||
return str(candidate)
|
||||
# 2. Percorsi noti per OS
|
||||
if _IS_WINDOWS:
|
||||
# Posizioni comuni su Windows
|
||||
for search in (
|
||||
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
|
||||
Path("C:/ffmpeg/bin"),
|
||||
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
|
||||
):
|
||||
if (search / "ffmpeg.exe").exists():
|
||||
return str(search)
|
||||
cand = search / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
else:
|
||||
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
||||
if (Path(search_path) / "ffmpeg").exists():
|
||||
return search_path
|
||||
|
||||
cand = Path(search_path) / exe_name
|
||||
if cand.exists():
|
||||
return str(cand)
|
||||
# 3. PATH generico
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg:
|
||||
return str(Path(ffmpeg).parent)
|
||||
found = shutil.which(name)
|
||||
return found if found else None
|
||||
|
||||
return None
|
||||
|
||||
def find_ffmpeg_dir() -> Optional[str]:
|
||||
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
|
||||
ff = _find_binary("ffmpeg")
|
||||
return str(Path(ff).parent) if ff else None
|
||||
|
||||
|
||||
def find_ffmpeg() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffmpeg."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffmpeg"))
|
||||
return None
|
||||
return _find_binary("ffmpeg")
|
||||
|
||||
|
||||
def find_ffprobe() -> Optional[str]:
|
||||
"""Ritorna il path completo di ffprobe."""
|
||||
d = find_ffmpeg_dir()
|
||||
if d:
|
||||
return str(Path(d) / _exe("ffprobe"))
|
||||
return None
|
||||
return _find_binary("ffprobe")
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Registrazione audio.
|
||||
|
||||
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
|
||||
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
|
||||
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
|
||||
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
|
||||
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ffmpeg, subprocess_flags
|
||||
|
||||
|
||||
_IS_MAC = sys.platform == "darwin"
|
||||
_IS_WIN = sys.platform == "win32"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Stato globale (un solo recording alla volta)
|
||||
# ============================================================
|
||||
_recording_lock = threading.Lock()
|
||||
_recording_state: dict = {}
|
||||
_stderr_buf: list = []
|
||||
_stderr_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_recording() -> bool:
|
||||
with _recording_lock:
|
||||
return bool(_recording_state.get("active"))
|
||||
|
||||
|
||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
|
||||
|
||||
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
|
||||
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
|
||||
stderr di ffmpeg per la registrazione DirectShow.
|
||||
"""
|
||||
with _stderr_lock:
|
||||
return list(_stderr_buf[-max_lines:])
|
||||
|
||||
|
||||
def _log_diag(line: str) -> None:
|
||||
with _stderr_lock:
|
||||
_stderr_buf.append(line)
|
||||
if len(_stderr_buf) > 200:
|
||||
del _stderr_buf[0]
|
||||
|
||||
|
||||
_VIRTUAL_KEYWORDS = (
|
||||
"blackhole", "soundflower", "loopback", "audio hijack",
|
||||
"vb-cable", "vb-audio", "stereo mix", "what u hear",
|
||||
"obs", "system audio",
|
||||
)
|
||||
|
||||
|
||||
def _is_virtual(name: str) -> bool:
|
||||
n = (name or "").lower()
|
||||
return any(k in n for k in _VIRTUAL_KEYWORDS)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Lista dispositivi
|
||||
# ============================================================
|
||||
def list_input_devices() -> list[dict]:
|
||||
"""Ritorna la lista dei dispositivi audio di input.
|
||||
|
||||
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
|
||||
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
|
||||
Su Windows e' il nome del device (richiesto da DirectShow).
|
||||
"""
|
||||
if _IS_MAC:
|
||||
return _list_macos_avf()
|
||||
if _IS_WIN:
|
||||
ffmpeg = find_ffmpeg()
|
||||
return _list_windows(ffmpeg) if ffmpeg else []
|
||||
return []
|
||||
|
||||
|
||||
def _list_macos_avf() -> list[dict]:
|
||||
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
|
||||
try:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
try:
|
||||
name = str(d.localizedName())
|
||||
uid = str(d.uniqueID())
|
||||
except Exception:
|
||||
continue
|
||||
devices.append({
|
||||
"id": uid,
|
||||
"name": name,
|
||||
"is_virtual": _is_virtual(name),
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
def _list_windows(ffmpeg: str) -> list[dict]:
|
||||
"""Parsa l'output di `ffmpeg -f dshow -list_devices true -i dummy`."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-f", "dshow",
|
||||
"-list_devices", "true", "-i", "dummy"],
|
||||
capture_output=True, text=True, timeout=10,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
devices: list[dict] = []
|
||||
in_audio = False
|
||||
line_re = re.compile(r'"([^"]+)"\s*\((audio|video)\)', re.IGNORECASE)
|
||||
name_re = re.compile(r'"([^"]+)"')
|
||||
|
||||
for raw in proc.stderr.splitlines():
|
||||
line = raw.strip()
|
||||
low = line.lower()
|
||||
if "directshow audio devices" in low:
|
||||
in_audio = True
|
||||
continue
|
||||
if "directshow video devices" in low:
|
||||
in_audio = False
|
||||
continue
|
||||
|
||||
m = line_re.search(line)
|
||||
if m:
|
||||
name, kind = m.group(1), m.group(2).lower()
|
||||
if kind == "audio":
|
||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||
continue
|
||||
|
||||
if in_audio:
|
||||
mn = name_re.search(line)
|
||||
if mn:
|
||||
name = mn.group(1)
|
||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||
|
||||
seen = set()
|
||||
out = []
|
||||
for d in devices:
|
||||
if d["id"] in seen:
|
||||
continue
|
||||
seen.add(d["id"])
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Permesso microfono (macOS)
|
||||
# ============================================================
|
||||
def _ensure_macos_mic_permission() -> tuple[bool, str]:
|
||||
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
|
||||
|
||||
Ritorna (granted, reason).
|
||||
"""
|
||||
try:
|
||||
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||
except ImportError as e:
|
||||
return False, f"AVFoundation non disponibile: {e}"
|
||||
|
||||
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
|
||||
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
|
||||
if status == 3:
|
||||
return True, "authorized"
|
||||
if status in (1, 2):
|
||||
return False, (
|
||||
"Permesso microfono negato.\n"
|
||||
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||
"Attiva MusicTools e riavvia l'app."
|
||||
)
|
||||
# notDetermined -> chiedi il prompt in modo sincrono.
|
||||
ev = threading.Event()
|
||||
granted = [False]
|
||||
|
||||
def cb(ok):
|
||||
granted[0] = bool(ok)
|
||||
ev.set()
|
||||
|
||||
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
|
||||
# PyObjC pumpa il run loop automaticamente in attesa del callback.
|
||||
ev.wait(60)
|
||||
if not granted[0]:
|
||||
return False, "Permesso microfono negato dall'utente."
|
||||
return True, "granted"
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione macOS via AVCaptureSession
|
||||
# ============================================================
|
||||
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
from AVFoundation import (
|
||||
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
|
||||
AVCaptureAudioFileOutput, AVMediaTypeAudio,
|
||||
)
|
||||
from Foundation import NSURL, NSObject
|
||||
|
||||
ok, reason = _ensure_macos_mic_permission()
|
||||
if not ok:
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
# Trova il device: prima per uniqueID, poi per nome (fallback)
|
||||
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
|
||||
if dev is None:
|
||||
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||
if str(d.localizedName()) == device_id:
|
||||
dev = d
|
||||
break
|
||||
if dev is None:
|
||||
return {"ok": False, "error": "Dispositivo audio non trovato."}
|
||||
|
||||
session = AVCaptureSession.alloc().init()
|
||||
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
|
||||
if err is not None:
|
||||
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
|
||||
if not session.canAddInput_(inp):
|
||||
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
|
||||
session.addInput_(inp)
|
||||
|
||||
out = AVCaptureAudioFileOutput.alloc().init()
|
||||
if not session.canAddOutput_(out):
|
||||
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
|
||||
session.addOutput_(out)
|
||||
|
||||
out_path = Path(output_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
caf_path = out_path.with_suffix(".caf")
|
||||
|
||||
# Delegate: aspetta che lo stop produca il file finale
|
||||
class _Delegate(NSObject):
|
||||
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
|
||||
self, output, url, conns):
|
||||
self.started = True
|
||||
|
||||
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
|
||||
self, output, url, conns, err):
|
||||
self.done = True
|
||||
self.err = err.localizedDescription() if err is not None else None
|
||||
|
||||
delegate = _Delegate.alloc().init()
|
||||
delegate.started = False
|
||||
delegate.done = False
|
||||
delegate.err = None
|
||||
|
||||
session.startRunning()
|
||||
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
|
||||
NSURL.fileURLWithPath_(str(caf_path)),
|
||||
"com.apple.coreaudio-format",
|
||||
delegate,
|
||||
)
|
||||
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"session": session,
|
||||
"output": out,
|
||||
"delegate": delegate,
|
||||
"caf_path": str(caf_path),
|
||||
"mp3_path": str(out_path),
|
||||
"bitrate": bitrate,
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out_path)})
|
||||
|
||||
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
|
||||
threading.Thread(target=_tick_worker, daemon=True).start()
|
||||
|
||||
_log_diag(f"[avf] Recording started -> {caf_path}")
|
||||
return {"ok": True, "output_path": str(out_path)}
|
||||
|
||||
|
||||
def _tick_worker():
|
||||
while is_recording():
|
||||
with _recording_lock:
|
||||
cb = _recording_state.get("progress_cb")
|
||||
started_at = _recording_state.get("started_at", time.monotonic())
|
||||
elapsed = int(time.monotonic() - started_at)
|
||||
if cb:
|
||||
cb("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def _stop_recording_macos() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
state = dict(_recording_state)
|
||||
_recording_state["active"] = False
|
||||
|
||||
session = state["session"]
|
||||
out = state["output"]
|
||||
delegate = state["delegate"]
|
||||
caf_path = Path(state["caf_path"])
|
||||
mp3_path = Path(state["mp3_path"])
|
||||
bitrate = state["bitrate"]
|
||||
cb = state["progress_cb"]
|
||||
started_at = state["started_at"]
|
||||
|
||||
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
|
||||
out.stopRecording()
|
||||
# Aspetta il completamento del file
|
||||
deadline = time.time() + 8
|
||||
while not getattr(delegate, "done", False) and time.time() < deadline:
|
||||
time.sleep(0.1)
|
||||
session.stopRunning()
|
||||
|
||||
if getattr(delegate, "err", None):
|
||||
_log_diag(f"[avf] delegate error: {delegate.err}")
|
||||
|
||||
seconds = int(time.monotonic() - started_at)
|
||||
|
||||
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
|
||||
if caf_path.exists():
|
||||
ffmpeg = find_ffmpeg()
|
||||
if ffmpeg:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
|
||||
"-acodec", "libmp3lame", "-b:a", bitrate,
|
||||
"-vn", str(mp3_path)],
|
||||
capture_output=True, text=True, timeout=120,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
|
||||
for ln in (proc.stderr or "").splitlines()[-10:]:
|
||||
_log_diag(ln)
|
||||
# Cleanup CAF temporaneo
|
||||
try:
|
||||
caf_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
_log_diag(f"[ffmpeg-convert] exception: {e}")
|
||||
else:
|
||||
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
|
||||
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
|
||||
# ma l'utente ha qualcosa)
|
||||
try:
|
||||
caf_path.rename(mp3_path.with_suffix(".caf"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
if cb:
|
||||
if mp3_path.exists():
|
||||
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
|
||||
else:
|
||||
cb("error", {"error": "File registrato non trovato dopo lo stop."})
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Registrazione Windows via ffmpeg DirectShow (invariato)
|
||||
# ============================================================
|
||||
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
|
||||
progress_callback: Optional[Callable]) -> dict:
|
||||
ffmpeg = find_ffmpeg()
|
||||
if not ffmpeg:
|
||||
return {"ok": False, "error": "ffmpeg non trovato"}
|
||||
|
||||
out = Path(output_path)
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
ffmpeg, "-hide_banner", "-y",
|
||||
"-f", "dshow",
|
||||
"-i", f"audio={device_id}",
|
||||
"-acodec", "libmp3lame",
|
||||
"-b:a", bitrate,
|
||||
"-vn",
|
||||
str(out),
|
||||
]
|
||||
|
||||
with _stderr_lock:
|
||||
_stderr_buf.clear()
|
||||
|
||||
def _drain(proc):
|
||||
try:
|
||||
for raw in iter(proc.stderr.readline, b""):
|
||||
if not raw:
|
||||
break
|
||||
line = raw.decode("utf-8", errors="ignore").rstrip()
|
||||
_log_diag(line)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
text=False,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
with _recording_lock:
|
||||
_recording_state.update({
|
||||
"active": True,
|
||||
"proc": proc,
|
||||
"output_path": str(out),
|
||||
"started_at": time.monotonic(),
|
||||
"progress_cb": progress_callback,
|
||||
})
|
||||
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
|
||||
|
||||
start = time.monotonic()
|
||||
opened = False
|
||||
for _ in range(15):
|
||||
if proc.poll() is not None:
|
||||
break
|
||||
with _stderr_lock:
|
||||
if any("Press [q]" in l for l in _stderr_buf):
|
||||
opened = True
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if not opened and proc.poll() is not None:
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
return
|
||||
|
||||
if progress_callback:
|
||||
progress_callback("started", {"output_path": str(out)})
|
||||
|
||||
while True:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
break
|
||||
rc = proc.poll()
|
||||
if rc is not None:
|
||||
break
|
||||
elapsed = int(time.monotonic() - start)
|
||||
if progress_callback:
|
||||
progress_callback("tick", {"seconds": elapsed})
|
||||
time.sleep(1.0)
|
||||
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.stdin.write(b"q\n")
|
||||
proc.stdin.flush()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
seconds = int(time.monotonic() - start)
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
|
||||
if progress_callback:
|
||||
if proc.returncode == 0 or out.exists():
|
||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||
else:
|
||||
progress_callback("error", {
|
||||
"error": _extract_friendly_error_windows(proc.returncode),
|
||||
})
|
||||
except Exception as e:
|
||||
with _recording_lock:
|
||||
_recording_state["active"] = False
|
||||
if progress_callback:
|
||||
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
return {"ok": True, "output_path": str(out)}
|
||||
|
||||
|
||||
def _extract_friendly_error_windows(rc: int) -> str:
|
||||
with _stderr_lock:
|
||||
lines = list(_stderr_buf)
|
||||
text = "\n".join(lines).lower()
|
||||
if "no such device" in text or "could not find" in text:
|
||||
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
|
||||
if "permission" in text or "denied" in text:
|
||||
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
|
||||
for line in reversed(lines):
|
||||
l = line.strip()
|
||||
if not l:
|
||||
continue
|
||||
low = l.lower()
|
||||
if "error" in low or "fail" in low or "denied" in low:
|
||||
return l
|
||||
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||
|
||||
|
||||
def _stop_recording_windows() -> dict:
|
||||
with _recording_lock:
|
||||
if not _recording_state.get("active"):
|
||||
return {"ok": True}
|
||||
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
|
||||
_recording_state["active"] = False
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# API pubblica
|
||||
# ============================================================
|
||||
def start_recording(
|
||||
device_id: str,
|
||||
output_path: str,
|
||||
bitrate: str = "320k",
|
||||
progress_callback: Optional[Callable] = None,
|
||||
) -> dict:
|
||||
if is_recording():
|
||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||
|
||||
with _stderr_lock:
|
||||
_stderr_buf.clear()
|
||||
|
||||
if _IS_MAC:
|
||||
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
|
||||
if _IS_WIN:
|
||||
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
|
||||
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
|
||||
|
||||
|
||||
def stop_recording() -> dict:
|
||||
if _IS_MAC:
|
||||
return _stop_recording_macos()
|
||||
if _IS_WIN:
|
||||
return _stop_recording_windows()
|
||||
return {"ok": True}
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@@ -175,3 +177,236 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
|
||||
else:
|
||||
name, tracks = get_playlist_info(token, item_id)
|
||||
return "Playlist", name, tracks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback senza credenziali (solo per singoli brani)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SpotifyAuthRequired(Exception):
|
||||
"""Sollevata quando un URL Spotify richiede le API keys (album/playlist)."""
|
||||
|
||||
|
||||
_OG_TITLE_RE = re.compile(
|
||||
r'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE,
|
||||
)
|
||||
_OG_DESC_RE = re.compile(
|
||||
r'<meta\s+property="og:description"\s+content="([^"]+)"', re.IGNORECASE,
|
||||
)
|
||||
# Tag HTML entities che ci interessano nelle og: (Spotify le scrive cosi').
|
||||
_HTML_ENTITIES = {
|
||||
"&": "&", "'": "'", "'": "'",
|
||||
""": '"', "<": "<", ">": ">",
|
||||
}
|
||||
|
||||
|
||||
def _decode_entities(s: str) -> str:
|
||||
for k, v in _HTML_ENTITIES.items():
|
||||
s = s.replace(k, v)
|
||||
return s
|
||||
|
||||
|
||||
def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]:
|
||||
"""Estrae name/artist da un URL Spotify SENZA credenziali API.
|
||||
|
||||
Funziona solo per brani singoli (open.spotify.com/track/<id>): legge
|
||||
i meta tag og:title e og:description della pagina pubblica.
|
||||
|
||||
Per album/playlist non c'e' modo affidabile senza JS rendering -> alza
|
||||
SpotifyAuthRequired.
|
||||
|
||||
Ritorna ("Brano", name, [{name, artist}]).
|
||||
"""
|
||||
kind, _ = detect_url_type(url)
|
||||
if kind != "track":
|
||||
raise SpotifyAuthRequired(
|
||||
f"I link a {kind} richiedono le credenziali Spotify (gratuite). "
|
||||
"Vai in Impostazioni -> Spotify API per configurarle in 2 minuti."
|
||||
)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers={"User-Agent": "Mozilla/5.0 (MusicTools)"},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
raise ValueError(f"Impossibile leggere il brano da Spotify: {e}") from e
|
||||
|
||||
html = resp.text
|
||||
title_m = _OG_TITLE_RE.search(html)
|
||||
desc_m = _OG_DESC_RE.search(html)
|
||||
if not title_m:
|
||||
raise ValueError("Risposta inattesa da Spotify (meta og:title mancante).")
|
||||
|
||||
name = _decode_entities(title_m.group(1)).strip()
|
||||
artist = ""
|
||||
if desc_m:
|
||||
# Formato osservato: "Artist Name · Song Title · Song · YYYY"
|
||||
# oppure con piu' artisti separati da ", ".
|
||||
parts = [_decode_entities(p).strip() for p in desc_m.group(1).split("·")]
|
||||
if parts:
|
||||
artist = parts[0]
|
||||
|
||||
if not artist:
|
||||
raise ValueError(
|
||||
"Impossibile estrarre l'artista dal brano Spotify senza API keys. "
|
||||
"Configura le credenziali in Impostazioni."
|
||||
)
|
||||
|
||||
track = {"name": name, "artist": artist}
|
||||
return "Brano", name, [track]
|
||||
|
||||
|
||||
def search_track(token: str, query: str):
|
||||
"""Cerca un brano su Spotify e ritorna il primo match.
|
||||
|
||||
Ritorna un dict con {id, url, name, artists} oppure None se nessun match.
|
||||
Solleva requests.HTTPError su errori server / auth.
|
||||
"""
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"q": query, "type": "track", "limit": 1},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("tracks", {}).get("items", [])
|
||||
if not items:
|
||||
return None
|
||||
t = items[0]
|
||||
return {
|
||||
"id": t.get("id"),
|
||||
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||
"name": t.get("name", ""),
|
||||
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
||||
}
|
||||
|
||||
|
||||
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
"""Cerca brani su Spotify e ritorna una lista di dict.
|
||||
|
||||
Args:
|
||||
token: access token Spotify
|
||||
query: query libera (titolo, artista, misto)
|
||||
limit: max risultati (Spotify cap = 50)
|
||||
|
||||
Returns:
|
||||
list[dict] con {id, url, name, artists, album, duration_sec}. Vuota se nessun match.
|
||||
"""
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"q": query, "type": "track", "limit": min(limit, 50)},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
items = resp.json().get("tracks", {}).get("items", [])
|
||||
return [_track_to_dict(t) for t in items]
|
||||
|
||||
|
||||
def _track_to_dict(t: dict) -> dict:
|
||||
"""Mappa il track object Spotify sul nostro schema uniforme."""
|
||||
images = t.get("album", {}).get("images", []) or []
|
||||
# Spotify torna 3 taglie ordinate large->small. Prendo la più piccola (~64px)
|
||||
# se disponibile, altrimenti la prima che c'è.
|
||||
image_url = ""
|
||||
if images:
|
||||
image_url = images[-1].get("url", "") or images[0].get("url", "")
|
||||
return {
|
||||
"id": t.get("id", ""),
|
||||
"url": t.get("external_urls", {}).get("spotify", ""),
|
||||
"name": t.get("name", ""),
|
||||
"artists": ", ".join(a.get("name", "") for a in t.get("artists", [])),
|
||||
"album": t.get("album", {}).get("name", ""),
|
||||
"duration_sec": int(t.get("duration_ms", 0)) // 1000,
|
||||
"image_url": image_url,
|
||||
}
|
||||
|
||||
|
||||
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
"""Trova l'artista esatto (o il piu' popolare tra i match) e ritorna
|
||||
tutti i suoi brani: top tracks + tracce di ogni album/single.
|
||||
Deduplica per (name.lower().strip(), first_artist.lower().strip()).
|
||||
|
||||
Raises:
|
||||
ValueError: se nessun artista trovato per il nome dato.
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# 1. Cerca artista
|
||||
resp = requests.get(
|
||||
"https://api.spotify.com/v1/search",
|
||||
headers=headers,
|
||||
params={"q": artist_name, "type": "artist", "limit": 5},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
candidates = resp.json().get("artists", {}).get("items", [])
|
||||
if not candidates:
|
||||
raise ValueError(f"Artista '{artist_name}' non trovato")
|
||||
|
||||
# Match esatto (case-insensitive) se possibile, altrimenti piu' popolare
|
||||
query_lower = artist_name.lower().strip()
|
||||
exact = [c for c in candidates if c.get("name", "").lower().strip() == query_lower]
|
||||
if exact:
|
||||
artist = exact[0]
|
||||
else:
|
||||
artist = max(candidates, key=lambda c: c.get("popularity", 0))
|
||||
artist_id = artist["id"]
|
||||
|
||||
collected: list = []
|
||||
|
||||
# 2. Top tracks
|
||||
r_top = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}/top-tracks",
|
||||
headers=headers,
|
||||
params={"market": "IT"},
|
||||
timeout=15,
|
||||
)
|
||||
r_top.raise_for_status()
|
||||
for t in r_top.json().get("tracks", []):
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 3. Albums (album + single)
|
||||
r_alb = requests.get(
|
||||
f"https://api.spotify.com/v1/artists/{artist_id}/albums",
|
||||
headers=headers,
|
||||
params={"include_groups": "album,single", "limit": 50, "market": "IT"},
|
||||
timeout=15,
|
||||
)
|
||||
r_alb.raise_for_status()
|
||||
albums = r_alb.json().get("items", [])
|
||||
|
||||
# 4. Per ogni album, tracce (album/track object non ha "album" sub-field, iniettiamola
|
||||
# includendo anche le images dell'album così _track_to_dict riesce a estrarre la cover)
|
||||
for alb in albums:
|
||||
alb_id = alb.get("id")
|
||||
alb_name = alb.get("name", "")
|
||||
alb_images = alb.get("images", []) or []
|
||||
if not alb_id:
|
||||
continue
|
||||
time.sleep(0.1) # rate limit interno
|
||||
r_at = requests.get(
|
||||
f"https://api.spotify.com/v1/albums/{alb_id}/tracks",
|
||||
headers=headers,
|
||||
params={"limit": 50},
|
||||
timeout=15,
|
||||
)
|
||||
r_at.raise_for_status()
|
||||
for t in r_at.json().get("items", []):
|
||||
t = dict(t)
|
||||
t.setdefault("album", {"name": alb_name, "images": alb_images})
|
||||
collected.append(_track_to_dict(t))
|
||||
|
||||
# 5. Dedupe
|
||||
seen: set = set()
|
||||
unique: list = []
|
||||
for t in collected:
|
||||
key = (t["name"].lower().strip(), t["artists"].split(",")[0].lower().strip())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique.append(t)
|
||||
return unique
|
||||
@@ -9,7 +9,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe
|
||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
|
||||
|
||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||
|
||||
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
data = json.loads(result.stdout)
|
||||
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
|
||||
@@ -94,7 +95,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||
if result.returncode != 0:
|
||||
raise ValueError("Ricerca fallita")
|
||||
|
||||
@@ -128,7 +129,7 @@ def update_cover_only(
|
||||
if cookies_path and Path(cookies_path).exists():
|
||||
cmd.extend(["--cookies", cookies_path])
|
||||
|
||||
subprocess.run(cmd, capture_output=True, timeout=60)
|
||||
subprocess.run(cmd, capture_output=True, timeout=60, **subprocess_flags())
|
||||
|
||||
cover_files = list(temp_dir.glob("cover*.jpg"))
|
||||
if not cover_files:
|
||||
@@ -152,6 +153,7 @@ def update_cover_only(
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=60,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except Exception:
|
||||
_cleanup_temp(temp_dir)
|
||||
@@ -284,6 +286,7 @@ def upgrade_folder(
|
||||
with _process_lock:
|
||||
_current_process = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
|
||||
for line in _current_process.stdout:
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Ricerca su YouTube via yt-dlp (subprocess, flat playlist metadata)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
from core.paths import find_ytdlp, subprocess_flags
|
||||
|
||||
|
||||
_TIMEOUT_SEC = 30
|
||||
|
||||
|
||||
def search_youtube(query: str, limit: int = 50) -> list:
|
||||
"""Cerca su YouTube (ytsearchN:query) e ritorna metadata "flat" (senza scaricare).
|
||||
|
||||
Args:
|
||||
query: testo di ricerca libero
|
||||
limit: numero massimo di risultati (default 50)
|
||||
|
||||
Returns:
|
||||
list[dict] con {id, url, title, channel, duration_sec}. Vuota se nessun match.
|
||||
|
||||
Raises:
|
||||
RuntimeError: se yt-dlp non disponibile, timeout, o exit non-zero.
|
||||
"""
|
||||
ytdlp = find_ytdlp()
|
||||
if not ytdlp:
|
||||
raise RuntimeError("yt-dlp non trovato nel bundle / PATH")
|
||||
|
||||
cmd = [
|
||||
ytdlp,
|
||||
"--flat-playlist",
|
||||
"--dump-single-json",
|
||||
"--no-warnings",
|
||||
f"ytsearch{limit}:{query}",
|
||||
]
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=_TIMEOUT_SEC,
|
||||
**subprocess_flags(),
|
||||
)
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise RuntimeError(f"Timeout ricerca YouTube ({_TIMEOUT_SEC}s)") from e
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = (proc.stderr or "").strip().splitlines()[-3:]
|
||||
raise RuntimeError(f"yt-dlp error: {' | '.join(tail) or 'unknown'}")
|
||||
|
||||
try:
|
||||
data = json.loads(proc.stdout)
|
||||
except json.JSONDecodeError as e:
|
||||
raise RuntimeError(f"yt-dlp output non-JSON: {e}") from e
|
||||
|
||||
entries = data.get("entries") or []
|
||||
result: list = []
|
||||
for e in entries:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
video_id = e.get("id") or ""
|
||||
url = e.get("url") or (f"https://www.youtube.com/watch?v={video_id}" if video_id else "")
|
||||
image_url = f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg" if video_id else ""
|
||||
result.append({
|
||||
"id": video_id,
|
||||
"url": url,
|
||||
"title": e.get("title") or "",
|
||||
"channel": e.get("uploader") or e.get("channel") or "",
|
||||
"duration_sec": int(e.get("duration") or 0),
|
||||
"image_url": image_url,
|
||||
})
|
||||
return result
|
||||
@@ -0,0 +1,325 @@
|
||||
# Beatport Charts — Design Spec
|
||||
|
||||
- **Data:** 2026-07-14
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.8.0
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Aggiungere una nuova tab all'app desktop MusicTools che permetta di:
|
||||
|
||||
1. Scegliere un genere musicale da un elenco di ~30 generi Beatport
|
||||
2. Caricare la classifica **Top 100** ufficiale di quel genere dal sito Beatport
|
||||
3. Visualizzare i 100 brani in tabella con checkbox per scegliere quali scaricare
|
||||
4. Scaricare i brani selezionati riusando il flusso esistente Spotify search → yt-dlp
|
||||
|
||||
Sostituisce il lavoro manuale attuale (l'utente copia/incolla la Top 100 in un file `.txt` che poi passa al downloader).
|
||||
|
||||
## Non-goals
|
||||
|
||||
Esplicitamente **fuori scope** in questa versione:
|
||||
|
||||
- Preview audio dei brani (snippet Beatport sono DRM protetti)
|
||||
- Chart diverse dalla Top 100 (Hype 100, DJ Charts, weekly picks) — rinviato a v2
|
||||
- Ricerca / filtro nella tabella (100 righe si scorrono senza)
|
||||
- Refresh automatico della classifica
|
||||
- Esportazione della lista come file `.txt`
|
||||
- Auto-update della mappa dei generi (resta hardcoded, aggiornata a ogni release)
|
||||
- Beatport account / login / preferiti utente
|
||||
|
||||
## Approccio scelto — Parse `__NEXT_DATA__`
|
||||
|
||||
Beatport è costruito su Next.js. Ogni pagina di classifica include un tag `<script id="__NEXT_DATA__" type="application/json">…</script>` con il payload completo della pagina in JSON strutturato: id, artist, title, mix, duration, chart position, label, key, BPM. Il parser:
|
||||
|
||||
1. `GET https://www.beatport.com/genre/<slug>/<id>/top-100` con User-Agent browser
|
||||
2. `re.search(r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', html)` per estrarre il JSON
|
||||
3. `json.loads` + walk fino ai 100 track object
|
||||
4. Mappa in dataclass `BeatportTrack`
|
||||
|
||||
**Vantaggi:** dati strutturati, robusto ai cambi di layout CSS, un solo GET per 100 brani.
|
||||
|
||||
**Alternative scartate:**
|
||||
- BeautifulSoup su CSS class → i class name Next.js sono hash generati, si rompono a ogni build Beatport
|
||||
- API interna `api.beatport.com` → richiede OAuth token, User-Agent specifici, ToS espressamente vietano l'uso non autorizzato → rischio ban
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovi moduli
|
||||
|
||||
#### `core/beatport.py` (nuovo)
|
||||
|
||||
```python
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
GENRES: dict[str, tuple[int, str]] = {
|
||||
"melodic-house-techno": (90, "Melodic House & Techno"),
|
||||
"techno-peak-time-driving": (6, "Techno (Peak Time / Driving)"),
|
||||
"techno-raw-deep-hypnotic": (92, "Techno (Raw / Deep / Hypnotic)"),
|
||||
"deep-house": (12, "Deep House"),
|
||||
"tech-house": (11, "Tech House"),
|
||||
"progressive-house": (15, "Progressive House"),
|
||||
"house": (5, "House"),
|
||||
"afro-house": (89, "Afro House"),
|
||||
"organic-house-downtempo": (93, "Organic House / Downtempo"),
|
||||
"trance": (7, "Trance"),
|
||||
"drum-bass": (1, "Drum & Bass"),
|
||||
"dubstep": (18, "Dubstep"),
|
||||
"minimal-deep-tech": (14, "Minimal / Deep Tech"),
|
||||
"indie-dance": (37, "Indie Dance"),
|
||||
# NOTA: la mappa completa (~30 slug/id) va enumerata in fase di
|
||||
# implementazione recuperando gli slug + numeric id dalla pagina
|
||||
# https://www.beatport.com/genres — lo script che li estrae va
|
||||
# committato in `scripts/refresh_beatport_genres.py` per potervi
|
||||
# rifare la refresh a mano quando Beatport aggiunge/rimuove generi.
|
||||
}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BeatportTrack:
|
||||
position: int
|
||||
title: str
|
||||
mix: str
|
||||
artists: str # "A, B & C" già formattato
|
||||
duration_sec: int
|
||||
beatport_id: int
|
||||
|
||||
@property
|
||||
def display(self) -> str:
|
||||
"""Formato compatibile con i file .txt esistenti:
|
||||
'Artista – Titolo (Mix) (M:SS)'"""
|
||||
m, s = divmod(self.duration_sec, 60)
|
||||
return f"{self.artists} – {self.title} ({self.mix}) ({m}:{s:02d})"
|
||||
|
||||
@property
|
||||
def spotify_query(self) -> str:
|
||||
return f"{self.artists} {self.title}"
|
||||
|
||||
|
||||
class BeatportError(Exception): pass
|
||||
class BeatportUnreachableError(BeatportError): pass
|
||||
class BeatportParseError(BeatportError): pass
|
||||
|
||||
|
||||
def fetch_top100(slug: str, force_refresh: bool = False) -> list[BeatportTrack]:
|
||||
"""Fetches Top 100 per il genere. Cache in-memory 15 min.
|
||||
Solleva ValueError se slug non valido, BeatportUnreachableError su rete,
|
||||
BeatportParseError su schema cambiato."""
|
||||
...
|
||||
|
||||
def list_genres() -> list[dict]:
|
||||
"""Ritorna [{slug, id, name}, ...] ordinato alfabeticamente per name."""
|
||||
...
|
||||
```
|
||||
|
||||
Cache in-memory: `dict[slug, tuple[timestamp, list[BeatportTrack]]]`, TTL 900s.
|
||||
|
||||
Retry: 2 tentativi con backoff 1s, 3s.
|
||||
|
||||
User-Agent: `"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"` (aggiornato per release).
|
||||
|
||||
#### `main.py` — nuove API pywebview esposte al JS
|
||||
|
||||
```python
|
||||
class Api:
|
||||
# …esistenti
|
||||
|
||||
def beatport_genres(self) -> list[dict]:
|
||||
return beatport.list_genres()
|
||||
|
||||
def beatport_fetch_chart(self, slug: str, force_refresh: bool = False) -> dict:
|
||||
try:
|
||||
tracks = beatport.fetch_top100(slug, force_refresh=force_refresh)
|
||||
return {"ok": True, "tracks": [asdict(t) for t in tracks]}
|
||||
except ValueError as e:
|
||||
return {"ok": False, "error": "invalid_genre", "message": str(e)}
|
||||
except BeatportUnreachableError as e:
|
||||
return {"ok": False, "error": "unreachable", "message": str(e)}
|
||||
except BeatportParseError as e:
|
||||
return {"ok": False, "error": "parse", "message": str(e)}
|
||||
|
||||
def beatport_check_existing(self, tracks: list[dict], genre_name: str) -> list[bool]:
|
||||
"""Ritorna una lista di boolean: True se il file esiste già in output_dir/Beatport/<Genre>/."""
|
||||
...
|
||||
|
||||
def beatport_download_selected(
|
||||
self,
|
||||
tracks: list[dict],
|
||||
genre_name: str,
|
||||
) -> None:
|
||||
"""Loop: per ogni track, spotify search → downloader.
|
||||
Emette log/progress sugli stessi eventi degli altri download."""
|
||||
...
|
||||
```
|
||||
|
||||
### Riuso codice esistente
|
||||
|
||||
- `core/spotify_client.py`: usa `search_track(query) -> Optional[str]` per ottenere lo Spotify URI (o il track object). **Da verificare in fase di implementazione** se il metodo esiste già come funzione pubblica riutilizzabile — se no, estrai la logica di search dal path esistente `download_from_spotify_url` in una funzione pubblica dedicata (refactor mirato, no cambi di comportamento per gli altri consumatori).
|
||||
- `core/downloader.py`: usa il path Spotify URI → yt-dlp esistente. `request_stop()` / `reset_stop()` già disponibili.
|
||||
- `webui/js/app.js`: riusa il pattern log/progress già in uso per le altre tab.
|
||||
- `core/config.py`: aggiunge un solo nuovo campo `beatport_last_genre: str` ai DEFAULTS.
|
||||
|
||||
### Frontend
|
||||
|
||||
**Nuova tab in `webui/index.html`:**
|
||||
|
||||
```html
|
||||
<button data-tab="beatport" class="tab-btn">🎧 Beatport</button>
|
||||
<section id="tab-beatport" class="tab-panel">
|
||||
<div class="beatport-header">
|
||||
<label>Genere:
|
||||
<select id="beatport-genre"></select>
|
||||
</label>
|
||||
<button id="beatport-load">Carica Top 100</button>
|
||||
<div id="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div id="beatport-status"></div>
|
||||
|
||||
<table id="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" id="beatport-select-all"></th>
|
||||
<th>#</th><th>Artista</th><th>Titolo</th><th>Durata</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="beatport-toolbar" hidden>
|
||||
<span id="beatport-selected-count">0/0 selezionati</span>
|
||||
<button id="beatport-download">⬇ Scarica selezionati</button>
|
||||
<button id="beatport-stop" hidden>■ Interrompi</button>
|
||||
</div>
|
||||
|
||||
<div id="beatport-log" class="log-area"></div>
|
||||
</section>
|
||||
```
|
||||
|
||||
**JavaScript in `webui/js/app.js`:** funzioni `beatportInit()`, `beatportLoadChart()`, `beatportRenderTable(tracks)`, `beatportUpdateSelection()`, `beatportStartDownload()`. Stato locale: `let currentChart = { slug, name, tracks: [] }`.
|
||||
|
||||
### Persistenza
|
||||
|
||||
- Ultimo genere selezionato → `config.beatport_last_genre`
|
||||
- File scaricati → rilevati con `os.path.exists()` sul filename generato dal downloader (stesso pattern degli altri tab)
|
||||
- Cache classifica → solo in-memory, si perde a restart (voluto)
|
||||
|
||||
### Percorso output
|
||||
|
||||
`{output_dir}/Beatport/{Genre Display Name}/`
|
||||
|
||||
Es. `/Users/luciano/MUSICA/Beatport/Melodic House & Techno/Kapuchon - Hot Sauce (Extended).mp3`
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
[JS] User seleziona genere, click "Carica Top 100"
|
||||
↓
|
||||
[JS] api.beatport_fetch_chart("melodic-house-techno")
|
||||
↓
|
||||
[PY] core/beatport.fetch_top100():
|
||||
├─ Cache hit (< 15 min)? → ritorna cached
|
||||
└─ GET Beatport, estrai __NEXT_DATA__, parse, mappa in BeatportTrack[]
|
||||
↓
|
||||
[JS] Riceve lista, chiama api.beatport_check_existing() per pre-deselezionare i già-scaricati
|
||||
↓
|
||||
[JS] Renderizza tabella
|
||||
↓
|
||||
[JS] User seleziona/deseleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.beatport_download_selected(tracks_selected, genre_name)
|
||||
↓
|
||||
[PY] Loop su tracks:
|
||||
├─ emit log "[i/N] Cerco su Spotify: <artista – titolo>"
|
||||
├─ spotify_uri = spotify_client.search_track(track.spotify_query)
|
||||
├─ Se None: emit ✗, continua
|
||||
├─ emit log "[i/N] Download da YouTube…"
|
||||
├─ downloader.download_from_spotify_uri(uri, output_dir=<beatport-subdir>)
|
||||
├─ emit log "[i/N] ✓" o "[i/N] ✗ <motivo>"
|
||||
└─ Se stop_event.is_set(): break
|
||||
```
|
||||
|
||||
## Matrice errori & recovery
|
||||
|
||||
| Errore | Dove | Cosa succede |
|
||||
|---|---|---|
|
||||
| Beatport 5xx / timeout | fetch_top100 | Retry ×2 con backoff 1s, 3s. Poi solleva `BeatportUnreachableError`. UI: banner rosso "Beatport irraggiungibile. Riprova." con retry |
|
||||
| `__NEXT_DATA__` mancante / schema cambiato | fetch_top100 | Dump primi 500 char HTML in `~/Library/Logs/MusicTools/beatport-<ts>.log`, solleva `BeatportParseError`. UI: banner arancione "Impossibile leggere la classifica. Segnala il bug." |
|
||||
| Genere non valido | fetch_top100 | `ValueError` early, nessuna richiesta di rete |
|
||||
| Spotify search no match | download loop | Log riga singola, marca riga tabella ✗ "non trovato", continua |
|
||||
| Spotify creds mancanti | download loop, primo brano | Interrompe subito, banner giallo con link a tab Impostazioni |
|
||||
| yt-dlp fallisce | download loop | Log riga singola ✗, continua con il resto |
|
||||
| Cartella output non scrivibile | pre-check prima del loop | Errore + banner rosso, non parte |
|
||||
| User preme Stop | download loop | `stop_event.set()`, si ferma dopo il brano corrente |
|
||||
| Cache stale | fetch_top100 | Shift+click su "Carica" invalida (`force_refresh=True`); comunque expira dopo 15 min |
|
||||
|
||||
## Rispetto Beatport / rate limiting
|
||||
|
||||
- Un solo GET per classifica (100 brani in un JSON)
|
||||
- Cache 15 min → max 4 richieste/ora anche con click ripetuti
|
||||
- User-Agent browser, no scraping massivo
|
||||
- Zero rischio ban IP in uso normale
|
||||
|
||||
## Licenza / piani
|
||||
|
||||
La feature è un audio download a tutti gli effetti → il `daily_limit` del piano si applica automaticamente (ogni brano scaricato conta come 1). Il piano `basic` con limite 10/giorno può scaricare al massimo 10 brani dalla Top 100. **Nessuna nuova feature-flag in `server/src/plans.js`.** Il gating avviene già nel downloader esistente.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests — `tests/test_beatport.py`
|
||||
|
||||
Nessuna rete durante i test. Fixture: snapshot HTML reale della pagina Beatport in `tests/fixtures/beatport_melodic_top100.html`.
|
||||
|
||||
| Test | Verifica |
|
||||
|---|---|
|
||||
| `test_parse_next_data_extracts_100_tracks` | Il parser trova esattamente 100 track dall'HTML fixture |
|
||||
| `test_track_shape` | Ogni track ha position, title, mix, artists, duration_sec, beatport_id |
|
||||
| `test_positions_sequential` | Posizioni 1..100 senza buchi |
|
||||
| `test_display_format_matches_txt_files` | `BeatportTrack.display` produce esattamente il formato dei file `.txt` attuali |
|
||||
| `test_parse_missing_next_data_raises` | HTML senza `<script id="__NEXT_DATA__">` → `BeatportParseError` |
|
||||
| `test_parse_malformed_json_raises` | `__NEXT_DATA__` con JSON rotto → `BeatportParseError` |
|
||||
| `test_parse_schema_change_raises` | JSON valido ma senza `results[]` → `BeatportParseError` con contesto |
|
||||
| `test_cache_hit_second_call` | 2 chiamate ravvicinate → una sola richiesta HTTP simulata |
|
||||
| `test_cache_expires_after_15min` | Freeze time + 16min → nuova richiesta |
|
||||
| `test_force_refresh_bypasses_cache` | `force_refresh=True` ignora cache anche fresca |
|
||||
| `test_invalid_genre_slug_early_error` | Slug non in `GENRES` → `ValueError` senza toccare la rete |
|
||||
|
||||
### Test HTTP mockato
|
||||
|
||||
`responses` (o `httpx` mock) per verificare timeout, retry policy, User-Agent inviato.
|
||||
|
||||
### Test manuale end-to-end (obbligatorio prima del release)
|
||||
|
||||
1. Avvia MusicTools, apri tab Beatport
|
||||
2. Seleziona "Melodic House & Techno" → click "Carica Top 100" → tabella si popola in <3s
|
||||
3. Deseleziona 90 brani, lascia i primi 10 → click "Scarica selezionati (10)"
|
||||
4. Verifica file .mp3 in `{output_dir}/Beatport/Melodic House & Techno/`, metadata Spotify complete
|
||||
5. Ricarica lo stesso genere → cache hit (nessuna nuova request osservabile in dev tools)
|
||||
6. Cambia genere → nuova fetch
|
||||
7. Interrompi a metà download → si ferma pulito dopo il brano corrente
|
||||
8. Riavvia app → dropdown ricorda l'ultimo genere
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/beatport-charts` (opzionale — la feature è additive, zero rischi per il resto)
|
||||
2. Bump `core/config.py` VERSION → `v1.8.0`
|
||||
3. Note release in `/tmp/notes-v1.8.0.md`:
|
||||
> **Nuova tab Beatport 🎧** — carica le Top 100 per genere direttamente da Beatport e scarica i brani in un click. 30+ generi disponibili.
|
||||
4. Commit + tag `v1.8.0` → CI builda macOS + Windows come da flusso standard
|
||||
5. Zero cambi lato server (`~/api/`), zero migration DB
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/beatport.py`
|
||||
- `tests/test_beatport.py`
|
||||
- `tests/fixtures/beatport_melodic_top100.html`
|
||||
|
||||
**Modificati:**
|
||||
- `main.py` — 4 nuovi metodi `Api.beatport_*`
|
||||
- `core/config.py` — VERSION bump + campo `beatport_last_genre` in DEFAULTS
|
||||
- `core/spotify_client.py` — espone `search_track()` come funzione pubblica se non lo è già
|
||||
- `webui/index.html` — nuovo tab button + section
|
||||
- `webui/js/app.js` — funzioni `beatport*`
|
||||
- `webui/css/*.css` — stile tabella e toolbar (riuso classi esistenti dove possibile)
|
||||
- `requirements.txt` — nessuna nuova dipendenza runtime (`requests` è già presente per gli altri client HTTP; `__NEXT_DATA__` si estrae con `re.search` + `json.loads`, no BeautifulSoup necessario). Solo dev dep: `responses` per i test HTTP mockati se non già presente
|
||||
@@ -0,0 +1,260 @@
|
||||
# Music Search (Spotify + YouTube) — Design Spec
|
||||
|
||||
- **Data:** 2026-07-14
|
||||
- **Autore:** LuZa + Claude
|
||||
- **Stato:** Approvato, pronto per implementation plan
|
||||
- **Target release:** MusicTools v1.8.1
|
||||
|
||||
## Obiettivo
|
||||
|
||||
Aggiungere due tab distinte all'app che permettano di **cercare brani/artisti/parole libere** e scaricarli:
|
||||
|
||||
1. **Tab 🟢 Spotify** — ricerca via Spotify API (canonica, metadata pulita). Toggle "Solo artista" per ottenere tutta la discografia di un artista invece della ricerca libera.
|
||||
2. **Tab ▶ YouTube** — ricerca diretta via `yt-dlp ytsearch:` (grezza, senza distinzione artista/album, ma copre mix DJ, unreleased, bootleg, live, video-only content).
|
||||
|
||||
Il download in entrambi i casi riusa la pipeline esistente (`start_tracks_download` in `api/bridge.py`) con licenza gate `_gate("audio")`.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Un'unica tab "Cerca" con radio Spotify/YouTube (scartato: UX molto diversa per le due sorgenti)
|
||||
- Ricerca combinata Spotify+YouTube in un'unica lista (nessuna deduplica cross-source affidabile)
|
||||
- Playlist temporanee salvabili
|
||||
- Preview audio
|
||||
- Filtri per genere/anno/BPM (non richiesto, YAGNI)
|
||||
- Ricerca album, artisti, playlist su Spotify (limitata a track)
|
||||
- Autocomplete / suggestions come digiti
|
||||
|
||||
## Approcci scelti
|
||||
|
||||
### Spotify
|
||||
|
||||
- Free-form search: `GET /v1/search?q=<query>&type=track&limit=50`
|
||||
- Ritorna fino a 50 track ordinati per rilevanza/popolarità (default Spotify)
|
||||
- Query può essere qualsiasi cosa: titolo, artista, "artista - titolo", parola singola
|
||||
- Artist-mode (toggle attivo):
|
||||
1. `GET /v1/search?q=<query>&type=artist&limit=5` → prendi il match con `name.lower() == query.lower()` altrimenti il più popolare
|
||||
2. `GET /v1/artists/{id}/top-tracks?market=IT` → ~10 top track
|
||||
3. `GET /v1/artists/{id}/albums?include_groups=album,single&limit=50&market=IT` → lista album
|
||||
4. Per ogni album (max 50): `GET /v1/albums/{id}/tracks?limit=50` → tracce
|
||||
5. Deduplica su `(name.lower().strip() + '|' + first_artist.lower().strip())`
|
||||
6. Rate limit interno: `time.sleep(0.1)` tra chiamate `/albums/{id}/tracks`
|
||||
7. Total: ~100-500 track per artista prolifico
|
||||
|
||||
**Vantaggi:** metadata pulita, download pipeline già rodata (Spotify→YouTube via yt-dlp).
|
||||
|
||||
### YouTube
|
||||
|
||||
- `yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"` → JSON con `entries[]`
|
||||
- Per ogni entry: `{title, uploader/channel, duration, id, url}`
|
||||
- Nessuna modalità artista (YouTube search non ha channel-exact disambiguation affidabile)
|
||||
|
||||
**Vantaggi:** trova ciò che Spotify non ha (mix, unreleased, bootleg, live).
|
||||
|
||||
## Architettura
|
||||
|
||||
### Nuovi moduli / estensioni
|
||||
|
||||
#### `core/spotify_client.py` (estensione)
|
||||
|
||||
```python
|
||||
def search_tracks(token: str, query: str, limit: int = 50) -> list:
|
||||
"""Ricerca free-form. Ritorna list[dict] con {id, url, name, artists, album, duration_sec}."""
|
||||
|
||||
def search_artist_discography(token: str, artist_name: str) -> list:
|
||||
"""Trova l'artista esatto e ritorna tutti i suoi brani (top tracks + tracce da album/singles).
|
||||
Deduplica per (title, first_artist) normalizzato. Solleva ValueError se nessun artista trovato."""
|
||||
```
|
||||
|
||||
Nota: la funzione `search_track` (singolare) aggiunta in Task 7 di Beatport resta com'è per retro-compatibilità. La nuova `search_tracks` (plurale, con limit) è quella usata da questa feature.
|
||||
|
||||
#### `core/youtube_search.py` (nuovo)
|
||||
|
||||
```python
|
||||
def search_youtube(query: str, limit: int = 50) -> list:
|
||||
"""Cerca su YouTube via `yt-dlp ytsearchN:query`.
|
||||
Ritorna list[dict] con {id, url, title, channel, duration_sec}."""
|
||||
```
|
||||
|
||||
Riusa `find_ytdlp()` e `subprocess_flags()` da `core/paths.py`. Timeout subprocess 30s.
|
||||
|
||||
#### `api/bridge.py` — nuovi metodi
|
||||
|
||||
- `spotify_search(query: str, artist_mode: bool) → dict` — salva query+toggle in config, poi chiama la funzione giusta e ritorna `{ok, tracks}` o `{ok:false, error, message}`
|
||||
- `spotify_search_download(tracks: list) → dict` — wrapper del pattern Beatport: converte in `[{name, artist}]` e chiama `start_tracks_download` con `subfolder="Spotify"`
|
||||
- `youtube_search(query: str) → dict` — salva query in config, chiama `core.youtube_search`, ritorna `{ok, tracks}`
|
||||
- `youtube_search_download(tracks: list) → dict` — chiama nuovo helper `start_urls_download` (see below) con URL YouTube e `subfolder="YouTube"`
|
||||
|
||||
#### `api/bridge.py` — nuovo helper (se non c'è già)
|
||||
|
||||
- `start_urls_download(payload: dict) → dict` — analogo a `start_tracks_download` ma accetta `{urls: [str], output_dir, subfolder}` e chiama `download_playlist_from_urls(urls, ...)` in `core/downloader.py`. Se non esiste una funzione equivalente in `downloader.py`, va aggiunta.
|
||||
|
||||
**Verifica in implementazione:** controlla se `download_playlist(tracks, ...)` può ricevere tracks contenenti solo `url` (bypass search); in tal caso riusa quella. Altrimenti aggiungi il nuovo path.
|
||||
|
||||
### Frontend
|
||||
|
||||
- 2 nuove tab in sidebar (`data-view="spotify"` e `data-view="youtube"`), entrambe con `data-feature="audio"` (stesso license gate del Beatport)
|
||||
- 2 nuove section `<section id="view-spotify">` e `<section id="view-youtube">` in `webui/index.html`
|
||||
- Modulo JS `SpotifyUI` e `YoutubeUI` in `webui/js/app.js`, entrambi con pattern init/loadResults/renderTable/updateSelection/startDownload (parallelo a `BeatportUI`)
|
||||
- Stili in `webui/css/style.css` — riusa `.beatport-table` come base, aggiunge varianti dove serve
|
||||
|
||||
### Persistenza
|
||||
|
||||
3 nuovi campi in `core/config.py::DEFAULTS`:
|
||||
- `spotify_search_last_query: str = ""`
|
||||
- `spotify_search_artist_mode: bool = False`
|
||||
- `youtube_search_last_query: str = ""`
|
||||
|
||||
Salvati dal backend a ogni ricerca (analogo a `beatport_last_genre`).
|
||||
|
||||
### Cartelle output
|
||||
|
||||
- `{output_dir}/Spotify/` (piatta, nessun sub-folder per query)
|
||||
- `{output_dir}/YouTube/` (piatta)
|
||||
|
||||
Sanitizzazione slash coerente con il pattern di `start_tracks_download` (subfolder singolo, no nested).
|
||||
|
||||
## Data flow
|
||||
|
||||
### Spotify search
|
||||
|
||||
```
|
||||
[JS] User digita query, opzionale toggle "Solo artista", click Cerca
|
||||
↓
|
||||
[JS] api.spotify_search(query, artist_mode)
|
||||
↓
|
||||
[PY] api/bridge.py::spotify_search():
|
||||
├─ Salva {spotify_search_last_query, spotify_search_artist_mode} in config
|
||||
├─ Verifica creds Spotify (client_id, client_secret) → altrimenti {ok:false, error:"no_creds"}
|
||||
├─ Get token (spotify_client.get_access_token)
|
||||
├─ Se artist_mode:
|
||||
│ └─ spotify_client.search_artist_discography(token, query)
|
||||
├─ Altrimenti:
|
||||
│ └─ spotify_client.search_tracks(token, query, limit=50)
|
||||
└─ Ritorna {ok:True, tracks: [...]}
|
||||
↓
|
||||
[JS] Riceve lista, chiama api.spotify_check_existing() per pre-deselezionare i già-scaricati
|
||||
↓
|
||||
[JS] Renderizza tabella (colonne: check, #, artista, titolo, album, durata, stato)
|
||||
↓
|
||||
[JS] User seleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.spotify_search_download(tracks_selected)
|
||||
↓
|
||||
[PY] converte in [{name, artist}] e chiama self.start_tracks_download({..., subfolder: "Spotify"})
|
||||
↓
|
||||
[EXISTING] pipeline yt-dlp, log/progress su canale "download"
|
||||
```
|
||||
|
||||
### YouTube search
|
||||
|
||||
```
|
||||
[JS] User digita query, click Cerca
|
||||
↓
|
||||
[JS] api.youtube_search(query)
|
||||
↓
|
||||
[PY] api/bridge.py::youtube_search():
|
||||
├─ Salva youtube_search_last_query in config
|
||||
├─ core.youtube_search.search_youtube(query, limit=50)
|
||||
│ └─ subprocess: yt-dlp --flat-playlist --dump-single-json "ytsearch50:<query>"
|
||||
└─ Ritorna {ok:True, tracks: [{id, url, title, channel, duration_sec}, ...]}
|
||||
↓
|
||||
[JS] Renderizza tabella (colonne: check, #, titolo video, canale, durata, stato)
|
||||
↓
|
||||
[JS] User seleziona, click "Scarica selezionati"
|
||||
↓
|
||||
[JS] api.youtube_search_download(tracks_selected) # passa URL, non {name,artist}
|
||||
↓
|
||||
[PY] start_urls_download({urls, subfolder: "YouTube"})
|
||||
↓
|
||||
[EXISTING] pipeline yt-dlp diretto sugli URL
|
||||
```
|
||||
|
||||
## Matrice errori & recovery
|
||||
|
||||
| Errore | Dove | Comportamento |
|
||||
|---|---|---|
|
||||
| Query vuota | UI JS | Bottone "Cerca" disabilitato, nessuna richiesta |
|
||||
| Creds Spotify mancanti | spotify_search | `{ok:false, error:"no_creds"}` → banner giallo con link Impostazioni |
|
||||
| Spotify 401 (token scaduto/invalid) | get_access_token | Refresh token nel handler; se persiste, banner rosso |
|
||||
| Spotify 429 rate limit | search endpoint | `{ok:false, error:"rate_limit", message:"Attendi qualche secondo"}` → banner arancione, retry manuale |
|
||||
| Spotify 5xx | search endpoint | `{ok:false, error:"server", message:...}` → banner rosso, retry |
|
||||
| Nessun risultato Spotify | search endpoint | `{ok:true, tracks:[]}` → messaggio grigio "Nessun brano trovato per '<query>'" |
|
||||
| Artist-mode senza match esatto | search_artist_discography | Solleva ValueError → `{ok:false, error:"artist_not_found", message:"Artista '<q>' non trovato — disattiva toggle per ricerca libera"}` |
|
||||
| yt-dlp non trovato | search_youtube | RuntimeError → banner rosso "yt-dlp non installato" |
|
||||
| yt-dlp timeout (rete lenta) | search_youtube | subprocess.TimeoutExpired → banner rosso "Timeout ricerca YouTube" |
|
||||
| yt-dlp errore generico | search_youtube | Non-zero exit → banner rosso con stderr tail |
|
||||
| Nessun risultato YouTube | search_youtube | `{ok:true, tracks:[]}` → messaggio grigio |
|
||||
| User preme Stop durante download | download pipeline | Comportamento invariato (esistente) |
|
||||
|
||||
## Licenza / piani
|
||||
|
||||
Entrambe le tab sono audio download → gate `_gate("audio")` automatico via `start_tracks_download` / `start_urls_download`. Il `daily_limit` del piano si applica. Nessuna nuova feature-flag.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
**`tests/test_spotify_client.py` (estensione):**
|
||||
- `test_search_tracks_returns_list_up_to_limit` — mock response con 50 items, verifica list length
|
||||
- `test_search_tracks_maps_fields_correctly` — sample dict → verifica keys {id, url, name, artists, album, duration_sec}
|
||||
- `test_search_tracks_empty_query_returns_empty` — mock 0 items
|
||||
- `test_search_artist_discography_exact_match_wins` — mock search artist con 3 candidati diversi, verifica quello esatto (case-insensitive)
|
||||
- `test_search_artist_discography_top_tracks_and_albums_combined` — mock 3 endpoint, verifica deduplica
|
||||
- `test_search_artist_discography_raises_when_no_match` — mock search artist con 0 risultati → ValueError
|
||||
- `test_search_artist_discography_deduplicates` — mock top-tracks e album-tracks con overlap → verifica no duplicati
|
||||
|
||||
**`tests/test_youtube_search.py` (nuovo):**
|
||||
- `test_search_youtube_parses_entries` — mock `subprocess.run` con JSON stub (5 entries), verifica list mapping
|
||||
- `test_search_youtube_empty_result` — mock JSON senza entries → []
|
||||
- `test_search_youtube_ytdlp_not_found` — mock `find_ytdlp` che ritorna None → RuntimeError
|
||||
- `test_search_youtube_timeout` — mock subprocess.TimeoutExpired → RuntimeError
|
||||
|
||||
### Test manuale end-to-end
|
||||
|
||||
**Tab Spotify:**
|
||||
1. Query semplice: "Solomun" senza toggle → 50 risultati, ordinati per rilevanza
|
||||
2. Query composta: "Kapuchon Hot Sauce" → 5-10 risultati pertinenti
|
||||
3. Artist mode: "Solomun" con toggle attivo → 100-300 risultati (top tracks + tutti album)
|
||||
4. Artist mode con nome inesistente: "sadgjhkasdg" → banner "Artista non trovato"
|
||||
5. Download di 3 brani → file in `MUSICA/Spotify/`
|
||||
6. Ricerca ripetuta → "già scaricato" mostrato correttamente
|
||||
7. Riavvio app → ultima query + toggle ricordati
|
||||
|
||||
**Tab YouTube:**
|
||||
1. Query: "Kapuchon Hot Sauce" → 50 risultati con canali diversi
|
||||
2. Query set DJ: "Solomun Cocoricò 2024" → set lunghi (60+ min) in lista
|
||||
3. Download di 2 brani → file in `MUSICA/YouTube/`
|
||||
4. Query vuota → bottone disabilitato
|
||||
5. Riavvio → ultima query ricordata
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Branch `feat/music-search`
|
||||
2. Bump `core/config.py::VERSION` → `v1.8.1`
|
||||
3. Note release `/tmp/notes-v1.8.1.md`:
|
||||
> **Nuove tab Spotify e YouTube 🔎** — cerca brani per titolo o artista su Spotify (con toggle "solo artista" per scaricare tutta la discografia), oppure cerca su YouTube per trovare mix DJ, unreleased, bootleg e brani non presenti su Spotify.
|
||||
4. Commit + tag `v1.8.1` → CI + release notes background task
|
||||
5. Update DB `releases` tabella sul server (macos + windows rows)
|
||||
|
||||
Zero cambi server-side backend, zero migration.
|
||||
|
||||
## Struttura file impattati
|
||||
|
||||
**Nuovi:**
|
||||
- `core/youtube_search.py`
|
||||
- `tests/test_youtube_search.py`
|
||||
|
||||
**Modificati:**
|
||||
- `core/spotify_client.py` — 2 nuove funzioni pubbliche (`search_tracks`, `search_artist_discography`)
|
||||
- `core/downloader.py` — se serve, aggiunta `download_playlist_from_urls()` per il flusso YouTube
|
||||
- `core/config.py` — VERSION bump + 3 nuovi campi in DEFAULTS
|
||||
- `api/bridge.py` — 6 nuovi metodi Api: `spotify_search`, `spotify_check_existing`, `spotify_search_download`, `youtube_search`, `youtube_check_existing`, `youtube_search_download` + eventuale helper `start_urls_download`
|
||||
- `tests/test_spotify_client.py` — 7 nuovi test
|
||||
- `webui/index.html` — 2 nav-item + 2 sezioni view
|
||||
- `webui/js/app.js` — moduli `SpotifyUI` e `YoutubeUI` (~200 righe cadauno, parallelo a `BeatportUI`)
|
||||
- `webui/css/style.css` — piccole estensioni (o riuso classi Beatport)
|
||||
- `requirements.txt` — nessuna nuova dep
|
||||
|
||||
## Riduzione della duplicazione
|
||||
|
||||
Se durante l'implementazione emerge che i 3 pannelli (Beatport, Spotify, YouTube) hanno logica JS quasi identica (renderTable con checkbox, updateSelectionCount, startDownload wrapper), **valuta** l'estrazione di un `SelectableTracksTable` component in `webui/js/app.js`. Se l'astrazione è chiara e riduce codice significativamente, fallo. Se costringe a hooks/callbacks tortuosi per gestire differenze di colonne, lascia stare — 3 istanze non sono tante e YAGNI.
|
||||
@@ -0,0 +1,72 @@
|
||||
# MusicTools — Landing page
|
||||
|
||||
Sito vetrina + checkout per MusicTools, deployato su Cloudflare Pages al dominio `musictools.djluza.com`.
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
landing/
|
||||
index.html # landing principale
|
||||
css/style.css # tema dark coerente con l'app
|
||||
js/main.js # FAQ accordion, animazioni scroll, hook Lemon Squeezy
|
||||
assets/ # favicon, immagini eventuali
|
||||
legal/
|
||||
terms.html # termini di servizio
|
||||
privacy.html # informativa privacy GDPR
|
||||
refund.html # politica rimborsi
|
||||
_headers # security + cache (Cloudflare Pages syntax)
|
||||
_redirects # redirect rules
|
||||
```
|
||||
|
||||
Nessun build step. Tutto e' HTML/CSS/JS statico servito così com'è.
|
||||
|
||||
## Anteprima locale
|
||||
|
||||
```bash
|
||||
cd landing
|
||||
python3 -m http.server 8080
|
||||
# apri http://localhost:8080
|
||||
```
|
||||
|
||||
Su `file://` Lemon Squeezy overlay non funziona — ma il sito si vede tutto.
|
||||
|
||||
## Deploy su Cloudflare Pages
|
||||
|
||||
**Una volta sola:**
|
||||
|
||||
1. Vai su <https://dash.cloudflare.com> → **Workers & Pages** → **Create application** → **Pages** → **Connect to Git**.
|
||||
2. Scegli il repo `luzadev/musicdownload`.
|
||||
3. Configura:
|
||||
- **Production branch**: `main`
|
||||
- **Build command**: *(lascia vuoto)*
|
||||
- **Build output directory**: `landing`
|
||||
- **Root directory**: *(lascia vuoto)*
|
||||
4. Salva. La build parte automatica — l'URL iniziale sara' `<project>.pages.dev`.
|
||||
5. Una volta verde, vai su **Custom domains** → **Set up a custom domain** → digita `musictools.djluza.com` → conferma.
|
||||
6. Cloudflare crea automaticamente il CNAME se il dominio e' gia su Cloudflare DNS.
|
||||
|
||||
Ogni `git push origin main` rilancia la build di Pages — push-to-deploy gratis.
|
||||
|
||||
## Routing /api/* vs landing
|
||||
|
||||
Il Worker di `server/` deve essere associato alla route `musictools.djluza.com/api/*` (vedi `server/README.md`).
|
||||
Pages serve tutto il resto. Verifica nel dashboard:
|
||||
|
||||
- **Workers & Pages** → `musictools-api` → **Settings** → **Triggers**:
|
||||
- Aggiungi route `musictools.djluza.com/api/*`
|
||||
- **Pages** → `musictools-landing` (o nome che scegli) → **Custom domains**:
|
||||
- `musictools.djluza.com`
|
||||
|
||||
Cloudflare applica prima la regola del Worker (path-specific) e poi quella di Pages (catch-all).
|
||||
|
||||
## Da fare prima del lancio
|
||||
|
||||
- [ ] Sostituisci `REPLACE_WITH_LS_PRODUCT_URL` in `index.html` con l'URL prodotto Lemon Squeezy
|
||||
- [ ] Crea immagine OG 1200x630 e referenziarla nei meta `og:image`
|
||||
- [ ] Aggiungi screenshot reali (sostituiscono il mockup CSS nell'hero se preferisci)
|
||||
- [ ] Verifica i tre documenti legali con un consulente fiscale se vendi in regime di forfettario / partita IVA italiana
|
||||
- [ ] Aggiungi Google Search Console (verification meta) per indicizzazione
|
||||
|
||||
## Analytics
|
||||
|
||||
Puoi aggiungere Cloudflare Web Analytics (gratuito, no cookie) dal dashboard Pages → Analytics. Si attiva con un click — niente codice da incollare.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Sicurezza + cache base.
|
||||
# Sintassi Cloudflare Pages: https://developers.cloudflare.com/pages/configuration/headers/
|
||||
|
||||
/*
|
||||
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
Permissions-Policy: interest-cohort=()
|
||||
|
||||
/css/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/js/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=2592000
|
||||
@@ -0,0 +1,7 @@
|
||||
# Redirects (sintassi Cloudflare Pages, Netlify-style).
|
||||
# Niente regola per /api/* qui: lo gestisce il Worker (route definita
|
||||
# nel dashboard Cloudflare). Pages serve il resto.
|
||||
|
||||
# Quando implementeremo /api/download (stream firmato R2->client), abilitare:
|
||||
# /download/macos /api/download/macos 302
|
||||
# /download/windows /api/download/windows 302
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1db954"/>
|
||||
<stop offset="100%" stop-color="#137a37"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||
<text x="32" y="44" font-family="-apple-system, Segoe UI, sans-serif" font-weight="800" font-size="36" text-anchor="middle" fill="#fff">♫</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 466 B |
|
After Width: | Height: | Size: 33 KiB |
@@ -0,0 +1,921 @@
|
||||
/* ============================================================
|
||||
MusicTools — Landing page
|
||||
Palette coerente con l'app: tema scuro Spotify-like.
|
||||
============================================================ */
|
||||
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-2: #060606;
|
||||
--bg-card: #161616;
|
||||
--bg-card-2: #1e1e1e;
|
||||
--border: #262626;
|
||||
--divider: #1f1f1f;
|
||||
--text: #ffffff;
|
||||
--text-muted: #a3a3a3;
|
||||
--text-dim: #707070;
|
||||
--accent: #1db954;
|
||||
--accent-dark: #137a37;
|
||||
--accent-glow: rgba(29, 185, 84, 0.35);
|
||||
|
||||
--pink: #f472b6;
|
||||
--blue: #60a5fa;
|
||||
--amber: #fbbf24;
|
||||
--red: #f87171;
|
||||
--purple: #a78bfa;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
*::before, *::after { box-sizing: border-box; }
|
||||
|
||||
html { scroll-behavior: smooth; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.55;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
img, svg { max-width: 100%; display: block; }
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.container {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NAV
|
||||
============================================================ */
|
||||
.nav {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: rgba(10, 10, 10, 0.82);
|
||||
backdrop-filter: saturate(180%) blur(14px);
|
||||
-webkit-backdrop-filter: saturate(180%) blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.nav-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 64px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Quando il logo e' un'immagine, niente gradient ne' radius forzato */
|
||||
.brand-logo-img {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
font-size: 14.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
|
||||
/* ============================================================
|
||||
BUTTONS
|
||||
============================================================ */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-family: inherit;
|
||||
font-weight: 700;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
padding: 12px 22px;
|
||||
font-size: 14px;
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:active { transform: scale(0.98); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #0a0a0a;
|
||||
box-shadow: 0 8px 24px var(--accent-glow);
|
||||
}
|
||||
.btn-primary:hover { background: #25d365; }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.btn-ghost:hover { background: var(--bg-card); border-color: #3a3a3a; }
|
||||
|
||||
.btn-sm { padding: 8px 16px; font-size: 13px; }
|
||||
.btn-lg { padding: 16px 32px; font-size: 16px; }
|
||||
.btn-block { width: 100%; }
|
||||
|
||||
/* ============================================================
|
||||
HERO
|
||||
============================================================ */
|
||||
.hero {
|
||||
position: relative;
|
||||
padding: 80px 0 100px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 0% 0%, rgba(29, 185, 84, 0.18), transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(96, 165, 250, 0.12), transparent 60%),
|
||||
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.05fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
padding: 5px 11px;
|
||||
border: 1px solid rgba(29, 185, 84, 0.35);
|
||||
border-radius: 999px;
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
}
|
||||
.eyebrow.center {
|
||||
display: block;
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 18px 0 18px;
|
||||
font-size: clamp(36px, 5.2vw, 58px);
|
||||
font-weight: 900;
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.accent { color: var(--accent); }
|
||||
|
||||
.lead {
|
||||
font-size: 17px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
max-width: 520px;
|
||||
margin: 0 0 28px;
|
||||
}
|
||||
|
||||
.hero-cta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-bullets {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 24px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.hero-bullets li { display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* ============================================================
|
||||
HERO MOCKUP
|
||||
============================================================ */
|
||||
.hero-art {
|
||||
position: relative;
|
||||
perspective: 1200px;
|
||||
}
|
||||
|
||||
.mockup {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
margin-left: auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 40px 100px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||
transform: rotate3d(0, 1, 0, -6deg) translateY(0);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.mockup:hover { transform: rotate3d(0, 1, 0, -3deg); }
|
||||
|
||||
.mockup-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 11px 14px;
|
||||
background: #111111;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: relative;
|
||||
}
|
||||
.dot { width: 11px; height: 11px; border-radius: 50%; }
|
||||
.dot.red { background: #ff5f57; }
|
||||
.dot.yellow { background: #febc2e; }
|
||||
.dot.green { background: #28c840; }
|
||||
.mockup-title {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.mockup-body {
|
||||
display: grid;
|
||||
grid-template-columns: 168px 1fr;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.mockup-side {
|
||||
background: #111;
|
||||
border-right: 1px solid var(--border);
|
||||
padding: 16px 12px;
|
||||
}
|
||||
|
||||
.ms-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 18px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.ms-brand span {
|
||||
width: 22px; height: 22px; border-radius: 7px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||
color: #fff; font-size: 12px;
|
||||
}
|
||||
|
||||
.ms-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ms-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 7px 9px; border-radius: 8px;
|
||||
font-size: 12.5px; color: var(--text-muted);
|
||||
}
|
||||
.ms-item.active { background: rgba(29, 185, 84, 0.14); color: #fff; }
|
||||
.ms-item span { width: 18px; text-align: center; font-size: 13px; }
|
||||
|
||||
.mockup-main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
.mockup-hero {
|
||||
position: relative;
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #1db954 0%, #0e7a35 100%);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mh-eyebrow { font-size: 9.5px; letter-spacing: 1.5px; font-weight: 800; opacity: 0.85; }
|
||||
.mh-title { font-size: 17px; font-weight: 800; margin-top: 4px; }
|
||||
.mh-sub { font-size: 11px; opacity: 0.85; margin-top: 3px; }
|
||||
.mh-deco { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 36px; opacity: 0.3; }
|
||||
|
||||
.mockup-card {
|
||||
background: var(--bg-card-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.mc-label { font-size: 9.5px; letter-spacing: 0.8px; color: var(--text-dim); text-transform: uppercase; font-weight: 700; }
|
||||
.mc-input {
|
||||
margin-top: 6px;
|
||||
padding: 8px 10px;
|
||||
background: #0d0d0d;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
color: var(--text-muted);
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mockup-actions { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mc-btn.primary {
|
||||
background: var(--accent);
|
||||
color: #0a0a0a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
padding: 9px 0;
|
||||
border-radius: 999px;
|
||||
text-align: center;
|
||||
}
|
||||
.mc-progress {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #0d0d0d;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mc-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), #25d365);
|
||||
border-radius: inherit;
|
||||
animation: progress-pulse 2.5s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes progress-pulse {
|
||||
0% { width: 40%; }
|
||||
100% { width: 78%; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SECTIONS
|
||||
============================================================ */
|
||||
.section {
|
||||
padding: 100px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: clamp(28px, 3.8vw, 42px);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
text-align: center;
|
||||
margin: 14px 0 12px;
|
||||
}
|
||||
|
||||
.section-lead {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
max-width: 620px;
|
||||
margin: 0 auto 48px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FEATURES
|
||||
============================================================ */
|
||||
.features { background: var(--bg-2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
|
||||
.feat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.feat {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px 22px;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
.feat:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: #383838;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.feat-icon {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.feat.green .feat-icon { background: rgba(29, 185, 84, 0.16); color: var(--accent); }
|
||||
.feat.pink .feat-icon { background: rgba(244, 114, 182, 0.16); color: var(--pink); }
|
||||
.feat.blue .feat-icon { background: rgba(96, 165, 250, 0.16); color: var(--blue); }
|
||||
.feat.amber .feat-icon { background: rgba(251, 191, 36, 0.16); color: var(--amber); }
|
||||
.feat.red .feat-icon { background: rgba(248, 113, 113, 0.16); color: var(--red); }
|
||||
.feat.purple .feat-icon { background: rgba(167, 139, 250, 0.16); color: var(--purple); }
|
||||
|
||||
.feat h3 {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.feat p {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
HOW IT WORKS
|
||||
============================================================ */
|
||||
.how-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.how-step {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.how-num {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||
color: #fff;
|
||||
font-weight: 900;
|
||||
font-size: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
box-shadow: 0 10px 24px var(--accent-glow);
|
||||
}
|
||||
|
||||
.how-step h3 {
|
||||
font-size: 17px;
|
||||
margin: 0 0 6px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.how-step p {
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PRICING
|
||||
============================================================ */
|
||||
.pricing { background: var(--bg-2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
|
||||
.price-card {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
padding: 36px 32px 32px;
|
||||
position: relative;
|
||||
text-align: center;
|
||||
box-shadow: 0 30px 80px rgba(29, 185, 84, 0.12);
|
||||
}
|
||||
|
||||
.price-badge {
|
||||
position: absolute;
|
||||
top: -14px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--accent);
|
||||
color: #0a0a0a;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
font-weight: 800;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.price-name {
|
||||
font-size: 13px;
|
||||
letter-spacing: 1.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin: 14px 0 24px;
|
||||
}
|
||||
.price-amount {
|
||||
font-size: 64px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.price-currency { font-size: 26px; font-weight: 700; color: var(--text-muted); }
|
||||
.price-once { font-size: 14px; color: var(--text-muted); margin-left: 8px; }
|
||||
|
||||
.price-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 28px;
|
||||
text-align: left;
|
||||
}
|
||||
.price-features li {
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px dashed var(--divider);
|
||||
font-size: 14.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.price-features li:last-child { border-bottom: none; }
|
||||
.price-features strong { color: var(--text); font-weight: 700; }
|
||||
|
||||
.price-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* Grid 4 colonne per i piani */
|
||||
.price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.price-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.price-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.price-card-sm {
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
padding: 32px 22px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.price-card-sm .price-amount { font-size: 44px; }
|
||||
.price-card-sm .price-currency { font-size: 20px; }
|
||||
.price-card-sm .price-once { font-size: 13px; }
|
||||
.price-card-sm .price-features { margin-bottom: 22px; flex: 1; }
|
||||
.price-card-sm .price-features li { padding: 7px 0; font-size: 13.5px; }
|
||||
|
||||
.price-card-featured {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 18px 48px rgba(29, 185, 84, 0.18);
|
||||
}
|
||||
|
||||
.price-badge-gold {
|
||||
background: linear-gradient(135deg, #f8c44a, #f4a51c);
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.price-shared {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Box informativo sulle chiavi API Spotify */
|
||||
.spotify-note {
|
||||
max-width: 900px;
|
||||
margin: 48px auto 0;
|
||||
padding: 28px 32px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
}
|
||||
.spotify-note h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.spotify-note > p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.spotify-note-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin: 18px 0 14px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.spotify-note-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.sn-col {
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.sn-with {
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
border-color: rgba(29, 185, 84, 0.3);
|
||||
}
|
||||
.sn-without {
|
||||
background: rgba(120, 120, 120, 0.06);
|
||||
}
|
||||
.sn-title {
|
||||
font-weight: 800;
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sn-col ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sn-col li { margin-bottom: 4px; }
|
||||
.sn-col strong { color: var(--text); }
|
||||
.spotify-note-fine {
|
||||
margin-top: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FAQ
|
||||
============================================================ */
|
||||
.faq-list {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.faq-item summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
padding: 18px 22px;
|
||||
font-weight: 700;
|
||||
font-size: 15.5px;
|
||||
position: relative;
|
||||
padding-right: 50px;
|
||||
transition: background 0.18s ease;
|
||||
}
|
||||
.faq-item summary::-webkit-details-marker { display: none; }
|
||||
.faq-item summary::after {
|
||||
content: "+";
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 22px;
|
||||
color: var(--accent);
|
||||
font-weight: 400;
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.faq-item[open] summary::after { content: "−"; }
|
||||
.faq-item summary:hover { background: var(--bg-card-2); }
|
||||
|
||||
.faq-item p {
|
||||
margin: 0;
|
||||
padding: 0 22px 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.faq-item code {
|
||||
background: var(--bg-card-2);
|
||||
padding: 2px 7px;
|
||||
border-radius: 5px;
|
||||
font-size: 13px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FINAL CTA
|
||||
============================================================ */
|
||||
.cta-final { padding: 60px 0 90px; }
|
||||
|
||||
.cta-box {
|
||||
text-align: center;
|
||||
background:
|
||||
radial-gradient(ellipse at center, rgba(29, 185, 84, 0.2), transparent 70%),
|
||||
linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card-2) 100%);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 22px;
|
||||
padding: 56px 28px;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.cta-box h2 { font-size: clamp(24px, 3.2vw, 34px); font-weight: 900; margin: 0 0 8px; letter-spacing: -0.02em; }
|
||||
.cta-box p { color: var(--text-muted); margin: 0 0 22px; font-size: 16px; }
|
||||
|
||||
/* ============================================================
|
||||
FOOTER
|
||||
============================================================ */
|
||||
.footer {
|
||||
background: var(--bg-2);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 36px 0 30px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.footer-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.footer-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
.footer-by { color: var(--text-dim); font-weight: 400; }
|
||||
|
||||
.footer-links { display: flex; gap: 22px; }
|
||||
.footer-links a:hover { color: var(--text); }
|
||||
|
||||
.footer-copy {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
border-top: 1px solid var(--divider);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
RESPONSIVE
|
||||
============================================================ */
|
||||
@media (max-width: 920px) {
|
||||
.hero { padding: 60px 0 70px; }
|
||||
.hero-grid { grid-template-columns: 1fr; gap: 40px; }
|
||||
.hero-art { display: none; } /* su mobile nascondiamo il mockup */
|
||||
.nav-links { display: none; }
|
||||
|
||||
.section { padding: 70px 0; }
|
||||
|
||||
.how-grid { grid-template-columns: 1fr; }
|
||||
.footer-row { flex-direction: column; align-items: flex-start; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.container { padding: 0 18px; }
|
||||
.hero-cta .btn { width: 100%; }
|
||||
.price-amount { font-size: 52px; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ANIMAZIONI SCROLL
|
||||
============================================================ */
|
||||
.nav.scrolled { box-shadow: 0 1px 0 var(--border), 0 8px 24px rgba(0,0,0,0.4); }
|
||||
|
||||
.feat, .how-step, .price-card, .faq-item {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
.feat.in-view, .how-step.in-view, .price-card.in-view, .faq-item.in-view {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.feat, .how-step, .price-card, .faq-item {
|
||||
opacity: 1; transform: none; transition: none;
|
||||
}
|
||||
.mockup { transform: none !important; }
|
||||
.mc-progress-fill { animation: none !important; width: 64% !important; }
|
||||
html { scroll-behavior: auto; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
USE POLICY BOX
|
||||
============================================================ */
|
||||
.use-policy {
|
||||
max-width: 720px;
|
||||
margin: 50px auto 0;
|
||||
padding: 26px 28px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--accent);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.use-policy h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.use-policy p {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.use-points {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 18px;
|
||||
}
|
||||
|
||||
.use-points li {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.use-fineprint {
|
||||
margin: 8px 0 0 !important;
|
||||
font-size: 12.5px !important;
|
||||
color: var(--text-dim) !important;
|
||||
}
|
||||
.use-fineprint a { color: var(--accent); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.use-points { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.price-consent {
|
||||
font-size: 11.5px !important;
|
||||
color: var(--text-dim) !important;
|
||||
margin: 12px 0 0 !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
.price-consent a { color: var(--text-muted); text-decoration: underline; }
|
||||
.price-consent a:hover { color: var(--accent); }
|
||||
|
||||
/* ============================================================
|
||||
"Hai gia acquistato? Scarica" — link discreti
|
||||
============================================================ */
|
||||
.hero-customer {
|
||||
margin: 18px 0 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hero-customer a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.hero-customer a:hover { text-decoration: underline; }
|
||||
|
||||
.price-already {
|
||||
margin: 10px 0 0 !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
.price-already a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.price-already a:hover { text-decoration: underline; }
|
||||
@@ -0,0 +1,303 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Scarica MusicTools</title>
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<style>
|
||||
.dl-page {
|
||||
padding: 60px 24px 100px;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.dl-head {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
.dl-head h1 {
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 12px 0 10px;
|
||||
}
|
||||
.dl-head p {
|
||||
color: var(--text-muted);
|
||||
font-size: 17px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dl-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.dl-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.dl-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 32px 28px;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
|
||||
}
|
||||
.dl-card:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: #383838;
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.dl-os { font-size: 64px; line-height: 1; margin-bottom: 12px; }
|
||||
.dl-card h2 { margin: 0 0 6px; font-size: 22px; font-weight: 800; }
|
||||
.dl-card .meta { color: var(--text-dim); font-size: 13px; margin: 0 0 24px; }
|
||||
|
||||
.dl-form {
|
||||
max-width: 460px;
|
||||
margin: 0 auto 32px;
|
||||
padding: 28px 30px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
}
|
||||
.dl-form h2 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.dl-form p { margin: 0 0 18px; color: var(--text-muted); font-size: 14px; }
|
||||
.dl-form .field { margin-bottom: 12px; }
|
||||
.dl-form .field-label { font-size: 12px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 6px; }
|
||||
.dl-form .input { width: 100%; padding: 12px 16px; border-radius: 999px; background: #0d0d0d; border: 1px solid var(--border); color: var(--text); font-size: 14px; font-family: inherit; box-sizing: border-box; }
|
||||
.dl-form .input:focus { outline: none; border-color: var(--accent); }
|
||||
.dl-form .submit { width: 100%; margin-top: 12px; }
|
||||
|
||||
.dl-steps {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
padding: 28px 32px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.dl-steps h2 { font-size: 18px; font-weight: 800; margin: 0 0 14px; }
|
||||
.dl-steps ol { margin: 0; padding-left: 22px; color: var(--text-muted); font-size: 14.5px; line-height: 1.7; }
|
||||
.dl-steps li { margin-bottom: 4px; }
|
||||
.dl-steps strong { color: var(--text); }
|
||||
.dl-steps code { background: var(--bg-card-2); padding: 2px 7px; border-radius: 5px; font-size: 13px; font-family: ui-monospace, monospace; color: var(--accent); }
|
||||
|
||||
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
|
||||
.dl-support a { color: var(--accent); }
|
||||
|
||||
.dl-code {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dl-sub {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dl-sub li { margin-bottom: 4px; }
|
||||
.dl-sub strong { color: var(--text); }
|
||||
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
|
||||
|
||||
.dl-status {
|
||||
max-width: 460px;
|
||||
margin: -16px auto 24px;
|
||||
padding: 12px 18px;
|
||||
text-align: center;
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
border: 1px solid rgba(29, 185, 84, 0.25);
|
||||
color: var(--accent);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dl-status.err {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="/" class="brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<span class="brand-name">MusicTools</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/#features">Funzioni</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
</nav>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Supporto</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dl-page">
|
||||
<div class="dl-head">
|
||||
<span class="eyebrow">SCARICA</span>
|
||||
<h1>Scarica MusicTools</h1>
|
||||
<p>Scegli il sistema operativo e procedi con l'installazione</p>
|
||||
</div>
|
||||
|
||||
<!-- Form mostrato quando NON arrivi dall'email (no key/email in URL) -->
|
||||
<div class="dl-form" id="dlForm">
|
||||
<h2>Inserisci le tue credenziali</h2>
|
||||
<p>Hai gia' acquistato? Inserisci l'email e la chiave per scaricare.</p>
|
||||
<div class="field">
|
||||
<label class="field-label" for="dlEmail">Email dell'acquisto</label>
|
||||
<input type="email" id="dlEmail" class="input" autocomplete="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="dlKey">Chiave di licenza</label>
|
||||
<input type="text" id="dlKey" class="input" autocomplete="off" placeholder="XXXX-XXXX-XXXX-XXXX" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg submit" id="dlSubmitBtn">Mostra i link di download</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards di download (popolate dopo inserimento credenziali o da URL) -->
|
||||
<div class="dl-grid" id="dlGrid" hidden>
|
||||
<article class="dl-card">
|
||||
<div class="dl-os">🍎</div>
|
||||
<h2>macOS</h2>
|
||||
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
|
||||
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlMacBtn" data-platform="macos">
|
||||
Scarica per Mac
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<article class="dl-card">
|
||||
<div class="dl-os">🪟</div>
|
||||
<h2>Windows</h2>
|
||||
<p class="meta">Windows 10 e 11 · 64-bit</p>
|
||||
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlWinBtn" data-platform="windows">
|
||||
Scarica per Windows
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p id="dlStatus" class="dl-status" hidden></p>
|
||||
|
||||
<div class="dl-steps">
|
||||
<h2>Installazione macOS</h2>
|
||||
<ol>
|
||||
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
||||
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
||||
<li>
|
||||
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
|
||||
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio → "Terminale"), incolla questa riga e premi Invio:
|
||||
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
|
||||
<strong>In alternativa</strong> (senza Terminale):
|
||||
<ul class="dl-sub">
|
||||
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG → <strong>Apri</strong> → <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
|
||||
<li>Apri <strong>Impostazioni di Sistema</strong> → <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="dl-steps">
|
||||
<h2>Installazione Windows</h2>
|
||||
<ol>
|
||||
<li>Estrai il file <code>.zip</code> in una cartella di tua scelta</li>
|
||||
<li>Doppio click su <strong>MusicTools.exe</strong></li>
|
||||
<li>Se Windows mostra "Windows ha protetto il PC": click su <strong>Ulteriori informazioni</strong> → <strong>Esegui comunque</strong></li>
|
||||
<li>Inserisci email e chiave per attivare</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="dl-support">
|
||||
Problemi con l'installazione o non hai ricevuto l'email con la chiave?<br>
|
||||
Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> — risposta entro 24 ore.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function getParam(name) {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get(name) || "";
|
||||
}
|
||||
|
||||
// Stato corrente — popolato da URL o dal form submit.
|
||||
const dl = { email: "", key: "" };
|
||||
|
||||
function setStatus(text, isError) {
|
||||
const el = document.getElementById("dlStatus");
|
||||
if (!text) { el.hidden = true; el.textContent = ""; return; }
|
||||
el.textContent = text;
|
||||
el.classList.toggle("err", !!isError);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function showDownloadCards(email, key) {
|
||||
dl.email = email;
|
||||
dl.key = key;
|
||||
document.getElementById("dlGrid").hidden = false;
|
||||
document.getElementById("dlForm").hidden = true;
|
||||
}
|
||||
|
||||
// Click sui bottoni: naviga sulla stessa tab verso /api/download/:platform.
|
||||
// Il server fa 302 con Content-Disposition: attachment -> il browser
|
||||
// scarica il file e la pagina /download resta visibile.
|
||||
document.querySelectorAll(".dl-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (!dl.email || !dl.key) {
|
||||
setStatus("Inserisci email e chiave prima di scaricare.", true);
|
||||
return;
|
||||
}
|
||||
const platform = btn.dataset.platform;
|
||||
const q = "key=" + encodeURIComponent(dl.key) +
|
||||
"&email=" + encodeURIComponent(dl.email);
|
||||
setStatus("Avvio del download in corso… il file si apre fra qualche secondo.");
|
||||
window.location.href = "/api/download/" + platform + "?" + q;
|
||||
});
|
||||
});
|
||||
|
||||
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni.
|
||||
const urlKey = getParam("key");
|
||||
const urlEmail = getParam("email");
|
||||
if (urlKey && urlEmail) {
|
||||
showDownloadCards(urlEmail, urlKey);
|
||||
}
|
||||
|
||||
// Form submit
|
||||
document.getElementById("dlSubmitBtn").addEventListener("click", () => {
|
||||
const email = document.getElementById("dlEmail").value.trim();
|
||||
const key = document.getElementById("dlKey").value.trim();
|
||||
if (!email || !key) {
|
||||
setStatus("Inserisci email e chiave di licenza.", true);
|
||||
return;
|
||||
}
|
||||
setStatus("");
|
||||
showDownloadCards(email, key);
|
||||
});
|
||||
|
||||
// Enter dentro i campi
|
||||
["dlEmail", "dlKey"].forEach(id => {
|
||||
document.getElementById(id).addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") document.getElementById("dlSubmitBtn").click();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,369 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag." />
|
||||
|
||||
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://musictools.djluza.com" />
|
||||
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ============== NAV ============== -->
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="#top" class="brand">
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span class="brand-name">MusicTools</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Funzioni</a>
|
||||
<a href="#how">Come funziona</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="/download">Download</a>
|
||||
</nav>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Contatti</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============== HERO ============== -->
|
||||
<section class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<span class="eyebrow">Per Mac e Windows</span>
|
||||
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
|
||||
<p class="lead">
|
||||
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
|
||||
Registra in alta qualità l'audio che esce dal tuo computer.
|
||||
Modifica i metadati e le copertine. Tutto offline, senza account.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a href="#features" class="btn btn-primary btn-lg">Scopri le funzioni</a>
|
||||
<a href="/download" class="btn btn-ghost btn-lg">Ho già una licenza</a>
|
||||
</div>
|
||||
<ul class="hero-bullets">
|
||||
<li>✓ Funziona offline · Su 3 dispositivi (Mac + Windows)</li>
|
||||
<li>✓ Aggiornamenti gratuiti inclusi</li>
|
||||
<li>✓ Scarica brani, video, registra l'audio di sistema, modifica i tag</li>
|
||||
</ul>
|
||||
|
||||
<p class="hero-customer">
|
||||
Hai già acquistato? <a href="/download">Scarica l'app →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
<!-- Mockup dell'app -->
|
||||
<div class="mockup">
|
||||
<div class="mockup-bar">
|
||||
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||
<span class="mockup-title">MusicTools</span>
|
||||
</div>
|
||||
<div class="mockup-body">
|
||||
<aside class="mockup-side">
|
||||
<div class="ms-brand"><span>♫</span> MusicTools</div>
|
||||
<div class="ms-nav">
|
||||
<div class="ms-item active"><span>⬇</span> Scarica</div>
|
||||
<div class="ms-item"><span>🎬</span> Video</div>
|
||||
<div class="ms-item"><span>⚡</span> Upgrade</div>
|
||||
<div class="ms-item"><span>🏷</span> Metadati</div>
|
||||
<div class="ms-item"><span>●</span> Registra</div>
|
||||
<div class="ms-item"><span>⚙</span> Impostazioni</div>
|
||||
<div class="ms-item"><span>📖</span> Guida</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="mockup-main">
|
||||
<div class="mockup-hero">
|
||||
<div class="mh-eyebrow">DOWNLOAD</div>
|
||||
<div class="mh-title">Scarica la tua musica</div>
|
||||
<div class="mh-sub">Spotify · YouTube · SoundCloud</div>
|
||||
<div class="mh-deco">♫</div>
|
||||
</div>
|
||||
<div class="mockup-card">
|
||||
<div class="mc-label">URL o titolo brano</div>
|
||||
<div class="mc-input">https://open.spotify.com/playlist/…</div>
|
||||
</div>
|
||||
<div class="mockup-card mockup-actions">
|
||||
<div class="mc-btn primary">▶ Avvia Download</div>
|
||||
<div class="mc-progress">
|
||||
<div class="mc-progress-fill" style="width: 64%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== FEATURES ============== -->
|
||||
<section class="section features" id="features">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">FUNZIONI</span>
|
||||
<h2 class="section-title">Sette strumenti, un'unica app</h2>
|
||||
<p class="section-lead">Tutto quello che ti serve per gestire la tua musica, senza saltare tra siti web e tool diversi.</p>
|
||||
|
||||
<div class="feat-grid">
|
||||
<article class="feat green">
|
||||
<div class="feat-icon">⬇</div>
|
||||
<h3>Scarica</h3>
|
||||
<p>Brani singoli, playlist Spotify, link YouTube, tracklist su file. Detect automatico, MP3 320K con copertina.</p>
|
||||
</article>
|
||||
<article class="feat pink">
|
||||
<div class="feat-icon">🎬</div>
|
||||
<h3>Video</h3>
|
||||
<p>YouTube, TikTok, Instagram, Facebook. Scegli la qualità (fino a 4K) o estrai solo l'audio in MP3.</p>
|
||||
</article>
|
||||
<article class="feat blue">
|
||||
<div class="feat-icon">⚡</div>
|
||||
<h3>Upgrade</h3>
|
||||
<p>Scansiona una cartella, trova i file a bitrate basso e li riscarica in alta qualità. Mantiene metadati e cover.</p>
|
||||
</article>
|
||||
<article class="feat amber">
|
||||
<div class="feat-icon">🏷</div>
|
||||
<h3>Metadati</h3>
|
||||
<p>Editor completo per MP3, M4A, FLAC, WAV. Cambia copertina, artista, album, BPM, key. Anche `kMDItemWhereFroms` su macOS.</p>
|
||||
</article>
|
||||
<article class="feat red">
|
||||
<div class="feat-icon">●</div>
|
||||
<h3>Registra</h3>
|
||||
<p>Cattura l'audio di qualunque app (Spotify, Apple Music, browser) via BlackHole o scheda interna. Output MP3 320K.</p>
|
||||
</article>
|
||||
<article class="feat purple">
|
||||
<div class="feat-icon">📖</div>
|
||||
<h3>Guida integrata</h3>
|
||||
<p>Manuale completo dentro l'app: setup, troubleshooting, esempi. Niente schede del browser da cercare.</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== HOW ============== -->
|
||||
<section class="section how" id="how">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">COME FUNZIONA</span>
|
||||
<h2 class="section-title">Tre passi, due minuti</h2>
|
||||
|
||||
<div class="how-grid">
|
||||
<div class="how-step">
|
||||
<div class="how-num">1</div>
|
||||
<h3>Ottieni la licenza</h3>
|
||||
<p>Ricevi la chiave via email. L'app non è ancora in vendita: scrivici per essere informato.</p>
|
||||
</div>
|
||||
<div class="how-step">
|
||||
<div class="how-num">2</div>
|
||||
<h3>Scarica l'app</h3>
|
||||
<p>Mac o Windows, link diretto nella stessa email. Installazione standard, niente dipendenze.</p>
|
||||
</div>
|
||||
<div class="how-step">
|
||||
<div class="how-num">3</div>
|
||||
<h3>Attiva e parti</h3>
|
||||
<p>Inserisci email e chiave all'avvio. L'app si attiva e resta valida a vita su questo dispositivo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== PRICING ============== -->
|
||||
<section class="section pricing" id="pricing">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">DISPONIBILITÀ</span>
|
||||
<h2 class="section-title">In arrivo</h2>
|
||||
<p class="section-lead">Stiamo definendo i piani commerciali. L'app non è ancora in vendita.</p>
|
||||
|
||||
<div class="price-shared">
|
||||
<p class="price-already">
|
||||
Hai già una licenza? <a href="/download">Scarica l'app →</a>
|
||||
</p>
|
||||
<p class="price-note">
|
||||
Per restare aggiornato sulla disponibilità scrivici a
|
||||
<a href="mailto:info@djluza.com">info@djluza.com</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="spotify-note">
|
||||
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
|
||||
<p>
|
||||
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
|
||||
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
|
||||
(anche con account Spotify gratuito).<br>
|
||||
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
|
||||
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
|
||||
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
|
||||
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
|
||||
</p>
|
||||
<div class="spotify-note-grid">
|
||||
<div class="sn-col sn-with">
|
||||
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
|
||||
<ul>
|
||||
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
|
||||
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
|
||||
<li>Tutte le altre fonti restano sempre attive</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="sn-col sn-without">
|
||||
<div class="sn-title">Senza Premium (modalita' base)</div>
|
||||
<ul>
|
||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
|
||||
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
|
||||
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="spotify-note-fine">
|
||||
MusicTools non e' affiliato a Spotify. Le chiavi API restano sul tuo
|
||||
dispositivo, non passano per i nostri server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="use-policy">
|
||||
<h3>Uso responsabile</h3>
|
||||
<p>
|
||||
MusicTools e' uno strumento tecnico per l'automazione di download e registrazione audio,
|
||||
paragonabile a un browser o a un registratore digitale.
|
||||
<strong>Sei tu a scegliere quali contenuti acquisire</strong> e sei l'unico responsabile
|
||||
del rispetto del diritto d'autore e dei Termini di servizio delle piattaforme di origine.
|
||||
</p>
|
||||
<ul class="use-points">
|
||||
<li>✓ Backup dei brani che hai gia acquistato</li>
|
||||
<li>✓ Contenuti tuoi o rilasciati con licenza libera</li>
|
||||
<li>✗ Distribuzione, rivendita o uso commerciale dei contenuti scaricati</li>
|
||||
<li>✗ Aggiramento di misure di protezione (DRM)</li>
|
||||
</ul>
|
||||
<p class="use-fineprint">
|
||||
Leggi i <a href="/legal/terms.html">Termini di servizio completi</a> prima dell'acquisto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== FAQ ============== -->
|
||||
<section class="section faq" id="faq">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">DOMANDE FREQUENTI</span>
|
||||
<h2 class="section-title">Tutto quello che devi sapere</h2>
|
||||
|
||||
<div class="faq-list">
|
||||
<details class="faq-item">
|
||||
<summary>Su quali sistemi operativi funziona?</summary>
|
||||
<p>macOS 11 (Big Sur) o successivi, e Windows 10/11. L'app non gira su Linux per ora.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Su quanti dispositivi posso installarla?</summary>
|
||||
<p>La licenza copre fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funzionano gli aggiornamenti?</summary>
|
||||
<p>Sono <strong>inclusi</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Mi serve un abbonamento Spotify Premium?</summary>
|
||||
<p>
|
||||
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
|
||||
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
|
||||
solo agli account Premium. Con un account Spotify gratuito (o senza
|
||||
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
|
||||
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
|
||||
</p>
|
||||
<p>YouTube non richiede alcun account.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Cosa posso fare senza configurare le chiavi API Spotify?</summary>
|
||||
<p>
|
||||
<strong>Da YouTube, SoundCloud, TikTok, Instagram, Facebook</strong>:
|
||||
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
|
||||
incollando il link al singolo brano (l'app estrae artista e titolo
|
||||
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
|
||||
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
|
||||
agli account Premium.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso registrare l'audio di Spotify mentre suona?</summary>
|
||||
<p>Sì, con BlackHole (macOS) o la scheda audio virtuale di Windows. La tab Registra ti guida nel setup passo-passo.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>L'app si installa senza problemi su Mac?</summary>
|
||||
<p>Al primo avvio macOS chiede una conferma di sicurezza. Tasto destro sull'app → Apri → Apri. Lo fai una volta sola, poi parte normalmente. Nel DMG trovi anche uno script <code>Fix Gatekeeper.command</code> che lo fa per te.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>E su Windows?</summary>
|
||||
<p>Al primo avvio Windows SmartScreen mostra "Windows protected your PC". Clicca su <strong>More info</strong> → <strong>Run anyway</strong>. È una procedura standard per app non firmate con cert EV.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso chiedere il rimborso se non mi piace?</summary>
|
||||
<p>Sì, hai <strong>14 giorni</strong> per chiederlo senza fornire motivazioni. Scrivi a info@djluza.com.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso usare i brani scaricati commercialmente?</summary>
|
||||
<p>No. La licenza copre <strong>esclusivamente l'uso personale</strong>. I contenuti scaricati restano soggetti al copyright dei rispettivi proprietari: sei tu responsabile di rispettare i loro termini d'uso e di non distribuirli, rivenderli o esibirli pubblicamente. Vedi <a href="/legal/terms.html#s5">§5 dei Termini</a>.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>MusicTools è collegato a Spotify, YouTube o altre piattaforme?</summary>
|
||||
<p>No. MusicTools <strong>non è affiliato, sponsorizzato o autorizzato</strong> da nessuna di queste società. È uno strumento tecnico indipendente che usa librerie open-source per interagire con risorse pubbliche. I marchi citati sono dei rispettivi proprietari.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Sono titolare di un copyright e voglio segnalare un uso illegittimo</summary>
|
||||
<p>Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> con prova della titolarità, identificazione del materiale e dichiarazione di buona fede. La procedura completa è descritta al <a href="/legal/terms.html#s8">§8 dei Termini</a>.</p>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== FINAL CTA ============== -->
|
||||
<section class="section cta-final">
|
||||
<div class="container cta-box">
|
||||
<h2>Resta in contatto</h2>
|
||||
<p>Scrivici per qualsiasi domanda o per essere informato sulla disponibilità.</p>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-primary btn-lg">Scrivici a info@djluza.com</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============== FOOTER ============== -->
|
||||
<footer class="footer">
|
||||
<div class="container footer-row">
|
||||
<div class="footer-brand">
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span>MusicTools</span>
|
||||
<span class="footer-by">by LuZa</span>
|
||||
</div>
|
||||
<nav class="footer-links">
|
||||
<a href="/download">Download</a>
|
||||
<a href="mailto:info@djluza.com">Supporto</a>
|
||||
<a href="/legal/terms.html">Termini</a>
|
||||
<a href="/legal/privacy.html">Privacy</a>
|
||||
<a href="/legal/refund.html">Rimborsi</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="container footer-copy">
|
||||
© <span id="year">2026</span> MusicTools — Luciano Zappacosta · Tutti i diritti riservati
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="js/main.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
/* ======================================================
|
||||
MusicTools landing — JS minimale
|
||||
====================================================== */
|
||||
|
||||
// Anno corrente in footer
|
||||
(function setYear() {
|
||||
const el = document.getElementById("year");
|
||||
if (el) el.textContent = String(new Date().getFullYear());
|
||||
})();
|
||||
|
||||
// Lemon Squeezy overlay: lemon.js intercetta automaticamente i link con
|
||||
// data-lemonsqueezy-url e apre l'overlay di checkout. Niente codice nostro
|
||||
// necessario qui — basta che lemon.js sia caricato (vedi <script> in index.html).
|
||||
|
||||
// Header che diventa piu' opaco mentre scorri (effetto subtle)
|
||||
const nav = document.querySelector(".nav");
|
||||
function onScroll() {
|
||||
if (!nav) return;
|
||||
if (window.scrollY > 24) nav.classList.add("scrolled");
|
||||
else nav.classList.remove("scrolled");
|
||||
}
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
onScroll();
|
||||
|
||||
// Reveal on scroll: aggiunge la classe .in-view alle sezioni
|
||||
// quando entrano in viewport. Niente librerie esterne.
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("in-view");
|
||||
io.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.12 });
|
||||
|
||||
document.querySelectorAll(".feat, .how-step, .price-card, .faq-item").forEach((el) => {
|
||||
io.observe(el);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Privacy · MusicTools</title>
|
||||
<link rel="stylesheet" href="../css/style.css" />
|
||||
<style>
|
||||
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
|
||||
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
|
||||
.legal-doc h2 { font-size: 20px; margin-top: 32px; font-weight: 800; }
|
||||
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
|
||||
.legal-doc a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav"><div class="container nav-row">
|
||||
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
|
||||
</div></header>
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Informativa Privacy</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||
|
||||
<h2>2. Dati trattati</h2>
|
||||
<ul>
|
||||
<li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li>
|
||||
<li><strong>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li>
|
||||
<li><strong>Piano sottoscritto, id ordine e id sottoscrizione Lemon Squeezy, data scadenza/rinnovo</strong>: necessari per applicare correttamente le funzioni del piano (Basic/Pro/Premium/Annual) e gestire rinnovi, disdette, rimborsi.</li>
|
||||
<li><strong>Contatore download giornaliero</strong>: numero aggregato di download effettuati ogni giorno (azzerato a mezzanotte ora di Roma). Serve esclusivamente ad applicare i limiti del piano (10/30/150 al giorno). Non vengono registrati URL ne' titoli dei brani scaricati.</li>
|
||||
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Finalita' e base giuridica</h2>
|
||||
<ul>
|
||||
<li>Esecuzione del contratto (consegna della licenza, gestione del piano sottoscritto, rinnovi, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
||||
<li>Sicurezza del software e applicazione dei limiti del piano (verifica anti-abuso delle attivazioni, contatore giornaliero) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Conservazione</h2>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
|
||||
|
||||
<h2>5. Terze parti</h2>
|
||||
<ul>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</li>
|
||||
<li><strong>Provider di hosting self-managed</strong> — i dati di licenza e il backend API sono ospitati su server gestiti dall'Editore. I dati non vengono condivisi con altri fornitori cloud.</li>
|
||||
</ul>
|
||||
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
||||
|
||||
<h2>6. Diritti dell'interessato</h2>
|
||||
<p>L'utente ha diritto di accedere, rettificare, cancellare i propri dati e di ottenere portabilita' o limitazione del trattamento. Per esercitare questi diritti scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||
|
||||
<h2>7. Cookie</h2>
|
||||
<p>Questo sito non usa cookie di tracciamento. Lemon Squeezy puo' impostare i propri cookie funzionali durante il checkout secondo la sua informativa.</p>
|
||||
|
||||
<h2>8. Reclami</h2>
|
||||
<p>In caso di violazioni puoi rivolgerti al Garante per la protezione dei dati personali (<a href="https://www.garanteprivacy.it">garanteprivacy.it</a>).</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Politica rimborsi · MusicTools</title>
|
||||
<link rel="stylesheet" href="../css/style.css" />
|
||||
<style>
|
||||
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
|
||||
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
|
||||
.legal-doc h2 { font-size: 20px; margin-top: 32px; font-weight: 800; }
|
||||
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
|
||||
.legal-doc a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav"><div class="container nav-row">
|
||||
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
|
||||
</div></header>
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Politica rimborsi</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>Garanzia 14 giorni sul primo acquisto</h2>
|
||||
<p>Hai <strong>14 giorni</strong> di tempo dal <strong>primo acquisto</strong> per chiedere il rimborso completo, senza fornire motivazioni. Vale per tutti i piani: Basic, Pro, Premium e Annual.</p>
|
||||
<p>La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
|
||||
<h2>Differenze per piano</h2>
|
||||
<ul>
|
||||
<li><strong>Basic / Pro / Premium</strong> (mensili): puoi disdire l'abbonamento <strong>in qualsiasi momento</strong> dalla dashboard di Lemon Squeezy o scrivendoci. La disdetta interrompe il rinnovo successivo; la licenza resta attiva fino alla scadenza del periodo gia pagato. Il rimborso integrale e' previsto solo entro 14 giorni dal primo addebito. <strong>I rinnovi successivi al primo non sono rimborsabili</strong> ma sono interrompibili con disdetta.</li>
|
||||
<li><strong>Annual</strong> (una tantum, 49,90 € per 12 mesi): rimborso integrale entro 14 giorni dall'acquisto. Oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Cosa succede dopo la richiesta</h2>
|
||||
<ol>
|
||||
<li>Riceverai una risposta entro 48 ore lavorative.</li>
|
||||
<li>Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento usato per l'acquisto. Tempi tipici: 5-10 giorni lavorativi.</li>
|
||||
<li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Come disdire un abbonamento mensile</h2>
|
||||
<p>Se sei oltre i 14 giorni e vuoi solo non rinnovare, accedi alla tua dashboard Lemon Squeezy (link nella ricevuta) e clicca "Cancel subscription". In alternativa scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a> e la disattiviamo per te.</p>
|
||||
|
||||
<h2>Limitazioni</h2>
|
||||
<p>Trascorsi 14 giorni dal primo acquisto non e' piu' possibile chiedere il rimborso integrale, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Spotify potrebbe modificare in qualsiasi momento le proprie API o le condizioni di accesso (es. richiedere un account Premium per generare le credenziali). Tali modifiche di terze parti non costituiscono titolo per il rimborso del piano MusicTools acquistato. La sezione "Esclusione di garanzie" dei <a href="/legal/terms.html">Termini di servizio</a> si applica integralmente.</p>
|
||||
|
||||
<h2>Domande?</h2>
|
||||
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,238 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Termini di servizio · MusicTools</title>
|
||||
<link rel="stylesheet" href="../css/style.css" />
|
||||
<style>
|
||||
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
|
||||
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
|
||||
.legal-doc h2 { font-size: 20px; margin-top: 36px; font-weight: 800; }
|
||||
.legal-doc h3 { font-size: 16px; margin-top: 22px; font-weight: 700; color: var(--text); }
|
||||
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
|
||||
.legal-doc strong { color: var(--text); }
|
||||
.legal-doc a { color: var(--accent); }
|
||||
.legal-doc .toc {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 18px 22px; margin-bottom: 32px;
|
||||
}
|
||||
.legal-doc .toc ol { margin: 0; padding-left: 22px; }
|
||||
.legal-doc .toc li { margin: 4px 0; }
|
||||
.legal-doc .toc a { color: var(--text-muted); font-size: 14px; }
|
||||
.legal-doc .toc a:hover { color: var(--accent); }
|
||||
.legal-doc .warn-box {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||
border-left: 4px solid var(--red);
|
||||
padding: 16px 20px; border-radius: 10px; margin: 22px 0;
|
||||
}
|
||||
.legal-doc .warn-box p { margin: 0; color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav"><div class="container nav-row">
|
||||
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
|
||||
</div></header>
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Termini di servizio e Licenza d'uso</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<div class="warn-box">
|
||||
<p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p>
|
||||
</div>
|
||||
|
||||
<div class="toc">
|
||||
<strong>Indice</strong>
|
||||
<ol>
|
||||
<li><a href="#s1">Definizioni</a></li>
|
||||
<li><a href="#s2">Accettazione dei termini</a></li>
|
||||
<li><a href="#s3">Licenza d'uso (EULA)</a></li>
|
||||
<li><a href="#s4">Restrizioni d'uso</a></li>
|
||||
<li><a href="#s5">Acceptable Use Policy</a></li>
|
||||
<li><a href="#s6">Copyright e responsabilita dell'utente</a></li>
|
||||
<li><a href="#s7">Marchi e affiliazioni con terze parti</a></li>
|
||||
<li><a href="#s8">Procedura DMCA / segnalazioni</a></li>
|
||||
<li><a href="#s9">Esclusione di garanzie</a></li>
|
||||
<li><a href="#s10">Limitazione di responsabilita</a></li>
|
||||
<li><a href="#s11">Manleva (indemnification)</a></li>
|
||||
<li><a href="#s12">Pagamenti e fatturazione</a></li>
|
||||
<li><a href="#s13">Diritto di recesso e rimborsi</a></li>
|
||||
<li><a href="#s14">Aggiornamenti e supporto</a></li>
|
||||
<li><a href="#s15">Risoluzione</a></li>
|
||||
<li><a href="#s16">Modifiche ai termini</a></li>
|
||||
<li><a href="#s17">Legge applicabile e foro competente</a></li>
|
||||
<li><a href="#s18">Contatti</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h2 id="s1">1. Definizioni</h2>
|
||||
<p>Nel presente documento:</p>
|
||||
<ul>
|
||||
<li><strong>"Editore"</strong> — Luciano Zappacosta, in qualita' di sviluppatore e distributore del software, contattabile a <a href="mailto:info@djluza.com">info@djluza.com</a>.</li>
|
||||
<li><strong>"Software" o "MusicTools"</strong> — l'applicazione desktop denominata MusicTools, comprensiva di codice eseguibile, asset, documentazione e aggiornamenti.</li>
|
||||
<li><strong>"Utente"</strong> — la persona fisica o giuridica che acquista, installa o utilizza il Software.</li>
|
||||
<li><strong>"Licenza"</strong> — il diritto d'uso personale concesso all'Utente dietro pagamento, come descritto al §3.</li>
|
||||
<li><strong>"Contenuti"</strong> — file audio, video, immagini, metadati o altri materiali che l'Utente sceglie di scaricare, registrare o modificare tramite il Software.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s2">2. Accettazione dei termini</h2>
|
||||
<p>L'installazione, l'attivazione o l'uso del Software costituiscono accettazione integrale e incondizionata dei presenti Termini. Se l'Utente non accetta una qualsiasi disposizione, deve interrompere l'uso del Software, disinstallarlo e richiedere il rimborso (vedi §13) entro i termini previsti.</p>
|
||||
|
||||
<h2 id="s3">3. Licenza d'uso (EULA)</h2>
|
||||
<h3>3.1 Concessione</h3>
|
||||
<p>L'Editore concede all'Utente una licenza <strong>personale, non esclusiva, non trasferibile, non sublicenziabile e revocabile</strong> per:</p>
|
||||
<ul>
|
||||
<li>Installare il Software su un massimo di <strong>3 (tre) dispositivi</strong> di proprieta' o sotto il controllo esclusivo dell'Utente;</li>
|
||||
<li>Utilizzare il Software per finalita' strettamente <strong>personali e non commerciali</strong>;</li>
|
||||
<li>Ricevere gli aggiornamenti rilasciati dall'Editore secondo §14.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.2 Durata</h3>
|
||||
<p>La Licenza e' a tempo indeterminato ("a vita") salvo i casi di risoluzione descritti al §15.</p>
|
||||
|
||||
<h3>3.3 Proprieta' intellettuale</h3>
|
||||
<p>Il Software resta di esclusiva proprieta' intellettuale dell'Editore. La Licenza concede solo un diritto d'uso e non trasferisce alcun diritto di proprieta' all'Utente. Marchi, design, codice sorgente e documentazione sono protetti dalle leggi sul diritto d'autore italiane e internazionali.</p>
|
||||
|
||||
<h2 id="s4">4. Restrizioni d'uso</h2>
|
||||
<p>L'Utente <strong>NON</strong> puo':</p>
|
||||
<ul>
|
||||
<li>Copiare, distribuire, vendere, affittare, prestare, sublicenziare o rendere altrimenti disponibile il Software a terzi;</li>
|
||||
<li>Decompilare, disassemblare, fare reverse-engineering o tentare di ricavare il codice sorgente, salvo nei limiti consentiti da norme inderogabili (es. art. 64-quater l.d.a.);</li>
|
||||
<li>Rimuovere, alterare od oscurare avvisi di copyright, marchi o note legali presenti nel Software;</li>
|
||||
<li>Aggirare, disattivare o manomettere i meccanismi di attivazione della Licenza;</li>
|
||||
<li>Condividere la propria chiave di licenza o farne uso simultaneo su un numero di dispositivi superiore a quello consentito;</li>
|
||||
<li>Usare il Software per scopi illeciti o per agevolare la violazione di diritti di terzi.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s5">5. Acceptable Use Policy</h2>
|
||||
<div class="warn-box">
|
||||
<p><strong>MusicTools e' uno strumento tecnico di automazione.</strong> Funziona allo stesso modo di un browser o di un registratore audio: e' l'Utente a scegliere quali contenuti acquisire e da quali fonti.</p>
|
||||
</div>
|
||||
|
||||
<h3>5.1 Usi consentiti</h3>
|
||||
<p>L'Utente puo' utilizzare il Software esclusivamente per:</p>
|
||||
<ul>
|
||||
<li>Scaricare, registrare o modificare Contenuti <strong>di cui possiede i diritti</strong> (es. opere proprie, contenuti acquistati legalmente, materiale di pubblico dominio, contenuti rilasciati con licenza permissiva come Creative Commons);</li>
|
||||
<li>Effettuare copie di backup di Contenuti gia' in suo legittimo possesso, nei limiti previsti dalla legge (es. copia privata, art. 71-sexies l.d.a.);</li>
|
||||
<li>Fini di studio, ricerca o uso personale non commerciale, sempre nel rispetto del diritto d'autore.</li>
|
||||
</ul>
|
||||
|
||||
<h3>5.2 Usi vietati</h3>
|
||||
<p>E' espressamente vietato usare il Software per:</p>
|
||||
<ul>
|
||||
<li>Scaricare o registrare Contenuti coperti da copyright senza l'autorizzazione del titolare dei diritti;</li>
|
||||
<li>Aggirare misure tecnologiche di protezione (DRM) imposte sui Contenuti, anche quando il Software permettesse tecnicamente di farlo;</li>
|
||||
<li>Distribuire, rivendere, esibire pubblicamente o monetizzare Contenuti acquisiti tramite il Software;</li>
|
||||
<li>Violare i Termini di servizio delle piattaforme di origine (es. YouTube ToS, Spotify ToS, ecc.);</li>
|
||||
<li>Qualsiasi attivita' che configuri reato o illecito civile secondo la legge italiana o secondo quella della giurisdizione dell'Utente.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s6">6. Copyright e responsabilita dell'utente</h2>
|
||||
<p>L'Editore <strong>non possiede, non ospita, non distribuisce alcun contenuto audio o video</strong>. Il Software accede esclusivamente alle risorse pubbliche o private a cui l'Utente decide di puntare.</p>
|
||||
<p>L'<strong>Utente e' l'unico responsabile</strong>:</p>
|
||||
<ul>
|
||||
<li>Della scelta dei Contenuti da scaricare, registrare o modificare;</li>
|
||||
<li>Del rispetto dei diritti d'autore, dei diritti connessi e dei diritti di immagine relativi a tali Contenuti;</li>
|
||||
<li>Del rispetto dei Termini di servizio delle piattaforme di origine;</li>
|
||||
<li>Dell'uso che fa dei Contenuti dopo averli acquisiti tramite il Software.</li>
|
||||
</ul>
|
||||
<p>L'Editore non puo' essere ritenuto responsabile in alcuna forma per l'uso che l'Utente fa del Software o dei Contenuti. Eventuali violazioni del diritto d'autore commesse dall'Utente restano di sua esclusiva responsabilita' civile, penale e amministrativa.</p>
|
||||
|
||||
<h2 id="s7">7. Marchi e affiliazioni con terze parti</h2>
|
||||
<p>I marchi <strong>Spotify</strong>, <strong>YouTube</strong>, <strong>SoundCloud</strong>, <strong>TikTok</strong>, <strong>Instagram</strong>, <strong>Facebook</strong>, <strong>Apple Music</strong> e qualsiasi altro marchio menzionato sono di proprieta' dei rispettivi titolari.</p>
|
||||
<p>MusicTools <strong>non e' affiliato, sponsorizzato, autorizzato, supportato</strong> ne' in alcun modo connesso ad alcuna di queste societa'. Le menzioni di tali marchi nel sito, nella documentazione e nell'interfaccia del Software hanno scopo esclusivamente descrittivo (indicare la compatibilita' tecnica del Software con tali servizi).</p>
|
||||
|
||||
<h2 id="s8">8. Procedura DMCA / segnalazioni</h2>
|
||||
<p>Sebbene l'Editore non distribuisca contenuti, accoglie eventuali segnalazioni ai sensi della normativa internazionale sul copyright (incluso il Digital Millennium Copyright Act statunitense). Le richieste vanno inviate a <a href="mailto:info@djluza.com">info@djluza.com</a> con:</p>
|
||||
<ul>
|
||||
<li>Identificazione del titolare dei diritti e prova della titolarita';</li>
|
||||
<li>Identificazione del materiale ritenuto in violazione;</li>
|
||||
<li>Dichiarazione di buona fede e firma elettronica.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s9">9. Esclusione di garanzie</h2>
|
||||
<p>Il Software e' fornito <strong>"COSI' COM'E'" e "COME DISPONIBILE"</strong> ("AS IS" e "AS AVAILABLE"), senza alcuna garanzia espressa o implicita di qualsiasi tipo, ivi incluse, a titolo non esaustivo, le garanzie di:</p>
|
||||
<ul>
|
||||
<li>Idoneita' a uno scopo specifico;</li>
|
||||
<li>Commerciabilita';</li>
|
||||
<li>Non violazione di diritti di terzi;</li>
|
||||
<li>Funzionamento ininterrotto, privo di errori o privo di malware;</li>
|
||||
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
|
||||
</ul>
|
||||
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
|
||||
<p><strong>Credenziali Spotify Web API:</strong> per scaricare playlist e album interi da Spotify il Software richiede che l'Utente generi proprie chiavi API (Client ID e Client Secret) dalla Spotify Developer Dashboard e le configuri all'interno del Software. Spotify, in seguito ad aggiornamenti della propria policy, consente la creazione di tali credenziali esclusivamente agli utenti con <strong>account Spotify Premium</strong> attivo. L'Editore non e' in alcun modo affiliato a Spotify, non fornisce, vende o rivende account Spotify, non puo' garantire l'accessibilita' o il funzionamento delle API Spotify per gli account Free, e non e' responsabile per limitazioni, sospensioni o ban applicati da Spotify alle credenziali dell'Utente. I download di singoli brani da Spotify, basati su metadati pubblici, non richiedono credenziali API ne' un abbonamento Premium.</p>
|
||||
|
||||
<h2 id="s10">10. Limitazione di responsabilita</h2>
|
||||
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
|
||||
<ul>
|
||||
<li>Danni diretti, indiretti, incidentali, speciali, consequenziali, esemplari o punitivi;</li>
|
||||
<li>Perdita di dati, profitti, ricavi, opportunita' commerciali, avviamento, reputazione;</li>
|
||||
<li>Interruzione dell'attivita';</li>
|
||||
<li>Costi di sostituzione di beni o servizi;</li>
|
||||
<li>Pretese o azioni di terzi conseguenti all'uso del Software da parte dell'Utente.</li>
|
||||
</ul>
|
||||
<p>La responsabilita' complessiva dell'Editore, per qualsiasi titolo o causa, e' in ogni caso limitata all'<strong>importo effettivamente corrisposto dall'Utente per la Licenza nei 12 (dodici) mesi precedenti all'evento dannoso</strong>.</p>
|
||||
<p>Le limitazioni sopra riportate non si applicano nei casi di dolo o colpa grave, ne' nei casi in cui la legge applicabile non consenta tale limitazione (es. art. 1229 c.c.).</p>
|
||||
|
||||
<h2 id="s11">11. Manleva (indemnification)</h2>
|
||||
<p>L'Utente si impegna a manlevare, difendere e tenere indenne l'Editore (e i suoi eventuali collaboratori, fornitori, agenti) da qualsiasi pretesa, azione, richiesta di risarcimento, costo, spesa legale, sanzione o danno avanzati da terzi e derivanti da:</p>
|
||||
<ul>
|
||||
<li>L'uso del Software da parte dell'Utente in violazione dei presenti Termini;</li>
|
||||
<li>La violazione, da parte dell'Utente, di diritti d'autore, diritti connessi, diritti di immagine, marchi o altri diritti di terzi nell'acquisizione o nell'uso di Contenuti;</li>
|
||||
<li>La violazione dei Termini di servizio delle piattaforme di origine;</li>
|
||||
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s12">12. Piani, prezzi e fatturazione</h2>
|
||||
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
|
||||
<p>Il Software e' commercializzato in quattro piani, ciascuno con prezzi indicati IVA inclusa secondo paese di residenza dell'Utente:</p>
|
||||
<ul>
|
||||
<li><strong>Basic</strong> — 2,99 € / mese (abbonamento). Include il download di brani audio fino a 10 al giorno; non include video, registrazione, editor metadati, upgrade libreria.</li>
|
||||
<li><strong>Pro</strong> — 5,99 € / mese (abbonamento). Include audio + video + registrazione + editor metadati + upgrade libreria, fino a 30 download al giorno.</li>
|
||||
<li><strong>Premium</strong> — 9,99 € / mese (abbonamento). Stesse funzioni del Pro con limite di 150 download al giorno.</li>
|
||||
<li><strong>Annual</strong> — 49,90 € una tantum. Tutte le funzioni sbloccate, nessun limite giornaliero, validita' 12 (dodici) mesi dalla data di acquisto. Non si rinnova automaticamente.</li>
|
||||
</ul>
|
||||
<p><strong>Limiti giornalieri:</strong> il contatore si azzera ogni giorno a mezzanotte ora di Roma (Europe/Rome). I download non utilizzati non si cumulano.</p>
|
||||
<p><strong>Abbonamenti mensili (Basic, Pro, Premium):</strong> si rinnovano automaticamente alla data di addebito mensile fino a disdetta dell'Utente, che puo' essere effettuata in qualsiasi momento dalla dashboard Lemon Squeezy o scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a>. La disdetta interrompe il rinnovo successivo; la Licenza resta attiva fino alla scadenza del periodo gia pagato.</p>
|
||||
<p><strong>Piano Annual (una tantum):</strong> il pagamento e' singolo e copre 12 mesi consecutivi. Allo scadere, la Licenza diventa inattiva e per continuare a utilizzare il Software l'Utente puo' acquistare nuovamente un piano a sua scelta.</p>
|
||||
<p>L'Utente puo' passare a un piano superiore (upgrade) o inferiore (downgrade) in qualsiasi momento. Il prorata viene gestito secondo le regole di Lemon Squeezy.</p>
|
||||
|
||||
<h2 id="s13">13. Diritto di recesso e rimborsi</h2>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dal primo acquisto, senza fornire motivazioni. Il diritto di recesso si applica al primo addebito; non si applica ai rinnovi successivi di un abbonamento mensile, per i quali resta possibile la disdetta come previsto al §12.</p>
|
||||
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
|
||||
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
|
||||
<p>L'Utente non puo' richiedere il rimborso dell'importo gia pagato per i mesi di abbonamento gia consumati. Il piano Annual, in caso di recesso entro 14 giorni, viene rimborsato interamente; oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</p>
|
||||
|
||||
<h2 id="s14">14. Aggiornamenti e supporto</h2>
|
||||
<p>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p>
|
||||
<p>Il supporto e' fornito via email a <a href="mailto:info@djluza.com">info@djluza.com</a>, con risposta entro tempi ragionevoli e in lingua italiana o inglese.</p>
|
||||
|
||||
<h2 id="s15">15. Risoluzione</h2>
|
||||
<p>L'Editore puo' risolvere immediatamente la Licenza, senza preavviso e senza obbligo di rimborso, qualora l'Utente:</p>
|
||||
<ul>
|
||||
<li>Violi una qualsiasi disposizione dei presenti Termini;</li>
|
||||
<li>Usi il Software per attivita' illecite o per violare diritti di terzi;</li>
|
||||
<li>Tenti di aggirare i meccanismi di attivazione o condivida la propria chiave;</li>
|
||||
<li>Inoltri un chargeback non giustificato successivamente al periodo di recesso.</li>
|
||||
</ul>
|
||||
<p>A seguito della risoluzione, l'Utente deve cessare l'uso del Software e disinstallarlo. Le disposizioni di cui ai §6, §9, §10, §11 sopravvivono alla risoluzione.</p>
|
||||
|
||||
<h2 id="s16">16. Modifiche ai termini</h2>
|
||||
<p>L'Editore puo' aggiornare i presenti Termini in qualsiasi momento per ragioni tecniche, normative o operative. La versione vigente e' sempre consultabile a questa URL. Per modifiche sostanziali sara' inviata notifica via email agli Utenti registrati con almeno 30 giorni di preavviso.</p>
|
||||
|
||||
<h2 id="s17">17. Legge applicabile e foro competente</h2>
|
||||
<p>I presenti Termini sono regolati dalla legge italiana. Per qualunque controversia il foro competente e' quello del luogo di residenza dell'Utente consumatore, ove inderogabile per legge; in caso contrario, foro esclusivo del Tribunale di Chieti, Italia.</p>
|
||||
<p>Resta salva la possibilita' per l'Utente consumatore residente nell'Unione Europea di rivolgersi alla piattaforma ODR della Commissione Europea: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">ec.europa.eu/consumers/odr</a>.</p>
|
||||
|
||||
<h2 id="s18">18. Contatti</h2>
|
||||
<p>Per qualsiasi richiesta relativa ai presenti Termini scrivere a <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||
|
||||
<p style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--border); color: var(--text-dim); font-size: 13px;">
|
||||
<strong>Disclaimer.</strong> Il presente documento e' un modello orientativo. L'Editore raccomanda all'Utente professionale di consultare un legale qualificato per verificare la conformita' a normative specifiche del proprio settore o paese.
|
||||
</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,49 @@
|
||||
"""MusicDownload — Entry point (pywebview + WebKit/EdgeChromium)."""
|
||||
"""MusicTools — Entry point (pywebview + WebKit/EdgeChromium)."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
|
||||
# webview. Con PyInstaller il lazy-loader fallisce con
|
||||
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
|
||||
# Python.Runtime.dll viene bundlata ma il loader non sa risolverla
|
||||
# senza l'inizializzazione esplicita. Fix conferma:
|
||||
# https://github.com/pythonnet/pythonnet/issues/2178
|
||||
if sys.platform == "win32":
|
||||
# ============================================================
|
||||
# Fix Windows: rimuovi Mark of the Web (MOTW) dalle DLL bundled.
|
||||
# Quando l'utente scarica il .zip dal browser, Windows aggiunge
|
||||
# uno stream NTFS Zone.Identifier a TUTTI i file estratti.
|
||||
# .NET Framework 4.x rifiuta silenziosamente di risolvere i
|
||||
# symbol export degli assembly managed con MOTW -> il loader
|
||||
# netfx di pythonnet fallisce con "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize".
|
||||
# Soluzione: cancelliamo lo stream :Zone.Identifier da ogni
|
||||
# .dll/.exe del bundle al primo avvio (operazione idempotente
|
||||
# e sicura: l'utente ha gia eseguito l'exe esplicitamente).
|
||||
# Vedi: https://github.com/r0x0r/pywebview/issues/1215
|
||||
# ============================================================
|
||||
if getattr(sys, "frozen", False):
|
||||
try:
|
||||
import ctypes
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
_bundle = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
for _f in _bundle.rglob("*"):
|
||||
if _f.suffix.lower() in (".dll", ".exe", ".pyd"):
|
||||
_kernel32.DeleteFileW(f"{_f}:Zone.Identifier")
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] MOTW cleanup failed: {_e}")
|
||||
|
||||
try:
|
||||
if getattr(sys, "frozen", False):
|
||||
os.environ["PYTHONNET_RUNTIME"] = "netfx"
|
||||
from pythonnet import load as _pythonnet_load
|
||||
_pythonnet_load("netfx")
|
||||
import clr # noqa: F401 - inizializza il loader
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] preload pythonnet failed: {_e}")
|
||||
|
||||
import webview
|
||||
|
||||
from api.bridge import Api
|
||||
@@ -23,7 +63,7 @@ def main():
|
||||
index_path = _resource_path("webui", "index.html")
|
||||
|
||||
window = webview.create_window(
|
||||
title="MusicDownload",
|
||||
title="MusicTools",
|
||||
url=index_path,
|
||||
js_api=api,
|
||||
width=1080,
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
pywebview>=5.0
|
||||
requests>=2.31.0
|
||||
yt-dlp>=2024.0.0
|
||||
mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet>=3.0 ; sys_platform == "win32"
|
||||
pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
curl_cffi>=0.9.0
|
||||
|
||||
# --- dev only ---
|
||||
pytest>=8.0.0
|
||||
responses>=0.25.0
|
||||
freezegun>=1.4.0
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Estrae lo slug + numeric id di tutti i generi Beatport.
|
||||
Uso: python3 scripts/refresh_beatport_genres.py > /tmp/genres.txt
|
||||
Poi copia manualmente in core/beatport.py::GENRES.
|
||||
|
||||
Nota: la pagina /genres (task originale) restituisce 404 sull'attuale sito
|
||||
Beatport. Aggreghiamo quindi la lista dei generi dalla pagina /charts, dove
|
||||
ogni featured chart include un array `genres` completo di slug + id + name.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import sys
|
||||
from curl_cffi import requests
|
||||
|
||||
|
||||
GENRES_URL = "https://www.beatport.com/genres"
|
||||
CHARTS_URL = "https://www.beatport.com/charts"
|
||||
|
||||
|
||||
def _fetch_next_data(url: str) -> dict:
|
||||
resp = requests.get(url, impersonate="chrome131", timeout=15)
|
||||
resp.raise_for_status()
|
||||
m = re.search(
|
||||
r'<script id="__NEXT_DATA__"[^>]*>(.+?)</script>', resp.text, re.DOTALL
|
||||
)
|
||||
if not m:
|
||||
raise RuntimeError(f"__NEXT_DATA__ non trovato su {url}")
|
||||
return json.loads(m.group(1))
|
||||
|
||||
|
||||
def _collect_genres(node, out: dict) -> None:
|
||||
"""Raccoglie ricorsivamente ogni dict con chiavi slug/id/name che appare
|
||||
dentro un array chiamato "genres"."""
|
||||
if isinstance(node, dict):
|
||||
for k, v in node.items():
|
||||
if (
|
||||
k == "genres"
|
||||
and isinstance(v, list)
|
||||
and v
|
||||
and all(
|
||||
isinstance(x, dict) and "slug" in x and "id" in x and "name" in x
|
||||
for x in v
|
||||
)
|
||||
):
|
||||
for g in v:
|
||||
out[g["id"]] = (g["slug"], g["name"])
|
||||
else:
|
||||
_collect_genres(v, out)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
_collect_genres(item, out)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Prova /genres (URL originale). Se 404 o schema inatteso, cade su /charts.
|
||||
try:
|
||||
data = _fetch_next_data(GENRES_URL)
|
||||
queries = data["props"]["pageProps"]["dehydratedState"]["queries"]
|
||||
for q in queries:
|
||||
d = q.get("state", {}).get("data", {})
|
||||
if (
|
||||
isinstance(d, dict)
|
||||
and "results" in d
|
||||
and isinstance(d["results"], list)
|
||||
):
|
||||
for g in d["results"]:
|
||||
if "slug" in g and "id" in g and "name" in g:
|
||||
print(f' "{g["slug"]}": ({g["id"]}, "{g["name"]}"),')
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"# /genres non disponibile ({e}), fallback su /charts", file=sys.stderr)
|
||||
|
||||
data = _fetch_next_data(CHARTS_URL)
|
||||
genres: dict = {}
|
||||
_collect_genres(data, genres)
|
||||
if not genres:
|
||||
print("nessun genere trovato su /charts", file=sys.stderr)
|
||||
return 1
|
||||
for gid in sorted(genres):
|
||||
slug, name = genres[gid]
|
||||
print(f' "{slug}": ({gid}, "{name}"),')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,35 @@
|
||||
# Copia questo file in `.env` sul server (non committarlo!) e compila.
|
||||
|
||||
# ---- Server ----
|
||||
PORT=4002
|
||||
HOST=127.0.0.1
|
||||
LATEST_VERSION=v1.5.2
|
||||
PUBLIC_BASE_URL=https://musictools.djluza.com
|
||||
|
||||
# ---- DB (MariaDB locale) ----
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=musictools_licenses
|
||||
DB_USER=musictools
|
||||
DB_PASS=cambiami
|
||||
|
||||
# ---- Licenze ----
|
||||
JWT_SECRET=cambiami_con_openssl_rand_base64_32
|
||||
MAX_ACTIVATIONS=3
|
||||
TOKEN_TTL_DAYS=30
|
||||
DOWNLOAD_URL_TTL_SECONDS=300
|
||||
|
||||
# ---- Lemon Squeezy ----
|
||||
# Dal dashboard LS: Settings > Webhooks > Signing Secret
|
||||
LEMONSQUEEZY_SIGNING_SECRET=cambiami
|
||||
|
||||
# ---- Resend ----
|
||||
# Dal dashboard Resend: API Keys
|
||||
RESEND_API_KEY=cambiami
|
||||
EMAIL_FROM=MusicTools <noreply@djluza.com>
|
||||
|
||||
# ---- Storage builds ----
|
||||
# Dove conservi i binari macOS/Windows da servire agli utenti.
|
||||
# Default: ../builds rispetto a server/. Puoi metterlo dove vuoi
|
||||
# purche' il processo Node abbia accesso lettura.
|
||||
BUILDS_DIR=/home/musictools/builds
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
logs/
|
||||
dist/
|
||||
@@ -0,0 +1,215 @@
|
||||
# MusicTools — License & Update API
|
||||
|
||||
Backend Node.js per gestire attivazione licenze, validazione e distribuzione binari MusicTools.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Node.js 20 + Express 4**
|
||||
- **MariaDB 10.11** (locale, localhost:3306)
|
||||
- **PM2** per process management
|
||||
- **Apache 2.4** come reverse proxy verso `127.0.0.1:4002`
|
||||
- **Resend** per email transazionali
|
||||
- **Lemon Squeezy** per i pagamenti (webhook -> `/api/webhook/lemonsqueezy`)
|
||||
|
||||
Tutto gira sull'hosting esistente (`musictools@musictools.djluza.com`), zero costi aggiuntivi.
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
server/
|
||||
src/
|
||||
server.js # Express app
|
||||
db.js # pool mysql2
|
||||
license.js # activate / validate / deactivate
|
||||
updates.js # /api/latest + /api/download
|
||||
lemonsqueezy.js # webhook ordini
|
||||
email.js # Resend client
|
||||
jwt.js # JWT HS256 senza dipendenze
|
||||
migrate.js # runner SQL idempotente
|
||||
migrations/
|
||||
0001_init.sql # schema licenses / activations / releases
|
||||
ecosystem.config.cjs # PM2
|
||||
.env.example
|
||||
package.json
|
||||
```
|
||||
|
||||
## Setup iniziale sul server
|
||||
|
||||
Una volta sola, da SSH `musictools@musictools.djluza.com`:
|
||||
|
||||
### 1) Crea database e utente MariaDB
|
||||
|
||||
Sul server (richiede root o l'utente master DB del tuo hosting — di solito Virtualmin lo crea per te dal pannello "Edit Databases"):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE musictools_licenses CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'musictools'@'localhost' IDENTIFIED BY 'PASSWORD_FORTE';
|
||||
GRANT ALL PRIVILEGES ON musictools_licenses.* TO 'musictools'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
In alternativa via Virtualmin: **Edit Databases → Create a new database**, poi tab **Manage** → crea utente con password.
|
||||
|
||||
### 2) Clona o sincronizza il codice
|
||||
|
||||
Due opzioni:
|
||||
|
||||
**A. Via git** (consigliato):
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/luzadev/musicdownload.git app-src
|
||||
ln -s app-src/server api
|
||||
cd api
|
||||
npm install --production
|
||||
```
|
||||
|
||||
**B. Via rsync da locale** (più semplice se preferisci non lasciare codice client sul server):
|
||||
```bash
|
||||
# Da Mac:
|
||||
rsync -avz --exclude node_modules /Users/luciano/Downloads/Progetti2026/MusicDownload/server/ musictools@musictools.djluza.com:~/api/
|
||||
# Poi SSH e:
|
||||
cd ~/api && npm install --production
|
||||
```
|
||||
|
||||
### 3) Configura .env
|
||||
|
||||
```bash
|
||||
cd ~/api
|
||||
cp .env.example .env
|
||||
nano .env # compila tutti i valori — vedi sotto
|
||||
```
|
||||
|
||||
Per generare `JWT_SECRET`:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
### 4) Migrate
|
||||
|
||||
```bash
|
||||
cd ~/api
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
Output atteso:
|
||||
```
|
||||
[migrate] applico 0001_init.sql
|
||||
[migrate] ok 0001_init.sql
|
||||
[migrate] done
|
||||
```
|
||||
|
||||
### 5) Avvia con PM2
|
||||
|
||||
```bash
|
||||
cd ~/api
|
||||
mkdir -p logs
|
||||
pm2 start ecosystem.config.cjs
|
||||
pm2 save
|
||||
# Test:
|
||||
curl -s http://127.0.0.1:4002/api/health
|
||||
# -> {"ok":true,"version":"v1.5.2"}
|
||||
```
|
||||
|
||||
### 6) Apache reverse proxy
|
||||
|
||||
Devi dire ad Apache che le richieste a `musictools.djluza.com/api/*` vanno a `127.0.0.1:4002`.
|
||||
|
||||
**Via Virtualmin** (consigliato):
|
||||
|
||||
1. Vai su **Webmin → Servers → Apache Webserver**
|
||||
2. Clicca sul VirtualHost di `musictools.djluza.com` (porta 443)
|
||||
3. Sezione **Aliases and redirects** o **Edit Directives**, aggiungi prima di `</VirtualHost>`:
|
||||
|
||||
```apache
|
||||
# MusicTools API
|
||||
ProxyPreserveHost On
|
||||
ProxyRequests Off
|
||||
|
||||
# Webhook: passa raw body senza alterazioni
|
||||
<Location /api/>
|
||||
ProxyPass http://127.0.0.1:4002/api/
|
||||
ProxyPassReverse http://127.0.0.1:4002/api/
|
||||
</Location>
|
||||
```
|
||||
|
||||
4. Apply changes.
|
||||
|
||||
**Verifica moduli Apache attivi** (da SSH se hai sudo):
|
||||
```bash
|
||||
apache2ctl -M | grep -E 'proxy|proxy_http'
|
||||
# Devono comparire proxy_module e proxy_http_module
|
||||
```
|
||||
Se mancano, abilitali (sudo richiesto):
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http
|
||||
sudo systemctl reload apache2
|
||||
```
|
||||
|
||||
### 7) Verifica end-to-end
|
||||
|
||||
Da qualsiasi posto:
|
||||
```bash
|
||||
curl -s https://musictools.djluza.com/api/health
|
||||
# -> {"ok":true,"version":"v1.5.2"}
|
||||
```
|
||||
|
||||
## Workflow pubblicazione release
|
||||
|
||||
1. GitHub Actions builda i due zip (gia in place).
|
||||
2. Carica gli zip sul server in `~/builds/<version>/`:
|
||||
```bash
|
||||
ssh musictools@musictools.djluza.com 'mkdir -p ~/builds/v1.5.3'
|
||||
scp MusicTools-macOS.zip musictools@musictools.djluza.com:~/builds/v1.5.3/
|
||||
scp MusicTools-Windows.zip musictools@musictools.djluza.com:~/builds/v1.5.3/
|
||||
```
|
||||
3. Inserisci il record in MariaDB:
|
||||
```bash
|
||||
ssh musictools@musictools.djluza.com 'mariadb musictools_licenses' <<SQL
|
||||
INSERT INTO releases (version, platform, file_path, size_bytes, sha256, notes, published_at)
|
||||
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', UNIX_TIMESTAMP()),
|
||||
('v1.5.3', 'windows', 'v1.5.3/MusicTools-Windows.zip', 67890, '<sha256>', 'Note...', UNIX_TIMESTAMP());
|
||||
SQL
|
||||
```
|
||||
4. Aggiorna `LATEST_VERSION` nel `.env` e `pm2 reload musictools-api`.
|
||||
|
||||
In futuro: script o workflow GitHub Actions che fa tutto in automatico.
|
||||
|
||||
## Operazioni quotidiane
|
||||
|
||||
| Cosa | Comando |
|
||||
|---|---|
|
||||
| Reload codice dopo deploy | `pm2 reload musictools-api` |
|
||||
| Vedere log live | `pm2 logs musictools-api` |
|
||||
| Stato | `pm2 status` |
|
||||
| Errori recenti | `pm2 logs musictools-api --err --lines 100` |
|
||||
| Restart hard | `pm2 restart musictools-api` |
|
||||
| Stop | `pm2 stop musictools-api` |
|
||||
| Console DB | `mariadb -u musictools -p musictools_licenses` |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Metodo | Path | Auth | Descrizione |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/license/activate` | — | Attiva licenza, ritorna JWT |
|
||||
| POST | `/api/license/validate` | — (token nel body) | Rivalida + ruota token |
|
||||
| POST | `/api/license/deactivate` | — (token nel body) | Libera uno slot |
|
||||
| GET | `/api/latest?platform=…` | Bearer token (opzionale) | Versione + URL download firmato |
|
||||
| GET | `/api/download?file=…&exp=…&sig=…` | Firma HMAC | Stream del binario |
|
||||
| POST | `/api/webhook/lemonsqueezy` | X-Signature HMAC | Crea licenza dopo ordine |
|
||||
| GET | `/api/health` | — | Healthcheck |
|
||||
|
||||
## Backup
|
||||
|
||||
Aggiungi un cronjob daily:
|
||||
|
||||
```bash
|
||||
# crontab -e
|
||||
0 4 * * * mariadb-dump musictools_licenses | gzip > ~/backups/musictools-$(date +\%F).sql.gz && find ~/backups -name 'musictools-*.sql.gz' -mtime +30 -delete
|
||||
```
|
||||
|
||||
## Costi
|
||||
|
||||
- **Hosting**: gia pagato (server condiviso esistente)
|
||||
- **Lemon Squeezy**: 5% + $0.50 per transazione = ~5 EUR su un acquisto da 39,90 EUR
|
||||
- **Resend**: free fino a 3k email/mese (= ~3k licenze/mese, oltre serve piano $20)
|
||||
- **Totale fisso**: 0 EUR/mese
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* PM2 ecosystem per il backend MusicTools.
|
||||
*
|
||||
* Avvio: pm2 start ecosystem.config.cjs --env production
|
||||
* Reload: pm2 reload musictools-api
|
||||
* Logs: pm2 logs musictools-api
|
||||
* Save: pm2 save && pm2 startup (al primo deploy, una sola volta)
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "musictools-api",
|
||||
script: "src/server.js",
|
||||
cwd: __dirname,
|
||||
exec_mode: "fork", // 1 processo, basta per i nostri volumi
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
max_memory_restart: "256M",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
// Le env sensibili stanno in .env: dotenv le carica al boot
|
||||
out_file: "logs/out.log",
|
||||
error_file: "logs/err.log",
|
||||
merge_logs: true,
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Schema MariaDB iniziale per MusicTools licenze.
|
||||
-- Tutto utf8mb4 + InnoDB con FK abilitate.
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
license_key VARCHAR(64) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
status ENUM('active','revoked','refunded') NOT NULL DEFAULT 'active',
|
||||
source VARCHAR(32) NULL,
|
||||
order_id VARCHAR(64) NULL,
|
||||
created_at BIGINT UNSIGNED NOT NULL,
|
||||
updated_at BIGINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_licenses_key (license_key),
|
||||
KEY idx_licenses_email (email),
|
||||
KEY idx_licenses_order_id (order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
license_id INT UNSIGNED NOT NULL,
|
||||
device_id VARCHAR(64) NOT NULL,
|
||||
device_name VARCHAR(255) NULL,
|
||||
app_version VARCHAR(32) NULL,
|
||||
activated_at BIGINT UNSIGNED NOT NULL,
|
||||
last_seen_at BIGINT UNSIGNED NOT NULL,
|
||||
revoked_at BIGINT UNSIGNED NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_activations_lic_dev (license_id, device_id),
|
||||
KEY idx_activations_license (license_id),
|
||||
CONSTRAINT fk_activations_license
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
version VARCHAR(32) NOT NULL,
|
||||
platform ENUM('macos','windows') NOT NULL,
|
||||
file_path VARCHAR(512) NOT NULL,
|
||||
size_bytes BIGINT UNSIGNED NULL,
|
||||
sha256 CHAR(64) NULL,
|
||||
notes TEXT NULL,
|
||||
published_at BIGINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_releases_ver_plat (version, platform),
|
||||
KEY idx_releases_platform_pub (platform, published_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,28 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN plan VARCHAR(16) NULL AFTER status,
|
||||
ADD COLUMN daily_limit INT UNSIGNED NULL AFTER plan,
|
||||
ADD COLUMN subscription_id VARCHAR(64) NULL AFTER daily_limit,
|
||||
ADD COLUMN current_period_end BIGINT UNSIGNED NULL AFTER subscription_id,
|
||||
ADD COLUMN expires_at BIGINT UNSIGNED NULL AFTER current_period_end;
|
||||
|
||||
UPDATE licenses
|
||||
SET plan = 'annual',
|
||||
daily_limit = NULL,
|
||||
expires_at = NULL
|
||||
WHERE plan IS NULL;
|
||||
|
||||
ALTER TABLE licenses
|
||||
ADD KEY idx_licenses_plan (plan),
|
||||
ADD KEY idx_licenses_sub (subscription_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||
license_id INT UNSIGNED NOT NULL,
|
||||
day CHAR(10) NOT NULL,
|
||||
count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (license_id, day),
|
||||
CONSTRAINT fk_daily_usage_license
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "musictools-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "License + update API per MusicTools (Node.js + Express + MariaDB)",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"migrate": "node src/migrate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.0",
|
||||
"mysql2": "^3.11.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env node
|
||||
// Genera una licenza nel DB delle licenze MusicTools.
|
||||
//
|
||||
// Da eseguire SUL SERVER (musictools@musictools.djluza.com) dentro ~/api/:
|
||||
// node scripts/gen-license.mjs <email> [plan]
|
||||
//
|
||||
// plan: annual (default, "full" — no limite, no scadenza)
|
||||
// basic | pro | premium (con daily_limit e period_end mensile)
|
||||
//
|
||||
// Da locale, usare lo script wrapper: server/scripts/gen-license.sh
|
||||
|
||||
import { generateLicenseKey } from "../src/license.js";
|
||||
import { getPlan } from "../src/plans.js";
|
||||
import mysql from "mysql2/promise";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const envPath = path.resolve(__dirname, "..", ".env");
|
||||
fs.readFileSync(envPath, "utf-8").split("\n").forEach((l) => {
|
||||
const m = l.match(/^([A-Z_]+)=(.*)$/);
|
||||
if (m) process.env[m[1]] = m[2];
|
||||
});
|
||||
|
||||
const [, , emailArg, planArg] = process.argv;
|
||||
if (!emailArg) {
|
||||
console.error("Uso: node scripts/gen-license.mjs <email> [plan]");
|
||||
console.error("plan: annual (default) | basic | pro | premium");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const email = emailArg.trim().toLowerCase();
|
||||
const planCode = (planArg || "annual").trim().toLowerCase();
|
||||
const plan = getPlan(planCode);
|
||||
if (!plan) {
|
||||
console.error(`Piano sconosciuto: ${planCode}`);
|
||||
console.error("Validi: annual, basic, pro, premium");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: "127.0.0.1",
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
});
|
||||
|
||||
const key = generateLicenseKey();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const isAnnual = planCode === "annual";
|
||||
const dailyLimit = plan.dailyLimit ?? null;
|
||||
const expiresAt = isAnnual ? null : null;
|
||||
const periodEnd = isAnnual ? null : now + 30 * 24 * 3600;
|
||||
|
||||
await pool.execute(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit, source,
|
||||
subscription_id, current_period_end,
|
||||
expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'manual', NULL, ?, ?, ?, ?)`,
|
||||
[key, email, planCode, dailyLimit, periodEnd, expiresAt, now, now],
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
key,
|
||||
email,
|
||||
plan: planCode,
|
||||
daily_limit: dailyLimit,
|
||||
expires_at: expiresAt,
|
||||
current_period_end: periodEnd,
|
||||
}, null, 2));
|
||||
|
||||
await pool.end();
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# Genera una licenza MusicTools da locale, eseguendo lo script sul server.
|
||||
#
|
||||
# Uso:
|
||||
# ./server/scripts/gen-license.sh <email> [plan]
|
||||
#
|
||||
# plan: annual (default, "full" — no limite, no scadenza)
|
||||
# basic | pro | premium (con daily_limit e period_end mensile)
|
||||
#
|
||||
# Richiede:
|
||||
# - ssh con chiave configurata verso musictools@musictools.djluza.com
|
||||
# - lo script server-side viene sincronizzato in automatico prima dell'esecuzione
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EMAIL="${1:-}"
|
||||
PLAN="${2:-annual}"
|
||||
|
||||
if [ -z "$EMAIL" ]; then
|
||||
echo "Uso: $0 <email> [plan]" >&2
|
||||
echo "plan: annual (default) | basic | pro | premium" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SSH_TARGET="musictools@musictools.djluza.com"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
ssh "$SSH_TARGET" 'mkdir -p ~/api/scripts'
|
||||
rsync -a --quiet \
|
||||
"$SCRIPT_DIR/gen-license.mjs" \
|
||||
"${SSH_TARGET}:api/scripts/gen-license.mjs"
|
||||
|
||||
ssh "$SSH_TARGET" "cd ~/api && node scripts/gen-license.mjs '$EMAIL' '$PLAN'"
|
||||
@@ -0,0 +1,30 @@
|
||||
import mysql from "mysql2/promise";
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || "127.0.0.1",
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
charset: "utf8mb4",
|
||||
});
|
||||
|
||||
export async function query(sql, params = []) {
|
||||
const [rows] = await pool.execute(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function one(sql, params = []) {
|
||||
const rows = await query(sql, params);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
export async function exec(sql, params = []) {
|
||||
const [result] = await pool.execute(sql, params);
|
||||
return result; // { insertId, affectedRows, ... }
|
||||
}
|
||||
|
||||
export default pool;
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Endpoint "1-click download" per i clienti.
|
||||
*
|
||||
* URL: GET /api/download/:platform?key=XXXX&email=YYY
|
||||
*
|
||||
* Verifica la coppia (key, email) contro la tabella licenses, recupera
|
||||
* l'ultima release attiva per la piattaforma, genera un signed URL e
|
||||
* fa redirect 302. Il link nell'email viene cliccato dall'utente: con
|
||||
* un click parte il download.
|
||||
*
|
||||
* Sicurezza:
|
||||
* - Niente token sulla URL: la chiave e' gia "il segreto"
|
||||
* - URL signed con scadenza breve (default 5 min)
|
||||
* - Nessuna info di licenza leakata in risposta (ritorna solo redirect o 401/404)
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { one } from "./db.js";
|
||||
|
||||
const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300);
|
||||
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||
|
||||
function signPayload(payload, secret) {
|
||||
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
||||
const normKey = (s) => String(s || "").trim().toUpperCase();
|
||||
|
||||
export async function downloadByKey(req, res) {
|
||||
const platform = String(req.params.platform || "").toLowerCase();
|
||||
if (platform !== "macos" && platform !== "windows") {
|
||||
return res.status(400).type("text/plain")
|
||||
.send("Piattaforma non valida. Usa /api/download/macos o /api/download/windows.");
|
||||
}
|
||||
|
||||
const key = normKey(req.query.key);
|
||||
const email = normEmail(req.query.email);
|
||||
if (!key || !email) {
|
||||
return res.status(400).type("text/plain")
|
||||
.send("Mancano email o chiave. Controlla il link nella tua email di acquisto.");
|
||||
}
|
||||
|
||||
const license = await one(
|
||||
"SELECT id, status FROM licenses WHERE license_key=? AND email=? LIMIT 1",
|
||||
[key, email],
|
||||
);
|
||||
if (!license) {
|
||||
return res.status(401).type("text/plain")
|
||||
.send("Email o chiave non valide. Verifica il link nell'email o scrivici a info@djluza.com.");
|
||||
}
|
||||
if (license.status !== "active") {
|
||||
return res.status(403).type("text/plain")
|
||||
.send("Licenza non piu' attiva (rimborsata o revocata).");
|
||||
}
|
||||
|
||||
const release = await one(
|
||||
`SELECT file_path FROM releases
|
||||
WHERE platform=? ORDER BY published_at DESC LIMIT 1`,
|
||||
[platform],
|
||||
);
|
||||
if (!release) {
|
||||
return res.status(503).type("text/plain")
|
||||
.send(`Build ${platform} non ancora disponibile. Riprova fra qualche ora o scrivici a info@djluza.com.`);
|
||||
}
|
||||
|
||||
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
|
||||
const sig = signPayload(`${release.file_path}.${exp}`, process.env.JWT_SECRET);
|
||||
const url = `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(release.file_path)}&exp=${exp}&sig=${sig}`;
|
||||
res.redirect(302, url);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Invio email transazionali via Resend.
|
||||
*
|
||||
* sendLicenseEmail(to, key, { plan, expiresAt, periodEnd })
|
||||
* - plan: codice piano ("basic" | "pro" | "premium" | "annual")
|
||||
* - expiresAt: epoch seconds (annual one-time) o null
|
||||
* - periodEnd: epoch seconds (subscription mensile) o null
|
||||
*
|
||||
* Variabili env richieste:
|
||||
* RESEND_API_KEY dal dashboard Resend
|
||||
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
|
||||
*/
|
||||
|
||||
import { getPlan } from "./plans.js";
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
||||
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||
|
||||
function fmtItalianDate(epochSeconds) {
|
||||
if (!epochSeconds) return "";
|
||||
const d = new Date(Number(epochSeconds) * 1000);
|
||||
return new Intl.DateTimeFormat("it-IT", {
|
||||
timeZone: "Europe/Rome",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function describePlan(planCode, expiresAt, periodEnd) {
|
||||
const p = getPlan(planCode);
|
||||
if (!p) {
|
||||
return {
|
||||
name: "MusicTools",
|
||||
summary: "La tua licenza e' attiva.",
|
||||
};
|
||||
}
|
||||
const features = {
|
||||
audio: "download brani audio",
|
||||
video: "download video",
|
||||
record: "registrazione audio",
|
||||
metadata: "editor metadati",
|
||||
upgrade: "upgrade qualita' libreria",
|
||||
};
|
||||
const featList = p.features.map((f) => features[f] || f).join(", ");
|
||||
const limit = p.daily_limit === null
|
||||
? "senza limiti giornalieri"
|
||||
: `fino a ${p.daily_limit} download al giorno`;
|
||||
let validity = "";
|
||||
if (planCode === "annual" && expiresAt) {
|
||||
validity = `Validita': 1 anno, scade il <strong>${fmtItalianDate(expiresAt)}</strong>.`;
|
||||
} else if (p.is_subscription && periodEnd) {
|
||||
validity = `Prossimo rinnovo: <strong>${fmtItalianDate(periodEnd)}</strong>.`;
|
||||
}
|
||||
return {
|
||||
name: p.name,
|
||||
summary: `Piano <strong>${p.name}</strong> — ${limit}. Funzioni incluse: ${featList}. ${validity}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendLicenseEmail(to, licenseKey, opts = {}) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
||||
return;
|
||||
}
|
||||
|
||||
const { plan: planCode, expiresAt, periodEnd } = opts;
|
||||
const planInfo = describePlan(planCode, expiresAt, periodEnd);
|
||||
|
||||
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
|
||||
const macUrl = `${BASE}/api/download/macos?${q}`;
|
||||
const winUrl = `${BASE}/api/download/windows?${q}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
||||
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 14px">
|
||||
${planInfo.summary}
|
||||
</p>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
|
||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||
</p>
|
||||
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
|
||||
${licenseKey}
|
||||
</p>
|
||||
|
||||
<p style="font-size:15px;margin:0 0 12px"><strong>Scarica l'app:</strong></p>
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px">
|
||||
<tr>
|
||||
<td style="padding-right:10px">
|
||||
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||
Scarica per Mac
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||
Scarica per Windows
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
|
||||
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
|
||||
<li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
|
||||
<li>Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)</li>
|
||||
<li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
|
||||
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
|
||||
</ol>
|
||||
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
|
||||
<p style="font-size:13px;color:#888;margin:8px 0 0">
|
||||
Hai bisogno di riscaricare in futuro? Vai su <a href="${BASE}/download" style="color:#1db954">${BASE.replace(/^https?:\/\//, "")}/download</a> e inserisci email + chiave.
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
|
||||
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const subject = planInfo.name === "MusicTools"
|
||||
? "La tua licenza MusicTools"
|
||||
: `La tua licenza MusicTools ${planInfo.name}`;
|
||||
|
||||
const resp = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from: FROM,
|
||||
to: [to],
|
||||
subject,
|
||||
html,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const txt = await resp.text();
|
||||
throw new Error(`Resend ${resp.status}: ${txt}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* JWT HS256 minimale (niente dipendenze). Stessa logica del worker
|
||||
* Cloudflare precedente: stesso token formato, stessa firma, stessa
|
||||
* verifica. L'app desktop non distingue.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString("base64")
|
||||
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function b64urlDecode(s) {
|
||||
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||
s += "=".repeat((4 - (s.length % 4)) % 4);
|
||||
return Buffer.from(s, "base64");
|
||||
}
|
||||
|
||||
function hmac(secret, data) {
|
||||
return crypto.createHmac("sha256", secret).update(data).digest();
|
||||
}
|
||||
|
||||
export function signJwt(claims, secret) {
|
||||
const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const body = b64url(JSON.stringify(claims));
|
||||
const sig = b64url(hmac(secret, `${head}.${body}`));
|
||||
return `${head}.${body}.${sig}`;
|
||||
}
|
||||
|
||||
export function verifyJwt(token, secret) {
|
||||
if (typeof token !== "string") return null;
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const [head, body, sig] = parts;
|
||||
const expected = b64url(hmac(secret, `${head}.${body}`));
|
||||
// confronto a tempo costante
|
||||
if (sig.length !== expected.length) return null;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||
if (diff !== 0) return null;
|
||||
try {
|
||||
const claims = JSON.parse(b64urlDecode(body).toString("utf-8"));
|
||||
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
|
||||
return null;
|
||||
}
|
||||
return claims;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* Webhook Lemon Squeezy.
|
||||
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||
*
|
||||
* Eventi gestiti:
|
||||
* - order_created : crea licenza ANNUAL (one-time) con expires_at = now+365d.
|
||||
* Per le subscription ignora: il record nasce da subscription_created.
|
||||
* - order_refunded : status -> refunded.
|
||||
* - subscription_created : crea licenza con plan, subscription_id, current_period_end.
|
||||
* - subscription_updated : aggiorna current_period_end e plan se cambiato.
|
||||
* - subscription_payment_success : estende current_period_end al nuovo renews_at.
|
||||
* - subscription_payment_failed : (no-op qui) lasciamo scadere current_period_end.
|
||||
* - subscription_cancelled : niente, il periodo corrente resta valido fino a current_period_end.
|
||||
* - subscription_expired : status -> revoked, l'utente perde l'accesso.
|
||||
*
|
||||
* Tutto idempotente: ogni evento si puo' rispedire senza spaccare nulla.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { generateLicenseKey } from "./license.js";
|
||||
import { sendLicenseEmail } from "./email.js";
|
||||
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
function hmacHex(secret, data) {
|
||||
return crypto.createHmac("sha256", secret).update(data).digest("hex");
|
||||
}
|
||||
|
||||
function timingSafe(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
const A = Buffer.from(a), B = Buffer.from(b);
|
||||
return crypto.timingSafeEqual(A, B);
|
||||
}
|
||||
|
||||
/** Converte una data ISO (es. "2026-07-09T13:24:00Z") in epoch seconds, o null. */
|
||||
function isoToEpoch(iso) {
|
||||
if (!iso) return null;
|
||||
const t = Date.parse(String(iso));
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.floor(t / 1000);
|
||||
}
|
||||
|
||||
/** Estrae variant_id dal payload, gestendo le diverse forme degli eventi. */
|
||||
function extractVariantId(payload, attrs) {
|
||||
// subscription_*: attrs.variant_id (diretto)
|
||||
if (attrs.variant_id != null) return String(attrs.variant_id);
|
||||
// order_created: attrs.first_order_item.variant_id
|
||||
const item = attrs.first_order_item;
|
||||
if (item && item.variant_id != null) return String(item.variant_id);
|
||||
// alcune forme passano da relationships
|
||||
const rel = payload?.data?.relationships?.variant?.data?.id;
|
||||
if (rel) return String(rel);
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Email cliente: nei subscription_* puo' essere customer_email. */
|
||||
function extractEmail(attrs) {
|
||||
const e = attrs.user_email || attrs.customer_email || attrs.email || "";
|
||||
return String(e).trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ---- Handlers per famiglia evento -----------------------------------------
|
||||
|
||||
async function handleOrderCreated(payload, attrs, t) {
|
||||
const email = extractEmail(attrs);
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!email || !orderId) {
|
||||
return { status: 400, body: { error: "Missing email or order_id" } };
|
||||
}
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
const plan = planCode ? getPlan(planCode) : null;
|
||||
|
||||
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
|
||||
// non duplichiamo licenza ne' email — ack 200 e basta.
|
||||
const existing = await one(
|
||||
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
|
||||
[orderId],
|
||||
);
|
||||
if (existing) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
|
||||
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
|
||||
// i limiti esatti del piano comprato).
|
||||
const planForRecord = plan?.code || "annual";
|
||||
const planMeta = plan || getPlan("annual");
|
||||
const limit = planMeta?.daily_limit ?? null;
|
||||
|
||||
// Annual one-time: settiamo expires_at = now + 365d.
|
||||
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
|
||||
// subscription_created/subscription_payment_success quando arriva.
|
||||
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
|
||||
// dashboard), la licenza resta utilizzabile (current_period_end NULL
|
||||
// = no scadenza imposta lato server: la sottoscrizione vivra' o
|
||||
// morira' su LS, e i webhook successivi aggiorneranno lo status).
|
||||
const expiresAt = planMeta?.is_subscription ? null : t + ANNUAL_TTL_SECONDS;
|
||||
const key = generateLicenseKey();
|
||||
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit,
|
||||
source, order_id, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?)`,
|
||||
[key, email, planForRecord, limit, orderId, expiresAt, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, {
|
||||
plan: planForRecord,
|
||||
expiresAt: expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
|
||||
return { status: 200, body: { ok: true, plan: planForRecord } };
|
||||
}
|
||||
|
||||
async function handleOrderRefunded(payload, attrs, t) {
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!orderId) return { status: 400, body: { error: "Missing order_id" } };
|
||||
await exec(
|
||||
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
|
||||
[t, orderId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionCreated(payload, attrs, t) {
|
||||
const email = extractEmail(attrs);
|
||||
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
if (!email || !subId) {
|
||||
return { status: 400, body: { error: "Missing email or subscription_id" } };
|
||||
}
|
||||
if (!planCode) {
|
||||
// Niente mapping -> non sappiamo che limiti applicare. Logghiamo e
|
||||
// ritorniamo 200 cosi' LS non rispedisce all'infinito.
|
||||
console.warn("[ls] subscription_created variant non mappato:", variantId, "email=", email);
|
||||
return { status: 200, body: { ok: true, ignored: "unknown_variant" } };
|
||||
}
|
||||
const plan = getPlan(planCode);
|
||||
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||
const orderId = String(attrs.order_id || "");
|
||||
const key = generateLicenseKey();
|
||||
|
||||
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
||||
// riuso la licenza esistente.
|
||||
const existing = await one(
|
||||
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
||||
[subId],
|
||||
);
|
||||
if (existing) {
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET status='active', plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
[planCode, plan.daily_limit, periodEnd, t, existing.id],
|
||||
);
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Caso normale ora: la licenza e' gia stata creata da order_created
|
||||
// (con subscription_id NULL). La aggiorno con subscription_id e
|
||||
// current_period_end senza generare una nuova chiave ne' una nuova email.
|
||||
const byOrder = orderId
|
||||
? await one(
|
||||
`SELECT id FROM licenses
|
||||
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
|
||||
[orderId],
|
||||
)
|
||||
: null;
|
||||
if (byOrder) {
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
|
||||
);
|
||||
return { status: 200, body: { ok: true, attached_subscription: subId } };
|
||||
}
|
||||
|
||||
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
|
||||
// (puo' succedere se order_created non e' arrivato per qualche motivo).
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit,
|
||||
source, order_id, subscription_id, current_period_end,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?, ?)`,
|
||||
[key, email, planCode, plan.daily_limit, orderId || null, subId, periodEnd, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, { plan: planCode, periodEnd });
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
return { status: 200, body: { ok: true, plan: planCode } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(payload, attrs, t) {
|
||||
const subId = String(payload?.data?.id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||
|
||||
if (planCode) {
|
||||
const plan = getPlan(planCode);
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||
WHERE subscription_id=?`,
|
||||
[planCode, plan.daily_limit, periodEnd, t, subId],
|
||||
);
|
||||
} else if (periodEnd) {
|
||||
await exec(
|
||||
`UPDATE licenses SET current_period_end=?, updated_at=? WHERE subscription_id=?`,
|
||||
[periodEnd, t, subId],
|
||||
);
|
||||
}
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionPaymentSuccess(payload, attrs, t) {
|
||||
// L'evento di pagamento riferisce alla subscription via attrs.subscription_id
|
||||
const subId = String(attrs.subscription_id || payload?.data?.id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
|
||||
// Quando un rinnovo va a buon fine LS sposta avanti renews_at.
|
||||
// Per essere sicuri leggiamo l'evento PIU' recente (subscription_updated
|
||||
// viene inviato a stretto giro), oppure deduciamo: +30 giorni.
|
||||
const periodEnd = isoToEpoch(attrs.renews_at)
|
||||
|| isoToEpoch(attrs.created_at)
|
||||
|| (t + 30 * 24 * 3600);
|
||||
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET status='active', current_period_end=?, updated_at=?
|
||||
WHERE subscription_id=?`,
|
||||
[periodEnd, t, subId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionExpired(payload, attrs, t) {
|
||||
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
await exec(
|
||||
`UPDATE licenses SET status='revoked', updated_at=? WHERE subscription_id=?`,
|
||||
[t, subId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
// ---- Entry point ----------------------------------------------------------
|
||||
|
||||
export async function webhook(req, res) {
|
||||
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
|
||||
const sig = req.get("X-Signature") || "";
|
||||
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
|
||||
if (!sig || !secret) return res.status(400).json({ error: "Missing signature" });
|
||||
|
||||
const expected = hmacHex(secret, raw);
|
||||
if (!timingSafe(sig, expected)) {
|
||||
return res.status(401).json({ error: "Invalid signature" });
|
||||
}
|
||||
|
||||
let payload;
|
||||
try { payload = JSON.parse(raw); }
|
||||
catch { return res.status(400).json({ error: "Invalid JSON" }); }
|
||||
|
||||
const eventName = payload?.meta?.event_name || "";
|
||||
const attrs = payload?.data?.attributes || {};
|
||||
const t = now();
|
||||
|
||||
let out;
|
||||
try {
|
||||
switch (eventName) {
|
||||
case "order_created":
|
||||
out = await handleOrderCreated(payload, attrs, t);
|
||||
break;
|
||||
case "order_refunded":
|
||||
out = await handleOrderRefunded(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_created":
|
||||
out = await handleSubscriptionCreated(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_updated":
|
||||
case "subscription_resumed":
|
||||
case "subscription_unpaused":
|
||||
out = await handleSubscriptionUpdated(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_payment_success":
|
||||
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_expired":
|
||||
out = await handleSubscriptionExpired(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_cancelled":
|
||||
case "subscription_paused":
|
||||
case "subscription_payment_failed":
|
||||
case "subscription_payment_refunded":
|
||||
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
|
||||
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||
break;
|
||||
default:
|
||||
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[ls] handler error", eventName, e?.message || e);
|
||||
return res.status(500).json({ error: "Internal error" });
|
||||
}
|
||||
|
||||
return res.status(out.status).json(out.body);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { signJwt, verifyJwt } from "./jwt.js";
|
||||
import { getPlan, planSnapshot } from "./plans.js";
|
||||
|
||||
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
|
||||
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
||||
const normKey = (s) => String(s || "").trim().toUpperCase();
|
||||
|
||||
/**
|
||||
* Verifica che una licenza sia ancora "utilizzabile" oggi.
|
||||
* Ritorna { ok: true } oppure { ok: false, error: "..." }.
|
||||
*
|
||||
* Casi gestiti:
|
||||
* - annual one-time scaduto (expires_at < now)
|
||||
* - subscription scaduta (current_period_end < now)
|
||||
*/
|
||||
function checkLicenseValidity(license, t = now()) {
|
||||
if (license.status !== "active") {
|
||||
return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." };
|
||||
}
|
||||
if (license.expires_at && Number(license.expires_at) < t) {
|
||||
return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." };
|
||||
}
|
||||
if (license.current_period_end && Number(license.current_period_end) < t) {
|
||||
return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function issueToken(license, deviceId) {
|
||||
const t = now();
|
||||
const plan = planSnapshot(license.plan);
|
||||
const claims = {
|
||||
sub: String(license.id),
|
||||
key_id: license.license_key,
|
||||
email: license.email,
|
||||
device_id: deviceId,
|
||||
iat: t,
|
||||
exp: t + TOKEN_TTL_DAYS * 86400,
|
||||
};
|
||||
if (plan) {
|
||||
claims.plan = plan.code;
|
||||
claims.plan_name = plan.name;
|
||||
claims.daily_limit = plan.daily_limit;
|
||||
claims.features = plan.features;
|
||||
claims.is_subscription = plan.is_subscription;
|
||||
}
|
||||
if (license.expires_at) claims.expires_at = Number(license.expires_at);
|
||||
if (license.current_period_end) claims.period_end = Number(license.current_period_end);
|
||||
return signJwt(claims, process.env.JWT_SECRET);
|
||||
}
|
||||
|
||||
const LICENSE_COLUMNS =
|
||||
"id, license_key, email, status, plan, daily_limit, " +
|
||||
"subscription_id, current_period_end, expires_at";
|
||||
|
||||
export async function activate(req, res) {
|
||||
const { key, email, device_id, device_name, app_version } = req.body || {};
|
||||
const K = normKey(key), E = normEmail(email);
|
||||
const D = String(device_id || "").trim();
|
||||
if (!K || !E || !D) {
|
||||
return res.status(400).json({ error: "Missing key, email or device_id" });
|
||||
}
|
||||
|
||||
const license = await one(
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses
|
||||
WHERE license_key=? AND email=? LIMIT 1`,
|
||||
[K, E],
|
||||
);
|
||||
if (!license) {
|
||||
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
|
||||
}
|
||||
const check = checkLicenseValidity(license);
|
||||
if (!check.ok) return res.status(403).json({ error: check.error });
|
||||
|
||||
const t = now();
|
||||
const existing = await one(
|
||||
"SELECT id FROM activations WHERE license_id=? AND device_id=? LIMIT 1",
|
||||
[license.id, D],
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await exec(
|
||||
`UPDATE activations SET app_version=?, device_name=?, last_seen_at=?, revoked_at=NULL
|
||||
WHERE id=?`,
|
||||
[app_version || null, device_name || null, t, existing.id],
|
||||
);
|
||||
} else {
|
||||
const row = await one(
|
||||
"SELECT COUNT(*) AS n FROM activations WHERE license_id=? AND revoked_at IS NULL",
|
||||
[license.id],
|
||||
);
|
||||
if ((row?.n || 0) >= MAX_ACTIVATIONS) {
|
||||
return res.status(409).json({
|
||||
error: `Hai gia attivato la licenza su ${MAX_ACTIVATIONS} dispositivi. Disattivane uno per usarla qui.`,
|
||||
});
|
||||
}
|
||||
await exec(
|
||||
`INSERT INTO activations
|
||||
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[license.id, D, device_name || null, app_version || null, t, t],
|
||||
);
|
||||
}
|
||||
|
||||
const token = await issueToken(license, D);
|
||||
const plan = planSnapshot(license.plan);
|
||||
res.json({
|
||||
token,
|
||||
activated_at: t,
|
||||
email: license.email,
|
||||
plan,
|
||||
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validate(req, res) {
|
||||
const { token, device_id, app_version } = req.body || {};
|
||||
const T = String(token || "").trim();
|
||||
const D = String(device_id || "").trim();
|
||||
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
|
||||
|
||||
const claims = verifyJwt(T, process.env.JWT_SECRET);
|
||||
if (!claims) return res.status(401).json({ error: "Token invalido o scaduto" });
|
||||
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
||||
|
||||
const license = await one(
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||
[claims.sub],
|
||||
);
|
||||
if (!license) return res.status(401).json({ error: "Licenza non trovata" });
|
||||
|
||||
const check = checkLicenseValidity(license);
|
||||
if (!check.ok) return res.status(401).json({ error: check.error });
|
||||
|
||||
const act = await one(
|
||||
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
|
||||
[license.id, D],
|
||||
);
|
||||
if (!act || act.revoked_at !== null) {
|
||||
return res.status(401).json({ error: "Attivazione non trovata o revocata" });
|
||||
}
|
||||
|
||||
await exec(
|
||||
"UPDATE activations SET last_seen_at=?, app_version=? WHERE id=?",
|
||||
[now(), app_version || null, act.id],
|
||||
);
|
||||
|
||||
const fresh = await issueToken(license, D);
|
||||
const plan = planSnapshot(license.plan);
|
||||
res.json({
|
||||
token: fresh,
|
||||
email: license.email,
|
||||
plan,
|
||||
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivate(req, res) {
|
||||
const { token, device_id } = req.body || {};
|
||||
const T = String(token || "").trim();
|
||||
const D = String(device_id || "").trim();
|
||||
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
|
||||
|
||||
const claims = verifyJwt(T, process.env.JWT_SECRET);
|
||||
if (!claims) return res.status(401).json({ error: "Token invalido" });
|
||||
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
||||
|
||||
await exec(
|
||||
`UPDATE activations SET revoked_at=?
|
||||
WHERE license_id=? AND device_id=? AND revoked_at IS NULL`,
|
||||
[now(), claims.sub, D],
|
||||
);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper interno: dato un token JWT valido, ritorna la riga licenze
|
||||
* fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se
|
||||
* il token e' invalido o la licenza non e' piu' attiva.
|
||||
*/
|
||||
export async function licenseFromToken(token) {
|
||||
const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET);
|
||||
if (!claims) return null;
|
||||
const lic = await one(
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||
[claims.sub],
|
||||
);
|
||||
if (!lic) return null;
|
||||
const check = checkLicenseValidity(lic);
|
||||
if (!check.ok) return null;
|
||||
return { license: lic, claims };
|
||||
}
|
||||
|
||||
// Esposto per uso interno (es. webhook genera chiave nuova)
|
||||
export function generateLicenseKey() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
const buf = crypto.randomBytes(16);
|
||||
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
|
||||
return [chars.slice(0,4), chars.slice(4,8), chars.slice(8,12), chars.slice(12,16)]
|
||||
.map((g) => g.join("")).join("-");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Applica i file SQL in migrations/ in ordine alfabetico.
|
||||
* Tiene traccia dei file gia eseguiti in una tabella `schema_migrations`.
|
||||
*
|
||||
* Run: npm run migrate
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pool, { query, exec } from "./db.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const MIG_DIR = path.resolve(__dirname, "../migrations");
|
||||
|
||||
async function ensureMigrationsTable() {
|
||||
await exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||
applied_at BIGINT UNSIGNED NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await ensureMigrationsTable();
|
||||
const applied = new Set(
|
||||
(await query("SELECT filename FROM schema_migrations")).map((r) => r.filename),
|
||||
);
|
||||
|
||||
const files = fs.readdirSync(MIG_DIR)
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
for (const f of files) {
|
||||
if (applied.has(f)) {
|
||||
console.log(`[migrate] skip ${f} (gia applicato)`);
|
||||
continue;
|
||||
}
|
||||
const sql = fs.readFileSync(path.join(MIG_DIR, f), "utf-8");
|
||||
console.log(`[migrate] applico ${f}`);
|
||||
// mysql2 supporta multipleStatements ma e' rischioso; splittiamo manualmente.
|
||||
const statements = sql
|
||||
.split(/;\s*\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0 && !s.startsWith("--"));
|
||||
for (const stmt of statements) {
|
||||
await exec(stmt);
|
||||
}
|
||||
await exec(
|
||||
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
|
||||
[f, Math.floor(Date.now() / 1000)],
|
||||
);
|
||||
console.log(`[migrate] ok ${f}`);
|
||||
}
|
||||
|
||||
await pool.end();
|
||||
console.log("[migrate] done");
|
||||
}
|
||||
|
||||
run().catch((e) => {
|
||||
console.error("[migrate] errore:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Source of truth per i piani MusicTools.
|
||||
*
|
||||
* Mappa anche i variant_id di Lemon Squeezy -> plan code, cosi' il
|
||||
* webhook puo' capire quale piano e' stato comprato senza if/else sparsi.
|
||||
*
|
||||
* I variant_id si trovano nel dashboard LS: Products -> click sul prodotto
|
||||
* -> Variants -> "Edit" -> URL contiene /variants/<id>. Vanno messi in
|
||||
* .env (LS_VARIANT_*); qui li leggiamo con fallback null.
|
||||
*
|
||||
* NOTA su 'features': non e' un set di "tab" ma un set di capability
|
||||
* lato server. Il client mostra/nasconde le tab in base a queste.
|
||||
* - "audio" : download brani (tab Brani)
|
||||
* - "video" : download video YouTube/TikTok/IG/FB (tab Video)
|
||||
* - "record" : registrazione audio (tab Registra)
|
||||
* - "metadata" : editor metadati (tab Metadati)
|
||||
* - "upgrade" : upgrade qualita' libreria (tab Upgrade)
|
||||
*
|
||||
* Limiti giornalieri (daily_limit, null = unlimited): contiamo come "uso"
|
||||
* ogni avvio di un job di download (1 chiamata a /api/usage/consume) E
|
||||
* ogni salvataggio metadati. La registrazione e' un'azione bound a 1
|
||||
* file/giorno: pure 1.
|
||||
*/
|
||||
|
||||
export const PLANS = {
|
||||
basic: {
|
||||
code: "basic",
|
||||
name: "Basic",
|
||||
price_eur: 2.99,
|
||||
interval: "monthly",
|
||||
daily_limit: 10,
|
||||
features: ["audio"],
|
||||
is_subscription: true,
|
||||
ls_variant_env: "LS_VARIANT_BASIC",
|
||||
},
|
||||
pro: {
|
||||
code: "pro",
|
||||
name: "Pro",
|
||||
price_eur: 5.99,
|
||||
interval: "monthly",
|
||||
daily_limit: 30,
|
||||
features: ["audio", "video", "record", "metadata", "upgrade"],
|
||||
is_subscription: true,
|
||||
ls_variant_env: "LS_VARIANT_PRO",
|
||||
},
|
||||
premium: {
|
||||
code: "premium",
|
||||
name: "Premium",
|
||||
price_eur: 9.99,
|
||||
interval: "monthly",
|
||||
daily_limit: 150,
|
||||
features: ["audio", "video", "record", "metadata", "upgrade"],
|
||||
is_subscription: true,
|
||||
ls_variant_env: "LS_VARIANT_PREMIUM",
|
||||
},
|
||||
annual: {
|
||||
code: "annual",
|
||||
name: "Annual",
|
||||
price_eur: 49.90,
|
||||
interval: "annual_one_time",
|
||||
daily_limit: null,
|
||||
features: ["audio", "video", "record", "metadata", "upgrade"],
|
||||
is_subscription: false,
|
||||
ls_variant_env: "LS_VARIANT_ANNUAL",
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAN_CODES = Object.keys(PLANS);
|
||||
|
||||
/** Ritorna il plan code mappato a un LS variant_id, o null se non noto. */
|
||||
export function planByVariantId(variantId) {
|
||||
const vid = String(variantId || "").trim();
|
||||
if (!vid) return null;
|
||||
for (const code of PLAN_CODES) {
|
||||
const envName = PLANS[code].ls_variant_env;
|
||||
const mapped = String(process.env[envName] || "").trim();
|
||||
if (mapped && mapped === vid) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPlan(code) {
|
||||
return PLANS[code] || null;
|
||||
}
|
||||
|
||||
/** Forma stabile per inclusione nei JWT claims / risposte API. */
|
||||
export function planSnapshot(code) {
|
||||
const p = getPlan(code);
|
||||
if (!p) return null;
|
||||
return {
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
daily_limit: p.daily_limit,
|
||||
features: p.features.slice(),
|
||||
is_subscription: p.is_subscription,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFeature(code, feature) {
|
||||
const p = getPlan(code);
|
||||
return !!(p && p.features.includes(feature));
|
||||
}
|
||||
|
||||
/** Durata annual one-time in secondi (365 giorni). */
|
||||
export const ANNUAL_TTL_SECONDS = 365 * 24 * 3600;
|
||||
@@ -0,0 +1,71 @@
|
||||
import "dotenv/config";
|
||||
import express from "express";
|
||||
|
||||
import * as license from "./license.js";
|
||||
import * as updates from "./updates.js";
|
||||
import * as ls from "./lemonsqueezy.js";
|
||||
import * as dl from "./downloads.js";
|
||||
import * as usage from "./usage.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// L'app sta dietro Apache reverse proxy: fidati di X-Forwarded-* dal localhost.
|
||||
app.set("trust proxy", "loopback");
|
||||
app.disable("x-powered-by");
|
||||
|
||||
// CORS minimale (solo per /api/*)
|
||||
app.use("/api", (req, res, next) => {
|
||||
res.set("Access-Control-Allow-Origin", "*");
|
||||
res.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
res.set("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
||||
if (req.method === "OPTIONS") return res.status(204).end();
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check (no body parser necessario)
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true, version: process.env.LATEST_VERSION || "" });
|
||||
});
|
||||
|
||||
// WEBHOOK Lemon Squeezy: deve ricevere il body RAW per verificare la firma.
|
||||
// Va registrato PRIMA del json parser globale.
|
||||
app.post(
|
||||
"/api/webhook/lemonsqueezy",
|
||||
express.raw({ type: "application/json", limit: "1mb" }),
|
||||
ls.webhook,
|
||||
);
|
||||
|
||||
// JSON parser per tutti gli altri endpoint
|
||||
app.use(express.json({ limit: "128kb" }));
|
||||
|
||||
// Licenze
|
||||
app.post("/api/license/activate", license.activate);
|
||||
app.post("/api/license/validate", license.validate);
|
||||
app.post("/api/license/deactivate", license.deactivate);
|
||||
|
||||
// Quota giornaliera
|
||||
app.get("/api/usage/status", usage.status);
|
||||
app.post("/api/usage/consume", usage.consume);
|
||||
|
||||
// Aggiornamenti + download firmato
|
||||
app.get("/api/latest", updates.latest);
|
||||
app.get("/api/download", updates.download);
|
||||
|
||||
// 1-click download dall'email (key+email come query -> redirect signed)
|
||||
app.get("/api/download/:platform", dl.downloadByKey);
|
||||
|
||||
// 404 JSON solo per /api/*
|
||||
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
|
||||
|
||||
// Error handler
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error("[unhandled]", err);
|
||||
if (res.headersSent) return;
|
||||
res.status(500).json({ error: "Internal error" });
|
||||
});
|
||||
|
||||
const PORT = Number(process.env.PORT || 4002);
|
||||
const HOST = process.env.HOST || "127.0.0.1";
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[musictools-api] listening on ${HOST}:${PORT}`);
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { one } from "./db.js";
|
||||
import { verifyJwt } from "./jwt.js";
|
||||
|
||||
const BUILDS_DIR = process.env.BUILDS_DIR || path.resolve(process.cwd(), "../builds");
|
||||
const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300);
|
||||
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||
|
||||
function signPayload(payload, secret) {
|
||||
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
|
||||
}
|
||||
|
||||
function buildDownloadUrl(filePath) {
|
||||
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
|
||||
const sig = signPayload(`${filePath}.${exp}`, process.env.JWT_SECRET);
|
||||
return `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(filePath)}&exp=${exp}&sig=${sig}`;
|
||||
}
|
||||
|
||||
// GET /api/latest?platform=macos|windows¤t=v1.5.2
|
||||
export async function latest(req, res) {
|
||||
const platform = String(req.query.platform || "").toLowerCase();
|
||||
if (platform !== "macos" && platform !== "windows") {
|
||||
return res.status(400).json({ error: "platform must be macos or windows" });
|
||||
}
|
||||
|
||||
const row = await one(
|
||||
`SELECT version, platform, file_path, size_bytes, sha256, notes, published_at
|
||||
FROM releases
|
||||
WHERE platform=?
|
||||
ORDER BY published_at DESC
|
||||
LIMIT 1`,
|
||||
[platform],
|
||||
);
|
||||
|
||||
if (!row) {
|
||||
return res.json({
|
||||
version: process.env.LATEST_VERSION || "",
|
||||
notes: "",
|
||||
download_url: "",
|
||||
requires_license: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Auth opzionale (Bearer): senza, niente download URL
|
||||
const auth = req.get("Authorization") || "";
|
||||
let licensed = false;
|
||||
if (auth.startsWith("Bearer ")) {
|
||||
const claims = verifyJwt(auth.slice(7).trim(), process.env.JWT_SECRET);
|
||||
licensed = !!claims;
|
||||
}
|
||||
|
||||
res.json({
|
||||
version: row.version,
|
||||
notes: row.notes || "",
|
||||
sha256: row.sha256 || "",
|
||||
size_bytes: Number(row.size_bytes || 0),
|
||||
download_url: licensed ? buildDownloadUrl(row.file_path) : "",
|
||||
requires_license: !licensed,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/download?file=...&exp=...&sig=...
|
||||
// Verifica firma e stream del file da disco.
|
||||
export async function download(req, res) {
|
||||
const file = String(req.query.file || "");
|
||||
const exp = Number(req.query.exp || 0);
|
||||
const sig = String(req.query.sig || "");
|
||||
if (!file || !exp || !sig) return res.status(400).send("Missing params");
|
||||
|
||||
const expected = signPayload(`${file}.${exp}`, process.env.JWT_SECRET);
|
||||
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||
return res.status(401).send("Invalid signature");
|
||||
}
|
||||
if (exp < Math.floor(Date.now() / 1000)) {
|
||||
return res.status(410).send("URL expired");
|
||||
}
|
||||
|
||||
// Sicurezza path: il file deve trovarsi sotto BUILDS_DIR.
|
||||
// 'file' arriva come "v1.5.3/MusicTools-macOS.zip"
|
||||
const abs = path.resolve(BUILDS_DIR, file);
|
||||
if (!abs.startsWith(path.resolve(BUILDS_DIR) + path.sep)) {
|
||||
return res.status(403).send("Forbidden");
|
||||
}
|
||||
if (!fs.existsSync(abs)) {
|
||||
return res.status(404).send("File not found");
|
||||
}
|
||||
|
||||
const name = path.basename(abs);
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const contentType = ext === ".dmg"
|
||||
? "application/x-apple-diskimage"
|
||||
: ext === ".exe"
|
||||
? "application/vnd.microsoft.portable-executable"
|
||||
: "application/zip";
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Quota giornaliera per piano.
|
||||
*
|
||||
* Il client chiama:
|
||||
* - GET /api/usage/status (Bearer JWT) -> solo lettura, no incremento
|
||||
* - POST /api/usage/consume (Bearer JWT) -> +1 al contatore, ritorna esito
|
||||
*
|
||||
* Il "giorno" e' YYYY-MM-DD nella timezone Europe/Rome. In questo modo
|
||||
* tutti i clienti hanno lo stesso reset (mezzanotte di Roma) — semplifica
|
||||
* il marketing ("10 download al giorno, resettati a mezzanotte").
|
||||
*/
|
||||
|
||||
import { one, exec } from "./db.js";
|
||||
import { licenseFromToken } from "./license.js";
|
||||
import { getPlan, planSnapshot } from "./plans.js";
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** YYYY-MM-DD nella timezone Europe/Rome, partendo da un istante epoch. */
|
||||
export function romeDay(epochSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const d = new Date(epochSeconds * 1000);
|
||||
// Intl con time zone -> formatta in YYYY-MM-DD senza problemi DST.
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Rome",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
// en-CA -> "2026-06-09"
|
||||
return fmt.format(d);
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const auth = req.get("Authorization") || "";
|
||||
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
||||
}
|
||||
|
||||
async function loadAuthorized(req, res) {
|
||||
const token = bearer(req) || String(req.body?.token || "");
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "Missing token" });
|
||||
return null;
|
||||
}
|
||||
const auth = await licenseFromToken(token);
|
||||
if (!auth) {
|
||||
res.status(401).json({ error: "Licenza non valida o scaduta" });
|
||||
return null;
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** Risposta compatta usata da entrambi gli endpoint. */
|
||||
function buildResponse(plan, limit, used) {
|
||||
const remaining = limit === null ? null : Math.max(0, limit - used);
|
||||
return {
|
||||
plan,
|
||||
used,
|
||||
limit, // null = illimitato
|
||||
remaining, // null = illimitato
|
||||
day: romeDay(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Quanti download ha gia' fatto la licenza oggi (Europe/Rome). */
|
||||
async function readUsedToday(licenseId, day) {
|
||||
const row = await one(
|
||||
"SELECT count FROM daily_usage WHERE license_id=? AND day=?",
|
||||
[licenseId, day],
|
||||
);
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function status(req, res) {
|
||||
const auth = await loadAuthorized(req, res);
|
||||
if (!auth) return;
|
||||
const { license } = auth;
|
||||
const plan = planSnapshot(license.plan);
|
||||
const limit = plan?.daily_limit ?? null;
|
||||
const day = romeDay();
|
||||
const used = limit === null ? 0 : await readUsedToday(license.id, day);
|
||||
res.json(buildResponse(plan, limit, used));
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa di 1 il contatore del giorno corrente per la licenza,
|
||||
* rifiutando con 429 se gia' al limite. Lo facciamo con una UPDATE
|
||||
* condizionale + INSERT-or-no-op cosi' e' atomico anche sotto carico.
|
||||
*/
|
||||
export async function consume(req, res) {
|
||||
const auth = await loadAuthorized(req, res);
|
||||
if (!auth) return;
|
||||
const { license } = auth;
|
||||
const plan = planSnapshot(license.plan);
|
||||
if (!plan) return res.status(403).json({ error: "Piano non riconosciuto" });
|
||||
|
||||
// (Opzionale) il client puo' passare {feature: "video"} per chiarezza,
|
||||
// ma noi gating-amo gia' lato client e basta avere un piano valido qui.
|
||||
const feature = String(req.body?.feature || "").trim();
|
||||
if (feature && !plan.features.includes(feature)) {
|
||||
return res.status(403).json({
|
||||
error: `Il piano ${plan.name} non include questa funzione.`,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
|
||||
const limit = plan.daily_limit;
|
||||
const day = romeDay();
|
||||
const t = now();
|
||||
|
||||
// Unlimited (annual): nessun gate, conta comunque per le statistiche.
|
||||
if (limit === null) {
|
||||
await exec(
|
||||
`INSERT INTO daily_usage (license_id, day, count, updated_at)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1, updated_at = VALUES(updated_at)`,
|
||||
[license.id, day, t],
|
||||
);
|
||||
const used = await readUsedToday(license.id, day);
|
||||
return res.json({ ...buildResponse(plan, limit, used), allowed: true });
|
||||
}
|
||||
|
||||
// Limited: prova incremento condizionato. Se la riga non esiste,
|
||||
// INSERT iniziale (count=1). Se esiste e count<limit -> +1, altrimenti
|
||||
// affectedRows=0 e ritorniamo 429.
|
||||
const upd = await exec(
|
||||
`UPDATE daily_usage
|
||||
SET count = count + 1, updated_at = ?
|
||||
WHERE license_id = ? AND day = ? AND count < ?`,
|
||||
[t, license.id, day, limit],
|
||||
);
|
||||
|
||||
if (upd.affectedRows === 0) {
|
||||
// Due casi: riga non esiste (mai usato oggi) oppure gia' al limite.
|
||||
const existing = await one(
|
||||
"SELECT count FROM daily_usage WHERE license_id=? AND day=?",
|
||||
[license.id, day],
|
||||
);
|
||||
if (!existing) {
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO daily_usage (license_id, day, count, updated_at)
|
||||
VALUES (?, ?, 1, ?)`,
|
||||
[license.id, day, t],
|
||||
);
|
||||
return res.json({ ...buildResponse(plan, limit, 1), allowed: true });
|
||||
} catch (e) {
|
||||
if (e?.code !== "ER_DUP_ENTRY") throw e;
|
||||
// race: qualcun altro l'ha inserita -> rileggi
|
||||
}
|
||||
}
|
||||
const used = await readUsedToday(license.id, day);
|
||||
return res.status(429).json({
|
||||
...buildResponse(plan, limit, used),
|
||||
allowed: false,
|
||||
error: `Limite giornaliero raggiunto (${limit}/${limit}). Riprova dopo mezzanotte o passa a un piano superiore.`,
|
||||
});
|
||||
}
|
||||
|
||||
const used = await readUsedToday(license.id, day);
|
||||
res.json({ ...buildResponse(plan, limit, used), allowed: true });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Fixtures pytest condivise."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixtures_dir() -> Path:
|
||||
return FIXTURES_DIR
|
||||
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html><html lang="en"><head><meta charSet="utf-8" data-next-head=""/><meta name="viewport" content="width=device-width" data-next-head=""/><link rel="canonical" hrefLang="en" href="https://www.beatport.com/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="de" href="https://www.beatport.com/de/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="fr" href="https://www.beatport.com/fr/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="es" href="https://www.beatport.com/es/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="it" href="https://www.beatport.com/it/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="ja" href="https://www.beatport.com/ja/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="pt" href="https://www.beatport.com/pt/genre/melodic-house-techno/90/top-100" data-next-head=""/><link rel="alternate" hrefLang="nl" href="https://www.beatport.com/nl/genre/melodic-house-techno/90/top-100" data-next-head=""/><title data-next-head="">Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport</title><meta property="og:title" content="Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport" data-next-head=""/><meta property="og:description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><meta property="og:image" content="https://www.beatport.com/images/beatport-social-image.png" data-next-head=""/><meta name="twitter:card" content="summary_large_image" data-next-head=""/><meta name="twitter:title" content="Melodic House & Techno - TOP 100 | Download & Stream Melodic House & Techno on Beatport" data-next-head=""/><meta name="twitter:description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><meta name="twitter:image" content="https://www.beatport.com/images/beatport-social-image.png" data-next-head=""/><meta name="description" content="Download the best Melodic House & Techno music and stream the top 100 Melodic House & Techno music tracks on Beatport. Explore the Top 100 Melodic House & Techno artists and DJ now. " data-next-head=""/><link rel="icon" type="image/x-icon" href="/images/favicon-48x48.png" data-next-head=""/><link href="https://js.recurly.com/v4/recurly.css" rel="stylesheet" type="text/css" data-next-head=""/><meta name="bp-env" content="production"/><script>window.__SENTRY_RELEASE__="BP-v1.162.0"</script><link rel="preload" href="/icons/sprite.svg" as="image"/><link rel="preload" href="/_next/static/media/9617edd9e02a645a-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><link rel="preload" href="/_next/static/media/9438673d1e72232d-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><link rel="preload" href="/_next/static/media/db5c0e5da3a6b3a9-s.p.ttf" as="font" type="font/ttf" crossorigin="anonymous" data-next-font="size-adjust"/><script id="google-analytics" data-nscript="beforeInteractive">
|
||||
(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
|
||||
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
|
||||
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
|
||||
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
|
||||
})(window,document,'script','dataLayer','GTM-TXN4GRJ');
|
||||
</script><link rel="preload" href="/_next/static/css/dbc5caec36a91cd9.css" as="style"/><link rel="stylesheet" href="/_next/static/css/dbc5caec36a91cd9.css" data-n-g=""/><noscript data-n-css=""></noscript><script defer="" nomodule="" src="/_next/static/chunks/polyfills-42372ed130431b0a.js"></script><script id="ab-tasty" src="https://try.abtasty.com/6a6f6df6afac8eb063d6dbbd68afdcc7.js" defer="" data-nscript="beforeInteractive"></script><script src="https://songbird.cardinalcommerce.com/edge/v1/songbird.js" defer="" data-nscript="beforeInteractive"></script><script id="pap_x2s6df8d" src="https://pluginboutique.postaffiliatepro.com/scripts/trackjs.js" defer="" data-nscript="beforeInteractive"></script><script src="/_next/static/chunks/webpack-6f1bbaae2c104d2c.js" defer=""></script><script src="/_next/static/chunks/framework-0353c99733a74dff.js" defer=""></script><script src="/_next/static/chunks/main-6be71f34df367310.js" defer=""></script><script src="/_next/static/chunks/pages/_app-481baf8cf9b61739.js" defer=""></script><script src="/_next/static/chunks/1672-274ad8535921289a.js" defer=""></script><script src="/_next/static/chunks/pages/genre/%5Bdescription%5D/%5Bid%5D/top-100-991e44049fcd48b3.js" defer=""></script><script src="/_next/static/z5IFYCzpmVKqxZJshoItQ/_buildManifest.js" defer=""></script><script src="/_next/static/z5IFYCzpmVKqxZJshoItQ/_ssgManifest.js" defer=""></script><style data-styled="" data-styled-version="6.4.3">.dXSwbq{box-sizing:border-box;display:flex;flex-direction:row;justify-content:center;align-items:center;padding:0.313rem 1rem 0.438rem;gap:0.5rem;border-radius:0.25rem;cursor:pointer;font-family:'aeonikFont','aeonikFont Fallback';font-size:0.875rem;line-height:1.25rem;font-weight:500;color:#141414;}/*!sc*/
|
||||
.dXSwbq.white{background-color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.white:hover{background-color:#01CC77;}/*!sc*/
|
||||
.dXSwbq.primary{background:#01FF95;}/*!sc*/
|
||||
.dXSwbq.primary:hover{background:#01CC77;}/*!sc*/
|
||||
.dXSwbq.primary.primary.disabled{pointer-events:none;color:#343434;background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.primary.danger{background-color:#CF222E;border-color:transparent;}/*!sc*/
|
||||
.dXSwbq.primary.danger:hover{background:#99242B;}/*!sc*/
|
||||
.dXSwbq.outline{border:0.063rem solid;border-image-source:linear-gradient( 0deg,rgba(0,0,0,0.6),rgba(0,0,0,0.6) ),linear-gradient( 0deg,#01FF95,#01FF95 );color:#01FF95;}/*!sc*/
|
||||
.dXSwbq.outline:hover{border:0.063rem solid;color:#141414;background:#01FF95;}/*!sc*/
|
||||
.dXSwbq.outline.outline.disabled{background:transparent;color:#01FF95;opacity:50%;}/*!sc*/
|
||||
.dXSwbq.outline-secondary{border:0.063rem solid;border-color:#8C8C8C;color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.outline-secondary:hover{background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.outline.disabled{background:transparent;color:#FFFFFF;opacity:50%;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:disabled{cursor:auto;border-color:#5A5A5A;color:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:disabled svg{fill:#5A5A5A;stroke:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.outline-secondary.caution:hover:not(:disabled){background:#CF222E;border-color:transparent;}/*!sc*/
|
||||
.dXSwbq.tertiary{color:#FFFFFF;background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.tertiary:hover{background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.tertiary.tertiary.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.secondary{color:#FFFFFF;background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.secondary:hover{background:#8C8C8C;}/*!sc*/
|
||||
.dXSwbq.secondary.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.danger{color:#FFFFFF;background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.danger:hover{background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.danger.disabled{background:#8C8C8C;opacity:0.5;}/*!sc*/
|
||||
.dXSwbq.transparent,.dXSwbq.transparent-danger{color:#FFFFFF;border:0.125rem solid #5A5A5A;padding:0.313rem 0.75rem;gap:0.375rem;}/*!sc*/
|
||||
.dXSwbq.transparent:hover{background:#5A5A5A;}/*!sc*/
|
||||
.dXSwbq.transparent-danger:hover{border:0.125rem solid #CF222E;background:#CF222E;}/*!sc*/
|
||||
.dXSwbq.pill{color:#FFFFFF;background:#343434;border-radius:0.375rem;padding:0.75rem 1rem;height:2.5rem;}/*!sc*/
|
||||
.dXSwbq.pill:hover{background:#494949;}/*!sc*/
|
||||
.dXSwbq.link{color:#FFFFFF;}/*!sc*/
|
||||
.dXSwbq.primary-link{color:#01FF95;}/*!sc*/
|
||||
.dXSwbq.primary-link.disabled{cursor:default;}/*!sc*/
|
||||
.dXSwbq.text{color:#B3B3B3;}/*!sc*/
|
||||
.dXSwbq.text:hover{color:#FFFFFF;text-decoration:underline;}/*!sc*/
|
||||
.dXSwbq.underline-link{color:#B3B3B3;text-decoration:underline;padding:0.25rem;}/*!sc*/
|
||||
.dXSwbq.unstyled{background:none;border:none;padding:0;margin:0;color:inherit;background-color:transparent;}/*!sc*/
|
||||
.dXSwbq.w-100{width:100%;}/*!sc*/
|
||||
.dXSwbq.w-75{width:75%;}/*!sc*/
|
||||
.dXSwbq.w-50{width:50%;}/*!sc*/
|
||||
.dXSwbq.w-25{width:25%;}/*!sc*/
|
||||
.dXSwbq.squared{padding:0.313rem;}/*!sc*/
|
||||
data-styled.g1[id="Button-style__ButtonStyled-sc-e1b4adaf-0"]{content:"dXSwbq,"}/*!sc*/
|
||||
.jVzvJX{position:relative;margin-left:0.75rem;padding:0.5rem 0;}/*!sc*/
|
||||
.jVzvJX >a,.jVzvJX >button{display:flex;flex-direction:row;gap:0.3125rem;cursor:pointer;align-items:center;padding:0.313rem 0.25rem 0.438rem 0.25rem;color:#FFFFFF;}/*!sc*/
|
||||
.jVzvJX >a svg,.jVzvJX >button svg{stroke:#FFFFFF;}/*!sc*/
|
||||
@media (min-width: 1200px){.jVzvJX >a:hover,.jVzvJX >button:hover{color:#01FF95;}.jVzvJX >a:hover svg,.jVzvJX >button:hover svg{stroke:#01FF95;}}/*!sc*/
|
||||
@media (min-width: 1200px){.jVzvJX >a,.jVzvJX >button{padding:0;}.jVzvJX >a svg,.jVzvJX >button svg{width:1.3rem;height:1.3rem;}}/*!sc*/
|
||||
data-styled.g35[id="HeaderCart-style__AccountCartMenuItem-sc-6f1edafa-2"]{content:"jVzvJX,"}/*!sc*/
|
||||
.dkwGxt{position:absolute;top:3.6875rem;left:50%;transform:translate(-50%,1px);transition:opacity 0.2s ease;background:#141414;border-radius:0 0 1rem 1rem;display:block;z-index:31;cursor:default;opacity:0;visibility:hidden;pointer-events:none;box-shadow:0 0.5rem 1.5rem rgba(0,0,0,0.5);}/*!sc*/
|
||||
.dkwGxt[data-dropdown-type='genre']{width:100%;max-width:90rem;}/*!sc*/
|
||||
.dkwGxt[data-dropdown-type='events']{left:auto;min-width:12.5rem;margin:0 0 0 4.75rem;}/*!sc*/
|
||||
.dkwGxt[data-visible='true']{opacity:1;pointer-events:auto;visibility:visible;}/*!sc*/
|
||||
data-styled.g40[id="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0"]{content:"dkwGxt,"}/*!sc*/
|
||||
.jgwRkR{display:flex;flex-direction:column;padding:0.5rem 0;}/*!sc*/
|
||||
.jgwRkR a{padding:0.5rem 1.5rem;color:#B3B3B3;text-decoration:none;display:flex;align-items:center;transition:background-color 0.2s ease;}/*!sc*/
|
||||
.jgwRkR a:hover{color:#FFFFFF;}/*!sc*/
|
||||
.jgwRkR a:hover >svg{stroke:#01FF95;}/*!sc*/
|
||||
data-styled.g42[id="HeaderEventsSubMenu-style__EventsSubMenuWrapper-sc-75ecafea-0"]{content:"jgwRkR,"}/*!sc*/
|
||||
.kNtdbg{display:grid;grid-template-columns:1fr;flex-direction:row;justify-content:space-between;align-items:flex-start;width:100%;padding:1.5rem;gap:2rem;}/*!sc*/
|
||||
data-styled.g48[id="HeaderGenreSubMenu-style__GenreDropDownGrid-sc-5f01336e-4"]{content:"kNtdbg,"}/*!sc*/
|
||||
.gZFJrf{display:flex;flex-direction:row;position:absolute;top:0;left:0;right:0;z-index:40;height:60px;width:100%;font-size:0.875rem;background:#141414;border-bottom:0.0625rem solid #343434;}/*!sc*/
|
||||
.gZFJrf.header-fixed{position:fixed;}/*!sc*/
|
||||
.gZFJrf ul> li{display:inline-flex;vertical-align:middle;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag){display:flex;flex-direction:row;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag) >img{margin-right:0.25rem;}/*!sc*/
|
||||
.gZFJrf span:not(.new_tag) svg{stroke:#8C8C8C;}/*!sc*/
|
||||
@keyframes slideDown{from{transform:translateY(-100%);}to{transform:translateY(0);}}/*!sc*/
|
||||
data-styled.g50[id="Header-style__HeaderWrapper-sc-54c5f6f1-0"]{content:"gZFJrf,"}/*!sc*/
|
||||
.kFaHkN{display:flex;flex-direction:row;width:100%;max-width:90rem;margin:0 auto;align-items:center;}/*!sc*/
|
||||
.kFaHkN svg{stroke:#8C8C8C;}/*!sc*/
|
||||
.kFaHkN >ul .dropdown_menu{animation:growDown 300ms ease-in-out forwards;transform-origin:top center;}/*!sc*/
|
||||
.kFaHkN >ul >li{line-height:1.25rem;padding:1.25rem 0.4375rem;cursor:pointer;}/*!sc*/
|
||||
.kFaHkN >ul >li.header_item:not(.dropdown_toggle):hover div{display:flex;}/*!sc*/
|
||||
.kFaHkN >ul >li.account button{padding:0;}/*!sc*/
|
||||
.kFaHkN >ul >li button{font-weight:400;}/*!sc*/
|
||||
.kFaHkN >ul .header_item [aria-expanded="true"] .rotatable svg{stroke:#01FF95;transform:rotate(180deg);margin-top:0.3125rem;}/*!sc*/
|
||||
.kFaHkN >ul .header_item .svg_wrapper{display:flex;align-items:center;height:100%;}/*!sc*/
|
||||
.kFaHkN >ul .header_item .svg_wrapper img{border-radius:50%;}/*!sc*/
|
||||
.kFaHkN >ul .header_item a[target="_blank"]:hover svg{stroke:#01FF95;}/*!sc*/
|
||||
.kFaHkN span{align-items:center;}/*!sc*/
|
||||
.kFaHkN span >img{margin-left:0.125rem;margin-bottom:0.0625rem;}/*!sc*/
|
||||
@keyframes growDown{0%{transform:scaleY(0);}100%{transform:scaleY(1);}}/*!sc*/
|
||||
data-styled.g52[id="Header-style__DesktopHeader-sc-54c5f6f1-2"]{content:"kFaHkN,"}/*!sc*/
|
||||
.eRPXdM{min-width:8rem;margin-left:1rem;}/*!sc*/
|
||||
.eRPXdM >li{padding:0 0 0 0.25rem;}/*!sc*/
|
||||
.eRPXdM >li button{padding:0.8rem 0.75rem 0.5rem 0.25rem;}/*!sc*/
|
||||
@media (min-width: 768px){.eRPXdM{width:100%;}}/*!sc*/
|
||||
@media (min-width: 1200px){.eRPXdM{min-width:44.5rem;}}/*!sc*/
|
||||
data-styled.g53[id="Header-style__LeftRegion-sc-54c5f6f1-3"]{content:"eRPXdM,"}/*!sc*/
|
||||
.exexVn{display:flex;justify-content:flex-end;align-items:center;width:100%;padding-right:1.25rem;}/*!sc*/
|
||||
.exexVn .svg_wrapper{display:flex;align-items:center;height:100%;}/*!sc*/
|
||||
.exexVn div.account_avatar{width:2rem;height:2rem;}/*!sc*/
|
||||
.exexVn div.account_avatar img{border-radius:50%;}/*!sc*/
|
||||
.exexVn div.account_avatar img{aspect-ratio:1/1;object-fit:cover;object-position:center;}/*!sc*/
|
||||
.exexVn .subscription_button a{padding:0.313rem 1rem 0.438rem;border-radius:0.25rem;font-weight:500;color:#141414;background:#01FF95;}/*!sc*/
|
||||
.exexVn .subscription_button a:hover{background:#01CC77;}/*!sc*/
|
||||
@media (min-width: 768px){.exexVn{min-width:9.5rem;max-width:49.375rem;}}/*!sc*/
|
||||
@media (min-width: 1200px){.exexVn li.search:first-child{width:100%;max-width:28.375rem;padding:0 0.5rem 0 0;}.exexVn li.subscription_button{min-width:9rem;}}/*!sc*/
|
||||
data-styled.g54[id="Header-style__RightRegion-sc-54c5f6f1-4"]{content:"exexVn,"}/*!sc*/
|
||||
.dNxFON{height:1.8125rem;width:1.375rem;margin-right:0.625rem;}/*!sc*/
|
||||
data-styled.g57[id="Header-style__BeatportLogoLarge-sc-54c5f6f1-7"]{content:"dNxFON,"}/*!sc*/
|
||||
.koymgh{display:flex;align-items:center;width:100%;max-width:28.375rem;padding:0 1.5rem;}/*!sc*/
|
||||
.koymgh >button>svg{margin-right:1.25rem;height:1.25rem;stroke-width:0.125rem;}/*!sc*/
|
||||
data-styled.g60[id="Header-style__HeaderSearchWrapper-sc-54c5f6f1-10"]{content:"koymgh,"}/*!sc*/
|
||||
.eCACUJ{display:flex;flex:1;width:100%;border:0.0625rem solid #343434;border-radius:0.25rem;background:#343434;}/*!sc*/
|
||||
.eCACUJ:focus-within{border:0.0625rem solid #01FF95;box-shadow:0px 0px 0px 3px rgba(1, 255, 149, 0.15);outline:0.125rem solid transparent;outline-offset:0.125rem;}/*!sc*/
|
||||
.eCACUJ div:first-child{display:flex;align-items:center;pointer-events:none;margin-left:0.3125rem;}/*!sc*/
|
||||
.eCACUJ button{display:flex;align-items:center;margin-right:0.625rem;}/*!sc*/
|
||||
.eCACUJ img{margin-left:0.3125rem;margin-top:0.0625rem;height:1rem;width:1rem;}/*!sc*/
|
||||
.eCACUJ input{height:2rem;margin-left:0.3125rem;width:100%;background:inherit;outline:none;border:none;}/*!sc*/
|
||||
.eCACUJ input::placeholder{color:#8C8C8C;}/*!sc*/
|
||||
data-styled.g61[id="Header-style__HeaderSearch-sc-54c5f6f1-11"]{content:"eCACUJ,"}/*!sc*/
|
||||
.dxGRL{font-size:max(100%,16px);}/*!sc*/
|
||||
.dxGRL:focus::placeholder{color:transparent;}/*!sc*/
|
||||
data-styled.g64[id="Search-style__SearchInput-sc-224b2fdd-1"]{content:"dxGRL,"}/*!sc*/
|
||||
.btldZz{position:fixed;top:3.75rem;left:calc(100% - 370px);width:22.5rem;display:flex;flex-direction:column;gap:0.5rem;font-size:0.875rem;line-height:1.25rem;z-index:60;}/*!sc*/
|
||||
@media (min-width: 580px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 768px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 1200px){.btldZz{left:calc(100% - 385px);}}/*!sc*/
|
||||
@media (min-width: 1440px){.btldZz{left:initial;right:calc((100% - 1371px) / 2);}}/*!sc*/
|
||||
data-styled.g98[id="Notifications-style__Wrapper-sc-6faed7f7-1"]{content:"btldZz,"}/*!sc*/
|
||||
.fUBPTR{padding:8px;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:12px;line-height:14px;color:#8C8C8C;}/*!sc*/
|
||||
.fUBPTR.p-0{padding:0;}/*!sc*/
|
||||
.fUBPTR.px-0{padding-left:0;padding-right:0;}/*!sc*/
|
||||
.fUBPTR.py-0{padding-top:0;padding-bottom:0;}/*!sc*/
|
||||
.fUBPTR .selectable:hover{opacity:1;}/*!sc*/
|
||||
data-styled.g99[id="Table-style__TableCell-sc-ef6c0b11-0"]{content:"fUBPTR,"}/*!sc*/
|
||||
.knBMpy{width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;height:40px;background:#343434;margin:0 0 2px;}/*!sc*/
|
||||
.knBMpy.selected{background:#494949;}/*!sc*/
|
||||
.knBMpy.tombstoned{background:#2D2D2D;}/*!sc*/
|
||||
.knBMpy.tombstoned a{pointer-events:none;}/*!sc*/
|
||||
.knBMpy.tombstoned:hover{background:#494949;}/*!sc*/
|
||||
.knBMpy svg.album-cover{display:none;position:absolute;margin-left:15px;}/*!sc*/
|
||||
.knBMpy:hover{background:#494949;position:relative;}/*!sc*/
|
||||
.knBMpy:hover div.chart-cell{color:white;}/*!sc*/
|
||||
.knBMpy:hover svg.album-cover{display:inline;}/*!sc*/
|
||||
.knBMpy:hover:hover::before{display:none;content:"";position:absolute;top:100%;left:50%;font-weight:400;font-size:12px;line-height:16px;border-radius:8px;transition:background 0.2s;z-index:1;background:#141414;color:#FFFFFF;margin-left:-5px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.knBMpy div:nth-child(2){flex-grow:1;}/*!sc*/
|
||||
data-styled.g100[id="Table-style__TableRow-sc-ef6c0b11-1"]{content:"knBMpy,"}/*!sc*/
|
||||
.kfOJRI{display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-start;width:100%;}/*!sc*/
|
||||
data-styled.g101[id="Table-style__TableData-sc-ef6c0b11-2"]{content:"kfOJRI,"}/*!sc*/
|
||||
.iJZhfa{width:100%;display:flex;flex-direction:row;justify-content:space-between;align-items:center;}/*!sc*/
|
||||
data-styled.g102[id="Table-style__TableHeader-sc-ef6c0b11-3"]{content:"iJZhfa,"}/*!sc*/
|
||||
.eshZZd{width:100%;display:flex;flex-direction:column;justify-content:flex-start;align-items:flex-start;margin-top:12px;border-top:1px solid #343434;}/*!sc*/
|
||||
.eshZZd a:hover{text-decoration:underline;}/*!sc*/
|
||||
data-styled.g103[id="Table-style__Table-sc-ef6c0b11-4"]{content:"eshZZd,"}/*!sc*/
|
||||
.czBPNh{display:inline-flex;align-items:center;justify-content:center;width:1rem;height:1rem;flex-shrink:0;margin-left:0.25rem;}/*!sc*/
|
||||
data-styled.g104[id="Icons-styles__ExplicitIconContainer-sc-2b56028a-0"]{content:"czBPNh,"}/*!sc*/
|
||||
.fErSXU{display:flex;flex-direction:row;justify-content:center;align-items:center;float:inline-end;}/*!sc*/
|
||||
data-styled.g105[id="AddToCart-style__Control-sc-af8417c8-0"]{content:"fErSXU,"}/*!sc*/
|
||||
.cXOApT{border:none;margin:0;padding:0;font:inherit;background:transparent;cursor:pointer;max-width:100%;display:block;width:3.75rem;white-space:nowrap;background:#FF53A0;transition:background 0.2s ease-in-out,color 0.2s ease-in-out;color:#FFFFFF;border-radius:0.25rem 0rem 0rem 0.25rem;padding:0.25rem 0.5rem;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;font-size:0.75rem;line-height:1rem;text-align:center;}/*!sc*/
|
||||
.row .cXOApT{background:#913B61;color:#B3B3B3;}/*!sc*/
|
||||
.row:hover .cXOApT{background:#FF53A0;color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g108[id="AddToCart-style__PriceButton-sc-af8417c8-3"]{content:"cXOApT,"}/*!sc*/
|
||||
.ewatVW{border:none;margin:0;padding:0;font:inherit;background:transparent;cursor:pointer;background:#D64988;transition:background 0.2s ease-in-out;border-radius:0rem 0.25rem 0.25rem 0rem;padding:0.25rem;font-weight:700;font-size:0.75rem;line-height:1rem;width:1.5rem;min-width:1.5rem;}/*!sc*/
|
||||
.ewatVW i{margin:0.25rem;display:inline-block;vertical-align:middle;transition:border-top-color 0.2s ease-in-out;border-left:0.25rem solid transparent;border-right:0.25rem solid transparent;border-top:0.25rem solid currentColor;}/*!sc*/
|
||||
.ewatVW >div>svg{width:1rem;height:1rem;}/*!sc*/
|
||||
.row .ewatVW{background:#7C3655;}/*!sc*/
|
||||
.row:hover .ewatVW{background:#D64988;}/*!sc*/
|
||||
.row:hover .ewatVW i{border-top-color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g110[id="AddToCart-style__ArrowButton-sc-af8417c8-5"]{content:"ewatVW,"}/*!sc*/
|
||||
.fXQEET{display:none;flex-direction:center;justify-content:center;background:#141414;padding:4px;font-weight:400;font-size:12px;line-height:16px;border-radius:8px;transition:background 0.2s;background:#141414;color:#FFFFFF;text-align:center;padding:4px 4px;width:86px;position:absolute;z-index:2;filter:none;left:50%;margin-left:-43px;}/*!sc*/
|
||||
.fXQEET.top{bottom:155%;}/*!sc*/
|
||||
.fXQEET.top:after{position:absolute;content:"";top:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.top-left{bottom:155%;left:-60%;}/*!sc*/
|
||||
.fXQEET.top-left:after{position:absolute;content:"";top:100%;margin-right:-50px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.top-right{bottom:155%;right:-60%;}/*!sc*/
|
||||
.fXQEET.top-right:after{position:absolute;content:"";top:100%;margin-left:-50px;border:5px solid transparent;border-top-color:#141414;}/*!sc*/
|
||||
.fXQEET.bottom{top:115%;}/*!sc*/
|
||||
.fXQEET.bottom:before{position:absolute;content:"";bottom:100%;left:50%;margin-left:-5px;border:5px solid transparent;border-bottom-color:#141414;}/*!sc*/
|
||||
.fXQEET.left{right:calc(100% + 0.4rem);top:0;bottom:0;margin:auto 0;left:unset;margin-left:unset;height:1.7rem;padding:0.3rem 0.6rem;width:600px;max-width:max-content;}/*!sc*/
|
||||
.fXQEET.left:after{position:absolute;content:"";top:50%;left:100%;margin-top:-5px;border:5px solid transparent;border-left-color:#141414;}/*!sc*/
|
||||
data-styled.g114[id="Tooltip-style__TooltipContainer-sc-6f1e293b-0"]{content:"fXQEET,"}/*!sc*/
|
||||
.gIOTyw{position:relative;}/*!sc*/
|
||||
.gIOTyw:hover .Tooltip-style__TooltipContainer-sc-6f1e293b-0{display:flex;}/*!sc*/
|
||||
data-styled.g115[id="Tooltip-style__Wrapper-sc-6f1e293b-1"]{content:"gIOTyw,"}/*!sc*/
|
||||
.bdZnJo{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.bdZnJo svg:hover{color:#FFFFFF;}/*!sc*/
|
||||
.bdZnJo:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g116[id="AddToPlaylist-style__Control-sc-8c3c49e4-0"]{content:"bdZnJo,"}/*!sc*/
|
||||
.kNIDMA{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.kNIDMA svg:hover{color:#FFFFFF;}/*!sc*/
|
||||
.kNIDMA:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g117[id="AddToQueue-style__Control-sc-1bc05ec0-0"]{content:"kNIDMA,"}/*!sc*/
|
||||
.gkrZWH{display:flex;align-items:center;position:relative;color:#B3B3B3;}/*!sc*/
|
||||
.gkrZWH:hover{--play-btn-bg:#5A5A5A;color:#FFFFFF;}/*!sc*/
|
||||
.gkrZWH:disabled svg{filter:unset;}/*!sc*/
|
||||
data-styled.g118[id="Play-style__Control-sc-6d5ab96d-0"]{content:"gkrZWH,"}/*!sc*/
|
||||
.gxWPvu{text-overflow:ellipsis;overflow:hidden;width:auto;}/*!sc*/
|
||||
data-styled.g123[id="Marquee-style__MarqueeElement-sc-b0373cc7-0"]{content:"gxWPvu,"}/*!sc*/
|
||||
.jkEITr{overflow:hidden;width:100%;display:grid;grid-auto-flow:column;position:relative;max-width:17rem;white-space:nowrap;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0{display:inline-block;width:max-content;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0:nth-child(2){margin-left:16px;visibility:hidden;}/*!sc*/
|
||||
.jkEITr>.Marquee-style__MarqueeElement-sc-b0373cc7-0{white-space:nowrap;}/*!sc*/
|
||||
.fjXTTJ{overflow:hidden;width:100%;display:grid;grid-auto-flow:column;position:relative;white-space:nowrap;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0{display:inline-block;width:max-content;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0:nth-child(2){margin-left:16px;visibility:hidden;}/*!sc*/
|
||||
.fjXTTJ>.Marquee-style__MarqueeElement-sc-b0373cc7-0{white-space:nowrap;}/*!sc*/
|
||||
data-styled.g124[id="Marquee-style__MarqueeWrapper-sc-b0373cc7-1"]{content:"jkEITr,fjXTTJ,"}/*!sc*/
|
||||
.gtSAGs{display:grid;grid-template-columns:1fr;width:100%;}/*!sc*/
|
||||
data-styled.g125[id="Marquee-style__MarqueeGrid-sc-b0373cc7-2"]{content:"gtSAGs,"}/*!sc*/
|
||||
.hoWvxf{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1.125rem;line-height:1rem;text-align:center;width:2.25rem;min-width:2.25rem;}/*!sc*/
|
||||
.hoWvxf.active{font-size:1rem;color:#FFFFFF;}/*!sc*/
|
||||
.izvHCz{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1rem;text-align:center;width:2.25rem;min-width:2.25rem;}/*!sc*/
|
||||
.izvHCz.active{font-size:1rem;color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g127[id="Tables-shared-style__TrackNo-sc-d94d4af-1"]{content:"hoWvxf,izvHCz,"}/*!sc*/
|
||||
.fBVOpY{right:0;top:0;height:0.875rem;padding:0 0.063rem;font-size:0.438rem;background:#01FF95;width:100%;color:#141414;position:absolute;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;}/*!sc*/
|
||||
data-styled.g128[id="Tables-shared-style__Exclusive-sc-d94d4af-2"]{content:"fBVOpY,"}/*!sc*/
|
||||
.iHAwiX{display:flex;}/*!sc*/
|
||||
data-styled.g129[id="Tables-shared-style__TitleRow-sc-d94d4af-3"]{content:"iHAwiX,"}/*!sc*/
|
||||
.jkqMEC{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:700;font-size:0.688rem;line-height:1rem;letter-spacing:0.005em;color:#FFFFFF;}/*!sc*/
|
||||
.jkqMEC span{font-weight:400;}/*!sc*/
|
||||
data-styled.g130[id="Tables-shared-style__ReleaseName-sc-d94d4af-4"]{content:"jkqMEC,"}/*!sc*/
|
||||
.febCWq{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:0.625rem;}/*!sc*/
|
||||
.febCWq a.artwork{position:relative;}/*!sc*/
|
||||
.febCWq img{aspect-ratio:1/1;object-fit:cover;object-position:center;}/*!sc*/
|
||||
.febCWq svg{transition:color 0.2s ease-in-out;color:inherit;}/*!sc*/
|
||||
.row:hover .febCWq svg{color:#FFFFFF;}/*!sc*/
|
||||
data-styled.g131[id="Tables-shared-style__ArtworkAndControls-sc-d94d4af-5"]{content:"febCWq,"}/*!sc*/
|
||||
.gLWMaE .row{justify-content:flex-start;}/*!sc*/
|
||||
.gLWMaE .row.current{background:#494949;}/*!sc*/
|
||||
.gLWMaE .cell{padding-top:0;padding-bottom:0;}/*!sc*/
|
||||
.gLWMaE .cell a:hover{text-decoration:underline;}/*!sc*/
|
||||
.gLWMaE .cell.price{font-size:0.875rem;font-weight:700;color:#FFFFFF;}/*!sc*/
|
||||
.gLWMaE .controls{flex-shrink:0;padding-left:0;width:8.125rem;min-width:8.125rem;}/*!sc*/
|
||||
.gLWMaE .title{width:100%;flex-grow:1;overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .title .container{overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .title .container >div,.gLWMaE .title .container >a>span{overflow:hidden;}/*!sc*/
|
||||
.gLWMaE .format{width:8.5rem;min-width:8.5rem;}/*!sc*/
|
||||
.gLWMaE .price{width:4.5rem;min-width:4.5rem;}/*!sc*/
|
||||
.gLWMaE .cart-actions{width:7.5rem;min-width:7.5rem;display:flex;justify-content:flex-end;}/*!sc*/
|
||||
.gLWMaE .card{width:7.5rem;min-width:7.5rem;display:flex;justify-content:flex-end;}/*!sc*/
|
||||
.gLWMaE.numbers .controls{width:11.125rem;min-width:11.125rem;}/*!sc*/
|
||||
data-styled.g132[id="Tables-shared-style__Wrapper-sc-d94d4af-6"]{content:"gLWMaE,"}/*!sc*/
|
||||
.kOioDs{display:flex;align-items:center;justify-content:flex-start;position:fixed;bottom:calc(0px + 1rem);right:calc(1rem + var(--scrollbar-width,0px));height:2.5rem;padding:0 1rem;font-weight:500;background:#39C0DE;border-radius:2rem;color:#141414;overflow:hidden;transition:max-width 0.3s ease-in-out;z-index:3;max-width:2.5rem;}/*!sc*/
|
||||
.kOioDs >svg{min-width:1rem;min-height:1rem;margin-left:-0.2rem;margin-right:0.5rem;}/*!sc*/
|
||||
.kOioDs >span{transition:opacity 0.4s ease-in-out;opacity:0;}/*!sc*/
|
||||
@media (min-width: 1024px){.kOioDs{bottom:1rem;}.kOioDs:hover{max-width:15rem;}.kOioDs:hover >span{opacity:1;}}/*!sc*/
|
||||
data-styled.g216[id="FeedbackButton-style__ButtonStyled-sc-75292206-0"]{content:"kOioDs,"}/*!sc*/
|
||||
.clBnoV{background-color:#343434;display:flex;min-height:19rem;justify-content:center;padding:3rem 1.25rem calc(3rem + 0px) 1.25rem;}/*!sc*/
|
||||
data-styled.g228[id="Footer-style__FooterContainer-sc-4f2908a0-0"]{content:"clBnoV,"}/*!sc*/
|
||||
.gxyJqq{max-width:90rem;width:100%;margin:0 auto;display:flex;justify-content:space-between;gap:2rem 1rem;flex-direction:column;}/*!sc*/
|
||||
@media (min-width: 768px){.gxyJqq{flex-direction:row;}}/*!sc*/
|
||||
@media (min-width: 1024px){.gxyJqq{gap:2rem;}}/*!sc*/
|
||||
@media (min-width: 1200px){.gxyJqq{padding:0 1.5rem;}}/*!sc*/
|
||||
data-styled.g229[id="Footer-style__FooterMaxWidth-sc-4f2908a0-1"]{content:"gxyJqq,"}/*!sc*/
|
||||
.iLPfwv{display:flex;flex-direction:column;gap:2.5rem;flex:0 1 auto;order:1;min-width:15rem;justify-content:space-between;}/*!sc*/
|
||||
@media (min-width: 1024px){.iLPfwv{min-width:16.25rem;}}/*!sc*/
|
||||
data-styled.g230[id="Footer-style__BrandColumnContainer-sc-4f2908a0-2"]{content:"iLPfwv,"}/*!sc*/
|
||||
.fsTWEU{display:flex;flex-direction:column;justify-content:start;gap:2.5rem;}/*!sc*/
|
||||
data-styled.g231[id="Footer-style__BrandWrapper-sc-4f2908a0-3"]{content:"fsTWEU,"}/*!sc*/
|
||||
.jvSVck{display:flex;flex-wrap:wrap;align-items:center;gap:1rem;padding-right:0.625rem;}/*!sc*/
|
||||
data-styled.g232[id="Footer-style__SocialLinksContainer-sc-4f2908a0-4"]{content:"jvSVck,"}/*!sc*/
|
||||
.dhzLKu{display:flex;flex-wrap:wrap;flex:1 1 auto;row-gap:2rem;max-width:45rem;order:3;justify-content:space-between;gap:1rem;}/*!sc*/
|
||||
@media (min-width: 768px){.dhzLKu{flex-wrap:nowrap;order:2;}}/*!sc*/
|
||||
data-styled.g233[id="Footer-style__LinksColumnContainer-sc-4f2908a0-5"]{content:"dhzLKu,"}/*!sc*/
|
||||
.csFdrZ{width:100%;}/*!sc*/
|
||||
@media (min-width: 580px){.csFdrZ{max-width:10rem;}}/*!sc*/
|
||||
data-styled.g234[id="Footer-style__LinksGroup-sc-4f2908a0-6"]{content:"csFdrZ,"}/*!sc*/
|
||||
.dNLOmf{color:#FFFFFF;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1.25rem;margin-bottom:1rem;display:block;}/*!sc*/
|
||||
@media (min-width: 580px){.dNLOmf{font-size:0.75rem;line-height:1rem;}}/*!sc*/
|
||||
data-styled.g235[id="Footer-style__GroupTitle-sc-4f2908a0-7"]{content:"dNLOmf,"}/*!sc*/
|
||||
.gUWvku{list-style:none;padding:0;}/*!sc*/
|
||||
data-styled.g236[id="Footer-style__LinksList-sc-4f2908a0-8"]{content:"gUWvku,"}/*!sc*/
|
||||
.kWoBNH{color:#8C8C8C;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;font-size:1rem;line-height:1.25rem;}/*!sc*/
|
||||
.kWoBNH a,.kWoBNH button{cursor:pointer;}/*!sc*/
|
||||
.kWoBNH button{text-align:start;font-weight:inherit;font-size:inherit;line-height:inherit;}/*!sc*/
|
||||
.kWoBNH .listItemLink:hover,.kWoBNH button:hover{color:rgba(255,255,255,0.9);}/*!sc*/
|
||||
@media (min-width: 580px){.kWoBNH{font-size:0.75rem;line-height:1rem;}}/*!sc*/
|
||||
data-styled.g237[id="Footer-style__ListItem-sc-4f2908a0-9"]{content:"kWoBNH,"}/*!sc*/
|
||||
.lesWfY{min-width:10.5rem;min-height:3.25rem;}/*!sc*/
|
||||
data-styled.g238[id="Footer-style__LogoWrapper-sc-4f2908a0-10"]{content:"lesWfY,"}/*!sc*/
|
||||
.UFZKa{display:block;color:#8C8C8C;font-size:0.75rem;font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:400;}/*!sc*/
|
||||
data-styled.g239[id="Footer-style__Copyright-sc-4f2908a0-11"]{content:"UFZKa,"}/*!sc*/
|
||||
.ghoRKa{display:flex;flex-direction:row;gap:1rem;order:2;}/*!sc*/
|
||||
@media (min-width: 768px){.ghoRKa{flex-direction:column;}}/*!sc*/
|
||||
.ghoRKa a{width:fit-content;}/*!sc*/
|
||||
data-styled.g240[id="Footer-style__MobileAppLinks-sc-4f2908a0-12"]{content:"ghoRKa,"}/*!sc*/
|
||||
.fSmyMD{display:flex;flex-direction:row;min-width:171px;width:208px;max-width:320px;position:sticky;max-height:calc(100vh - (0px + 60px + 3.875rem));top:calc(60px + 1.75rem);margin-left:1.5rem;transition:max-height 0.3s ease-in-out;overflow:hidden;}/*!sc*/
|
||||
.fSmyMD .sidebar-content{background-color:#343434;flex:1;}/*!sc*/
|
||||
.fSmyMD .sidebar-resizer{flex-grow:0;flex-shrink:0;flex-basis:0.375rem;justify-self:flex-end;cursor:col-resize;resize:horizontal;}/*!sc*/
|
||||
.fSmyMD .sidebar-resizer:hover{width:0.1875rem;background-color:#141414;}/*!sc*/
|
||||
data-styled.g245[id="Sidebar-style__DesktopSidebar-sc-ec5c0dc3-0"]{content:"fSmyMD,"}/*!sc*/
|
||||
.kARIcT{position:relative;display:flex;align-items:center;font-size:1.25rem;padding:0.25rem 1.5rem;line-height:1.75rem;transition:all 0.1s ease-in-out;width:max-content;cursor:pointer;}/*!sc*/
|
||||
@media (min-width: 1200px){.kARIcT{border-bottom:0.0625rem solid #2D2D2D;padding:0.75rem 1.25rem;margin-left:0;font-size:0.875rem;line-height:1.5rem;width:auto;}.kARIcT:hover{cursor:pointer;background-color:#2D2D2D;box-shadow:inset 0.125rem 0 0 0 #01FF95;}}/*!sc*/
|
||||
.kARIcT svg{margin-right:0.5rem;width:1.5rem!important;height:auto!important;}/*!sc*/
|
||||
.kARIcT .forward-arrow{width:0.9rem!important;height:0.9rem!important;margin-left:0.375rem;}/*!sc*/
|
||||
@media (min-width: 1200px){.kARIcT svg{margin-right:0.375rem;width:1rem!important;height:1rem!important;}}/*!sc*/
|
||||
data-styled.g252[id="Sidebar-style__SidebarLink-sc-ec5c0dc3-7"]{content:"kARIcT,"}/*!sc*/
|
||||
.bOxODG{margin:0.5rem auto;background-color:#343434;width:calc(100% - 2 * 0.9375rem);height:0.0625rem;}/*!sc*/
|
||||
data-styled.g255[id="Sidebar-style__HorizontalBar-sc-ec5c0dc3-10"]{content:"bOxODG,"}/*!sc*/
|
||||
.cpMYKr{color:#01FF95;text-transform:capitalize;font-weight:500;font-size:1rem;align-items:flex-start;}/*!sc*/
|
||||
.cpMYKr a{display:flex;align-items:center;}/*!sc*/
|
||||
.cpMYKr:hover{color:#01CC77;}/*!sc*/
|
||||
@media (min-width: 1200px){.cpMYKr{font-size:0.875rem;}}/*!sc*/
|
||||
.cpMYKr svg{margin-right:0.375rem;}/*!sc*/
|
||||
data-styled.g258[id="Sidebar-style__CreatePlaylist-sc-ec5c0dc3-13"]{content:"cpMYKr,"}/*!sc*/
|
||||
.cgwoae{flex-grow:1;display:flex;flex-direction:column;min-height:0;padding:0 1.25rem;}/*!sc*/
|
||||
.cgwoae .element{margin:0.8rem 0;}/*!sc*/
|
||||
.cgwoae .mobile-playlist-element{margin-bottom:1.5rem;}/*!sc*/
|
||||
.cgwoae .mobile-playlist-element:first-of-type{margin-top:1rem;}/*!sc*/
|
||||
.cgwoae.content{height:3.75rem;min-height:3.75rem;max-height:3.75rem;}/*!sc*/
|
||||
.cgwoae.scrollable{flex-grow:1;overflow:auto;min-height:0;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar{width:0rem;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar-track{background-color:#343434;}/*!sc*/
|
||||
.cgwoae.scrollable::-webkit-scrollbar-thumb{background-color:#2D2D2D;}/*!sc*/
|
||||
.cgwoae.scrollable:hover::-webkit-scrollbar{width:0.3125rem;}/*!sc*/
|
||||
data-styled.g260[id="Sidebar-style__PlaylistSectionContent-sc-ec5c0dc3-15"]{content:"cgwoae,"}/*!sc*/
|
||||
.cXCcbl{position:relative;height:calc(100% - 12.9375rem);width:100%;}/*!sc*/
|
||||
.cXCcbl div.wrapper{position:absolute;top:0;bottom:0;left:0;width:100%;display:flex;flex-direction:column;}/*!sc*/
|
||||
data-styled.g261[id="Sidebar-style__PlaylistSectionContainer-sc-ec5c0dc3-16"]{content:"cXCcbl,"}/*!sc*/
|
||||
.ccUqKr{position:relative;width:100%;background:#262626;min-height:calc(100vh - 60px);display:flex;justify-content:center;}/*!sc*/
|
||||
data-styled.g262[id="MainLayout-style__MainWrapper-sc-909801dd-0"]{content:"ccUqKr,"}/*!sc*/
|
||||
.eEEXCV{padding:1.75rem 0;width:100%;max-width:1420px;display:flex;margin-top:60px;}/*!sc*/
|
||||
data-styled.g263[id="MainLayout-style__Main-sc-909801dd-1"]{content:"eEEXCV,"}/*!sc*/
|
||||
.AtWIy{width:100%;padding:0 1.5rem;display:flex;flex-direction:column;justify-content:start;}/*!sc*/
|
||||
@media (min-width: 1024px){.AtWIy{width:calc( 100% - 208px );}}/*!sc*/
|
||||
data-styled.g264[id="MainLayout-style__MainContent-sc-909801dd-2"]{content:"AtWIy,"}/*!sc*/
|
||||
.iLohBb{font-family:'aeonikFont','aeonikFont Fallback';font-style:normal;font-weight:500;font-size:11px;line-height:16px;letter-spacing:0.005em;color:#39C0DE;}/*!sc*/
|
||||
.iLohBb a:hover{text-decoration:underline;}/*!sc*/
|
||||
data-styled.g265[id="ArtistNames-sc-881a9c20-0"]{content:"iLohBb,"}/*!sc*/
|
||||
.iKReJc{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:0.75rem;height:2.125rem;padding-right:0.75rem;border-radius:1.406rem;transition:background 0.2s;cursor:pointer;}/*!sc*/
|
||||
data-styled.g497[id="CollectionControls-style__Controls-sc-33f388f9-0"]{content:"iKReJc,"}/*!sc*/
|
||||
.fIvIm{display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:16px;}/*!sc*/
|
||||
data-styled.g498[id="CollectionControls-style__Wrapper-sc-33f388f9-1"]{content:"fIvIm,"}/*!sc*/
|
||||
.dMoKwU a.artwork>img{min-width:2.375rem;}/*!sc*/
|
||||
.dMoKwU .droppable{width:100%;}/*!sc*/
|
||||
.dMoKwU .cell.bpm a{white-space:nowrap;overflow:hidden;display:block;text-overflow:ellipsis;}/*!sc*/
|
||||
.dMoKwU .cell.format{padding-left:0;}/*!sc*/
|
||||
.dMoKwU .label{width:7.5rem;min-width:7.5rem;}/*!sc*/
|
||||
.dMoKwU .date{width:7.5rem;min-width:7.5rem;}/*!sc*/
|
||||
.dMoKwU .bpm{width:9.375rem;min-width:9.375rem;display:block;text-overflow:elipsis;white-space:no-wrap;}/*!sc*/
|
||||
.dMoKwU .price{width:3.75rem;min-width:3.75rem;}/*!sc*/
|
||||
.dMoKwU i.arrow{margin:0.25rem;display:inline-block;vertical-align:middle;width:0;height:0;border-left:0.25rem solid transparent;border-right:0.25rem solid transparent;border-top:0.25rem solid #01FF95;}/*!sc*/
|
||||
.dMoKwU i.arrow.desc{transform:rotate(180deg);}/*!sc*/
|
||||
data-styled.g547[id="TracksTable-style__TracksWrapper-sc-fcbf7f55-0"]{content:"dMoKwU,"}/*!sc*/
|
||||
.cNqYNo{padding-bottom:0.1rem;font-weight:400;font-size:0.875rem;line-height:1.25rem;color:#8C8C8C;}/*!sc*/
|
||||
data-styled.g617[id="TitleControls-style__PreText-sc-10266053-1"]{content:"cNqYNo,"}/*!sc*/
|
||||
.kHnlXM{padding-bottom:0;}/*!sc*/
|
||||
@media (min-width: 768px){.kHnlXM{padding-right:1rem;}}/*!sc*/
|
||||
data-styled.g618[id="TitleControls-style__TitleContainer-sc-10266053-2"]{content:"kHnlXM,"}/*!sc*/
|
||||
.eZXJZk{padding-bottom:.25rem;}/*!sc*/
|
||||
.eZXJZk.with-pre{padding-top:1.25rem;}/*!sc*/
|
||||
data-styled.g619[id="TitleControls-style__Controls-sc-10266053-3"]{content:"eZXJZk,"}/*!sc*/
|
||||
.kigZFc{width:100%;display:flex;flex-direction:row;justify-content:flex-start;align-items:center;gap:.5rem;}/*!sc*/
|
||||
.kigZFc>:last-child{margin-left:auto;}/*!sc*/
|
||||
@media (min-width: 768px){.kigZFc{display:flex;}.kigZFc>:last-child{margin-left:inherit;}}/*!sc*/
|
||||
data-styled.g620[id="TitleControls-style__Container-sc-10266053-4"]{content:"kigZFc,"}/*!sc*/
|
||||
</style></head><body class="__variable_a8b512"><div id="__next"><div><div id="bp-header"><div class="Header-style__HeaderWrapper-sc-54c5f6f1-0 gZFJrf header-fixed"><div class="Header-style__DesktopHeader-sc-54c5f6f1-2 kFaHkN"><ul class="Header-style__LeftRegion-sc-54c5f6f1-3 eRPXdM"><li><a href="/"><img src="/images/beatport-logo-icon.svg" alt="Beatport Logo" class="Header-style__BeatportLogoLarge-sc-54c5f6f1-7 dNxFON"/></a></li><li class="header_item dropdown_toggle genre_toggle"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq unstyled" id="genre-dropdown-menu-button" aria-haspopup="menu" aria-expanded="false" aria-controls="genre-dropdown-menu" aria-label="Genres menu"><span class="rotatable">Genres<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-drop-down" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#drop-down"></use></svg></span></button><div id="genre-dropdown-menu" aria-labelledby="genre-dropdown-menu-button" data-visible="false" data-dropdown-type="genre" class="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0 dkwGxt"><div role="menu" aria-labelledby="genres-dropdown-menu" class="HeaderGenreSubMenu-style__GenreDropDownGrid-sc-5f01336e-4 kNtdbg"></div></div></li><li class="header_item"><a data-testid="header-mainnav-link-staffpicks" href="/staff-picks"><span>Dig Deeper</span></a></li><li class="header_item"><a data-testid="header-mainnav-link-djapp" target="_blank" rel="noopener noreferrer" href="https://dj.beatport.com/"><span>DJ App<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item"><a data-testid="header-mainnav-link-beatport" target="_blank" rel="noopener noreferrer" href="https://www.beatportal.com/"><span>Beatportal<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item"><a data-testid="header-mainnav-link-artists-labels" target="_blank" rel="noopener noreferrer" href="https://greenroom.beatport.com/"><span>For Artists & Labels<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></span></a></li><li class="header_item dropdown_toggle"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq unstyled" id="events-dropdown-menu-button" aria-haspopup="menu" aria-expanded="false" aria-controls="events-dropdown-menu" aria-label="Events menu"><span class="rotatable">Events<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-drop-down" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#drop-down"></use></svg></span></button><div id="events-dropdown-menu" aria-labelledby="events-dropdown-menu-button" data-visible="false" data-dropdown-type="events" class="HeaderDropdownToggle-style__DropdownMenu-sc-fff30e07-0 dkwGxt"><div role="menu" class="HeaderEventsSubMenu-style__EventsSubMenuWrapper-sc-75ecafea-0 jgwRkR"><a href="http://greenroom.beatport.com/tickets?utm_medium=bp_store_banner&utm_source=bp_store&utm_campaign=2025_evergreen_greenroom&utm_content=banner" target="_blank" rel="noopener noreferrer" role="menuitem">For Promoters<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></a><a href="http://beatportal.com/events?utm_source=beatport&utm_medium=banner&utm_campaign=header_banner" target="_blank" rel="noopener noreferrer" role="menuitem">Upcoming Events<svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-menu-arrow" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#menu-arrow"></use></svg></a></div></div></li></ul><ul class="Header-style__RightRegion-sc-54c5f6f1-4 exexVn"><li class="search"><div id="site-search" class="Header-style__HeaderSearchWrapper-sc-54c5f6f1-10 koymgh"><div class="Header-style__HeaderSearch-sc-54c5f6f1-11 eCACUJ"><div><svg role="img" fill="#8C8C8C" width="16" height="16" data-testid="icon-search" focusable="false" style="min-height:16;min-width:16"><use href="/icons/sprite.svg#search"></use></svg></div><input id="search-field" name="search-field" data-testid="header-search-input" placeholder="Search" aria-label="Search" type="search" autoComplete="no-username" class="Search-style__SearchInput-sc-224b2fdd-1 dxGRL" value=""/></div></div></li><li class="subscription_button"><a href="/subscriptions">Add Streaming</a></li><li class="header_item account"><button type="button" class="Button-style__ButtonStyled-sc-e1b4adaf-0 dXSwbq linkLine truncated
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Test per core.beatport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from core import beatport
|
||||
|
||||
|
||||
class TestListGenres:
|
||||
def test_returns_list_of_dicts(self):
|
||||
result = beatport.list_genres()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 10 # almeno 10 generi
|
||||
for g in result:
|
||||
assert set(g.keys()) == {"slug", "id", "name"}
|
||||
assert isinstance(g["slug"], str) and g["slug"]
|
||||
assert isinstance(g["id"], int) and g["id"] > 0
|
||||
assert isinstance(g["name"], str) and g["name"]
|
||||
|
||||
def test_sorted_alphabetically_by_name(self):
|
||||
result = beatport.list_genres()
|
||||
names = [g["name"] for g in result]
|
||||
assert names == sorted(names, key=str.casefold)
|
||||
|
||||
def test_melodic_house_techno_present(self):
|
||||
result = beatport.list_genres()
|
||||
slugs = [g["slug"] for g in result]
|
||||
assert "melodic-house-techno" in slugs
|
||||
|
||||
|
||||
class TestBeatportTrack:
|
||||
def test_display_format(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1,
|
||||
title="Hot Sauce",
|
||||
mix="Extended",
|
||||
artists="Kapuchon, Miss Monique & GLZ",
|
||||
duration_sec=336,
|
||||
beatport_id=12345,
|
||||
)
|
||||
assert t.display == "Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)"
|
||||
|
||||
def test_display_pads_seconds(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1, title="X", mix="Y", artists="A",
|
||||
duration_sec=65, beatport_id=1,
|
||||
)
|
||||
assert t.display.endswith("(1:05)")
|
||||
|
||||
def test_spotify_query(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1,
|
||||
title="Hot Sauce",
|
||||
mix="Extended",
|
||||
artists="Kapuchon, Miss Monique & GLZ",
|
||||
duration_sec=336,
|
||||
beatport_id=12345,
|
||||
)
|
||||
assert t.spotify_query == "Kapuchon, Miss Monique & GLZ Hot Sauce"
|
||||
|
||||
def test_is_frozen(self):
|
||||
t = beatport.BeatportTrack(
|
||||
position=1, title="X", mix="Y", artists="A",
|
||||
duration_sec=1, beatport_id=1,
|
||||
)
|
||||
with pytest.raises(Exception):
|
||||
t.title = "Z" # frozen=True impedisce mutazione
|
||||
|
||||
|
||||
class TestExtractNextData:
|
||||
def test_extracts_json_from_valid_html(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
data = beatport._extract_next_data(html)
|
||||
assert isinstance(data, dict)
|
||||
assert "props" in data
|
||||
|
||||
def test_missing_script_raises(self):
|
||||
with pytest.raises(beatport.BeatportParseError, match="__NEXT_DATA__ non trovato"):
|
||||
beatport._extract_next_data("<html><body>nulla</body></html>")
|
||||
|
||||
def test_malformed_json_raises(self):
|
||||
broken = '<script id="__NEXT_DATA__" type="application/json">{not: valid}</script>'
|
||||
with pytest.raises(beatport.BeatportParseError, match="JSON malformato"):
|
||||
beatport._extract_next_data(broken)
|
||||
|
||||
|
||||
class TestParseTracks:
|
||||
def test_extracts_100_tracks(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
data = beatport._extract_next_data(html)
|
||||
tracks = beatport._parse_tracks(data)
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_positions_are_sequential_1_to_100(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
tracks = beatport._parse_tracks(beatport._extract_next_data(html))
|
||||
positions = [t.position for t in tracks]
|
||||
assert positions == list(range(1, 101))
|
||||
|
||||
def test_track_shape(self, fixtures_dir):
|
||||
html = (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
tracks = beatport._parse_tracks(beatport._extract_next_data(html))
|
||||
first = tracks[0]
|
||||
assert isinstance(first, beatport.BeatportTrack)
|
||||
assert first.title
|
||||
assert first.artists
|
||||
assert first.duration_sec > 0
|
||||
assert first.beatport_id > 0
|
||||
|
||||
def test_schema_missing_results_raises(self):
|
||||
with pytest.raises(beatport.BeatportParseError, match="results"):
|
||||
beatport._parse_tracks({"props": {"pageProps": {}}})
|
||||
|
||||
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
|
||||
def _mock_response(text: str, status_code: int = 200) -> MagicMock:
|
||||
resp = MagicMock()
|
||||
resp.text = text
|
||||
resp.status_code = status_code
|
||||
def _raise():
|
||||
if status_code >= 400:
|
||||
raise Exception(f"HTTP {status_code}")
|
||||
resp.raise_for_status = _raise
|
||||
return resp
|
||||
|
||||
|
||||
class TestFetchTop100:
|
||||
@pytest.fixture
|
||||
def fixture_html(self, fixtures_dir):
|
||||
return (fixtures_dir / "beatport_melodic_top100.html").read_text()
|
||||
|
||||
def test_success_returns_100_tracks(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
tracks = beatport.fetch_top100("melodic-house-techno")
|
||||
assert len(tracks) == 100
|
||||
|
||||
def test_invalid_slug_raises_value_error(self):
|
||||
with pytest.raises(ValueError, match="slug"):
|
||||
beatport.fetch_top100("not-a-real-genre")
|
||||
|
||||
def test_5xx_retries_and_raises_unreachable(self):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response("", 503)
|
||||
with patch("core.beatport.time.sleep"): # skip backoff
|
||||
with pytest.raises(beatport.BeatportUnreachableError):
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 3 # 1 + 2 retry
|
||||
|
||||
def test_cache_hit_within_ttl(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 1
|
||||
|
||||
def test_cache_expires_after_ttl(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
with freeze_time("2026-01-01 10:00:00") as frozen:
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
frozen.tick(delta=beatport._CACHE_TTL_SEC + 1)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_force_refresh_bypasses_cache(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
beatport.fetch_top100("melodic-house-techno", force_refresh=True)
|
||||
assert mock_get.call_count == 2
|
||||
|
||||
def test_uses_chrome_impersonation(self, fixture_html):
|
||||
beatport._cache.clear()
|
||||
with patch("core.beatport._cffi_requests.get") as mock_get:
|
||||
mock_get.return_value = _mock_response(fixture_html, 200)
|
||||
beatport.fetch_top100("melodic-house-techno")
|
||||
call_kwargs = mock_get.call_args.kwargs
|
||||
assert "impersonate" in call_kwargs
|
||||
assert call_kwargs["impersonate"].startswith("chrome")
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Test per core.spotify_client.search_track()."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import spotify_client
|
||||
|
||||
|
||||
class TestSearchTrack:
|
||||
@responses.activate
|
||||
def test_returns_track_dict_when_found(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"name": "Hot Sauce",
|
||||
"artists": [{"name": "Kapuchon"}],
|
||||
"external_urls": {"spotify": "https://open.spotify.com/track/abc123"},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_track("fake-token", "Kapuchon Hot Sauce")
|
||||
assert result is not None
|
||||
assert result["id"] == "abc123"
|
||||
assert result["url"] == "https://open.spotify.com/track/abc123"
|
||||
|
||||
@responses.activate
|
||||
def test_returns_none_when_no_results(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_track("fake-token", "no-match")
|
||||
assert result is None
|
||||
|
||||
@responses.activate
|
||||
def test_sends_bearer_token(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_track("my-token", "q")
|
||||
assert responses.calls[0].request.headers["Authorization"] == "Bearer my-token"
|
||||
|
||||
@responses.activate
|
||||
def test_query_params_correct(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_track("t", "Kapuchon Hot Sauce")
|
||||
params = responses.calls[0].request.params
|
||||
assert params["q"] == "Kapuchon Hot Sauce"
|
||||
assert params["type"] == "track"
|
||||
assert params["limit"] == "1"
|
||||
|
||||
|
||||
class TestSearchTracks:
|
||||
@responses.activate
|
||||
def test_returns_list_of_tracks(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={
|
||||
"tracks": {
|
||||
"items": [
|
||||
{
|
||||
"id": f"id{i}",
|
||||
"name": f"Track {i}",
|
||||
"artists": [{"name": "Solomun"}],
|
||||
"album": {"name": "Album X"},
|
||||
"duration_ms": 300000 + i * 1000,
|
||||
"external_urls": {"spotify": f"https://open.spotify.com/track/id{i}"},
|
||||
}
|
||||
for i in range(50)
|
||||
]
|
||||
}
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "solomun", limit=50)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 50
|
||||
first = result[0]
|
||||
assert first["id"] == "id0"
|
||||
assert first["name"] == "Track 0"
|
||||
assert first["artists"] == "Solomun"
|
||||
assert first["album"] == "Album X"
|
||||
assert first["duration_sec"] == 300
|
||||
assert first["url"] == "https://open.spotify.com/track/id0"
|
||||
|
||||
@responses.activate
|
||||
def test_multiple_artists_joined_with_comma(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": [{
|
||||
"id": "x", "name": "n",
|
||||
"artists": [{"name": "A"}, {"name": "B"}, {"name": "C"}],
|
||||
"album": {"name": "Alb"},
|
||||
"duration_ms": 60000,
|
||||
"external_urls": {"spotify": "u"},
|
||||
}]}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "q", limit=1)
|
||||
assert result[0]["artists"] == "A, B, C"
|
||||
|
||||
@responses.activate
|
||||
def test_empty_query_returns_empty_list(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_tracks("t", "no-match", limit=50)
|
||||
assert result == []
|
||||
|
||||
@responses.activate
|
||||
def test_query_params_include_limit(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"tracks": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_tracks("t", "q", limit=25)
|
||||
params = responses.calls[0].request.params
|
||||
assert params["q"] == "q"
|
||||
assert params["type"] == "track"
|
||||
assert params["limit"] == "25"
|
||||
|
||||
|
||||
class TestSearchArtistDiscography:
|
||||
@responses.activate
|
||||
def test_exact_name_match_beats_popular(self):
|
||||
# 3 candidati: uno esatto (case-insensitive), altri popolari
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [
|
||||
{"id": "pop", "name": "Solomun Tribute", "popularity": 90},
|
||||
{"id": "exact", "name": "SOLOMUN", "popularity": 60},
|
||||
{"id": "unrelated", "name": "Other", "popularity": 70},
|
||||
]}},
|
||||
status=200,
|
||||
)
|
||||
# Mock top-tracks vuoto per non allungare il test
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/exact/top-tracks",
|
||||
json={"tracks": []},
|
||||
status=200,
|
||||
)
|
||||
# Mock albums vuoto
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/exact/albums",
|
||||
json={"items": []},
|
||||
status=200,
|
||||
)
|
||||
result = spotify_client.search_artist_discography("t", "Solomun")
|
||||
assert result == []
|
||||
# Verifica che sia stato chiamato l'artista "exact", non "pop"
|
||||
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||
assert len(top_tracks_calls) == 1
|
||||
assert "/exact/top-tracks" in top_tracks_calls[0].request.url
|
||||
|
||||
@responses.activate
|
||||
def test_falls_back_to_most_popular_if_no_exact_match(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [
|
||||
{"id": "a1", "name": "Solomun Fanpage", "popularity": 30},
|
||||
{"id": "a2", "name": "Solomun Live", "popularity": 80},
|
||||
]}},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/a2/top-tracks",
|
||||
json={"tracks": []},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/a2/albums",
|
||||
json={"items": []},
|
||||
status=200,
|
||||
)
|
||||
spotify_client.search_artist_discography("t", "solomun")
|
||||
top_tracks_calls = [c for c in responses.calls if "/top-tracks" in c.request.url]
|
||||
assert "/a2/top-tracks" in top_tracks_calls[0].request.url
|
||||
|
||||
@responses.activate
|
||||
def test_raises_when_no_artist_found(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": []}},
|
||||
status=200,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Artista"):
|
||||
spotify_client.search_artist_discography("t", "asdgjhkasdgj")
|
||||
|
||||
@responses.activate
|
||||
def test_combines_top_tracks_and_album_tracks(self):
|
||||
# 1 artista esatto
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [{"id": "artX", "name": "artX", "popularity": 50}]}},
|
||||
status=200,
|
||||
)
|
||||
# 3 top-tracks
|
||||
top_tracks_data = {"tracks": [
|
||||
{
|
||||
"id": f"t{i}", "name": f"Top{i}",
|
||||
"artists": [{"name": "artX"}],
|
||||
"album": {"name": "AlbTop"},
|
||||
"duration_ms": 200000,
|
||||
"external_urls": {"spotify": f"u{i}"},
|
||||
} for i in range(3)
|
||||
]}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/artX/top-tracks",
|
||||
json=top_tracks_data,
|
||||
status=200,
|
||||
)
|
||||
# 2 album
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/artX/albums",
|
||||
json={"items": [
|
||||
{"id": "alb1", "name": "Album 1"},
|
||||
{"id": "alb2", "name": "Album 2"},
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
# album 1: 2 tracce
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb1/tracks",
|
||||
json={"items": [
|
||||
{
|
||||
"id": f"a1t{i}", "name": f"Alb1Track{i}",
|
||||
"artists": [{"name": "artX"}],
|
||||
"duration_ms": 180000,
|
||||
"external_urls": {"spotify": f"a1u{i}"},
|
||||
} for i in range(2)
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
# album 2: 1 traccia
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb2/tracks",
|
||||
json={"items": [
|
||||
{
|
||||
"id": "a2t0", "name": "Alb2Track0",
|
||||
"artists": [{"name": "artX"}],
|
||||
"duration_ms": 240000,
|
||||
"external_urls": {"spotify": "a2u0"},
|
||||
}
|
||||
]},
|
||||
status=200,
|
||||
)
|
||||
with patch("core.spotify_client.time.sleep"): # no wait
|
||||
result = spotify_client.search_artist_discography("t", "artX")
|
||||
# 3 top + 2 alb1 + 1 alb2 = 6
|
||||
assert len(result) == 6
|
||||
titles = {r["name"] for r in result}
|
||||
assert "Top0" in titles
|
||||
assert "Alb1Track0" in titles
|
||||
assert "Alb2Track0" in titles
|
||||
|
||||
@responses.activate
|
||||
def test_dedupe_across_top_and_album(self):
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/search",
|
||||
json={"artists": {"items": [{"id": "aX", "name": "aX", "popularity": 50}]}},
|
||||
status=200,
|
||||
)
|
||||
# Stesso track name+artist in top-tracks e in album (id diverso)
|
||||
common = {
|
||||
"name": "Same Song",
|
||||
"artists": [{"name": "aX"}],
|
||||
"album": {"name": "OG Album"},
|
||||
"duration_ms": 200000,
|
||||
"external_urls": {"spotify": "u"},
|
||||
}
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/aX/top-tracks",
|
||||
json={"tracks": [{**common, "id": "top-id"}]},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/artists/aX/albums",
|
||||
json={"items": [{"id": "alb", "name": "OG"}]},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
"https://api.spotify.com/v1/albums/alb/tracks",
|
||||
json={"items": [{**common, "id": "alb-id"}]},
|
||||
status=200,
|
||||
)
|
||||
with patch("core.spotify_client.time.sleep"):
|
||||
result = spotify_client.search_artist_discography("t", "aX")
|
||||
assert len(result) == 1
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Test per core.youtube_search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core import youtube_search
|
||||
|
||||
|
||||
def _mock_ytdlp_result(entries: list) -> MagicMock:
|
||||
"""Mock subprocess.CompletedProcess con JSON stub."""
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = json.dumps({"entries": entries})
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
|
||||
class TestSearchYoutube:
|
||||
def test_parses_entries(self):
|
||||
entries = [
|
||||
{
|
||||
"id": "abc123",
|
||||
"url": "https://www.youtube.com/watch?v=abc123",
|
||||
"title": "Kapuchon - Hot Sauce (Official Video)",
|
||||
"uploader": "Kapuchon Official",
|
||||
"duration": 336,
|
||||
},
|
||||
{
|
||||
"id": "def456",
|
||||
"url": "https://www.youtube.com/watch?v=def456",
|
||||
"title": "Solomun @ Cocoricò 2024",
|
||||
"uploader": "Cocoricò",
|
||||
"duration": 3600,
|
||||
},
|
||||
]
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result(entries)):
|
||||
result = youtube_search.search_youtube("kapuchon hot sauce", limit=50)
|
||||
assert len(result) == 2
|
||||
assert result[0]["title"] == "Kapuchon - Hot Sauce (Official Video)"
|
||||
assert result[0]["channel"] == "Kapuchon Official"
|
||||
assert result[0]["duration_sec"] == 336
|
||||
assert result[0]["url"] == "https://www.youtube.com/watch?v=abc123"
|
||||
|
||||
def test_empty_entries_returns_empty(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])):
|
||||
result = youtube_search.search_youtube("no-match", limit=50)
|
||||
assert result == []
|
||||
|
||||
def test_raises_when_ytdlp_missing(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value=None):
|
||||
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
|
||||
def test_command_uses_ytsearch_with_limit(self):
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=_mock_ytdlp_result([])) as mock_run:
|
||||
youtube_search.search_youtube("solomun", limit=30)
|
||||
args = mock_run.call_args.args[0]
|
||||
assert args[0] == "/fake/yt-dlp"
|
||||
search_arg = [a for a in args if a.startswith("ytsearch")]
|
||||
assert search_arg == ["ytsearch30:solomun"]
|
||||
|
||||
def test_timeout_raises_runtime_error(self):
|
||||
import subprocess as sp
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", side_effect=sp.TimeoutExpired("yt-dlp", 30)):
|
||||
with pytest.raises(RuntimeError, match="[Tt]imeout"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
|
||||
def test_nonzero_exit_raises(self):
|
||||
bad = MagicMock()
|
||||
bad.returncode = 1
|
||||
bad.stdout = ""
|
||||
bad.stderr = "some yt-dlp error"
|
||||
with patch("core.youtube_search.find_ytdlp", return_value="/fake/yt-dlp"), \
|
||||
patch("core.youtube_search.subprocess.run", return_value=bad):
|
||||
with pytest.raises(RuntimeError, match="yt-dlp"):
|
||||
youtube_search.search_youtube("q", limit=50)
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicDownload</title>
|
||||
<title>MusicTools</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -15,30 +15,67 @@
|
||||
<div class="brand-logo">
|
||||
<span>♫</span>
|
||||
</div>
|
||||
<div class="brand-name">MusicDownload</div>
|
||||
<div class="brand-name">MusicTools</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<nav class="nav">
|
||||
<button class="nav-item active" data-view="download">
|
||||
<button class="nav-item active" data-view="download" data-feature="audio">
|
||||
<span class="nav-icon">⬇</span>
|
||||
<span>Scarica</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="video">
|
||||
<button class="nav-item" data-view="video" data-feature="video">
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade">
|
||||
<button class="nav-item" data-view="beatport" data-feature="audio">
|
||||
<span class="nav-icon">🎧</span>
|
||||
<span>Beatport</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="spotify" data-feature="audio">
|
||||
<span class="nav-icon">🟢</span>
|
||||
<span>Spotify</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="youtube" data-feature="audio">
|
||||
<span class="nav-icon">▶</span>
|
||||
<span>YouTube</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="meta" data-feature="metadata">
|
||||
<span class="nav-icon">🏷</span>
|
||||
<span>Metadati</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="record" data-feature="record">
|
||||
<span class="nav-icon">●</span>
|
||||
<span>Registra</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="settings">
|
||||
<span class="nav-icon">⚙</span>
|
||||
<span>Impostazioni</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="guide">
|
||||
<span class="nav-icon">📖</span>
|
||||
<span>Guida</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="quota-box" id="quotaBox" hidden>
|
||||
<div class="quota-label">Oggi</div>
|
||||
<div class="quota-value">
|
||||
<span id="quotaUsed">0</span>
|
||||
<span class="quota-sep">/</span>
|
||||
<span id="quotaLimit">10</span>
|
||||
</div>
|
||||
<div class="quota-plan" id="quotaPlanName">Basic</div>
|
||||
<button type="button" class="quota-upgrade" id="quotaUpgradeBtn" hidden>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-by">by LuZa</div>
|
||||
<div class="footer-version" id="footerVersion">v1.0</div>
|
||||
@@ -63,15 +100,22 @@
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">
|
||||
URL
|
||||
URL o titolo brano
|
||||
<span class="badge" id="urlListBadge" hidden></span>
|
||||
</label>
|
||||
<div class="row">
|
||||
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" />
|
||||
<input type="text" id="urlInput" class="input pill" placeholder="Link Spotify/YouTube… oppure Artista - Titolo · Titolo brano" />
|
||||
<button class="btn btn-ghost pill" id="loadListBtn">
|
||||
<span>📂</span> Carica lista
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
Puoi incollare un <strong>link</strong> (Spotify, YouTube, SoundCloud…) oppure scrivere direttamente
|
||||
<strong>Artista - Titolo</strong> o solo il <strong>titolo del brano</strong>: l'app cerca il match migliore su YouTube e scarica in MP3.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
@@ -136,6 +180,14 @@
|
||||
</div>
|
||||
|
||||
<div class="field inline">
|
||||
<label class="field-label">Modalità</label>
|
||||
<div class="segmented" id="videoMode">
|
||||
<button type="button" class="seg-btn active" data-mode="video">🎬 Video (MP4)</button>
|
||||
<button type="button" class="seg-btn" data-mode="audio">🎵 Solo audio (MP3)</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field inline" id="videoQualityField">
|
||||
<label class="field-label">Qualità</label>
|
||||
<select id="videoQuality" class="input pill input-sm">
|
||||
<option value="best">Migliore disponibile</option>
|
||||
@@ -147,7 +199,10 @@
|
||||
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
|
||||
<div>
|
||||
In modalità <strong>Solo audio</strong> il file viene salvato in MP3 al bitrate impostato in Impostazioni → Download.<br>
|
||||
Per Instagram/Facebook privati serve un file <strong>cookies.txt</strong> valido (Impostazioni → Percorsi).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -237,6 +292,188 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: REGISTRA ===== -->
|
||||
<section class="view" id="view-record">
|
||||
<header class="hero hero-red">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow red">REGISTRAZIONE</div>
|
||||
<h1 class="hero-title">Cattura audio</h1>
|
||||
<p class="hero-subtitle">Registra in MP3 320 kbps da qualsiasi ingresso audio (mixer, scheda, microfono, BlackHole/loopback)</p>
|
||||
</div>
|
||||
<div class="hero-deco">●</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Dispositivo</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Ingresso audio</label>
|
||||
<div class="row">
|
||||
<select id="recDevice" class="input pill">
|
||||
<option value="">Caricamento…</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost pill btn-sm" id="recRefreshBtn">↻ Aggiorna</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint" id="recHint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
<strong>Per registrare l'audio di sistema (Spotify/YouTube):</strong>
|
||||
installa <code>brew install blackhole-2ch</code>, imposta l'uscita audio di macOS su <strong>BlackHole 2ch</strong>, poi seleziona qui sotto <strong>🔄 BlackHole 2ch (loopback)</strong>.
|
||||
<br><br>
|
||||
Per sentire l'audio mentre registri serve un <strong>Multi-Output Device</strong> in <em>Configurazione MIDI Audio</em> (BlackHole + altoparlanti) — guida completa nel
|
||||
<a href="#" id="recOpenGuide">README su GitHub</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Destinazione</h2>
|
||||
<div class="card">
|
||||
<div class="field">
|
||||
<label class="field-label">Cartella output</label>
|
||||
<div class="row">
|
||||
<div class="path-display" id="recPathDisplay">Nessuna cartella selezionata</div>
|
||||
<button class="btn btn-ghost pill btn-sm" id="recBrowseBtn">Sfoglia</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">Nome file (opzionale)</label>
|
||||
<input type="text" id="recFilename" class="input pill" placeholder="Registrazione_AAAAMMGG_HHMMSS.mp3" />
|
||||
</div>
|
||||
|
||||
<div class="field inline">
|
||||
<label class="field-label">Bitrate</label>
|
||||
<select id="recBitrate" class="input pill input-sm">
|
||||
<option value="128k">128 kbps</option>
|
||||
<option value="192k">192 kbps</option>
|
||||
<option value="256k">256 kbps</option>
|
||||
<option value="320k" selected>320 kbps</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-red pill btn-lg" id="recStartBtn">
|
||||
<span class="ico">●</span> REC
|
||||
</button>
|
||||
<button class="btn btn-danger pill btn-lg" id="recStopBtn" disabled>
|
||||
<span class="ico">◼</span> Stop
|
||||
</button>
|
||||
<div class="rec-timer" id="recTimer">00:00:00</div>
|
||||
<div class="rec-status" id="recStatus"></div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="recOutputCard" hidden>
|
||||
<div class="micro-label">Ultima registrazione</div>
|
||||
<div class="rec-result" id="recResult"></div>
|
||||
</div>
|
||||
|
||||
<div class="card rec-error-card" id="recErrorCard" hidden>
|
||||
<div class="micro-label red">Errore registrazione</div>
|
||||
<div class="rec-error-msg" id="recErrorMsg"></div>
|
||||
<div class="row" style="gap: 10px; margin-top: 12px;">
|
||||
<button class="btn btn-ghost pill btn-sm" id="recShowLogBtn">Mostra log tecnici</button>
|
||||
<button class="btn btn-ghost pill btn-sm" id="recCopyLogBtn" hidden>Copia log</button>
|
||||
</div>
|
||||
<pre class="rec-error-log" id="recErrorLog" hidden></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: METADATI ===== -->
|
||||
<section class="view" id="view-meta">
|
||||
<header class="hero hero-amber">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow amber">METADATI</div>
|
||||
<h1 class="hero-title">Modifica tag audio</h1>
|
||||
<p class="hero-subtitle">Titolo · Artista · Album · Anno · Copertina · BPM · Key · MP3/M4A/FLAC</p>
|
||||
</div>
|
||||
<div class="hero-deco">🏷</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">File</h2>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<button class="btn btn-amber pill" id="metaPickBtn">📂 Apri file audio</button>
|
||||
<div class="path-display" id="metaPathDisplay">Nessun file selezionato</div>
|
||||
</div>
|
||||
<div class="meta-info" id="metaInfo" hidden></div>
|
||||
</div>
|
||||
|
||||
<div id="metaForm" hidden>
|
||||
<h2 class="section-label">Tag</h2>
|
||||
<div class="card">
|
||||
<div class="form-grid">
|
||||
<div class="field full">
|
||||
<label class="field-label">Titolo</label>
|
||||
<input type="text" id="metaTitle" class="input pill">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Artista</label>
|
||||
<input type="text" id="metaArtist" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Album</label>
|
||||
<input type="text" id="metaAlbum" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Album Artist</label>
|
||||
<input type="text" id="metaAlbumArtist" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Anno</label>
|
||||
<input type="text" id="metaYear" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Traccia</label>
|
||||
<input type="text" id="metaTrack" class="input pill" placeholder="1/12">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Genere</label>
|
||||
<input type="text" id="metaGenre" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">BPM</label>
|
||||
<input type="text" id="metaBpm" class="input pill">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Key</label>
|
||||
<input type="text" id="metaKey" class="input pill">
|
||||
</div>
|
||||
<div class="field full">
|
||||
<label class="field-label">Commento</label>
|
||||
<textarea id="metaComment" class="input textarea" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="field full" id="metaWhereFromField" hidden>
|
||||
<label class="field-label">
|
||||
Origine (macOS · kMDItemWhereFroms)
|
||||
</label>
|
||||
<textarea id="metaWhereFrom" class="input textarea" rows="2" placeholder="https://... (uno per riga)"></textarea>
|
||||
<div class="cover-hint">Extended attribute macOS — una URL per riga. Vuoto = rimuove l'attributo.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Copertina</h2>
|
||||
<div class="card cover-card">
|
||||
<div class="cover-preview" id="metaCoverPreview">
|
||||
<span class="cover-empty">Nessuna copertina</span>
|
||||
</div>
|
||||
<div class="cover-actions">
|
||||
<button class="btn btn-ghost pill" id="metaCoverBtn">📂 Cambia copertina</button>
|
||||
<button class="btn btn-ghost pill" id="metaCoverRemoveBtn">✕ Rimuovi</button>
|
||||
<div class="cover-hint" id="metaCoverHint"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card actions">
|
||||
<button class="btn btn-amber pill btn-lg" id="metaSaveBtn">💾 Salva tag</button>
|
||||
<button class="btn btn-ghost pill btn-lg" id="metaReloadBtn">↺ Ricarica</button>
|
||||
<div class="counter" id="metaStatus"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SETTINGS ===== -->
|
||||
<section class="view" id="view-settings">
|
||||
<header class="hero hero-purple">
|
||||
@@ -318,6 +555,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Licenza</h2>
|
||||
<div class="card">
|
||||
<div class="license-box" id="licenseStatusBox"></div>
|
||||
<div class="license-actions">
|
||||
<button class="btn btn-ghost pill" id="revalidateLicenseBtn">Verifica ora</button>
|
||||
<button class="btn btn-danger pill" id="deactivateLicenseBtn">Disattiva su questo device</button>
|
||||
</div>
|
||||
<div class="hint">
|
||||
<span class="hint-ico">ⓘ</span>
|
||||
<div>
|
||||
Disattivando liberi uno slot di attivazione, utile se sposti l'app su un altro Mac/PC.
|
||||
Puoi riattivare in qualsiasi momento con la stessa email e chiave.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Aggiornamenti</h2>
|
||||
<div class="card">
|
||||
<div class="row spread">
|
||||
@@ -337,9 +590,495 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: GUIDA ===== -->
|
||||
<section class="view" id="view-guide">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">GUIDA</div>
|
||||
<h1 class="hero-title">Manuale d'uso</h1>
|
||||
<p class="hero-subtitle">Come usare MusicTools al meglio · setup · troubleshooting</p>
|
||||
</div>
|
||||
<div class="hero-deco">📖</div>
|
||||
</header>
|
||||
|
||||
<div class="guide-layout">
|
||||
<!-- TOC sticky -->
|
||||
<nav class="guide-toc">
|
||||
<div class="micro-label" style="margin-bottom: 8px;">Sommario</div>
|
||||
<a href="#g-intro" class="toc-link">Introduzione</a>
|
||||
<a href="#g-setup" class="toc-link">Setup iniziale</a>
|
||||
<a href="#g-download" class="toc-link">Tab Scarica</a>
|
||||
<a href="#g-video" class="toc-link">Tab Video</a>
|
||||
<a href="#g-upgrade" class="toc-link">Tab Upgrade</a>
|
||||
<a href="#g-meta" class="toc-link">Tab Metadati</a>
|
||||
<a href="#g-record" class="toc-link">Tab Registra</a>
|
||||
<a href="#g-settings" class="toc-link">Impostazioni</a>
|
||||
<a href="#g-update" class="toc-link">Aggiornamenti</a>
|
||||
<a href="#g-troubleshoot" class="toc-link">Troubleshooting</a>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<article class="guide-content">
|
||||
|
||||
<section id="g-intro" class="g-section">
|
||||
<h2>Introduzione</h2>
|
||||
<p><strong>MusicTools</strong> è una app desktop tutto-in-uno per chi lavora con la musica: scarica brani e video da decine di piattaforme, migliora la qualità dei file esistenti, modifica i tag/metadati e registra l'audio da qualsiasi ingresso del computer.</p>
|
||||
<p>L'interfaccia è organizzata in 6 sezioni accessibili dalla barra laterale: <span class="kbd">⬇ Scarica</span>, <span class="kbd">🎬 Video</span>, <span class="kbd">⚡ Upgrade</span>, <span class="kbd">🏷 Metadati</span>, <span class="kbd">● Registra</span>, <span class="kbd">⚙ Impostazioni</span>.</p>
|
||||
</section>
|
||||
|
||||
<section id="g-setup" class="g-section">
|
||||
<h2>Setup iniziale</h2>
|
||||
<p>Prima di usare l'app, conviene fare 3 cose nelle <strong>Impostazioni</strong>:</p>
|
||||
<ol>
|
||||
<li><strong>Cartella output predefinita</strong> — dove vengono salvati tutti i download.</li>
|
||||
<li><strong>Credenziali Spotify</strong> (solo se vuoi scaricare da link Spotify) — vedi il bottone "Come ottenere le credenziali?" nella tab Impostazioni.</li>
|
||||
<li><strong>cookies.txt</strong> (opzionale) — necessario solo per scaricare contenuti privati Instagram/Facebook o aggirare rate-limit YouTube. Esportalo dal browser con l'estensione "Get cookies.txt LOCALLY".</li>
|
||||
</ol>
|
||||
<p>Tutti i campi vengono salvati automaticamente quando premi <strong>Salva Impostazioni</strong>.</p>
|
||||
</section>
|
||||
|
||||
<section id="g-download" class="g-section">
|
||||
<h2>Tab ⬇ Scarica</h2>
|
||||
<p>Scarica musica in MP3 al bitrate impostato in Impostazioni (default 320 kbps).</p>
|
||||
|
||||
<h3>1 · Da Spotify</h3>
|
||||
<p>Incolla un link a una <strong>playlist, album o brano</strong> (es. <code>open.spotify.com/playlist/...</code>). L'app risolve i metadati via API Spotify e scarica i brani da YouTube. <strong>Richiede</strong> le credenziali API impostate.</p>
|
||||
|
||||
<h3>2 · Da YouTube, SoundCloud e altri</h3>
|
||||
<p>Incolla qualsiasi URL supportato da yt-dlp (singolo video o playlist). Funziona anche con Bandcamp, Mixcloud, ecc.</p>
|
||||
|
||||
<h3>3 · Lista da file .txt</h3>
|
||||
<p>Clicca <strong>📂 Carica lista</strong>. Il file può contenere:</p>
|
||||
<ul>
|
||||
<li><strong>Una URL per riga</strong> — l'app le scarica una dopo l'altra.</li>
|
||||
<li><strong>Tracklist con titoli</strong> (es. Beatport Top 100): formato <code>1. Artista – Titolo (variant) (durata)</code>. L'app riconosce automaticamente il formato e cerca ogni brano su YouTube.</li>
|
||||
</ul>
|
||||
<p>Nel caso tracklist, viene creata automaticamente una <strong>sottocartella</strong> col nome del file (es. <code>Beatport Top 100.txt</code> → <code>Beatport Top 100/</code>).</p>
|
||||
|
||||
<h3>4 · Skip duplicati</h3>
|
||||
<p>Se riesegui la stessa playlist, i brani già scaricati vengono saltati in automatico. Il tracking funziona in due modi:</p>
|
||||
<ul>
|
||||
<li>File <code>.downloaded_tracks</code> dentro la cartella output.</li>
|
||||
<li>Scan dei file presenti, con match dei nomi token-per-token (ignora parole come "Official Video", "Lyrics", "HD"...).</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="g-video" class="g-section">
|
||||
<h2>Tab 🎬 Video</h2>
|
||||
<p>Scarica video da <strong>YouTube, TikTok, Instagram, Facebook</strong> e oltre 1000 altri siti supportati da yt-dlp.</p>
|
||||
|
||||
<h3>Modalità</h3>
|
||||
<ul>
|
||||
<li><strong>🎬 Video (MP4)</strong> — output video+audio in MP4, qualità a scelta (best / 1080p / 720p / 480p).</li>
|
||||
<li><strong>🎵 Solo audio (MP3)</strong> — estrae solo l'audio in MP3 al bitrate impostato in Impostazioni, con thumbnail embedded come copertina.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Contenuti privati</h3>
|
||||
<p>Per scaricare reel Instagram privati, post Facebook privati o aggirare rate-limit YouTube serve un file <strong><code>cookies.txt</code></strong> valido (formato Netscape). Esportalo dal browser e impostalo in <strong>Impostazioni → Percorsi</strong>.</p>
|
||||
|
||||
<div class="g-box info">
|
||||
<strong>Esempio:</strong> per scaricare la <em>Storia</em> di un utente Instagram seguito, fai login al browser, esporta i cookies, riavvia l'app.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="g-upgrade" class="g-section">
|
||||
<h2>Tab ⚡ Upgrade qualità</h2>
|
||||
<p>Scansiona una cartella e <strong>riscarica i file sotto la soglia kbps</strong> impostata, sostituendoli con la versione a qualità maggiore.</p>
|
||||
<ol>
|
||||
<li>Seleziona la cartella con i file audio.</li>
|
||||
<li>Imposta la <strong>Soglia HQ (kbps)</strong> — i file con bitrate inferiore verranno riprocessati. Default 310 kbps.</li>
|
||||
<li>(Opzionale) Spunta <strong>Includi sottocartelle</strong> per la scansione ricorsiva.</li>
|
||||
<li>Premi <strong>Avvia Upgrade</strong>.</li>
|
||||
</ol>
|
||||
<p>Per ogni file l'app cerca la versione migliore su YouTube e la sostituisce mantenendo nome originale. I file già processati vengono saltati grazie al tracking <code>.upgraded_tracks</code>.</p>
|
||||
</section>
|
||||
|
||||
<section id="g-meta" class="g-section">
|
||||
<h2>Tab 🏷 Metadati</h2>
|
||||
<p>Editor completo dei tag audio. Apri un file con <strong>📂 Apri file audio</strong>.</p>
|
||||
|
||||
<h3>Formati supportati</h3>
|
||||
<ul>
|
||||
<li><strong>MP3</strong> — ID3v2.3</li>
|
||||
<li><strong>M4A / AAC / MP4</strong> — Apple atoms</li>
|
||||
<li><strong>FLAC</strong> — Vorbis comments</li>
|
||||
<li><strong>WAV</strong> — ID3 embedded + fallback al chunk RIFF LIST/INFO (per file taggati da ffmpeg/Audacity)</li>
|
||||
</ul>
|
||||
|
||||
<h3>Campi editabili</h3>
|
||||
<p>Titolo, artista, album, album artist, anno, traccia (formato <code>1/12</code>), genere, BPM, key musicale, commento, copertina (JPG/PNG), e su macOS anche <strong>kMDItemWhereFroms</strong> (l'extended attribute che traccia l'origine del file scaricato).</p>
|
||||
|
||||
<h3>Copertina</h3>
|
||||
<p>Anteprima 180×180 della copertina corrente. <strong>Cambia copertina</strong> ne carica una nuova; <strong>Rimuovi</strong> la marca per la rimozione al prossimo salvataggio.</p>
|
||||
|
||||
<h3>Annulla modifiche</h3>
|
||||
<p>Premi <strong>↺ Ricarica</strong> per rileggere i tag dal file e scartare le modifiche non salvate.</p>
|
||||
</section>
|
||||
|
||||
<section id="g-record" class="g-section">
|
||||
<h2>Tab ● Registra</h2>
|
||||
<p>Cattura audio in MP3 da qualsiasi ingresso del computer: <strong>mixer, scheda audio esterna, microfono, driver loopback</strong> (BlackHole/Soundflower/VB-Cable). Bitrate selezionabile 128–320 kbps.</p>
|
||||
|
||||
<h3>Setup standard (mixer/microfono)</h3>
|
||||
<ol>
|
||||
<li>Dropdown <strong>Dispositivo</strong>: seleziona la sorgente.</li>
|
||||
<li>Imposta una cartella output e (opzionale) un nome file.</li>
|
||||
<li>Premi <strong>● REC</strong> → registrazione, timer in tempo reale.</li>
|
||||
<li><strong>◼ Stop</strong> → file MP3 finalizzato e linkato sotto.</li>
|
||||
</ol>
|
||||
|
||||
<h3>Registrare l'audio di sistema su macOS (Spotify, YouTube...)</h3>
|
||||
<p>macOS non espone nativamente l'uscita audio. Serve un driver loopback gratuito, il più diffuso è <strong>BlackHole</strong>.</p>
|
||||
|
||||
<h4>Installazione</h4>
|
||||
<p>Apri Terminale e lancia:</p>
|
||||
<pre><code>brew install blackhole-2ch</code></pre>
|
||||
<p>Se subito dopo l'install BlackHole non compare nel dropdown, riavvia il servizio audio:</p>
|
||||
<pre><code>sudo killall coreaudiod</code></pre>
|
||||
<p>(rilancia da solo in pochi secondi, nessun rischio)</p>
|
||||
|
||||
<h4>Permesso microfono</h4>
|
||||
<p>macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation. Vai su <strong>Preferenze di Sistema → Privacy e sicurezza → Microfono</strong> e abilita Terminal/MusicTools. Se la voce non c'è, comparirà un popup la prima volta che premi REC.</p>
|
||||
|
||||
<h4>Setup A — minimo (non senti l'audio durante)</h4>
|
||||
<ol>
|
||||
<li>Click icona altoparlante nella barra menu → <strong>Output → BlackHole 2ch</strong></li>
|
||||
<li>Fai partire Spotify (non sentirai nulla — l'audio va a BlackHole)</li>
|
||||
<li>Tab Registra → dispositivo <strong>🔄 BlackHole 2ch (loopback)</strong> → REC</li>
|
||||
</ol>
|
||||
|
||||
<h4>Setup B — completo (registri E senti l'audio)</h4>
|
||||
<ol>
|
||||
<li>Apri <strong>Configurazione MIDI Audio</strong> (<code>Applicazioni → Utility</code>)</li>
|
||||
<li>In basso a sinistra: <strong>+ → Crea dispositivo aggregato a uscita multipla</strong></li>
|
||||
<li>Spunta sia <strong>BlackHole 2ch</strong> sia i tuoi <strong>Altoparlanti integrati</strong></li>
|
||||
<li>Imposta <strong>BlackHole 2ch come Master Device</strong> (menu in alto)</li>
|
||||
<li>Abilita <strong>Drift Correction</strong> sulla riga degli altoparlanti</li>
|
||||
<li>(Opzionale) Rinomina in <code>Speakers + BlackHole</code></li>
|
||||
<li>Preferenze di Sistema → Audio → Uscita → seleziona <code>Speakers + BlackHole</code></li>
|
||||
<li>Nella tab Registra: scegli <strong>🔄 BlackHole 2ch (loopback)</strong>, non il dispositivo aggregato</li>
|
||||
</ol>
|
||||
<div class="g-box success">
|
||||
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="g-settings" class="g-section">
|
||||
<h2>Impostazioni</h2>
|
||||
|
||||
<h3>Spotify API</h3>
|
||||
<p>Le credenziali API Spotify sono <strong>gratuite</strong> e non richiedono Premium. Vai su <a href="#" data-ext="https://developer.spotify.com/dashboard">Spotify Developer Dashboard</a>, "Create app", compila i campi (Redirect URI <code>http://localhost:8888/callback</code>, seleziona "Web API"), e copia Client ID + Client Secret nei campi corrispondenti.</p>
|
||||
|
||||
<h3>Download</h3>
|
||||
<ul>
|
||||
<li><strong>Bitrate</strong> — qualità target per gli MP3 (128/192/256/320 kbps).</li>
|
||||
<li><strong>Soglia HQ</strong> — soglia kbps per la tab Upgrade. I file sotto questa soglia vengono riscaricati.</li>
|
||||
</ul>
|
||||
|
||||
<h3>Percorsi</h3>
|
||||
<ul>
|
||||
<li><strong>cookies.txt</strong> — per contenuti privati/protetti.</li>
|
||||
<li><strong>Cartella output predefinita</strong> — viene proposta in tutte le tab.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="g-update" class="g-section">
|
||||
<h2>Aggiornamenti</h2>
|
||||
<p>In <strong>Impostazioni → Aggiornamenti</strong> trovi il bottone <strong>Controlla aggiornamenti</strong>. L'app interroga la pagina release di GitHub e ti mostra se c'è una versione nuova.</p>
|
||||
<p>Se è disponibile un aggiornamento, comparirà un secondo bottone <strong>Scarica aggiornamento</strong> che apre direttamente il browser sull'asset corretto (macOS o Windows) della release più recente.</p>
|
||||
</section>
|
||||
|
||||
<section id="g-troubleshoot" class="g-section">
|
||||
<h2>Troubleshooting</h2>
|
||||
|
||||
<table class="g-table">
|
||||
<thead><tr><th>Sintomo</th><th>Causa probabile</th><th>Fix</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>BlackHole non compare nel dropdown della tab Registra</td>
|
||||
<td>CoreAudio non l'ha ancora caricato dopo l'install</td>
|
||||
<td><code>sudo killall coreaudiod</code> + premi <strong>↻ Aggiorna</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>"Permesso microfono mancante" o errore ffmpeg -6</td>
|
||||
<td>macOS nega l'accesso al device</td>
|
||||
<td>Privacy e sicurezza → Microfono → abilita Terminal/MusicTools</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File MP3 della registrazione è muto</td>
|
||||
<td>L'output di sistema non passa da BlackHole</td>
|
||||
<td>Imposta output su BlackHole o Multi-Output Device (Setup A/B)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Spotify dice "Autenticazione fallita"</td>
|
||||
<td>Client ID/Secret mancanti o errati</td>
|
||||
<td>Impostazioni → Spotify API → ripeti la procedura</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Instagram/Facebook non scarica</td>
|
||||
<td>Contenuto privato senza cookies validi</td>
|
||||
<td>Esporta cookies.txt dal browser dopo login</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Brano scaricato è sbagliato (versione cover invece dell'originale)</td>
|
||||
<td>Ricerca YouTube ha preso il primo risultato</td>
|
||||
<td>Modifica la query nel file di tracklist (più dettagli artista/titolo)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Tag non vengono letti su un WAV</td>
|
||||
<td>File usa solo chunk INFO non ID3 (raro)</td>
|
||||
<td>L'app ora gestisce entrambi — controlla di avere v1.2.1+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Stop button non risponde durante il download</td>
|
||||
<td>yt-dlp sta finalizzando il file</td>
|
||||
<td>Attendi qualche secondo, il processo si chiude dopo l'I/O</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>Reset configurazione</h3>
|
||||
<p>Se vuoi azzerare tutte le impostazioni, cancella il file <code>config.json</code>. Posizione:</p>
|
||||
<ul>
|
||||
<li><strong>macOS</strong>: <code>~/Library/Application Support/MusicTools/config.json</code></li>
|
||||
<li><strong>Windows</strong>: <code>%APPDATA%\MusicTools\config.json</code></li>
|
||||
</ul>
|
||||
<p>Verrà ricreato con valori di default al prossimo avvio.</p>
|
||||
</section>
|
||||
|
||||
<section class="g-section">
|
||||
<h2>Risorse</h2>
|
||||
<ul>
|
||||
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload">Codice sorgente su GitHub</a></li>
|
||||
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload/releases">Tutte le release</a></li>
|
||||
<li><a href="#" data-ext="https://github.com/yt-dlp/yt-dlp/wiki/FAQ">FAQ di yt-dlp</a></li>
|
||||
<li><a href="#" data-ext="https://existential.audio/blackhole/">BlackHole — driver loopback macOS</a></li>
|
||||
</ul>
|
||||
<p style="color: var(--text-3); font-size: 12px; margin-top: 20px;">MusicTools — by <strong style="color: var(--green);">LuZa</strong></p>
|
||||
</section>
|
||||
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: BEATPORT ===== -->
|
||||
<section class="view" id="view-beatport">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">BEATPORT CHARTS</div>
|
||||
<h1 class="hero-title">Top 100 per genere</h1>
|
||||
<p class="hero-subtitle">Sfoglia la Top 100 di Beatport, seleziona i brani e scaricali via YouTube</p>
|
||||
</div>
|
||||
<div class="hero-deco">🎧</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Genere</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<select id="beatport-genre" class="input pill"></select>
|
||||
<button class="btn btn-primary pill" id="beatport-load-btn">
|
||||
<span class="ico">↓</span> Carica Top 100
|
||||
</button>
|
||||
<span class="hint-inline">Shift+click per bypassare la cache</span>
|
||||
</div>
|
||||
<div id="beatport-status" class="beatport-status"></div>
|
||||
<div id="beatport-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card">
|
||||
<div class="beatport-table-wrap">
|
||||
<table id="beatport-table" class="beatport-table" hidden>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="beatport-select-all" /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo (Mix)</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-state">Stato</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="beatport-toolbar" hidden>
|
||||
<span class="counter" id="beatport-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="beatport-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="beatport-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="beatport-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: SPOTIFY SEARCH ===== -->
|
||||
<section class="view" id="view-spotify">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">RICERCA SPOTIFY</div>
|
||||
<h1 class="hero-title">Cerca brani su Spotify</h1>
|
||||
<p class="hero-subtitle">Digita un artista, un titolo, o entrambi. Attiva "Solo artista" per scaricare tutta la discografia.</p>
|
||||
</div>
|
||||
<div class="hero-deco">🟢</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Ricerca</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<input type="text" id="spotify-query" class="input pill" placeholder="Es: Solomun, Hot Sauce, Kapuchon Hot Sauce…" style="flex:1; min-width:260px;" />
|
||||
<label style="display:flex; gap:6px; align-items:center; color:var(--text-2); font-size:13px;">
|
||||
<input type="checkbox" id="spotify-artist-mode" />
|
||||
<span>Solo artista (discografia)</span>
|
||||
</label>
|
||||
<button class="btn btn-primary pill" id="spotify-search-btn">
|
||||
<span class="ico">🔎</span> Cerca
|
||||
</button>
|
||||
</div>
|
||||
<div id="spotify-status" class="beatport-status"></div>
|
||||
<div id="spotify-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="spotify-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="spotify-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="spotify-select-all" checked /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Artista</th>
|
||||
<th>Titolo</th>
|
||||
<th>Album</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spotify-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="spotify-toolbar">
|
||||
<span class="counter" id="spotify-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="spotify-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="spotify-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="spotify-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===== VIEW: YOUTUBE SEARCH ===== -->
|
||||
<section class="view" id="view-youtube">
|
||||
<header class="hero hero-indigo">
|
||||
<div class="hero-content">
|
||||
<div class="hero-eyebrow indigo">RICERCA YOUTUBE</div>
|
||||
<h1 class="hero-title">Cerca video su YouTube</h1>
|
||||
<p class="hero-subtitle">Perfetto per mix DJ, unreleased, bootleg e brani non presenti su Spotify.</p>
|
||||
</div>
|
||||
<div class="hero-deco">▶</div>
|
||||
</header>
|
||||
|
||||
<h2 class="section-label">Ricerca</h2>
|
||||
<div class="card">
|
||||
<div class="beatport-header">
|
||||
<input type="text" id="youtube-query" class="input pill" placeholder="Es: Solomun Cocoricò 2024, Kapuchon Hot Sauce remix…" style="flex:1; min-width:260px;" />
|
||||
<button class="btn btn-primary pill" id="youtube-search-btn">
|
||||
<span class="ico">🔎</span> Cerca
|
||||
</button>
|
||||
</div>
|
||||
<div id="youtube-status" class="beatport-status"></div>
|
||||
<div id="youtube-output-info" class="beatport-output-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="card beatport-table-card" id="youtube-table-wrap" hidden>
|
||||
<div class="beatport-table-wrap">
|
||||
<table class="beatport-table" id="youtube-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-check"><input type="checkbox" id="youtube-select-all" checked /></th>
|
||||
<th class="col-cover"></th>
|
||||
<th class="col-pos">#</th>
|
||||
<th>Titolo video</th>
|
||||
<th>Canale</th>
|
||||
<th class="col-dur">Durata</th>
|
||||
<th class="col-state"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="youtube-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="beatport-toolbar" id="youtube-toolbar">
|
||||
<span class="counter" id="youtube-selected-count">0/0 selezionati</span>
|
||||
<button class="btn btn-primary pill" id="youtube-download-btn" disabled>
|
||||
<span class="ico">▶</span> Scarica selezionati
|
||||
</button>
|
||||
<button class="btn btn-danger pill" id="youtube-stop-btn" hidden>
|
||||
<span class="ico">◼</span> Interrompi
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="section-label">Log</h2>
|
||||
<div class="card log-card">
|
||||
<div class="log" id="youtube-log"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ============== SCHERMATA ATTIVAZIONE LICENZA ============== -->
|
||||
<div class="activate-screen" id="activateScreen" hidden>
|
||||
<div class="activate-card">
|
||||
<div class="activate-logo">♫</div>
|
||||
<h1 class="activate-title">Benvenuto in MusicTools</h1>
|
||||
<p class="activate-sub">
|
||||
Inserisci la tua licenza per iniziare. Se non ne hai ancora una,
|
||||
acquistala dal nostro sito ed entra in pochi secondi.
|
||||
</p>
|
||||
|
||||
<div class="activate-form">
|
||||
<div class="field">
|
||||
<label class="field-label">Email dell'acquisto</label>
|
||||
<input type="email" id="actEmail" class="input pill" placeholder="tu@esempio.com" autocomplete="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Chiave di licenza</label>
|
||||
<input type="text" id="actKey" class="input pill" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="activate-error" id="actError" hidden></div>
|
||||
|
||||
<div class="activate-actions">
|
||||
<button class="btn btn-primary pill btn-lg" id="actActivateBtn">
|
||||
<span class="ico">🔓</span> Attiva
|
||||
</button>
|
||||
<button class="btn btn-ghost pill btn-lg" id="actBuyBtn">
|
||||
Compra una licenza
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activate-foot">
|
||||
Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== MODAL: Spotify Guide ============== -->
|
||||
<div class="modal-backdrop" id="guideModal" hidden>
|
||||
<div class="modal">
|
||||
@@ -355,6 +1094,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== MODAL: Upgrade richiesto ============== -->
|
||||
<div class="modal-backdrop" id="upgradeModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2 id="upgradeModalTitle">Passa a un piano superiore</h2>
|
||||
<button class="modal-close" id="upgradeCloseBtn">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="upgradeModalBody">
|
||||
<!-- popolato dinamicamente -->
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-primary pill" id="upgradeOpenBtn">Vedi i piani</button>
|
||||
<button class="btn btn-ghost pill" id="upgradeCloseBtn2">Chiudi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== TOAST ============== -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||