diff --git a/api/bridge.py b/api/bridge.py index c1dbcfc..aaa512d 100644 --- a/api/bridge.py +++ b/api/bridge.py @@ -123,6 +123,7 @@ class Api: "config": safe_cfg, "spotify_guide": SPOTIFY_GUIDE_TEXT, "license": license_status, + "plan": license_mod.get_plan(cfg), "purchase_url": "https://musictools.djluza.com", } @@ -181,6 +182,63 @@ class Api: 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. @@ -326,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)) @@ -360,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): @@ -412,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), @@ -441,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), @@ -627,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", "") @@ -700,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), diff --git a/core/config.py b/core/config.py index 2cdc91b..300afb3 100644 --- a/core/config.py +++ b/core/config.py @@ -5,7 +5,7 @@ import os import sys from pathlib import Path -VERSION = "v1.6.0" +VERSION = "v1.7.0" APP_NAME = "MusicTools" @@ -77,6 +77,14 @@ DEFAULTS = { "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 } diff --git a/core/license.py b/core/license.py index 33372c8..0f52506 100644 --- a/core/license.py +++ b/core/license.py @@ -28,6 +28,7 @@ import json import platform import time import urllib.error +import urllib.parse import urllib.request import uuid from typing import Optional @@ -140,12 +141,42 @@ def _http_post(path: str, body: dict) -> dict: # ============================================================ # 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). + needs_revalidation (bool), plan (dict). """ cfg = config or load_config() 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() 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 { @@ -164,9 +196,27 @@ def get_status(config: Optional[dict] = None) -> dict: "last_validated_at": 0, "days_since_validation": 0, "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 grace_expired = days_since >= LICENSE_GRACE_DAYS @@ -180,6 +230,7 @@ def get_status(config: Optional[dict] = None) -> dict: "last_validated_at": last_val, "days_since_validation": days_since, "needs_revalidation": True, + "plan": plan, } return { @@ -191,6 +242,7 @@ def get_status(config: Optional[dict] = None) -> dict: "last_validated_at": last_val, "days_since_validation": days_since, "needs_revalidation": needs_reval, + "plan": plan, } @@ -199,6 +251,55 @@ def is_licensed() -> bool: 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. @@ -231,6 +332,7 @@ def activate(license_key: str, email: str) -> dict: 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) @@ -260,6 +362,7 @@ def validate() -> dict: # 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) @@ -268,6 +371,7 @@ def validate() -> dict: 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) @@ -298,5 +402,86 @@ def deactivate(release_remote: bool = True) -> dict: 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 diff --git a/landing/css/style.css b/landing/css/style.css index 932b6d5..ed94d3d 100644 --- a/landing/css/style.css +++ b/landing/css/style.css @@ -555,6 +555,52 @@ a { color: inherit; text-decoration: none; } 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 ============================================================ */ diff --git a/landing/index.html b/landing/index.html index cfa6f12..8b44261 100644 --- a/landing/index.html +++ b/landing/index.html @@ -4,10 +4,10 @@
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.
Niente abbonamenti. Niente sorprese. Compri una volta, la usi per sempre.
+ PIANI +Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.
-Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa
+ + +Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy
${payload.error || "Questa funzione non e' inclusa nel tuo piano."}
+Passa a un piano superiore per sbloccarla.
+ `; + } else if (reason === "quota_exceeded") { + const q = payload.quota || {}; + title.textContent = "Limite giornaliero raggiunto"; + body.innerHTML = ` +${payload.error || "Hai raggiunto il limite giornaliero del tuo piano."}
+Usati oggi: ${q.used ?? "?"} / ${q.limit ?? "?"}. Il contatore si resetta a mezzanotte (ora di Roma).
+ `; + } else if (reason === "license_invalid") { + title.textContent = "Licenza non valida"; + body.innerHTML = `${payload.error || "La tua licenza non e' piu' valida."}
`; + } else if (reason === "offline") { + title.textContent = "Connessione assente"; + body.innerHTML = `${payload.error || "Impossibile contattare il server."}
`; + } else { + title.textContent = "Operazione bloccata"; + body.innerHTML = `${payload.error || "L'operazione non e' stata avviata."}
`; + } + 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(); @@ -109,6 +229,8 @@ async function activateLicense() { state.license = res.license; applyLicenseGate(); renderLicenseStatus(); + applyPlanGate(); + refreshQuota(); toast("Licenza attivata. Benvenuto!", "success"); } else { err.textContent = res.error || "Errore di attivazione."; @@ -143,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") { @@ -336,6 +459,8 @@ async function init() { applyLicenseGate(); renderLicenseStatus(); + applyPlanGate(); + refreshQuota(); // Populate Settings fields $("#clientIdInput").value = state.config.client_id || ""; @@ -471,7 +596,7 @@ $("#downloadBtn").addEventListener("click", async () => { subfolder: "", }); if (!res.ok) { - toast(res.error || "Errore", "error"); + if (!handleGateBlock(res)) toast(res.error || "Errore", "error"); bridgeHandlers["download:done"](); } return; @@ -484,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"](); } }); @@ -541,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"](); } }); @@ -602,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"](); } }); @@ -694,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"); } }); @@ -876,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"); } }); @@ -1003,6 +1128,8 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => { if (res.ok) { state.license = res.license; renderLicenseStatus(); + applyPlanGate(); + refreshQuota(); if (res.license.licensed) { toast("Licenza verificata", "success"); } 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 // ============================================================