Compare commits

...
11 Commits
Author SHA1 Message Date
luzadev 61459099f9 v1.7.0: piani Basic/Pro/Premium/Annual + quota giornaliera
Pricing model: subscription mensili (Basic 2.99 / Pro 5.99 / Premium 9.99)
piu' Annual 49.90 one-time. Feature gating per piano (Basic = solo audio,
Pro+ = tutto). Quota giornaliera (10/30/150) con reset mezzanotte Europe/Rome.

Server: tabella daily_usage, endpoint /api/usage/consume e /status,
webhook LS gestisce subscription_* events + mappatura variant_id -> plan
via .env (LS_VARIANT_BASIC|PRO|PREMIUM|ANNUAL).

Client: gating tab in sidebar in base ai features del piano, contatore
"X/Y oggi" + bottone Upgrade, modal blocco con CTA verso landing.
2026-06-09 11:02:42 +02:00
luzadevandClaude Opus 4.7 bcd4f22b1e Asset brand + pagina download + 1-click download endpoint
- assets/: script generate_logo.py + generate_ls_images.py per produrre
  loghi (mark/horizontal light+dark/stacked light+dark) e immagini
  prodotto Lemon Squeezy (thumbnail 1024x1024, cover 1920x1080,
  OG 1200x630). Riproducibili: rilanciare gli script per rigenerare.
