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.
This commit is contained in:
luzadev committed 2026-06-09 11:02:42 +02:00
1 parent bcd4f22b1e
commit 61459099f9
15 files changed
+1395 -108

No files matched your search

+83
View File
@@ -123,6 +123,7 @@ class Api:
"config": safe_cfg, "config": safe_cfg,
"spotify_guide": SPOTIFY_GUIDE_TEXT, "spotify_guide": SPOTIFY_GUIDE_TEXT,
"license": license_status, "license": license_status,
"plan": license_mod.get_plan(cfg),
"purchase_url": "https://musictools.djluza.com", "purchase_url": "https://musictools.djluza.com",
} }
@@ -181,6 +182,63 @@ class Api:
webbrowser.open("https://musictools.djluza.com") webbrowser.open("https://musictools.djluza.com")
return {"ok": True} 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: def check_update(self) -> dict:
"""Controlla aggiornamenti interrogando l'API djluza.com. """Controlla aggiornamenti interrogando l'API djluza.com.
@@ -326,6 +384,10 @@ class Api:
if not os.path.exists(path): if not os.path.exists(path):
return {"ok": False, "error": "File non trovato"} return {"ok": False, "error": "File non trovato"}
gate = self._gate("metadata")
if gate:
return gate
data = payload.get("data") or {} data = payload.get("data") or {}
cover_path = (payload.get("cover_path") or "").strip() or None cover_path = (payload.get("cover_path") or "").strip() or None
remove_cover = bool(payload.get("remove_cover", False)) remove_cover = bool(payload.get("remove_cover", False))
@@ -360,6 +422,10 @@ class Api:
if not filename.lower().endswith(".mp3"): if not filename.lower().endswith(".mp3"):
filename += ".mp3" filename += ".mp3"
gate = self._gate("record")
if gate:
return gate
out_path = os.path.join(output_dir, filename) out_path = os.path.join(output_dir, filename)
def cb(status, payload_evt): def cb(status, payload_evt):
@@ -412,6 +478,10 @@ class Api:
if not output_dir: if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"} return {"ok": False, "error": "Cartella output non impostata"}
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread( self._download_thread = threading.Thread(
target=self._download_worker, target=self._download_worker,
args=(list(urls), output_dir), args=(list(urls), output_dir),
@@ -441,6 +511,10 @@ class Api:
if safe: if safe:
output_dir = os.path.join(output_dir, safe) output_dir = os.path.join(output_dir, safe)
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread( self._download_thread = threading.Thread(
target=self._tracks_worker, target=self._tracks_worker,
args=(list(tracks), output_dir), args=(list(tracks), output_dir),
@@ -627,6 +701,10 @@ class Api:
if not directory: if not directory:
return {"ok": False, "error": "Cartella non impostata"} return {"ok": False, "error": "Cartella non impostata"}
gate = self._gate("upgrade")
if gate:
return gate
cfg = load_config() cfg = load_config()
cookies_path = cfg.get("cookies_path", "") cookies_path = cfg.get("cookies_path", "")
@@ -700,6 +778,11 @@ class Api:
if not output_dir: if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"} 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( self._video_thread = threading.Thread(
target=self._video_worker, target=self._video_worker,
args=(list(urls), output_dir, quality, audio_only), args=(list(urls), output_dir, quality, audio_only),
+9 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.6.0" VERSION = "v1.7.0"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
@@ -77,6 +77,14 @@ DEFAULTS = {
"license_activated_at": 0, # epoch della prima attivazione "license_activated_at": 0, # epoch della prima attivazione
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita "last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
"device_id": "", # UUID generato al primo avvio "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
} }
+187 -2
View File
@@ -28,6 +28,7 @@ import json
import platform import platform
import time import time
import urllib.error import urllib.error
import urllib.parse
import urllib.request import urllib.request
import uuid import uuid
from typing import Optional from typing import Optional
@@ -140,12 +141,42 @@ def _http_post(path: str, body: dict) -> dict:
# ============================================================ # ============================================================
# API pubblica # 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: def get_status(config: Optional[dict] = None) -> dict:
"""Ritorna lo stato corrente della licenza per la UI. """Ritorna lo stato corrente della licenza per la UI.
Chiavi: licensed (bool), reason (str), email, key, activated_at, Chiavi: licensed (bool), reason (str), email, key, activated_at,
last_validated_at, days_since_validation, expires_soon (bool), last_validated_at, days_since_validation, expires_soon (bool),
needs_revalidation (bool). needs_revalidation (bool), plan (dict).
""" """
cfg = config or load_config() cfg = config or load_config()
token = (cfg.get("license_token") or "").strip() token = (cfg.get("license_token") or "").strip()
@@ -153,6 +184,7 @@ def get_status(config: Optional[dict] = None) -> dict:
email = (cfg.get("license_email") or "").strip() email = (cfg.get("license_email") or "").strip()
activated = int(cfg.get("license_activated_at") or 0) activated = int(cfg.get("license_activated_at") or 0)
last_val = int(cfg.get("last_validated_at") or 0) last_val = int(cfg.get("last_validated_at") or 0)
plan = _plan_snapshot(cfg)
if not token or not key: if not token or not key:
return { return {
@@ -164,9 +196,27 @@ def get_status(config: Optional[dict] = None) -> dict:
"last_validated_at": 0, "last_validated_at": 0,
"days_since_validation": 0, "days_since_validation": 0,
"needs_revalidation": False, "needs_revalidation": False,
"plan": plan,
} }
days_since = (_now() - last_val) // 86400 if last_val else 999 # 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 needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
grace_expired = days_since >= LICENSE_GRACE_DAYS grace_expired = days_since >= LICENSE_GRACE_DAYS
@@ -180,6 +230,7 @@ def get_status(config: Optional[dict] = None) -> dict:
"last_validated_at": last_val, "last_validated_at": last_val,
"days_since_validation": days_since, "days_since_validation": days_since,
"needs_revalidation": True, "needs_revalidation": True,
"plan": plan,
} }
return { return {
@@ -191,6 +242,7 @@ def get_status(config: Optional[dict] = None) -> dict:
"last_validated_at": last_val, "last_validated_at": last_val,
"days_since_validation": days_since, "days_since_validation": days_since,
"needs_revalidation": needs_reval, "needs_revalidation": needs_reval,
"plan": plan,
} }
@@ -199,6 +251,55 @@ def is_licensed() -> bool:
return get_status()["licensed"] 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: def activate(license_key: str, email: str) -> dict:
"""Attiva una licenza contro il server. Salva token su success. """Attiva una licenza contro il server. Salva token su success.
@@ -231,6 +332,7 @@ def activate(license_key: str, email: str) -> dict:
cfg["license_token"] = token cfg["license_token"] = token
cfg["license_activated_at"] = int(resp.get("activated_at") or now) cfg["license_activated_at"] = int(resp.get("activated_at") or now)
cfg["last_validated_at"] = now cfg["last_validated_at"] = now
_store_plan_from_response(cfg, resp, token)
save_config(cfg) save_config(cfg)
return get_status(cfg) return get_status(cfg)
@@ -260,6 +362,7 @@ def validate() -> dict:
# Server ha risposto 4xx -> token non piu valido # Server ha risposto 4xx -> token non piu valido
cfg["license_token"] = "" cfg["license_token"] = ""
cfg["last_validated_at"] = 0 cfg["last_validated_at"] = 0
_clear_plan(cfg)
save_config(cfg) save_config(cfg)
return get_status(cfg) return get_status(cfg)
@@ -268,6 +371,7 @@ def validate() -> dict:
if new_token: if new_token:
cfg["license_token"] = new_token cfg["license_token"] = new_token
cfg["last_validated_at"] = _now() cfg["last_validated_at"] = _now()
_store_plan_from_response(cfg, resp, cfg.get("license_token", ""))
save_config(cfg) save_config(cfg)
return get_status(cfg) return get_status(cfg)
@@ -298,5 +402,86 @@ def deactivate(release_remote: bool = True) -> dict:
cfg["license_token"] = "" cfg["license_token"] = ""
cfg["license_activated_at"] = 0 cfg["license_activated_at"] = 0
cfg["last_validated_at"] = 0 cfg["last_validated_at"] = 0
_clear_plan(cfg)
save_config(cfg) save_config(cfg)
return get_status(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
+46
View File
@@ -555,6 +555,52 @@ a { color: inherit; text-decoration: none; }
margin: 14px 0 0; 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
============================================================ */ ============================================================ */
+123 -36
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicTools — Scarica, modifica e registra la tua musica</title> <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. Una licenza a vita, 39,90 €." /> <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: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. Licenza a vita 39,90 €." /> <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:type" content="website" />
<meta property="og:url" content="https://musictools.djluza.com" /> <meta property="og:url" content="https://musictools.djluza.com" />
@@ -26,11 +26,11 @@
<nav class="nav-links"> <nav class="nav-links">
<a href="#features">Funzioni</a> <a href="#features">Funzioni</a>
<a href="#how">Come funziona</a> <a href="#how">Come funziona</a>
<a href="#pricing">Prezzo</a> <a href="#pricing">Piani</a>
<a href="#faq">FAQ</a> <a href="#faq">FAQ</a>
<a href="/download">Download</a> <a href="/download">Download</a>
</nav> </nav>
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1" class="btn btn-primary btn-sm lemonsqueezy-button">Compra — 39,90 €</a> <a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
</div> </div>
</header> </header>
@@ -38,7 +38,7 @@
<section class="hero" id="top"> <section class="hero" id="top">
<div class="container hero-grid"> <div class="container hero-grid">
<div class="hero-text"> <div class="hero-text">
<span class="eyebrow">Per Mac e Windows · Licenza a vita</span> <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> <h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
<p class="lead"> <p class="lead">
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K. Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
@@ -46,12 +46,12 @@
Modifica i metadati e le copertine. Tutto offline, senza account. Modifica i metadati e le copertine. Tutto offline, senza account.
</p> </p>
<div class="hero-cta"> <div class="hero-cta">
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1" class="btn btn-primary btn-lg lemonsqueezy-button">Compra ora — 39,90 €</a> <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> <a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
</div> </div>
<ul class="hero-bullets"> <ul class="hero-bullets">
<li>✓ Acquisto una tantum, nessun abbonamento</li> <li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
<li>✓ Aggiornamenti gratuiti a vita</li> <li>✓ Aggiornamenti gratuiti inclusi</li>
<li>✓ Funziona offline · Su 3 dispositivi</li> <li>✓ Funziona offline · Su 3 dispositivi</li>
</ul> </ul>
@@ -175,32 +175,104 @@
<!-- ============== PRICING ============== --> <!-- ============== PRICING ============== -->
<section class="section pricing" id="pricing"> <section class="section pricing" id="pricing">
<div class="container"> <div class="container">
<span class="eyebrow center">PREZZO</span> <span class="eyebrow center">PIANI</span>
<h2 class="section-title">Una licenza. Per sempre.</h2> <h2 class="section-title">Scegli il piano giusto per te</h2>
<p class="section-lead">Niente abbonamenti. Niente sorprese. Compri una volta, la usi per sempre.</p> <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-card"> <div class="price-grid">
<div class="price-badge">Più popolare</div>
<div class="price-name">MusicTools Personal</div> <!-- BASIC -->
<div class="price-row"> <div class="price-card price-card-sm">
<span class="price-amount">39,90</span> <div class="price-name">Basic</div>
<span class="price-currency">€</span> <div class="price-row">
<span class="price-once">una tantum</span> <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> </div>
<ul class="price-features">
<li>✓ <strong>Licenza a vita</strong> — paghi una volta, mai più</li> <!-- PRO -->
<li>✓ <strong>3 dispositivi</strong> — Mac e Windows mixati</li> <div class="price-card price-card-sm">
<li>✓ <strong>Aggiornamenti gratuiti</strong> per tutte le versioni future</li> <div class="price-badge">Più scelto</div>
<li>✓ <strong>Tutte le 7 tab</strong> sbloccate</li> <div class="price-name">Pro</div>
<li>✓ <strong>Supporto via email</strong> a info@djluza.com</li> <div class="price-row">
<li>✓ <strong>Garanzia 14 giorni</strong> soddisfatti o rimborsati</li> <span class="price-amount">5,99</span>
</ul> <span class="price-currency">€</span>
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1" <span class="price-once">/mese</span>
class="btn btn-primary btn-lg btn-block lemonsqueezy-button" </div>
id="buyBtn"> <ul class="price-features">
Acquista ora — 39,90 € <li>✓ <strong>Tutto di Basic</strong></li>
</a> <li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa</p> <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"> <p class="price-consent">
Procedendo con l'acquisto dichiari di aver letto e di accettare i Procedendo con l'acquisto dichiari di aver letto e di accettare i
<a href="/legal/terms.html">Termini di servizio</a>, <a href="/legal/terms.html">Termini di servizio</a>,
@@ -247,12 +319,27 @@
<details class="faq-item"> <details class="faq-item">
<summary>Su quanti dispositivi posso installarla?</summary> <summary>Su quanti dispositivi posso installarla?</summary>
<p>Una licenza copre fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p> <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>
<details class="faq-item"> <details class="faq-item">
<summary>Come funzionano gli aggiornamenti?</summary> <summary>Come funzionano gli aggiornamenti?</summary>
<p>Sono <strong>gratis a vita</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p> <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>
<details class="faq-item"> <details class="faq-item">
@@ -307,8 +394,8 @@
<section class="section cta-final"> <section class="section cta-final">
<div class="container cta-box"> <div class="container cta-box">
<h2>Pronto a semplificarti la vita?</h2> <h2>Pronto a semplificarti la vita?</h2>
<p>Una sola app per tutto quello che fai con la musica.</p> <p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1" class="btn btn-primary btn-lg lemonsqueezy-button">Compra ora — 39,90 €</a> <a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
</div> </div>
</section> </section>
@@ -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;
+66 -8
View File
@@ -1,21 +1,72 @@
/** /**
* Invio email transazionali via Resend (https://resend.com). * Invio email transazionali via Resend.
* Usa fetch nativo di Node >= 20 — nessuna dipendenza. *
* 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: * Variabili env richieste:
* RESEND_API_KEY dal dashboard Resend * RESEND_API_KEY dal dashboard Resend
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato) * EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
*/ */
import { getPlan } from "./plans.js";
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>"; const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com"; const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
export async function sendLicenseEmail(to, licenseKey) { 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) { if (!process.env.RESEND_API_KEY) {
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to); console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
return; return;
} }
const { plan: planCode, expiresAt, periodEnd } = opts;
const planInfo = describePlan(planCode, expiresAt, periodEnd);
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`; const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
const macUrl = `${BASE}/api/download/macos?${q}`; const macUrl = `${BASE}/api/download/macos?${q}`;
const winUrl = `${BASE}/api/download/windows?${q}`; const winUrl = `${BASE}/api/download/windows?${q}`;
@@ -23,8 +74,11 @@ export async function sendLicenseEmail(to, licenseKey) {
const html = ` const html = `
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111"> <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> <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"> <p style="font-size:15px;line-height:1.55;margin:0 0 18px">
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app. Ecco la tua chiave di licenza. Conservala con cura &mdash; ti servira' per attivare l'app.
</p> </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"> <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} ${licenseKey}
@@ -35,12 +89,12 @@ export async function sendLicenseEmail(to, licenseKey) {
<tr> <tr>
<td style="padding-right:10px"> <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"> <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 Scarica per Mac
</a> </a>
</td> </td>
<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"> <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 Scarica per Windows
</a> </a>
</td> </td>
</tr> </tr>
@@ -49,7 +103,7 @@ export async function sendLicenseEmail(to, licenseKey) {
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p> <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"> <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>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
<li>Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)</li> <li>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>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li> <li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
</ol> </ol>
@@ -62,6 +116,10 @@ export async function sendLicenseEmail(to, licenseKey) {
</div> </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", { const resp = await fetch("https://api.resend.com/emails", {
method: "POST", method: "POST",
headers: { headers: {
@@ -71,7 +129,7 @@ export async function sendLicenseEmail(to, licenseKey) {
body: JSON.stringify({ body: JSON.stringify({
from: FROM, from: FROM,
to: [to], to: [to],
subject: "La tua licenza MusicTools", subject,
html, html,
}), }),
}); });
+253 -38
View File
@@ -1,13 +1,26 @@
/** /**
* Webhook Lemon Squeezy. * Webhook Lemon Squeezy.
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy * URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
* Eventi gestiti: order_created, order_refunded. *
* 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 crypto from "node:crypto";
import { one, exec } from "./db.js"; import { one, exec } from "./db.js";
import { generateLicenseKey } from "./license.js"; import { generateLicenseKey } from "./license.js";
import { sendLicenseEmail } from "./email.js"; import { sendLicenseEmail } from "./email.js";
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
const now = () => Math.floor(Date.now() / 1000); const now = () => Math.floor(Date.now() / 1000);
@@ -21,8 +34,210 @@ function timingSafe(a, b) {
return crypto.timingSafeEqual(A, 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) { export async function webhook(req, res) {
// express.raw() salva il body come Buffer in req.body
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : ""; const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
const sig = req.get("X-Signature") || ""; const sig = req.get("X-Signature") || "";
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET; const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
@@ -39,45 +254,45 @@ export async function webhook(req, res) {
const eventName = payload?.meta?.event_name || ""; const eventName = payload?.meta?.event_name || "";
const attrs = payload?.data?.attributes || {}; const attrs = payload?.data?.attributes || {};
const email = String(attrs.user_email || "").trim().toLowerCase();
const orderId = String(payload?.data?.id || attrs.order_number || "");
if (!email || !orderId) {
return res.status(400).json({ error: "Missing email or order_id" });
}
const t = now(); const t = now();
if (eventName === "order_created") { let out;
const key = generateLicenseKey(); try {
try { switch (eventName) {
await exec( case "order_created":
`INSERT INTO licenses out = await handleOrderCreated(payload, attrs, t);
(license_key, email, status, source, order_id, created_at, updated_at) break;
VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`, case "order_refunded":
[key, email, orderId, t, t], out = await handleOrderRefunded(payload, attrs, t);
); break;
} catch (e) { case "subscription_created":
// duplicate webhook delivery out = await handleSubscriptionCreated(payload, attrs, t);
if (e?.code === "ER_DUP_ENTRY") { break;
return res.json({ ok: true, duplicate: true }); case "subscription_updated":
} case "subscription_resumed":
throw e; 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 } };
} }
try { } catch (e) {
await sendLicenseEmail(email, key); console.error("[ls] handler error", eventName, e?.message || e);
} catch (e) { return res.status(500).json({ error: "Internal error" });
console.error("[email] send failed:", e?.message || e);
}
return res.json({ ok: true });
} }
if (eventName === "order_refunded") { return res.status(out.status).json(out.body);
await exec(
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
[t, orderId],
);
return res.json({ ok: true });
}
res.json({ ok: true, ignored: eventName });
} }
+84 -12
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { one, exec } from "./db.js"; import { one, exec } from "./db.js";
import { signJwt, verifyJwt } from "./jwt.js"; import { signJwt, verifyJwt } from "./jwt.js";
import { getPlan, planSnapshot } from "./plans.js";
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3); const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30); const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
@@ -9,18 +10,54 @@ const now = () => Math.floor(Date.now() / 1000);
const normEmail = (s) => String(s || "").trim().toLowerCase(); const normEmail = (s) => String(s || "").trim().toLowerCase();
const normKey = (s) => String(s || "").trim().toUpperCase(); 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) { async function issueToken(license, deviceId) {
const t = now(); const t = now();
return signJwt({ const plan = planSnapshot(license.plan);
const claims = {
sub: String(license.id), sub: String(license.id),
key_id: license.license_key, key_id: license.license_key,
email: license.email, email: license.email,
device_id: deviceId, device_id: deviceId,
iat: t, iat: t,
exp: t + TOKEN_TTL_DAYS * 86400, exp: t + TOKEN_TTL_DAYS * 86400,
}, process.env.JWT_SECRET); };
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) { export async function activate(req, res) {
const { key, email, device_id, device_name, app_version } = req.body || {}; const { key, email, device_id, device_name, app_version } = req.body || {};
const K = normKey(key), E = normEmail(email); const K = normKey(key), E = normEmail(email);
@@ -30,15 +67,15 @@ export async function activate(req, res) {
} }
const license = await one( const license = await one(
"SELECT id, license_key, email, status FROM licenses WHERE license_key=? AND email=? LIMIT 1", `SELECT ${LICENSE_COLUMNS} FROM licenses
WHERE license_key=? AND email=? LIMIT 1`,
[K, E], [K, E],
); );
if (!license) { if (!license) {
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." }); return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
} }
if (license.status !== "active") { const check = checkLicenseValidity(license);
return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." }); if (!check.ok) return res.status(403).json({ error: check.error });
}
const t = now(); const t = now();
const existing = await one( const existing = await one(
@@ -71,7 +108,15 @@ export async function activate(req, res) {
} }
const token = await issueToken(license, D); const token = await issueToken(license, D);
res.json({ token, activated_at: t, email: license.email }); 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) { export async function validate(req, res) {
@@ -85,12 +130,14 @@ export async function validate(req, res) {
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" }); if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
const license = await one( const license = await one(
"SELECT id, license_key, email, status FROM licenses WHERE id=?", `SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
[claims.sub], [claims.sub],
); );
if (!license || license.status !== "active") { if (!license) return res.status(401).json({ error: "Licenza non trovata" });
return res.status(401).json({ error: "Licenza non attiva" });
} const check = checkLicenseValidity(license);
if (!check.ok) return res.status(401).json({ error: check.error });
const act = await one( const act = await one(
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?", "SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
[license.id, D], [license.id, D],
@@ -105,7 +152,14 @@ export async function validate(req, res) {
); );
const fresh = await issueToken(license, D); const fresh = await issueToken(license, D);
res.json({ token: fresh, email: license.email }); 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) { export async function deactivate(req, res) {
@@ -126,6 +180,24 @@ export async function deactivate(req, res) {
res.json({ ok: true }); 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) // Esposto per uso interno (es. webhook genera chiave nuova)
export function generateLicenseKey() { export function generateLicenseKey() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+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;
+5
View File
@@ -5,6 +5,7 @@ import * as license from "./license.js";
import * as updates from "./updates.js"; import * as updates from "./updates.js";
import * as ls from "./lemonsqueezy.js"; import * as ls from "./lemonsqueezy.js";
import * as dl from "./downloads.js"; import * as dl from "./downloads.js";
import * as usage from "./usage.js";
const app = express(); const app = express();
@@ -42,6 +43,10 @@ app.post("/api/license/activate", license.activate);
app.post("/api/license/validate", license.validate); app.post("/api/license/validate", license.validate);
app.post("/api/license/deactivate", license.deactivate); 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 // Aggiornamenti + download firmato
app.get("/api/latest", updates.latest); app.get("/api/latest", updates.latest);
app.get("/api/download", updates.download); app.get("/api/download", updates.download);
+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 });
}
+61
View File
@@ -194,6 +194,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
margin-top: 2px; 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 MAIN
=============================== */ =============================== */
+35 -5
View File
@@ -21,23 +21,23 @@
<div class="divider"></div> <div class="divider"></div>
<nav class="nav"> <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 class="nav-icon">⬇</span>
<span>Scarica</span> <span>Scarica</span>
</button> </button>
<button class="nav-item" data-view="video"> <button class="nav-item" data-view="video" data-feature="video">
<span class="nav-icon">🎬</span> <span class="nav-icon">🎬</span>
<span>Video</span> <span>Video</span>
</button> </button>
<button class="nav-item" data-view="upgrade"> <button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span> <span class="nav-icon">⚡</span>
<span>Upgrade</span> <span>Upgrade</span>
</button> </button>
<button class="nav-item" data-view="meta"> <button class="nav-item" data-view="meta" data-feature="metadata">
<span class="nav-icon">🏷</span> <span class="nav-icon">🏷</span>
<span>Metadati</span> <span>Metadati</span>
</button> </button>
<button class="nav-item" data-view="record"> <button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span> <span class="nav-icon">●</span>
<span>Registra</span> <span>Registra</span>
</button> </button>
@@ -51,6 +51,19 @@
</button> </button>
</nav> </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">
<div class="footer-by">by LuZa</div> <div class="footer-by">by LuZa</div>
<div class="footer-version" id="footerVersion">v1.0</div> <div class="footer-version" id="footerVersion">v1.0</div>
@@ -892,6 +905,23 @@
</div> </div>
</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 ============== --> <!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
+149 -6
View File
@@ -80,7 +80,24 @@ function renderLicenseStatus() {
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`; box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
return; 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 = ` box.innerHTML = `
${planLine}
${limitLine}
${expiryLine}
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div> <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>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>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
@@ -88,6 +105,109 @@ function renderLicenseStatus() {
`; `;
} }
// ============================================================
// 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() { async function activateLicense() {
const email = $("#actEmail").value.trim(); const email = $("#actEmail").value.trim();
const key = $("#actKey").value.trim(); const key = $("#actKey").value.trim();
@@ -109,6 +229,8 @@ async function activateLicense() {
state.license = res.license; state.license = res.license;
applyLicenseGate(); applyLicenseGate();
renderLicenseStatus(); renderLicenseStatus();
applyPlanGate();
refreshQuota();
toast("Licenza attivata. Benvenuto!", "success"); toast("Licenza attivata. Benvenuto!", "success");
} else { } else {
err.textContent = res.error || "Errore di attivazione."; err.textContent = res.error || "Errore di attivazione.";
@@ -143,6 +265,7 @@ window.bridge = {
const bridgeHandlers = { const bridgeHandlers = {
"log": ({ view, msg }) => appendLog(view, msg), "log": ({ view, msg }) => appendLog(view, msg),
"quota:update": (q) => renderQuotaBox(q),
"download:progress": (p) => { "download:progress": (p) => {
if (typeof p.overall === "number") { if (typeof p.overall === "number") {
@@ -336,6 +459,8 @@ async function init() {
applyLicenseGate(); applyLicenseGate();
renderLicenseStatus(); renderLicenseStatus();
applyPlanGate();
refreshQuota();
// Populate Settings fields // Populate Settings fields
$("#clientIdInput").value = state.config.client_id || ""; $("#clientIdInput").value = state.config.client_id || "";
@@ -471,7 +596,7 @@ $("#downloadBtn").addEventListener("click", async () => {
subfolder: "", subfolder: "",
}); });
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"](); bridgeHandlers["download:done"]();
} }
return; return;
@@ -484,7 +609,7 @@ $("#downloadBtn").addEventListener("click", async () => {
} }
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"](); bridgeHandlers["download:done"]();
} }
}); });
@@ -541,7 +666,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
audio_only: videoMode === "audio", audio_only: videoMode === "audio",
}); });
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["video:done"](); bridgeHandlers["video:done"]();
} }
}); });
@@ -602,7 +727,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
threshold: parseInt($("#upThreshold").value, 10) || 310, threshold: parseInt($("#upThreshold").value, 10) || 310,
}); });
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["upgrade:done"](); bridgeHandlers["upgrade:done"]();
} }
}); });
@@ -694,7 +819,7 @@ $("#recStartBtn").addEventListener("click", async () => {
bitrate: $("#recBitrate").value, bitrate: $("#recBitrate").value,
}); });
if (!res.ok) { if (!res.ok) {
toast(res.error || "Errore", "error"); if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
} }
}); });
@@ -876,7 +1001,7 @@ $("#metaSaveBtn").addEventListener("click", async () => {
await loadMetaFile(state.meta.path); await loadMetaFile(state.meta.path);
} else { } else {
status.textContent = ""; status.textContent = "";
toast("Errore: " + (res.error || ""), "error"); if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error");
} }
}); });
@@ -1003,6 +1128,8 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
if (res.ok) { if (res.ok) {
state.license = res.license; state.license = res.license;
renderLicenseStatus(); renderLicenseStatus();
applyPlanGate();
refreshQuota();
if (res.license.licensed) { if (res.license.licensed) {
toast("Licenza verificata", "success"); toast("Licenza verificata", "success");
} else { } else {
@@ -1016,6 +1143,22 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
} }
}); });
// ============================================================
// 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 // Boot
// ============================================================ // ============================================================