Compare commits

...
3 Commits
Author SHA1 Message Date
luzadev a82f6f420c v1.7.1: fix switch tema light/dark
Il selettore Impostazioni > Tema salvava il valore ma nessuno applicava
data-theme al <html>, e mancavano gli override CSS per il tema chiaro.
Aggiunte custom properties light + applyTheme() su init/change/save.
2026-06-09 11:13:04 +02:00
luzadev 61459099f9 v1.7.0: piani Basic/Pro/Premium/Annual + quota giornaliera
Pricing model: subscription mensili (Basic 2.99 / Pro 5.99 / Premium 9.99)
piu' Annual 49.90 one-time. Feature gating per piano (Basic = solo audio,
Pro+ = tutto). Quota giornaliera (10/30/150) con reset mezzanotte Europe/Rome.

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-09 01:29:48 +02:00
28 changed files with 2239 additions and 125 deletions

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),
+146
View File
@@ -0,0 +1,146 @@
"""Genera i loghi brand MusicTools partendo dall'icona master.
Produce:
- logo_mark.png 1024x1024 solo squircle + nota, bg trasparente
- logo_horizontal_dark.png 2400x720 mark + "MusicTools" testo bianco (per bg scuri)
- logo_horizontal_light.png 2400x720 mark + "MusicTools" testo nero (per bg chiari)
- logo_stacked.png 1280x1600 mark sopra + testo sotto, bianco (per bg scuri)
Lo squircle riuso quello generato da generate_icon.py (assets/icon.png),
cosi' resta coerente con l'icona dell'app.
"""
from __future__ import annotations
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
ICON_PATH = HERE / "icon.png"
ARIAL_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
WHITE = (255, 255, 255)
BLACK = (10, 10, 10)
def font(size: int, *, black: bool = True) -> ImageFont.FreeTypeFont:
path = ARIAL_BLACK if black else ARIAL_BOLD
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
def load_mark(target_size: int) -> Image.Image:
"""Carica l'icona master e la riscala alla dimensione richiesta.
L'icona originale ha sfondo trasparente fuori dallo squircle
(vedi generate_icon.py), quindi va bene per overlay su qualunque bg.
"""
if not ICON_PATH.exists():
raise FileNotFoundError(
f"icon.png non trovata: rilancia prima 'python3 assets/generate_icon.py'"
)
icon = Image.open(ICON_PATH).convert("RGBA")
if icon.size != (target_size, target_size):
icon = icon.resize((target_size, target_size), Image.LANCZOS)
return icon
def measure_text(text: str, f: ImageFont.FreeTypeFont) -> tuple[int, int]:
"""Ritorna (larghezza, altezza) effettiva del testo."""
bbox = f.getbbox(text)
return bbox[2] - bbox[0], bbox[3] - bbox[1]
def draw_text_top_left(canvas: Image.Image, text: str, top_left: tuple[int, int],
f: ImageFont.FreeTypeFont, color: tuple) -> None:
"""Disegna testo con angolo top-left = top_left (compensa font bbox)."""
draw = ImageDraw.Draw(canvas, "RGBA")
bbox = f.getbbox(text)
x = top_left[0] - bbox[0]
y = top_left[1] - bbox[1]
draw.text((x, y), text, font=f, fill=(*color, 255))
def make_mark(path: Path) -> None:
mark = load_mark(1024)
mark.save(path, "PNG", optimize=True)
print(f"[ok] mark -> {path}")
def make_horizontal(path: Path, text_color: tuple) -> None:
"""Layout: mark a sinistra + testo. Canvas si autodimensiona al testo."""
H = 720
mark_size = int(H * 0.92)
pad_left = 60
gap = 70
pad_right = 80
text = "MusicTools"
f = font(260, black=True)
tw, th = measure_text(text, f)
W = pad_left + mark_size + gap + tw + pad_right
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
# Mark a sinistra, centrato verticalmente
mark = load_mark(mark_size)
mark_y = (H - mark_size) // 2
canvas.alpha_composite(mark, (pad_left, mark_y))
# Testo allineato top-left calcolato in modo che la sua baseline
# sia visivamente centrata sull'altezza dello squircle.
text_x = pad_left + mark_size + gap
text_y = (H - th) // 2
draw_text_top_left(canvas, text, (text_x, text_y), f, text_color)
canvas.save(path, "PNG", optimize=True)
name = "dark" if text_color == WHITE else "light"
print(f"[ok] horizontal ({name}) -> {path} [{W}x{H}]")
def make_stacked(path: Path, text_color: tuple) -> None:
"""Mark sopra + 'MusicTools' sotto. Canvas autodimensionato al testo."""
mark_size = 1024
f = font(180, black=True)
text = "MusicTools"
tw, th = measure_text(text, f)
pad = 80
gap = 50
W = max(mark_size, tw) + 2 * pad
H = pad + mark_size + gap + th + pad
canvas = Image.new("RGBA", (W, H), (0, 0, 0, 0))
mark = load_mark(mark_size)
mark_x = (W - mark_size) // 2
canvas.alpha_composite(mark, (mark_x, pad))
text_x = (W - tw) // 2
text_y = pad + mark_size + gap
draw_text_top_left(canvas, text, (text_x, text_y), f, text_color)
canvas.save(path, "PNG", optimize=True)
name = "dark" if text_color == WHITE else "light"
print(f"[ok] stacked ({name}) -> {path} [{W}x{H}]")
def main() -> None:
make_mark(HERE / "logo_mark.png")
make_horizontal(HERE / "logo_horizontal_dark.png", WHITE) # per sfondi scuri
make_horizontal(HERE / "logo_horizontal_light.png", BLACK) # per sfondi chiari
make_stacked(HERE / "logo_stacked_dark.png", WHITE) # per sfondi scuri
make_stacked(HERE / "logo_stacked_light.png", BLACK) # per sfondi chiari
# rimuovo eventuale vecchio file senza suffisso
old = HERE / "logo_stacked.png"
if old.exists():
old.unlink()
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
"""Genera le immagini prodotto per Lemon Squeezy.
Produce:
- ls_thumbnail.png 1024x1024 (thumbnail prodotto + checkout)
- ls_cover.png 1920x1080 (cover/hero per la pagina prodotto)
- ls_og.png 1200x630 (Open Graph per social share)
La nota musicale e' disegnata geometricamente (no glyph Unicode)
per essere indipendente dai font installati sul sistema.
"""
from __future__ import annotations
import math
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter, ImageFont
OUT_DIR = Path(__file__).resolve().parent
GREEN_LIGHT = (29, 185, 84)
GREEN_DARK = (19, 122, 55)
BG_TOP = (10, 50, 26)
BG_BOTTOM = (6, 6, 6)
WHITE = (255, 255, 255)
MUTED = (190, 190, 190)
ARIAL_BOLD = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
ARIAL_BLACK = "/System/Library/Fonts/Supplemental/Arial Black.ttf"
ARIAL_REG = "/System/Library/Fonts/Supplemental/Arial.ttf"
def font(size: int, *, black: bool = False, bold: bool = True) -> ImageFont.FreeTypeFont:
path = ARIAL_BLACK if black else (ARIAL_BOLD if bold else ARIAL_REG)
try:
return ImageFont.truetype(path, size)
except OSError:
return ImageFont.load_default()
def vgradient(size: tuple[int, int], top: tuple, bottom: tuple) -> Image.Image:
"""Gradient verticale tra due colori (RGB)."""
w, h = size
img = Image.new("RGB", size, top)
px = img.load()
for y in range(h):
t = y / max(h - 1, 1)
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(w):
px[x, y] = (r, g, b)
return img
def radial_glow(size: tuple[int, int], center: tuple[int, int],
radius: int, color: tuple, opacity: int = 90) -> Image.Image:
"""Glow radiale soft (RGBA)."""
glow = Image.new("RGBA", size, (0, 0, 0, 0))
draw = ImageDraw.Draw(glow)
cx, cy = center
draw.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(*color, opacity),
)
return glow.filter(ImageFilter.GaussianBlur(radius // 3))
def draw_text_centered(canvas: Image.Image, text: str, center: tuple[int, int],
size_px: int, color=WHITE, *, black: bool = True,
bold: bool = True) -> None:
draw = ImageDraw.Draw(canvas, "RGBA")
f = font(size_px, black=black, bold=bold)
bbox = draw.textbbox((0, 0), text, font=f)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x = center[0] - tw // 2 - bbox[0]
y = center[1] - th // 2 - bbox[1]
draw.text((x, y), text, font=f, fill=(*color, 255))
def draw_text_left(canvas: Image.Image, text: str, anchor: tuple[int, int],
size_px: int, color=WHITE, *, black: bool = True,
bold: bool = True) -> None:
"""Disegna testo allineato a sinistra (anchor = baseline-left)."""
draw = ImageDraw.Draw(canvas, "RGBA")
f = font(size_px, black=black, bold=bold)
bbox = draw.textbbox(anchor, text, font=f)
draw.text(anchor, text, font=f, fill=(*color, 255))
def draw_pill(canvas: Image.Image, center: tuple[int, int], text: str,
pad_x: int, pad_y: int, font_size: int,
fill=(255, 255, 255, 30), text_color=WHITE,
*, bold: bool = True) -> None:
draw = ImageDraw.Draw(canvas, "RGBA")
f = font(font_size, bold=bold, black=False)
bbox = draw.textbbox((0, 0), text, font=f)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
pill_w = tw + 2 * pad_x
pill_h = th + 2 * pad_y
x0 = center[0] - pill_w // 2
y0 = center[1] - pill_h // 2
draw.rounded_rectangle(
[x0, y0, x0 + pill_w, y0 + pill_h],
radius=pill_h // 2,
fill=fill,
)
tx = center[0] - tw // 2 - bbox[0]
ty = center[1] - th // 2 - bbox[1]
draw.text((tx, ty), text, font=f, fill=(*text_color, 255))
def draw_music_note(canvas: Image.Image, center: tuple[int, int],
size_px: int, color=WHITE) -> None:
"""Disegna una nota musicale stilizzata (due crome connesse).
Geometria approssimativa:
- 2 teste ellittiche (sinistra e destra)
- 2 aste verticali sottili
- barra orizzontale curva in alto che le connette
Tutto scalato in funzione di size_px (larghezza disegno).
"""
cx, cy = center
W = size_px
head_w = int(W * 0.30) # larghezza testa
head_h = int(head_w * 0.74) # altezza testa (ovale schiacciata)
stem_w = max(int(W * 0.035), 6)
stem_h = int(W * 0.78)
spacing = int(W * 0.40) # distanza tra le 2 aste
layer = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
# Posizioni delle 2 aste
left_stem_x = cx - spacing // 2
right_stem_x = cx + spacing // 2
top_y = cy - stem_h // 2
bot_y = cy + stem_h // 2
# Aste (rettangoli verticali)
d.rounded_rectangle(
[left_stem_x - stem_w // 2, top_y, left_stem_x + stem_w // 2, bot_y],
radius=stem_w // 2, fill=color + (255,),
)
d.rounded_rectangle(
[right_stem_x - stem_w // 2, top_y, right_stem_x + stem_w // 2, bot_y],
radius=stem_w // 2, fill=color + (255,),
)
# Barra orizzontale spessa in alto che unisce le aste (ramo)
bar_thick = max(int(W * 0.09), 14)
d.rounded_rectangle(
[left_stem_x - stem_w // 2 - 4, top_y,
right_stem_x + stem_w // 2 + 4, top_y + bar_thick],
radius=bar_thick // 3, fill=color + (255,),
)
# Seconda barra subito sotto (la nota doppia ha sempre 2 ramificazioni)
second_bar_y = top_y + int(bar_thick * 1.6)
d.rounded_rectangle(
[left_stem_x - stem_w // 2 - 4, second_bar_y,
right_stem_x + stem_w // 2 + 4, second_bar_y + int(bar_thick * 0.7)],
radius=bar_thick // 3, fill=color + (255,),
)
# Teste ellittiche: in basso, leggermente a sinistra dell'asta (inclinate)
def head_at(stem_x: int) -> None:
# Centro testa: poco sotto al fondo dell'asta, leggermente a sinistra
hx = stem_x - int(head_w * 0.42)
hy = bot_y - int(head_h * 0.20)
# Disegnata su layer rotato per dare l'inclinazione
tmp = Image.new("RGBA", (head_w + 40, head_h + 40), (0, 0, 0, 0))
td = ImageDraw.Draw(tmp)
td.ellipse([10, 10, 10 + head_w, 10 + head_h], fill=color + (255,))
rotated = tmp.rotate(-22, resample=Image.BICUBIC, expand=True)
rw, rh = rotated.size
layer.alpha_composite(rotated, (hx - rw // 2 + head_w // 2, hy - rh // 2 + head_h // 2))
head_at(left_stem_x)
head_at(right_stem_x)
# Glow morbido sotto la nota per profondita'
glow_src = layer.filter(ImageFilter.GaussianBlur(W // 22))
# Riduco un po' l'alpha del glow
alpha = glow_src.split()[3].point(lambda a: int(a * 0.45))
glow = Image.merge("RGBA", (*glow_src.split()[:3], alpha))
canvas.alpha_composite(glow)
canvas.alpha_composite(layer)
# ============================================================
# Thumbnail 1024x1024
# ============================================================
def make_thumbnail(path: Path) -> None:
W = H = 1024
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
img.alpha_composite(radial_glow((W, H), (W // 2, H // 2 - 80), 430, GREEN_LIGHT, 100))
# Eyebrow in cima (testo semplice, no pill)
draw_text_centered(img, "DESKTOP APP - MAC e WINDOWS", (W // 2, 165),
size_px=30, color=GREEN_LIGHT, black=False, bold=True)
# Nota geometrica centrale
draw_music_note(img, (W // 2, H // 2 - 40), 360, color=WHITE)
# Titolo grande
draw_text_centered(img, "MusicTools", (W // 2, H - 280),
size_px=110, color=WHITE, black=True)
# Tagline
draw_text_centered(img, "Scarica - Modifica - Registra", (W // 2, H - 195),
size_px=38, color=MUTED, black=False, bold=True)
# Pill prezzo in basso
draw_pill(img, (W // 2, H - 100), "Licenza a vita 39,90 EUR",
pad_x=26, pad_y=14, font_size=30,
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
img.convert("RGB").save(path, "PNG", optimize=True)
print(f"[ok] thumbnail -> {path}")
# ============================================================
# Cover/Hero 1920x1080
# ============================================================
def make_cover(path: Path) -> None:
W, H = 1920, 1080
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
# Glow grosso a sinistra (zona del testo)
img.alpha_composite(radial_glow((W, H), (W // 3, H // 2), 640, GREEN_LIGHT, 85))
# Glow secondario a destra (zona nota)
img.alpha_composite(radial_glow((W, H), (int(W * 0.78), H // 2), 380, GREEN_LIGHT, 55))
LEFT_X = 140
# Eyebrow testo semplice
draw_text_left(img, "DESKTOP APP", (LEFT_X, 220),
size_px=30, color=GREEN_LIGHT, black=False, bold=True)
# Heading 2 righe (sinistra)
draw_text_left(img, "La tua musica.", (LEFT_X, 310),
size_px=120, color=WHITE, black=True)
draw_text_left(img, "In un'unica app.", (LEFT_X, 460),
size_px=120, color=GREEN_LIGHT, black=True)
# Sub-tagline
draw_text_left(img, "Scarica da Spotify e YouTube.", (LEFT_X, 640),
size_px=42, color=MUTED, black=False, bold=True)
draw_text_left(img, "Registra audio. Modifica metadati.", (LEFT_X, 700),
size_px=42, color=MUTED, black=False, bold=True)
# Pill prezzo (sinistra, basso)
draw_pill(img, (LEFT_X + 320, H - 160), "Licenza a vita - 39,90 EUR",
pad_x=30, pad_y=18, font_size=40,
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
# Nota grande a destra
draw_music_note(img, (int(W * 0.78), H // 2 - 20), 540, color=WHITE)
img.convert("RGB").save(path, "PNG", optimize=True)
print(f"[ok] cover -> {path}")
# ============================================================
# OG image 1200x630
# ============================================================
def make_og(path: Path) -> None:
W, H = 1200, 630
img = vgradient((W, H), BG_TOP, BG_BOTTOM).convert("RGBA")
img.alpha_composite(radial_glow((W, H), (W // 2, H // 2 - 50), 420, GREEN_LIGHT, 95))
# Nota in alto centro
draw_music_note(img, (W // 2, 180), 180, color=WHITE)
# Titolo
draw_text_centered(img, "MusicTools", (W // 2, 360),
size_px=92, color=WHITE, black=True)
# Tagline
draw_text_centered(img, "Scarica - Modifica - Registra", (W // 2, 440),
size_px=32, color=MUTED, black=False, bold=True)
# Pill prezzo in basso
draw_pill(img, (W // 2, H - 80), "Licenza a vita 39,90 EUR - Mac e Windows",
pad_x=24, pad_y=13, font_size=28,
fill=GREEN_LIGHT + (255,), text_color=(10, 10, 10), bold=True)
img.convert("RGB").save(path, "PNG", optimize=True)
print(f"[ok] og -> {path}")
def main() -> None:
make_thumbnail(OUT_DIR / "ls_thumbnail.png")
make_cover(OUT_DIR / "ls_cover.png")
make_og(OUT_DIR / "ls_og.png")
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+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.1"
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
+74
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
============================================================ */ ============================================================ */
@@ -775,3 +821,31 @@ a { color: inherit; text-decoration: none; }
} }
.price-consent a { color: var(--text-muted); text-decoration: underline; } .price-consent a { color: var(--text-muted); text-decoration: underline; }
.price-consent a:hover { color: var(--accent); } .price-consent a:hover { color: var(--accent); }
/* ============================================================
"Hai gia acquistato? Scarica" — link discreti
============================================================ */
.hero-customer {
margin: 18px 0 0;
font-size: 13.5px;
color: var(--text-dim);
}
.hero-customer a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.hero-customer a:hover { text-decoration: underline; }
.price-already {
margin: 10px 0 0 !important;
font-size: 13px !important;
color: var(--text-muted) !important;
}
.price-already a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.price-already a:hover { text-decoration: underline; }
+221
View File
@@ -0,0 +1,221 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scarica MusicTools</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link rel="stylesheet" href="css/style.css" />
<style>
.dl-page {
padding: 60px 24px 100px;
max-width: 900px;
margin: 0 auto;
}
.dl-head {
text-align: center;
margin-bottom: 48px;
}
.dl-head h1 {
font-size: clamp(28px, 4vw, 42px);
font-weight: 900;
letter-spacing: -0.02em;
margin: 12px 0 10px;
}
.dl-head p {
color: var(--text-muted);
font-size: 17px;
margin: 0;
}
.dl-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 40px;
}
@media (max-width: 700px) {
.dl-grid { grid-template-columns: 1fr; }
}
.dl-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 32px 28px;
text-align: center;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
}
.dl-card:hover {
transform: translateY(-3px);
border-color: #383838;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.dl-os { font-size: 64px; line-height: 1; margin-bottom: 12px; }
.dl-card h2 { margin: 0 0 6px; font-size: 22px; font-weight: 800; }
.dl-card .meta { color: var(--text-dim); font-size: 13px; margin: 0 0 24px; }
.dl-form {
max-width: 460px;
margin: 0 auto 32px;
padding: 28px 30px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
}
.dl-form h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 800;
}
.dl-form p { margin: 0 0 18px; color: var(--text-muted); font-size: 14px; }
.dl-form .field { margin-bottom: 12px; }
.dl-form .field-label { font-size: 12px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 6px; }
.dl-form .input { width: 100%; padding: 12px 16px; border-radius: 999px; background: #0d0d0d; border: 1px solid var(--border); color: var(--text); font-size: 14px; font-family: inherit; box-sizing: border-box; }
.dl-form .input:focus { outline: none; border-color: var(--accent); }
.dl-form .submit { width: 100%; margin-top: 12px; }
.dl-steps {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 28px 32px;
margin-bottom: 18px;
}
.dl-steps h2 { font-size: 18px; font-weight: 800; margin: 0 0 14px; }
.dl-steps ol { margin: 0; padding-left: 22px; color: var(--text-muted); font-size: 14.5px; line-height: 1.7; }
.dl-steps li { margin-bottom: 4px; }
.dl-steps strong { color: var(--text); }
.dl-steps code { background: var(--bg-card-2); padding: 2px 7px; border-radius: 5px; font-size: 13px; font-family: ui-monospace, monospace; color: var(--accent); }
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
.dl-support a { color: var(--accent); }
</style>
</head>
<body>
<header class="nav">
<div class="container nav-row">
<a href="/" class="brand">
<span class="brand-logo">♫</span>
<span class="brand-name">MusicTools</span>
</a>
<nav class="nav-links">
<a href="/">Home</a>
<a href="/#features">Funzioni</a>
<a href="/#faq">FAQ</a>
</nav>
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Supporto</a>
</div>
</header>
<main class="dl-page">
<div class="dl-head">
<span class="eyebrow">SCARICA</span>
<h1>Scarica MusicTools</h1>
<p>Scegli il sistema operativo e procedi con l'installazione</p>
</div>
<!-- Form mostrato quando NON arrivi dall'email (no key/email in URL) -->
<div class="dl-form" id="dlForm">
<h2>Inserisci le tue credenziali</h2>
<p>Hai gia' acquistato? Inserisci l'email e la chiave per scaricare.</p>
<div class="field">
<label class="field-label" for="dlEmail">Email dell'acquisto</label>
<input type="email" id="dlEmail" class="input" autocomplete="email" />
</div>
<div class="field">
<label class="field-label" for="dlKey">Chiave di licenza</label>
<input type="text" id="dlKey" class="input" autocomplete="off" placeholder="XXXX-XXXX-XXXX-XXXX" />
</div>
<button class="btn btn-primary btn-lg submit" id="dlSubmitBtn">Mostra i link di download</button>
</div>
<!-- Cards di download (popolate dopo inserimento credenziali o da URL) -->
<div class="dl-grid" id="dlGrid" hidden>
<article class="dl-card">
<div class="dl-os">🍎</div>
<h2>macOS</h2>
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
<a class="btn btn-primary btn-lg btn-block" id="dlMacBtn" target="_blank" rel="noopener">
Scarica per Mac
</a>
</article>
<article class="dl-card">
<div class="dl-os">🪟</div>
<h2>Windows</h2>
<p class="meta">Windows 10 e 11 · 64-bit</p>
<a class="btn btn-primary btn-lg btn-block" id="dlWinBtn" target="_blank" rel="noopener">
Scarica per Windows
</a>
</article>
</div>
<div class="dl-steps">
<h2>Installazione macOS</h2>
<ol>
<li>Apri il file <code>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li>
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li>
<li>Vai in Applicazioni, <strong>tasto destro</strong> su MusicTools → <strong>Apri</strong> → <strong>Apri</strong> (solo la prima volta, e' la procedura standard per app non firmate)</li>
<li>Inserisci email e chiave per attivare</li>
</ol>
</div>
<div class="dl-steps">
<h2>Installazione Windows</h2>
<ol>
<li>Estrai il file <code>.zip</code> in una cartella di tua scelta</li>
<li>Doppio click su <strong>MusicTools.exe</strong></li>
<li>Se Windows mostra "Windows ha protetto il PC": click su <strong>Ulteriori informazioni</strong> → <strong>Esegui comunque</strong></li>
<li>Inserisci email e chiave per attivare</li>
</ol>
</div>
<div class="dl-support">
Problemi con l'installazione o non hai ricevuto l'email con la chiave?<br>
Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> — risposta entro 24 ore.
</div>
</main>
<script>
function getParam(name) {
const url = new URL(window.location.href);
return url.searchParams.get(name) || "";
}
function setDownloadLinks(email, key) {
const q = "key=" + encodeURIComponent(key) + "&email=" + encodeURIComponent(email);
document.getElementById("dlMacBtn").href = "/api/download/macos?" + q;
document.getElementById("dlWinBtn").href = "/api/download/windows?" + q;
document.getElementById("dlGrid").hidden = false;
document.getElementById("dlForm").hidden = true;
}
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni
const urlKey = getParam("key");
const urlEmail = getParam("email");
if (urlKey && urlEmail) {
setDownloadLinks(urlEmail, urlKey);
}
// Form submit
document.getElementById("dlSubmitBtn").addEventListener("click", () => {
const email = document.getElementById("dlEmail").value.trim();
const key = document.getElementById("dlKey").value.trim();
if (!email || !key) {
alert("Inserisci email e chiave di licenza.");
return;
}
setDownloadLinks(email, key);
});
// Enter dentro i campi
["dlEmail", "dlKey"].forEach(id => {
document.getElementById(id).addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("dlSubmitBtn").click();
});
});
</script>
</body>
</html>
+127 -32
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,10 +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>
</nav> </nav>
<a href="#pricing" class="btn btn-primary btn-sm">Compra — 39,90 €</a> <a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
</div> </div>
</header> </header>
@@ -37,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.
@@ -45,14 +46,18 @@
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="#pricing" class="btn btn-primary btn-lg">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>
<p class="hero-customer">
Hai già acquistato? <a href="/download">Scarica l'app →</a>
</p>
</div> </div>
<div class="hero-art" aria-hidden="true"> <div class="hero-art" aria-hidden="true">
@@ -170,39 +175,113 @@
<!-- ============== 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-card price-card-sm">
<div class="price-name">Basic</div>
<div class="price-row"> <div class="price-row">
<span class="price-amount">39,90</span> <span class="price-amount">2,99</span>
<span class="price-currency">€</span> <span class="price-currency">€</span>
<span class="price-once">una tantum</span> <span class="price-once">/mese</span>
</div> </div>
<ul class="price-features"> <ul class="price-features">
<li>✓ <strong>Licenza a vita</strong> — paghi una volta, mai più</li> <li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
<li>✓ <strong>3 dispositivi</strong> — Mac e Windows mixati</li> <li>✓ <strong>Fino a 10 al giorno</strong></li>
<li>✓ <strong>Aggiornamenti gratuiti</strong> per tutte le versioni future</li> <li>✓ MP3 fino a 320kbps</li>
<li>✓ <strong>Tutte le 7 tab</strong> sbloccate</li> <li>✓ 3 dispositivi (Mac + Win)</li>
<li>✓ <strong>Supporto via email</strong> a info@djluza.com</li> <li>✗ Download video</li>
<li>✓ <strong>Garanzia 14 giorni</strong> soddisfatti o rimborsati</li> <li>✗ Registrazione, metadati, upgrade libreria</li>
</ul> </ul>
<a href="#" <a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
class="btn btn-primary btn-lg btn-block" class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
data-lemonsqueezy-url="REPLACE_WITH_LS_PRODUCT_URL" Inizia con Basic
id="buyBtn">
Acquista ora — 39,90 €
</a> </a>
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa</p> </div>
<!-- PRO -->
<div class="price-card price-card-sm">
<div class="price-badge">Più scelto</div>
<div class="price-name">Pro</div>
<div class="price-row">
<span class="price-amount">5,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Basic</strong></li>
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
<li>✓ <strong>Fino a 30 al giorno</strong></li>
<li>✓ Registrazione audio (BlackHole / loopback)</li>
<li>✓ Editor metadati + copertine</li>
<li>✓ Upgrade qualità libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
Scegli Pro
</a>
</div>
<!-- PREMIUM -->
<div class="price-card price-card-sm">
<div class="price-name">Premium</div>
<div class="price-row">
<span class="price-amount">9,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Pro</strong></li>
<li>✓ <strong>Fino a 150 al giorno</strong></li>
<li>✓ Priorità nel supporto via email</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Passa a Premium
</a>
</div>
<!-- ANNUAL ONE-TIME -->
<div class="price-card price-card-sm price-card-featured">
<div class="price-badge price-badge-gold">Miglior valore</div>
<div class="price-name">Annual</div>
<div class="price-row">
<span class="price-amount">49,90</span>
<span class="price-currency">€</span>
<span class="price-once">/anno</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
<li>✓ <strong>Nessun limite giornaliero</strong></li>
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
<li>✓ Una tantum, niente rinnovo automatico</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
id="buyBtn">
Acquista — 49,90 €
</a>
</div>
</div>
<div class="price-shared">
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
<p class="price-consent"> <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>,
l'<a href="/legal/privacy.html">Informativa privacy</a> e la l'<a href="/legal/privacy.html">Informativa privacy</a> e la
<a href="/legal/refund.html">Politica rimborsi</a>. <a href="/legal/refund.html">Politica rimborsi</a>.
</p> </p>
<p class="price-already">
Hai già una licenza? <a href="/download">Scarica l'app →</a>
</p>
</div> </div>
<div class="use-policy"> <div class="use-policy">
@@ -240,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">
@@ -300,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="#pricing" class="btn btn-primary btn-lg">Compra ora — 39,90 €</a> <a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
</div> </div>
</section> </section>
@@ -314,6 +408,7 @@
<span class="footer-by">by LuZa</span> <span class="footer-by">by LuZa</span>
</div> </div>
<nav class="footer-links"> <nav class="footer-links">
<a href="/download">Download</a>
<a href="mailto:info@djluza.com">Supporto</a> <a href="mailto:info@djluza.com">Supporto</a>
<a href="/legal/terms.html">Termini</a> <a href="/legal/terms.html">Termini</a>
<a href="/legal/privacy.html">Privacy</a> <a href="/legal/privacy.html">Privacy</a>
+3 -19
View File
@@ -8,25 +8,9 @@
if (el) el.textContent = String(new Date().getFullYear()); if (el) el.textContent = String(new Date().getFullYear());
})(); })();
// Lemon Squeezy overlay // Lemon Squeezy overlay: lemon.js intercetta automaticamente i link con
// Lo script di Lemon (lemon.js) intercetta automaticamente i link con // data-lemonsqueezy-url e apre l'overlay di checkout. Niente codice nostro
// data-lemonsqueezy-url e apre l'overlay. Qui ci limitiamo a impedire // necessario qui — basta che lemon.js sia caricato (vedi <script> in index.html).
// che il click navighi via se l'URL non e' ancora stato configurato.
document.querySelectorAll("[data-lemonsqueezy-url]").forEach((el) => {
el.addEventListener("click", (e) => {
const url = el.getAttribute("data-lemonsqueezy-url");
if (!url || url === "REPLACE_WITH_LS_PRODUCT_URL") {
e.preventDefault();
alert(
"Il prodotto Lemon Squeezy non e' ancora configurato.\n" +
"Crea il prodotto su Lemon Squeezy e incolla l'URL dentro " +
"landing/index.html in data-lemonsqueezy-url."
);
}
// Se la pagina e' aperta via file:// lemon.js non riesce a inizializzare:
// in quel caso lasciamo che il link navighi via.
});
});
// Header che diventa piu' opaco mentre scorri (effetto subtle) // Header che diventa piu' opaco mentre scorri (effetto subtle)
const nav = document.querySelector(".nav"); const nav = document.querySelector(".nav");
@@ -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;
+71
View File
@@ -0,0 +1,71 @@
/**
* Endpoint "1-click download" per i clienti.
*
* URL: GET /api/download/:platform?key=XXXX&email=YYY
*
* Verifica la coppia (key, email) contro la tabella licenses, recupera
* l'ultima release attiva per la piattaforma, genera un signed URL e
* fa redirect 302. Il link nell'email viene cliccato dall'utente: con
* un click parte il download.
*
* Sicurezza:
* - Niente token sulla URL: la chiave e' gia "il segreto"
* - URL signed con scadenza breve (default 5 min)
* - Nessuna info di licenza leakata in risposta (ritorna solo redirect o 401/404)
*/
import crypto from "node:crypto";
import { one } from "./db.js";
const DOWNLOAD_TTL = Number(process.env.DOWNLOAD_URL_TTL_SECONDS || 300);
const PUBLIC_BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
function signPayload(payload, secret) {
return crypto.createHmac("sha256", secret).update(payload).digest("base64url");
}
const normEmail = (s) => String(s || "").trim().toLowerCase();
const normKey = (s) => String(s || "").trim().toUpperCase();
export async function downloadByKey(req, res) {
const platform = String(req.params.platform || "").toLowerCase();
if (platform !== "macos" && platform !== "windows") {
return res.status(400).type("text/plain")
.send("Piattaforma non valida. Usa /api/download/macos o /api/download/windows.");
}
const key = normKey(req.query.key);
const email = normEmail(req.query.email);
if (!key || !email) {
return res.status(400).type("text/plain")
.send("Mancano email o chiave. Controlla il link nella tua email di acquisto.");
}
const license = await one(
"SELECT id, status FROM licenses WHERE license_key=? AND email=? LIMIT 1",
[key, email],
);
if (!license) {
return res.status(401).type("text/plain")
.send("Email o chiave non valide. Verifica il link nell'email o scrivici a info@djluza.com.");
}
if (license.status !== "active") {
return res.status(403).type("text/plain")
.send("Licenza non piu' attiva (rimborsata o revocata).");
}
const release = await one(
`SELECT file_path FROM releases
WHERE platform=? ORDER BY published_at DESC LIMIT 1`,
[platform],
);
if (!release) {
return res.status(503).type("text/plain")
.send(`Build ${platform} non ancora disponibile. Riprova fra qualche ora o scrivici a info@djluza.com.`);
}
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
const sig = signPayload(`${release.file_path}.${exp}`, process.env.JWT_SECRET);
const url = `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(release.file_path)}&exp=${exp}&sig=${sig}`;
res.redirect(302, url);
}
+93 -9
View File
@@ -1,41 +1,125 @@
/** /**
* 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)
*/ */
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>"; import { getPlan } from "./plans.js";
export async function sendLicenseEmail(to, licenseKey) { const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
function fmtItalianDate(epochSeconds) {
if (!epochSeconds) return "";
const d = new Date(Number(epochSeconds) * 1000);
return new Intl.DateTimeFormat("it-IT", {
timeZone: "Europe/Rome",
day: "2-digit",
month: "long",
year: "numeric",
}).format(d);
}
function describePlan(planCode, expiresAt, periodEnd) {
const p = getPlan(planCode);
if (!p) {
return {
name: "MusicTools",
summary: "La tua licenza e' attiva.",
};
}
const features = {
audio: "download brani audio",
video: "download video",
record: "registrazione audio",
metadata: "editor metadati",
upgrade: "upgrade qualita' libreria",
};
const featList = p.features.map((f) => features[f] || f).join(", ");
const limit = p.daily_limit === null
? "senza limiti giornalieri"
: `fino a ${p.daily_limit} download al giorno`;
let validity = "";
if (planCode === "annual" && expiresAt) {
validity = `Validita': 1 anno, scade il <strong>${fmtItalianDate(expiresAt)}</strong>.`;
} else if (p.is_subscription && periodEnd) {
validity = `Prossimo rinnovo: <strong>${fmtItalianDate(periodEnd)}</strong>.`;
}
return {
name: p.name,
summary: `Piano <strong>${p.name}</strong> &mdash; ${limit}. Funzioni incluse: ${featList}. ${validity}`,
};
}
export async function sendLicenseEmail(to, licenseKey, opts = {}) {
if (!process.env.RESEND_API_KEY) { 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 macUrl = `${BASE}/api/download/macos?${q}`;
const winUrl = `${BASE}/api/download/windows?${q}`;
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}
</p> </p>
<p style="font-size:15px;margin:0 0 12px"><strong>Scarica l'app:</strong></p>
<table cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px">
<tr>
<td style="padding-right:10px">
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
Scarica per Mac
</a>
</td>
<td>
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
Scarica per Windows
</a>
</td>
</tr>
</table>
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p> <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>Scarica MusicTools per <a href="https://musictools.djluza.com#pricing">Mac o Windows</a></li> <li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
<li>Apri l'app: alla prima schermata ti chiedera' email e chiave</li> <li>Installa l'app (su Mac: tasto destro &rarr; Apri &rarr; Apri; su Windows: Esegui comunque)</li>
<li>Inserisci questa email (<code>${to}</code>) e la chiave qui sopra</li> <li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
</ol> </ol>
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p> <p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
<p style="font-size:13px;color:#888;margin:8px 0 0">
Hai bisogno di riscaricare in futuro? Vai su <a href="${BASE}/download" style="color:#1db954">${BASE.replace(/^https?:\/\//, "")}/download</a> e inserisci email + chiave.
</p>
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/> <hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p> <p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
</div> </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: {
@@ -45,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,
}), }),
}); });
+252 -37
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 {
await exec( switch (eventName) {
`INSERT INTO licenses case "order_created":
(license_key, email, status, source, order_id, created_at, updated_at) out = await handleOrderCreated(payload, attrs, t);
VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`, break;
[key, email, orderId, t, t], case "order_refunded":
); out = await handleOrderRefunded(payload, attrs, t);
break;
case "subscription_created":
out = await handleSubscriptionCreated(payload, attrs, t);
break;
case "subscription_updated":
case "subscription_resumed":
case "subscription_unpaused":
out = await handleSubscriptionUpdated(payload, attrs, t);
break;
case "subscription_payment_success":
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
break;
case "subscription_expired":
out = await handleSubscriptionExpired(payload, attrs, t);
break;
case "subscription_cancelled":
case "subscription_paused":
case "subscription_payment_failed":
case "subscription_payment_refunded":
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
out = { status: 200, body: { ok: true, ignored: eventName } };
break;
default:
out = { status: 200, body: { ok: true, ignored: eventName } };
}
} catch (e) { } catch (e) {
// duplicate webhook delivery console.error("[ls] handler error", eventName, e?.message || e);
if (e?.code === "ER_DUP_ENTRY") { return res.status(500).json({ error: "Internal error" });
return res.json({ ok: true, duplicate: true });
}
throw e;
}
try {
await sendLicenseEmail(email, key);
} catch (e) {
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;
+9
View File
@@ -4,6 +4,8 @@ import express from "express";
import * as license from "./license.js"; 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 usage from "./usage.js";
const app = express(); const app = express();
@@ -41,10 +43,17 @@ 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);
// 1-click download dall'email (key+email come query -> redirect signed)
app.get("/api/download/:platform", dl.downloadByKey);
// 404 JSON solo per /api/* // 404 JSON solo per /api/*
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" })); app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
+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 });
}
+95 -2
View File
@@ -58,6 +58,38 @@
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7); --shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
} }
/* Tema chiaro: override delle custom properties via data-theme="light"
sull'elemento <html>. I colori brand (verde, blu, viola, ecc.) restano
uguali per non perdere identita'. */
html[data-theme="light"] {
--bg: #FFFFFF;
--bg-elev: #F7F7F8;
--bg-card: #FFFFFF;
--bg-card-2: #F2F3F5;
--bg-input: #EDEEF1;
--bg-input-hover: #E2E4E8;
--border: #E2E4E8;
--divider: #ECEDF0;
--text: #111315;
--text-2: #4A4D52;
--text-3: #8A8E94;
--green-dim: #C8F0D5;
--blue-dim: #D6E6FB;
--purple-dim: #E9D8FD;
--pink-dim: #FBCFE8;
--amber-dim: #FDE68A;
--rec-red-dim: #FECACA;
--indigo-dim: #C7D2FE;
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
color-scheme: light;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -194,6 +226,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
=============================== */ =============================== */
@@ -1433,13 +1526,13 @@ input[type="number"]::-webkit-inner-spin-button {
.rec-error-log { .rec-error-log {
margin-top: 12px; margin-top: 12px;
background: #0d0d0d; background: var(--bg-card-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
padding: 14px; padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px; font-size: 12px;
color: #c0c0c0; color: var(--text-2);
max-height: 320px; max-height: 320px;
overflow: auto; overflow: auto;
white-space: pre-wrap; white-space: pre-wrap;
+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>
+160 -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") {
@@ -317,6 +440,11 @@ function showView(name) {
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); $$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
} }
function applyTheme(theme) {
const t = theme === "light" ? "light" : "dark";
document.documentElement.dataset.theme = t;
}
$$(".nav-item").forEach((btn) => { $$(".nav-item").forEach((btn) => {
btn.addEventListener("click", () => showView(btn.dataset.view)); btn.addEventListener("click", () => showView(btn.dataset.view));
}); });
@@ -336,6 +464,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 || "";
@@ -345,6 +475,7 @@ async function init() {
$("#cookiesInput").value = state.config.cookies_path || ""; $("#cookiesInput").value = state.config.cookies_path || "";
$("#outputInput").value = state.config.output_dir || ""; $("#outputInput").value = state.config.output_dir || "";
$("#themeSelect").value = state.config.theme || "dark"; $("#themeSelect").value = state.config.theme || "dark";
applyTheme(state.config.theme);
// Download tab — usa output_dir come default // Download tab — usa output_dir come default
state.dlOutputDir = state.config.output_dir || ""; state.dlOutputDir = state.config.output_dir || "";
@@ -471,7 +602,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 +615,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 +672,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 +733,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 +825,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 +1007,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");
} }
}); });
@@ -902,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
if (path) $("#outputInput").value = path; if (path) $("#outputInput").value = path;
}); });
$("#themeSelect").addEventListener("change", (e) => {
applyTheme(e.target.value);
});
$("#saveBtn").addEventListener("click", async () => { $("#saveBtn").addEventListener("click", async () => {
const payload = { const payload = {
client_id: $("#clientIdInput").value, client_id: $("#clientIdInput").value,
@@ -912,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
output_dir: $("#outputInput").value, output_dir: $("#outputInput").value,
theme: $("#themeSelect").value, theme: $("#themeSelect").value,
}; };
applyTheme(payload.theme);
const res = await window.pywebview.api.save_settings(payload); const res = await window.pywebview.api.save_settings(payload);
if (res.ok) { if (res.ok) {
state.config = payload; state.config = payload;
@@ -1003,6 +1139,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 +1154,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
// ============================================================ // ============================================================