- landing/download.html: pagina con form email+chiave o autofill da
  query string ?key=&email= (link 1-click dall'email). Bottoni
  Mac/Windows + istruzioni installazione per OS.
- landing/index.html: link Download in nav, hero ("Hai gia
  acquistato?"), pricing card ("Hai gia una licenza?"), footer.
- landing/js/main.js: rimosso warning placeholder LS (URL ora reale).
- server/src/downloads.js: endpoint GET /api/download/:platform
  che verifica (key, email) contro DB e redirect a URL firmato del
  binario. Errori chiari (400/401/403/503) leggibili dall'utente.
- server/src/email.js: template Resend con 2 bottoni 1-click
  (link con key+email gia compilati).
- server/src/server.js: route aggiunta.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:29:48 +02:00
luzadevandClaude Opus 4.7 8272985459 VERSION bump v1.5.2 -> v1.6.0
Prima release con sistema licenze + landing musictools.djluza.com.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:29:09 +02:00
luzadevandClaude Opus 4.7 f339197861 Registra: messaggi di errore azionabili + log tecnici espandibili
Sul sistema dell'utente il comando ffmpeg che usa l'app funziona
correttamente (testato come subprocess identico), ma il vecchio
reporting era confuso quando l'errore era transitorio (BlackHole
non pronto, clock drift, ecc.) o quando la parola 'abort' non
compariva nello stderr.

Cambiamenti:

core/recorder.py:
- _extract_friendly_error riconosce ora piu casi: input/output error
  (BlackHole non pronto -> suggerisce 'sudo killall coreaudiod'),
  device not configured, permission denied, no such device
- rc=-6 su macOS ritorna SEMPRE il messaggio sul permesso Microfono
  (prima richiedeva 'abort' nello stderr, non sempre presente)
- Fallback finale mostra anche le ultime 3 righe di stderr
- Nuova funzione get_last_stderr() per debugging

api/bridge.py:
- Nuovo metodo get_recorder_log() che ritorna stderr per la UI

webui:
- Nuova card di errore persistente con bottoni
  'Mostra log tecnici' / 'Copia log'
- Si resetta automaticamente all'avvio di una nuova registrazione

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:08:11 +02:00
luzadevandClaude Opus 4.7 2d8290579b Riscrivi backend per hosting Node/MariaDB/PM2 (no Cloudflare)
L'utente ha un hosting Virtualmin esistente (musictools.djluza.com)
con Apache + Node 20 + MariaDB + PM2. Tutta l'infrastruttura
Cloudflare (Workers/D1/R2/Pages) e' rimpiazzata con un backend
Express che gira sul server gia' presente, a costo zero.

Cambia:
- server/src/*.ts (Workers) -> server/src/*.js (Node ESM puro)
- D1 (sqlite) -> MariaDB 10.11 via mysql2/promise
- R2 (storage) -> filesystem locale ~/builds/ + signed URL HMAC
- wrangler.toml -> ecosystem.config.cjs (PM2)
- Aggiunto src/migrate.js: runner SQL idempotente

Endpoints invariati - l'app desktop non vede differenze:
- POST /api/license/{activate,validate,deactivate}
- GET  /api/latest, /api/download
- POST /api/webhook/lemonsqueezy
- GET  /api/health

README riscritto con istruzioni complete: creazione DB,
deploy via rsync/git, config Apache reverse proxy via
Virtualmin, gestione PM2, backup, workflow release.

Email rimane su Resend (deliverability) - free tier sufficiente.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 00:15:53 +02:00
luzadevandClaude Opus 4.7 339dd4f3cc Foro competente: Tribunale di Chieti (residenza Ortona, CH)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:57:56 +02:00
luzadevandClaude Opus 4.7 4e917fd9ef Rafforza Termini: EULA, Acceptable Use, manleva, disclaimer copyright
- terms.html: riscritto da 9 a 18 sezioni con indice cliccabile.
  Include EULA (concessione, restrizioni, IP), Acceptable Use Policy
  (cosa si puo'/non si puo' fare con i Contenuti), disclaimer marchi
  terze parti (Spotify/YouTube/...), procedura DMCA, esclusione
  garanzie AS IS, cap responsabilita = importo pagato negli ultimi
  12 mesi, clausola di manleva (indemnification) a carico dell'utente.
- index.html: nuova box "Uso responsabile" sotto al pricing con i 4
  punti chiave; FAQ ampliate (no affiliazione, segnalazioni copyright);
  testo di accettazione esplicito Termini+Privacy+Refund sotto al CTA;
  footer con titolarita "Luciano Zappacosta".
- css/style.css: stili per use-policy box e price-consent.

Linee guida prese da boilerplate di settore (4K Video Downloader,
JDownloader, MediaHuman) adattate al diritto italiano e GDPR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:56:17 +02:00
luzadevandClaude Opus 4.7 729f3cd8a0 Fix nome titolare nei documenti legali: Luciano Zappacosta
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:52:11 +02:00
luzadevandClaude Opus 4.7 4ebfbd472e Landing page per musictools.djluza.com (Cloudflare Pages)
- index.html: hero con mockup app, 6 feature cards, come funziona,
  pricing 39,90 EUR, FAQ accordion, footer
- css/style.css: tema dark Spotify-like coerente con l'app
- js/main.js: animazioni scroll, hook Lemon Squeezy overlay
- legal/{terms,privacy,refund}.html: documenti richiesti da LS/GDPR
- _headers: HSTS, no-sniff, cache lunga per asset
- _redirects: placeholder per futuri /download/{macos,windows}
- README con istruzioni deploy push-to-deploy via Cloudflare Pages

Da fare prima del lancio: sostituire REPLACE_WITH_LS_PRODUCT_URL
in index.html con l'URL del prodotto Lemon Squeezy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:49:58 +02:00
luzadevandClaude Opus 4.7 5202b2c586 Setup commercial launch: licenze + backend Cloudflare Worker
Client (Python/JS):
- core/license.py: attivazione, validazione, deactivate (HTTP client + JWT decode)
- core/config.py: campi license_* + URL endpoint + costanti grace/revalidate
- api/bridge.py: get_init_data ritorna license status; activate_license/
  deactivate_license/revalidate_license/open_purchase_page
- check_update riscritto: punta a musictools.djluza.com/api/latest con
  Bearer token, URL di download firmato dal server solo se licensed
- webui: schermata di attivazione bloccante all'avvio; sezione Licenza
  in Impostazioni con verifica/disattiva

Backend (server/):
- Cloudflare Worker + D1 + R2 (vedi server/README.md)
- Endpoints: /api/license/{activate,validate,deactivate}, /api/latest,
  /api/webhook/lemonsqueezy, /api/health
- JWT HS256 con rotazione; max 3 attivazioni/licenza
- Generazione licenze via webhook Lemon Squeezy + email Resend
- Schema D1 in migrations/0001_init.sql

Memory: nuova musictools-commercial-launch.md, rimosso pending obsoleto

NON taggare finche backend non e' deployato (l'app e' bloccante)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:36:41 +02:00
luzadevandClaude Opus 4.7 05a2a58ace Tab Scarica: ricerca diretta per titolo o "Artista - Titolo"
L'input URL ora accetta anche query libere: se non sembra un link
(http/https/spotify:/dominio noto), il testo viene parsato come
{artist, name} e scaricato via download_playlist con una traccia.

Riusa lo stesso flusso ytsearch1: gia in uso per le tracklist da file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:13:10 +02:00
48 changed files with 5544 additions and 64 deletions

No files matched your search

+6
View File
@@ -43,3 +43,9 @@ 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/
+167 -31
View File
@@ -10,7 +10,8 @@ import time
import webbrowser
from typing import Any, Optional
from core.config import load_config, save_config, VERSION
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
from core import license as license_mod
from core.downloader import (
download_playlist,
download_direct_url,
@@ -26,6 +27,7 @@ from core.recorder import (
start_recording,
stop_recording as recorder_stop,
is_recording,
get_last_stderr as recorder_last_stderr,
)
from core.upgrader import (
upgrade_folder,
@@ -113,10 +115,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",
}
# ------------------------------------------------------------------
@@ -128,7 +136,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"),
@@ -136,40 +147,130 @@ 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}
def check_update(self) -> dict:
"""Controlla aggiornamenti interrogando l'API GitHub Releases."""
import platform
import requests
# ------------------------------------------------------------------
# Licenza
# ------------------------------------------------------------------
def get_license_status(self) -> dict:
return license_mod.get_status()
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
def activate_license(self, payload: dict) -> dict:
try:
resp = requests.get(API_URL, headers={
"User-Agent": "MusicTools",
"Accept": "application/vnd.github+json",
}, timeout=10)
resp.raise_for_status()
data = resp.json()
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)}
remote = data.get("tag_name", "")
notes = data.get("body", "") or ""
html_url = data.get("html_url", "")
def deactivate_license(self) -> dict:
status = license_mod.deactivate(release_remote=True)
return {"ok": True, "license": status}
# Trova l'asset giusto per la piattaforma
assets = data.get("assets", []) or []
is_macos = platform.system() == "Darwin"
keyword = "macos" if is_macos else "windows"
download_url = ""
for a in assets:
name = (a.get("name") or "").lower()
if keyword in name and name.endswith(".zip"):
download_url = a.get("browser_download_url", "")
break
if not download_url:
download_url = html_url # fallback: pagina release
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
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 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
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}&current={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:
with urllib.request.urlopen(req, timeout=10) as resp:
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,
@@ -177,8 +278,13 @@ class Api:
"remote": remote,
"is_new": bool(remote and remote != VERSION),
"download_url": download_url,
"notes": notes[:500] if notes else "", # tronca per UI
"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}
@@ -278,6 +384,10 @@ class Api:
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))
@@ -312,6 +422,10 @@ class Api:
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):
@@ -329,6 +443,11 @@ class Api:
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)
# ------------------------------------------------------------------
@@ -359,6 +478,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),
@@ -388,6 +511,10 @@ class Api:
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),
@@ -574,6 +701,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", "")
@@ -647,6 +778,11 @@ class Api:
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, audio_only),
+146
View File
@@ -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()
+296
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+27 -1
View File
@@ -5,12 +5,23 @@ import os
import sys
from pathlib import Path
VERSION = "v1.5.1"
VERSION = "v1.7.0"
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.
@@ -59,6 +70,21 @@ DEFAULTS = {
"cookies_path": str(_project_dir / "cookies.txt"),
"output_dir": str(_project_dir / "MUSICA"),
"theme": "dark",
# ---- 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
}
+4 -2
View File
@@ -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:
+487
View File
@@ -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
+54 -15
View File
@@ -335,39 +335,78 @@ def start_recording(
def _extract_friendly_error(rc: int) -> str:
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
Riconosce i casi tipici: permessi microfono, device occupato,
device non disponibile, ..."""
device non disponibile, clock drift di BlackHole, ecc.
Fallback: se non riconosce il pattern, ritorna le ultime righe
significative dello stderr cosi' l'utente puo' diagnosticare
o aprire un ticket.
"""
with _stderr_lock:
lines = list(_stderr_buf)
text = "\n".join(lines).lower()
is_mac = sys.platform == "darwin"
if "abort" in text and rc == -6 and sys.platform == "darwin":
# ---- Errori specifici (priorita' alta) ----
if "input/output error" in text or "errno 22" in text:
return (
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
"Poi riavvia l'app."
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
"Spesso si risolve cosi':\n"
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
" 3) Verifica che nessun'altra app stia gia' registrando il device"
)
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
if "device not configured" in text or "device not available" in text:
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
if "permission" in text or "not authorized" in text or "not permitted" in text:
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
if "permission" in text or "not authorized" in text or "not permitted" in text \
or "tcc" in text or "denied" in text:
return _permission_message()
if "no such device" in text or "no such audio device" in text:
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
# dovuto al permesso Microfono mancante. La parola 'abort' non
# sempre compare nello stderr di ffmpeg recenti.
if rc == -6 and is_mac:
return _permission_message()
# ---- Fallback: cerca le ultime righe significative ----
for line in reversed(lines):
l = line.strip()
if not l:
continue
low = l.lower()
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
return l
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
return l
return f"ffmpeg exit {rc}"
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
def _permission_message() -> str:
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
return (
"Permesso Microfono mancante. Su macOS:\n"
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
" (se usi l'app installata). Poi riavvia l'app."
)
def get_last_stderr(max_lines: int = 50) -> list[str]:
"""Espone le ultime righe di stderr ai fini diagnostici.
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
with _stderr_lock:
return list(_stderr_buf[-max_lines:])
def stop_recording() -> dict:
+72
View File
@@ -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.
+17
View File
@@ -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
+7
View File
@@ -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
+10
View File
@@ -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

+851
View File
@@ -0,0 +1,851 @@
/* ============================================================
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;
}
.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;
}
/* ============================================================
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; }
+221
View File
@@ -0,0 +1,221 @@
<!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); }
</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" id="dlMacBtn" target="_blank" rel="noopener">
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" id="dlWinBtn" target="_blank" rel="noopener">
Scarica per Windows
</a>
</article>
</div>
<div class="dl-steps">
<h2>Installazione macOS</h2>
<ol>
<li>Apri il file <code>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li>
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li>
<li>Vai in Applicazioni, <strong>tasto destro</strong> su MusicTools → <strong>Apri</strong> → <strong>Apri</strong> (solo la prima volta, e' la procedura standard per app non firmate)</li>
<li>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) || "";
}
function setDownloadLinks(email, key) {
const q = "key=" + encodeURIComponent(key) + "&email=" + encodeURIComponent(email);
document.getElementById("dlMacBtn").href = "/api/download/macos?" + q;
document.getElementById("dlWinBtn").href = "/api/download/windows?" + q;
document.getElementById("dlGrid").hidden = false;
document.getElementById("dlForm").hidden = true;
}
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni
const urlKey = getParam("key");
const urlEmail = getParam("email");
if (urlKey && urlEmail) {
setDownloadLinks(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) {
alert("Inserisci email e chiave di licenza.");
return;
}
setDownloadLinks(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>
+426
View File
@@ -0,0 +1,426 @@
<!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. Piani da 2,99 €/mese." />
<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. Piani da 2,99 €/mese o 49,90 €/anno una tantum." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://musictools.djluza.com" />
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<!-- ============== NAV ============== -->
<header class="nav">
<div class="container nav-row">
<a href="#top" class="brand">
<span class="brand-logo">♫</span>
<span class="brand-name">MusicTools</span>
</a>
<nav class="nav-links">
<a href="#features">Funzioni</a>
<a href="#how">Come funziona</a>
<a href="#pricing">Piani</a>
<a href="#faq">FAQ</a>
<a href="/download">Download</a>
</nav>
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</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 · Da 2,99 €/mese</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="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
</div>
<ul class="hero-bullets">
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
<li>✓ Aggiornamenti gratuiti inclusi</li>
<li>✓ Funziona offline · Su 3 dispositivi</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>Compra la licenza</h3>
<p>Checkout sicuro tramite Lemon Squeezy. Ricevi la chiave via email entro pochi secondi.</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">PIANI</span>
<h2 class="section-title">Scegli il piano giusto per te</h2>
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
<div class="price-grid">
<!-- BASIC -->
<div class="price-card price-card-sm">
<div class="price-name">Basic</div>
<div class="price-row">
<span class="price-amount">2,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
<li>✓ <strong>Fino a 10 al giorno</strong></li>
<li>✓ MP3 fino a 320kbps</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
<li>✗ Download video</li>
<li>✗ Registrazione, metadati, upgrade libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Inizia con Basic
</a>
</div>
<!-- PRO -->
<div class="price-card price-card-sm">
<div class="price-badge">Più scelto</div>
<div class="price-name">Pro</div>
<div class="price-row">
<span class="price-amount">5,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Basic</strong></li>
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
<li>✓ <strong>Fino a 30 al giorno</strong></li>
<li>✓ Registrazione audio (BlackHole / loopback)</li>
<li>✓ Editor metadati + copertine</li>
<li>✓ Upgrade qualità libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
Scegli Pro
</a>
</div>
<!-- PREMIUM -->
<div class="price-card price-card-sm">
<div class="price-name">Premium</div>
<div class="price-row">
<span class="price-amount">9,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Pro</strong></li>
<li>✓ <strong>Fino a 150 al giorno</strong></li>
<li>✓ Priorità nel supporto via email</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Passa a Premium
</a>
</div>
<!-- ANNUAL ONE-TIME -->
<div class="price-card price-card-sm price-card-featured">
<div class="price-badge price-badge-gold">Miglior valore</div>
<div class="price-name">Annual</div>
<div class="price-row">
<span class="price-amount">49,90</span>
<span class="price-currency">€</span>
<span class="price-once">/anno</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
<li>✓ <strong>Nessun limite giornaliero</strong></li>
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
<li>✓ Una tantum, niente rinnovo automatico</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
id="buyBtn">
Acquista — 49,90 €
</a>
</div>
</div>
<div class="price-shared">
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
<p class="price-consent">
Procedendo con l'acquisto dichiari di aver letto e di accettare i
<a href="/legal/terms.html">Termini di servizio</a>,
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
<a href="/legal/refund.html">Politica rimborsi</a>.
</p>
<p class="price-already">
Hai già una licenza? <a href="/download">Scarica l'app →</a>
</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>Tutti i piani coprono 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 funziona il limite giornaliero?</summary>
<p>Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a <strong>mezzanotte ora di Roma</strong>. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.</p>
</details>
<details class="faq-item">
<summary>Posso passare a un piano superiore?</summary>
<p>Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.</p>
</details>
<details class="faq-item">
<summary>Cosa succede quando scade l'abbonamento annuale?</summary>
<p>Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.</p>
</details>
<details class="faq-item">
<summary>Come funzionano gli aggiornamenti?</summary>
<p>Sono <strong>inclusi in tutti i piani</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>Devo creare un account Spotify Premium o YouTube Premium?</summary>
<p>No. Per Spotify ti basta un account gratuito (serve solo per ottenere le credenziali API dal loro dashboard, è gratis). YouTube non richiede account.</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>
<details class="faq-item">
<summary>Quali metodi di pagamento sono accettati?</summary>
<p>Tramite Lemon Squeezy: carta di credito/debito, PayPal, Apple Pay, Google Pay. Riceverai una fattura per il pagamento.</p>
</details>
</div>
</div>
</section>
<!-- ============== FINAL CTA ============== -->
<section class="section cta-final">
<div class="container cta-box">
<h2>Pronto a semplificarti la vita?</h2>
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
</div>
</section>
<!-- ============== FOOTER ============== -->
<footer class="footer">
<div class="container footer-row">
<div class="footer-brand">
<span class="brand-logo">♫</span>
<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="https://assets.lemonsqueezy.com/lemon.js" defer></script>
<script src="js/main.js" defer></script>
</body>
</html>
+38
View File
@@ -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);
});
+64
View File
@@ -0,0 +1,64 @@
<!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: 8 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>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, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — 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.</p>
<h2>5. Terze parti</h2>
<ul>
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li>
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li>
<li><strong>Resend Inc.</strong> — invio email transazionali.</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>
+43
View File
@@ -0,0 +1,43 @@
<!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: 8 giugno 2026</p>
<h2>Garanzia 14 giorni</h2>
<p>Hai 14 giorni di tempo dall'acquisto per chiedere il rimborso completo, senza fornire motivazioni. La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
<h2>Cosa succede dopo</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>Limitazioni</h2>
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</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>
+226
View File
@@ -0,0 +1,226 @@
<!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: 8 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>
<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. Pagamenti 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>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</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> dall'acquisto, senza fornire motivazioni.</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>
<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>
+35
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
node_modules/
.env
*.log
logs/
dist/
+215
View File
@@ -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
+31
View File
@@ -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,
},
],
};
+49
View File
@@ -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;
+21
View File
@@ -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"
}
}
+30
View File
@@ -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;
+71
View File
@@ -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);
}
+141
View File
@@ -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> &mdash; ${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 &mdash; 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 &rarr; Apri &rarr; 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}`);
}
}
+51
View File
@@ -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;
}
}
+298
View File
@@ -0,0 +1,298 @@
/**
* 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;
// Subscriptions: il record vero arriva con subscription_created.
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
if (plan && plan.is_subscription) {
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
}
// Annual one-time (o piano non mappato -> trattalo come annual di default
// cosi' non lasciamo l'utente senza licenza se ha pagato).
const planForRecord = plan?.code || "annual";
const limit = plan?.daily_limit ?? null;
const expiresAt = 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 });
} 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 (non ne creo un'altra).
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 } };
}
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);
}
+208
View File
@@ -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("-");
}
+65
View File
@@ -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);
});
+105
View File
@@ -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;
+71
View File
@@ -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}`);
});
+95
View File
@@ -0,0 +1,95 @@
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&current=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);
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
fs.createReadStream(abs).pipe(res);
}
+161
View File
@@ -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 });
}
+250
View File
@@ -194,6 +194,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
margin-top: 2px;
}
/* ===============================
QUOTA BOX (sidebar)
=============================== */
.quota-box {
margin: 10px 8px 0;
padding: 10px 12px;
background: rgba(29, 185, 84, 0.08);
border: 1px solid rgba(29, 185, 84, 0.25);
border-radius: 10px;
text-align: center;
}
.quota-box[hidden] { display: none; }
.quota-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
color: var(--text-3);
text-transform: uppercase;
}
.quota-value {
font-size: 22px;
font-weight: 800;
color: var(--text);
margin: 2px 0;
}
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
.quota-plan {
font-size: 11px;
color: var(--green);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.quota-upgrade {
margin-top: 8px;
background: var(--green);
border: none;
color: #000;
font-weight: 700;
font-size: 11px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
width: 100%;
}
.quota-upgrade[hidden] { display: none; }
.quota-upgrade:hover { filter: brightness(1.1); }
/* Modal Upgrade — testo prose, non monospace */
#upgradeModal .modal-body {
font-family: inherit;
font-size: 14px;
color: var(--text);
white-space: normal;
}
#upgradeModal .modal-body p { margin: 0 0 10px; line-height: 1.5; }
#upgradeModal .modal-body .modal-hint {
color: var(--text-2);
font-size: 13px;
}
/* ===============================
MAIN
=============================== */
@@ -1256,3 +1317,192 @@ input[type="number"]::-webkit-inner-spin-button {
max-height: none;
}
}
/* ============================================================
SCHERMATA ATTIVAZIONE LICENZA
============================================================ */
.activate-screen {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 40px 20px;
background:
radial-gradient(ellipse at top, rgba(29, 185, 84, 0.18), transparent 60%),
linear-gradient(180deg, #0a0a0a 0%, #060606 100%);
overflow: auto;
}
.activate-screen[hidden] { display: none; }
.activate-card {
width: 100%;
max-width: 460px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 40px 36px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
text-align: center;
}
.activate-logo {
width: 72px;
height: 72px;
margin: 0 auto 20px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
background: linear-gradient(135deg, #1db954 0%, #137a37 100%);
color: #fff;
font-size: 36px;
box-shadow: 0 12px 30px rgba(29, 185, 84, 0.4);
}
.activate-title {
margin: 0 0 8px;
font-size: 22px;
font-weight: 800;
color: var(--text);
}
.activate-sub {
margin: 0 0 28px;
font-size: 13.5px;
color: var(--text-muted);
line-height: 1.55;
}
.activate-form {
text-align: left;
}
.activate-form .field {
margin-bottom: 14px;
}
.activate-error {
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.4);
color: #ff6b7a;
padding: 10px 14px;
border-radius: 12px;
font-size: 13px;
margin: 6px 0 14px;
}
.activate-actions {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 6px;
}
.activate-actions .btn {
width: 100%;
justify-content: center;
}
.activate-foot {
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--text-muted);
}
.activate-foot a {
color: var(--accent);
text-decoration: none;
}
.activate-foot a:hover { text-decoration: underline; }
/* ============================================================
IMPOSTAZIONI > LICENZA
============================================================ */
.license-box {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.lic-row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px dashed var(--divider);
font-size: 13.5px;
}
.lic-row:last-child { border-bottom: none; }
.lic-row span {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.6px;
font-size: 11px;
font-weight: 600;
}
.lic-row strong {
color: var(--text);
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
word-break: break-all;
}
.lic-warn {
padding: 14px;
border-radius: 12px;
background: rgba(220, 53, 69, 0.12);
border: 1px solid rgba(220, 53, 69, 0.35);
color: #ff6b7a;
font-weight: 700;
text-align: center;
}
.license-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
/* ============================================================
REGISTRA: card errore con log tecnici
============================================================ */
.rec-error-card {
border-left: 4px solid var(--red, #f87171);
}
.micro-label.red {
color: #ff8a92;
font-weight: 800;
}
.rec-error-msg {
margin-top: 6px;
font-size: 14px;
color: var(--text);
white-space: pre-line;
line-height: 1.55;
}
.rec-error-log {
margin-top: 12px;
background: #0d0d0d;
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #c0c0c0;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
+108 -7
View File
@@ -21,23 +21,23 @@
<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="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
</button>
<button class="nav-item" data-view="meta">
<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">
<button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
@@ -51,6 +51,19 @@
</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>
@@ -75,15 +88,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">
@@ -336,6 +356,16 @@
<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 ===== -->
@@ -513,6 +543,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">
@@ -806,6 +852,44 @@
</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">
@@ -821,6 +905,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>
+344 -8
View File
@@ -7,6 +7,8 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
const state = {
config: {},
license: { licensed: false },
purchaseUrl: "https://musictools.djluza.com",
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
dlOutputDir: "",
upDir: "",
@@ -27,6 +29,230 @@ function waitApi() {
});
}
// Riconosce se una stringa e un URL (incluso schema spotify:)
function looksLikeUrl(s) {
if (!s) return false;
if (/^(https?|spotify):/i.test(s)) return true;
if (s.includes("://")) return true;
// Domini comuni anche senza http (es. "youtube.com/...")
return /^(www\.)?(youtube\.com|youtu\.be|spotify\.com|soundcloud\.com|tiktok\.com|instagram\.com|facebook\.com|fb\.watch)\//i.test(s);
}
// Trasforma una query libera in {name, artist}.
// Formati riconosciuti: "Artista - Titolo" (anche con – o —) oppure solo "Titolo".
function parseQueryToTrack(q) {
const s = (q || "").trim();
const m = s.match(/^(.+?)\s+[-–—]\s+(.+)$/);
if (m) return { artist: m[1].trim(), name: m[2].trim() };
return { artist: "", name: s };
}
// ============================================================
// Licenza: gating della UI principale
// ============================================================
function applyLicenseGate() {
const lic = state.license || { licensed: false };
const screen = $("#activateScreen");
const appEl = document.querySelector(".app");
if (!lic.licensed) {
if (screen) screen.hidden = false;
if (appEl) appEl.style.display = "none";
} else {
if (screen) screen.hidden = true;
if (appEl) appEl.style.display = "";
}
}
function fmtDate(epoch) {
if (!epoch) return "—";
try {
return new Date(epoch * 1000).toLocaleDateString("it-IT", {
day: "2-digit", month: "short", year: "numeric"
});
} catch (_e) { return "—"; }
}
function renderLicenseStatus() {
const lic = state.license || {};
const box = $("#licenseStatusBox");
if (!box) return; // panel non ancora montato
if (!lic.licensed) {
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
return;
}
const plan = lic.plan || {};
const planLine = plan.code
? `<div class="lic-row"><span>Piano</span><strong>${plan.name || plan.code}</strong></div>`
: "";
const limitLine = plan.daily_limit
? `<div class="lic-row"><span>Limite giornaliero</span><strong>${plan.daily_limit}</strong></div>`
: (plan.code === "annual"
? `<div class="lic-row"><span>Limite giornaliero</span><strong>Illimitato</strong></div>`
: "");
const expiryLine = plan.expires_at
? `<div class="lic-row"><span>Scade il</span><strong>${fmtDate(plan.expires_at)}</strong></div>`
: (plan.period_end
? `<div class="lic-row"><span>Prossimo rinnovo</span><strong>${fmtDate(plan.period_end)}</strong></div>`
: "");
box.innerHTML = `
${planLine}
${limitLine}
${expiryLine}
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
<div class="lic-row"><span>Ultima verifica</span><strong>${fmtDate(lic.last_validated_at)}</strong></div>
`;
}
// ============================================================
// Piano e quota giornaliera
// ============================================================
function applyPlanGate() {
// Nasconde le tab non incluse nel piano corrente.
const lic = state.license || {};
const features = (lic.plan && lic.plan.features) || [];
$$(".nav-item[data-feature]").forEach((btn) => {
const f = btn.dataset.feature;
const allowed = features.includes(f);
btn.hidden = !allowed;
if (!allowed && btn.classList.contains("active")) {
// Fallback alla prima tab disponibile (audio o settings)
const fallback = $$(".nav-item[data-feature]:not([hidden])")[0]
|| $(".nav-item[data-view='settings']");
if (fallback) showView(fallback.dataset.view);
}
});
}
function renderQuotaBox(q) {
const box = $("#quotaBox");
if (!box) return;
const lic = state.license || {};
const plan = (q && q.plan) || lic.plan || {};
if (!plan.code || plan.code === "annual" || plan.daily_limit == null) {
// Annual / unlimited -> niente contatore
box.hidden = true;
return;
}
box.hidden = false;
$("#quotaUsed").textContent = String((q && q.used) ?? 0);
$("#quotaLimit").textContent = String(plan.daily_limit);
$("#quotaPlanName").textContent = plan.name || plan.code;
const upBtn = $("#quotaUpgradeBtn");
if (upBtn) upBtn.hidden = plan.code === "premium";
}
async function refreshQuota() {
const lic = state.license || {};
const plan = lic.plan || {};
if (!lic.licensed || !plan.code || plan.daily_limit == null) {
renderQuotaBox(null);
return;
}
try {
const res = await window.pywebview.api.get_quota_status();
if (res && res.ok) {
renderQuotaBox(res.quota);
}
} catch (_e) { /* offline: lascia stato precedente */ }
}
function showUpgradeModal(reason, payload) {
const modal = $("#upgradeModal");
if (!modal) return;
const title = $("#upgradeModalTitle");
const body = $("#upgradeModalBody");
if (reason === "feature_not_in_plan") {
title.textContent = "Funzione non inclusa nel piano";
body.innerHTML = `
<p>${payload.error || "Questa funzione non e' inclusa nel tuo piano."}</p>
<p class="modal-hint">Passa a un piano superiore per sbloccarla.</p>
`;
} else if (reason === "quota_exceeded") {
const q = payload.quota || {};
title.textContent = "Limite giornaliero raggiunto";
body.innerHTML = `
<p>${payload.error || "Hai raggiunto il limite giornaliero del tuo piano."}</p>
<p class="modal-hint">Usati oggi: <strong>${q.used ?? "?"} / ${q.limit ?? "?"}</strong>. Il contatore si resetta a mezzanotte (ora di Roma).</p>
`;
} else if (reason === "license_invalid") {
title.textContent = "Licenza non valida";
body.innerHTML = `<p>${payload.error || "La tua licenza non e' piu' valida."}</p>`;
} else if (reason === "offline") {
title.textContent = "Connessione assente";
body.innerHTML = `<p>${payload.error || "Impossibile contattare il server."}</p>`;
} else {
title.textContent = "Operazione bloccata";
body.innerHTML = `<p>${payload.error || "L'operazione non e' stata avviata."}</p>`;
}
modal.hidden = false;
}
function hideUpgradeModal() {
const modal = $("#upgradeModal");
if (modal) modal.hidden = true;
}
/** Gestisce il dict di errore restituito dai metodi start_*: se e' un
* gate failure (feature/quota/license/offline), mostra il modal e
* ritorna true (= caller deve interrompere il flow). */
function handleGateBlock(res) {
if (!res || res.ok !== false) return false;
const r = res.reason;
if (r === "feature_not_in_plan" || r === "quota_exceeded"
|| r === "license_invalid" || r === "offline") {
showUpgradeModal(r, res);
return true;
}
return false;
}
async function activateLicense() {
const email = $("#actEmail").value.trim();
const key = $("#actKey").value.trim();
const err = $("#actError");
err.hidden = true;
err.textContent = "";
if (!email || !key) {
err.textContent = "Inserisci email e chiave di licenza.";
err.hidden = false;
return;
}
const btn = $("#actActivateBtn");
btn.disabled = true;
const oldText = btn.innerHTML;
btn.innerHTML = "Attivazione in corso…";
try {
const res = await window.pywebview.api.activate_license({ email, key });
if (res.ok) {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
applyPlanGate();
refreshQuota();
toast("Licenza attivata. Benvenuto!", "success");
} else {
err.textContent = res.error || "Errore di attivazione.";
err.hidden = false;
}
} finally {
btn.disabled = false;
btn.innerHTML = oldText;
}
}
async function deactivateLicense() {
if (!confirm("Disattivare la licenza su questo dispositivo?")) return;
const res = await window.pywebview.api.deactivate_license();
if (res.ok) {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
toast("Licenza disattivata", "info");
}
}
// ============================================================
// Bridge — eventi spinti dal backend
// ============================================================
@@ -39,6 +265,7 @@ window.bridge = {
const bridgeHandlers = {
"log": ({ view, msg }) => appendLog(view, msg),
"quota:update": (q) => renderQuotaBox(q),
"download:progress": (p) => {
if (typeof p.overall === "number") {
@@ -118,6 +345,7 @@ const bridgeHandlers = {
$("#recRefreshBtn").disabled = true;
$("#recTimer").textContent = "00:00:00";
$("#recStatus").textContent = "● Registrazione in corso…";
$("#recErrorCard").hidden = true; // pulisci eventuale errore precedente
} else if (status === "tick") {
state.rec.seconds = p.seconds || 0;
$("#recTimer").textContent = formatHms(state.rec.seconds);
@@ -143,7 +371,14 @@ const bridgeHandlers = {
$("#recDevice").disabled = false;
$("#recRefreshBtn").disabled = false;
$("#recStatus").textContent = "";
toast("Errore registrazione: " + (p.error || ""), "error");
const msg = p.error || "Errore sconosciuto";
$("#recErrorMsg").textContent = msg;
$("#recErrorCard").hidden = false;
$("#recErrorLog").hidden = true;
$("#recErrorLog").textContent = "";
$("#recCopyLogBtn").hidden = true;
$("#recShowLogBtn").textContent = "Mostra log tecnici";
toast("Errore registrazione", "error");
}
},
};
@@ -216,10 +451,17 @@ async function init() {
await waitApi();
const data = await window.pywebview.api.get_init_data();
state.config = data.config || {};
state.license = data.license || { licensed: false };
state.purchaseUrl = data.purchase_url || "https://musictools.djluza.com";
$("#footerVersion").textContent = data.version;
$("#currentVersionLabel").textContent = data.version;
$("#guideBody").textContent = data.spotify_guide;
applyLicenseGate();
renderLicenseStatus();
applyPlanGate();
refreshQuota();
// Populate Settings fields
$("#clientIdInput").value = state.config.client_id || "";
$("#clientSecretInput").value = state.config.client_secret || "";
@@ -340,11 +582,25 @@ $("#downloadBtn").addEventListener("click", async () => {
if (!urls.length) {
const single = $("#urlInput").value.trim();
if (!single) {
toast("Inserisci un URL o carica una lista", "error");
toast("Inserisci un URL, un titolo o carica una lista", "error");
bridgeHandlers["download:done"]();
return;
}
urls = [single];
if (looksLikeUrl(single)) {
urls = [single];
} else {
const track = parseQueryToTrack(single);
res = await window.pywebview.api.start_tracks_download({
tracks: [track],
output_dir: state.dlOutputDir,
subfolder: "",
});
if (!res.ok) {
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
return;
}
}
res = await window.pywebview.api.start_download({
urls,
@@ -353,7 +609,7 @@ $("#downloadBtn").addEventListener("click", async () => {
}
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
});
@@ -410,7 +666,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
audio_only: videoMode === "audio",
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["video:done"]();
}
});
@@ -471,7 +727,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
threshold: parseInt($("#upThreshold").value, 10) || 310,
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["upgrade:done"]();
}
});
@@ -563,7 +819,7 @@ $("#recStartBtn").addEventListener("click", async () => {
bitrate: $("#recBitrate").value,
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
}
});
@@ -572,6 +828,32 @@ $("#recStopBtn").addEventListener("click", async () => {
await window.pywebview.api.stop_audio_recording();
});
$("#recShowLogBtn")?.addEventListener("click", async () => {
const pre = $("#recErrorLog");
if (!pre.hidden) {
pre.hidden = true;
$("#recShowLogBtn").textContent = "Mostra log tecnici";
$("#recCopyLogBtn").hidden = true;
return;
}
const res = await window.pywebview.api.get_recorder_log();
const lines = (res && res.lines) || [];
pre.textContent = lines.length ? lines.join("\n") : "(log vuoto)";
pre.hidden = false;
$("#recShowLogBtn").textContent = "Nascondi log tecnici";
$("#recCopyLogBtn").hidden = false;
});
$("#recCopyLogBtn")?.addEventListener("click", async () => {
const text = $("#recErrorLog").textContent || "";
try {
await navigator.clipboard.writeText(text);
toast("Log copiato negli appunti", "success");
} catch {
toast("Impossibile copiare", "error");
}
});
// ============================================================
// METADATA editor
// ============================================================
@@ -719,7 +1001,7 @@ $("#metaSaveBtn").addEventListener("click", async () => {
await loadMetaFile(state.meta.path);
} else {
status.textContent = "";
toast("Errore: " + (res.error || ""), "error");
if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error");
}
});
@@ -823,6 +1105,60 @@ $("#checkUpdateBtn").addEventListener("click", async () => {
}
});
// ============================================================
// Licenza: listener dello schermo di attivazione + impostazioni
// ============================================================
$("#actActivateBtn")?.addEventListener("click", activateLicense);
$("#actBuyBtn")?.addEventListener("click", async () => {
await window.pywebview.api.open_purchase_page();
});
$("#actKey")?.addEventListener("keydown", (e) => {
if (e.key === "Enter") activateLicense();
});
$("#deactivateLicenseBtn")?.addEventListener("click", deactivateLicense);
$("#revalidateLicenseBtn")?.addEventListener("click", async () => {
const btn = $("#revalidateLicenseBtn");
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "Verifica in corso…";
try {
const res = await window.pywebview.api.revalidate_license();
if (res.ok) {
state.license = res.license;
renderLicenseStatus();
applyPlanGate();
refreshQuota();
if (res.license.licensed) {
toast("Licenza verificata", "success");
} else {
applyLicenseGate();
toast("Licenza non piu valida", "error");
}
}
} finally {
btn.disabled = false;
btn.textContent = old;
}
});
// ============================================================
// Modal Upgrade
// ============================================================
$("#upgradeCloseBtn")?.addEventListener("click", hideUpgradeModal);
$("#upgradeCloseBtn2")?.addEventListener("click", hideUpgradeModal);
$("#upgradeModal")?.addEventListener("click", (e) => {
if (e.target === e.currentTarget) hideUpgradeModal();
});
$("#upgradeOpenBtn")?.addEventListener("click", async () => {
hideUpgradeModal();
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
});
$("#quotaUpgradeBtn")?.addEventListener("click", async () => {
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
});
// ============================================================
// Boot
// ============================================================