Compare commits

...
6 Commits
Author SHA1 Message Date
luzadev 9d21ca051f v1.7.4: DMG macOS con Fix Gatekeeper integrato
Macos ora viene distribuito come .dmg invece di .zip:
- contiene MusicTools.app + alias Applicazioni + "Fix Gatekeeper.command"
- doppio click sullo script rimuove com.apple.quarantine senza
  Terminal manuale -> niente piu' "App non verificata"

Backend: /api/download serve .dmg con Content-Type
application/x-apple-diskimage. Landing /download aggiornata
con il nuovo flusso (3 step).
2026-06-09 11:53:37 +02:00
luzadev 05ea4c6181 v1.7.3: nuova icona app + landing chiarisce limiti senza chiavi Spotify
Icona: rigenerati icon.icns (macOS multi-size) e icon.ico (Windows
multi-size 16-256) dalla sorgente assets/icon_1024.png.

Landing: nuovo box "Per sfruttare al massimo MusicTools serve un
account Spotify" + FAQ esplicita su cosa funziona senza setup
e dove servono le chiavi.
2026-06-09 11:43:20 +02:00
luzadev 30be62af78 v1.7.2: brani Spotify scaricabili senza API keys + fix download.html
Spotify: per i singoli brani ora basta incollare il link, l'app
estrae name+artist dai meta og:title/og:description pubblici e
risolve via YouTube. Album/playlist continuano a richiedere
le credenziali (gratis, 2 min).

Landing /download: bottoni Mac/Windows ora navigano nella stessa
tab via JS handler + feedback "avvio in corso" invece del fragile
target="_blank".
2026-06-09 11:33:28 +02:00
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
35 changed files with 2573 additions and 148 deletions

No files matched your search

+27 -6
View File
@@ -33,17 +33,38 @@ jobs:
- name: Build app
run: python build_macos.py
- name: Package .app -> zip
- name: Create DMG
run: |
cd dist
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
ls -lh ../MusicTools-macOS.zip
STAGING="dmg_staging"
rm -rf "$STAGING"
mkdir -p "$STAGING"
cp -R dist/MusicTools.app "$STAGING/"
cp "assets/Fix Gatekeeper.command" "$STAGING/"
chmod +x "$STAGING/Fix Gatekeeper.command"
ln -s /Applications "$STAGING/Applications"
cat > "$STAGING/LEGGIMI.txt" <<'EOF'
INSTALLAZIONE MusicTools
========================
1) Trascina MusicTools.app sulla cartella "Applicazioni"
(l'alias e' qui dentro nel DMG)
2) Doppio click su "Fix Gatekeeper.command"
Rimuove la quarantena macOS Gatekeeper (una sola volta).
3) Apri MusicTools dalle Applicazioni e attiva con email + chiave.
Problemi? scrivici a info@djluza.com
EOF
hdiutil create -volname "MusicTools" -srcfolder "$STAGING" \
-ov -format UDZO MusicTools-macOS.dmg
ls -lh MusicTools-macOS.dmg
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: MusicTools-macOS
path: MusicTools-macOS.zip
path: MusicTools-macOS.dmg
retention-days: 14
# =========================================================
@@ -107,5 +128,5 @@ jobs:
generate_release_notes: true
fail_on_unmatched_files: true
files: |
artifacts/macos/MusicTools-macOS.zip
artifacts/macos/MusicTools-macOS.dmg
artifacts/windows/MusicTools-Windows.zip
+109 -10
View File
@@ -20,7 +20,12 @@ from core.downloader import (
reset_stop as reset_download_stop,
is_stopped as download_is_stopped,
)
from core.spotify_client import get_access_token, resolve_spotify_url
from core.spotify_client import (
get_access_token,
resolve_spotify_url,
resolve_spotify_track_no_auth,
SpotifyAuthRequired,
)
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
from core.recorder import (
list_input_devices,
@@ -123,6 +128,7 @@ class Api:
"config": safe_cfg,
"spotify_guide": SPOTIFY_GUIDE_TEXT,
"license": license_status,
"plan": license_mod.get_plan(cfg),
"purchase_url": "https://musictools.djluza.com",
}
@@ -181,6 +187,63 @@ class Api:
webbrowser.open("https://musictools.djluza.com")
return {"ok": True}
# ------------------------------------------------------------------
# Quota giornaliera
# ------------------------------------------------------------------
def get_quota_status(self) -> dict:
"""Ritorna lo stato quota corrente (lettura senza incremento)."""
try:
data = license_mod.get_quota_status()
return {"ok": True, "quota": data}
except license_mod.LicenseNetworkError as e:
return {"ok": False, "offline": True, "error": str(e)}
except license_mod.LicenseError as e:
return {"ok": False, "error": str(e)}
def _gate(self, feature: str) -> Optional[dict]:
"""Verifica feature + quota prima di un'azione.
Ritorna None se l'azione e' permessa; altrimenti un dict di
errore pronto per la UI (con eventuale info quota per il modal
"limite raggiunto").
"""
# 1) Feature gate locale (dai claims JWT in config)
if not license_mod.has_feature(feature):
plan = license_mod.get_plan()
plan_name = plan.get("name") or "il tuo piano"
return {
"ok": False,
"reason": "feature_not_in_plan",
"feature": feature,
"error": (
f"Questa funzione non e' inclusa in {plan_name}. "
"Passa a un piano superiore per sbloccarla."
),
"plan": plan,
}
# 2) Quota gate server-side
try:
res = license_mod.consume_quota(feature)
except license_mod.LicenseNetworkError as e:
return {
"ok": False, "reason": "offline", "offline": True,
"error": f"Impossibile contattare il server: {e}",
}
except license_mod.LicenseError as e:
return {"ok": False, "reason": "license_invalid", "error": str(e)}
if not res.get("allowed", True):
return {
"ok": False,
"reason": "quota_exceeded",
"feature": feature,
"error": res.get("error") or "Limite giornaliero raggiunto.",
"quota": res,
}
# OK: notifico la UI dello stato quota aggiornato
self._emit("quota:update", res)
return None
def check_update(self) -> dict:
"""Controlla aggiornamenti interrogando l'API djluza.com.
@@ -326,6 +389,10 @@ class Api:
if not os.path.exists(path):
return {"ok": False, "error": "File non trovato"}
gate = self._gate("metadata")
if gate:
return gate
data = payload.get("data") or {}
cover_path = (payload.get("cover_path") or "").strip() or None
remove_cover = bool(payload.get("remove_cover", False))
@@ -360,6 +427,10 @@ class Api:
if not filename.lower().endswith(".mp3"):
filename += ".mp3"
gate = self._gate("record")
if gate:
return gate
out_path = os.path.join(output_dir, filename)
def cb(status, payload_evt):
@@ -412,6 +483,10 @@ class Api:
if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"}
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread(
target=self._download_worker,
args=(list(urls), output_dir),
@@ -441,6 +516,10 @@ class Api:
if safe:
output_dir = os.path.join(output_dir, safe)
gate = self._gate("audio")
if gate:
return gate
self._download_thread = threading.Thread(
target=self._tracks_worker,
args=(list(tracks), output_dir),
@@ -516,14 +595,11 @@ class Api:
view = "download"
token = None
client_id = cfg.get("client_id", "")
client_secret = cfg.get("client_secret", "")
has_credentials = bool(client_id and client_secret)
has_spotify = any("spotify.com" in u for u in urls)
if has_spotify:
client_id = cfg.get("client_id", "")
client_secret = cfg.get("client_secret", "")
if not client_id or not client_secret:
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
self._emit("download:done", {"ok": False})
return
if has_spotify and has_credentials:
self._log(view, "[INFO] Autenticazione Spotify...")
try:
token = get_access_token(client_id, client_secret)
@@ -531,6 +607,13 @@ class Api:
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
self._emit("download:done", {"ok": False})
return
elif has_spotify:
self._log(
view,
"[INFO] Spotify API non configurate: i singoli brani "
"verranno comunque scaricati via metadata pubblico. "
"Per album/playlist servono le credenziali (gratuite, 2 min)."
)
for url_idx, url in enumerate(urls):
if download_is_stopped():
@@ -590,12 +673,19 @@ class Api:
if is_spotify:
self._log(view, "[INFO] Recupero informazioni da Spotify...")
try:
label, name, tracks = resolve_spotify_url(token, url)
if token:
label, name, tracks = resolve_spotify_url(token, url)
else:
# Senza API keys: fallback solo per singoli brani.
label, name, tracks = resolve_spotify_track_no_auth(url)
except SpotifyAuthRequired as e:
self._log(view, f"[ERRORE] {e}")
continue
except Exception as e:
self._log(view, f"[ERRORE] {e}")
continue
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
dest_dir = os.path.join(output_dir, name)
dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
else:
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
@@ -627,6 +717,10 @@ class Api:
if not directory:
return {"ok": False, "error": "Cartella non impostata"}
gate = self._gate("upgrade")
if gate:
return gate
cfg = load_config()
cookies_path = cfg.get("cookies_path", "")
@@ -700,6 +794,11 @@ class Api:
if not output_dir:
return {"ok": False, "error": "Cartella output non impostata"}
# Anche "audio_only" video count come video: usa codec/yt-dlp diversi
gate = self._gate("video")
if gate:
return gate
self._video_thread = threading.Thread(
target=self._video_worker,
args=(list(urls), output_dir, quality, audio_only),
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Rimuove la quarantena Gatekeeper da MusicTools.app
# Doppio click in macOS apre Terminal ed esegue questo script.
set -e
APP_NAME="MusicTools.app"
# Cerca l'app in /Applications, poi nella stessa cartella del DMG montato.
if [ -d "/Applications/${APP_NAME}" ]; then
APP="/Applications/${APP_NAME}"
elif [ -d "$(dirname "$0")/${APP_NAME}" ]; then
APP="$(dirname "$0")/${APP_NAME}"
else
osascript -e "display alert \"${APP_NAME} non trovata\" message \"Trascina MusicTools.app nella cartella Applicazioni prima di eseguire questo script.\" as critical"
exit 1
fi
echo "Rimuovo la quarantena da: ${APP}"
xattr -dr com.apple.quarantine "${APP}" 2>/dev/null || true
# Notifica visuale
osascript -e "display notification \"MusicTools puo' essere aperta normalmente con doppio click.\" with title \"Fix completato\""
echo ""
echo "Fatto. Ora puoi aprire MusicTools normalmente con doppio click."
echo "Puoi chiudere questa finestra."
+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()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 61 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 207 KiB

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
from pathlib import Path
VERSION = "v1.6.0"
VERSION = "v1.7.4"
APP_NAME = "MusicTools"
@@ -77,6 +77,14 @@ DEFAULTS = {
"license_activated_at": 0, # epoch della prima attivazione
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
"device_id": "", # UUID generato al primo avvio
# ---- Piano (snapshot dei claims JWT) ----
"plan_code": "", # "basic" | "pro" | "premium" | "annual"
"plan_name": "", # nome user-facing
"plan_features": [], # ["audio", "video", "record", "metadata", "upgrade"]
"plan_daily_limit": None, # None = unlimited (annual), altrimenti int
"plan_is_subscription": False,
"plan_expires_at": 0, # solo per annual one-time
"plan_period_end": 0, # solo per subscription mensili
}
+187 -2
View File
@@ -28,6 +28,7 @@ import json
import platform
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
from typing import Optional
@@ -140,12 +141,42 @@ def _http_post(path: str, body: dict) -> dict:
# ============================================================
# API pubblica
# ============================================================
def _plan_snapshot(cfg: dict) -> dict:
"""Estrae i dati del piano dal config (popolati da activate/validate)."""
code = (cfg.get("plan_code") or "").strip()
if not code:
return {}
return {
"code": code,
"name": (cfg.get("plan_name") or "").strip() or code.title(),
"features": list(cfg.get("plan_features") or []),
"daily_limit": cfg.get("plan_daily_limit"), # puo' essere None
"is_subscription": bool(cfg.get("plan_is_subscription")),
"expires_at": int(cfg.get("plan_expires_at") or 0),
"period_end": int(cfg.get("plan_period_end") or 0),
}
def has_feature(name: str, config: Optional[dict] = None) -> bool:
"""True se il piano corrente include la feature richiesta."""
cfg = config or load_config()
plan = _plan_snapshot(cfg)
if not plan:
return False
return name in (plan.get("features") or [])
def get_plan(config: Optional[dict] = None) -> dict:
"""Ritorna il dict del piano corrente, oppure {} se non noto."""
return _plan_snapshot(config or load_config())
def get_status(config: Optional[dict] = None) -> dict:
"""Ritorna lo stato corrente della licenza per la UI.
Chiavi: licensed (bool), reason (str), email, key, activated_at,
last_validated_at, days_since_validation, expires_soon (bool),
needs_revalidation (bool).
needs_revalidation (bool), plan (dict).
"""
cfg = config or load_config()
token = (cfg.get("license_token") or "").strip()
@@ -153,6 +184,7 @@ def get_status(config: Optional[dict] = None) -> dict:
email = (cfg.get("license_email") or "").strip()
activated = int(cfg.get("license_activated_at") or 0)
last_val = int(cfg.get("last_validated_at") or 0)
plan = _plan_snapshot(cfg)
if not token or not key:
return {
@@ -164,9 +196,27 @@ def get_status(config: Optional[dict] = None) -> dict:
"last_validated_at": 0,
"days_since_validation": 0,
"needs_revalidation": False,
"plan": plan,
}
days_since = (_now() - last_val) // 86400 if last_val else 999
# Scadenza locale derivata dai claims (best-effort: la verita' e' sul server).
t = _now()
expires_at = plan.get("expires_at") or 0
period_end = plan.get("period_end") or 0
if expires_at and expires_at < t:
return {
"licensed": False, "reason": "plan_expired", "email": email, "key": key,
"activated_at": activated, "last_validated_at": last_val,
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
}
if period_end and period_end < t:
return {
"licensed": False, "reason": "subscription_expired", "email": email, "key": key,
"activated_at": activated, "last_validated_at": last_val,
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
}
days_since = (t - last_val) // 86400 if last_val else 999
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
grace_expired = days_since >= LICENSE_GRACE_DAYS
@@ -180,6 +230,7 @@ def get_status(config: Optional[dict] = None) -> dict:
"last_validated_at": last_val,
"days_since_validation": days_since,
"needs_revalidation": True,
"plan": plan,
}
return {
@@ -191,6 +242,7 @@ def get_status(config: Optional[dict] = None) -> dict:
"last_validated_at": last_val,
"days_since_validation": days_since,
"needs_revalidation": needs_reval,
"plan": plan,
}
@@ -199,6 +251,55 @@ def is_licensed() -> bool:
return get_status()["licensed"]
def _store_plan_from_response(cfg: dict, resp: dict, token: str) -> None:
"""Aggiorna il config con i campi del piano estratti dalla risposta API.
Server risposta puo' contenere:
- plan: {code, name, daily_limit, features, is_subscription}
- expires_at: epoch (annual)
- period_end: epoch (subscription)
Come fallback decodifica i claims dal JWT.
"""
plan = resp.get("plan") if isinstance(resp.get("plan"), dict) else None
claims = _decode_jwt_claims(token) if token else {}
code = ""
name = ""
features = []
daily_limit = None
is_sub = False
if plan:
code = (plan.get("code") or "").strip()
name = (plan.get("name") or "").strip()
features = list(plan.get("features") or [])
daily_limit = plan.get("daily_limit")
is_sub = bool(plan.get("is_subscription"))
else:
code = (claims.get("plan") or "").strip()
name = (claims.get("plan_name") or "").strip()
features = list(claims.get("features") or [])
daily_limit = claims.get("daily_limit")
is_sub = bool(claims.get("is_subscription"))
cfg["plan_code"] = code
cfg["plan_name"] = name
cfg["plan_features"] = features
cfg["plan_daily_limit"] = daily_limit # None ammesso (unlimited)
cfg["plan_is_subscription"] = is_sub
cfg["plan_expires_at"] = int(resp.get("expires_at") or claims.get("expires_at") or 0)
cfg["plan_period_end"] = int(resp.get("period_end") or claims.get("period_end") or 0)
def _clear_plan(cfg: dict) -> None:
cfg["plan_code"] = ""
cfg["plan_name"] = ""
cfg["plan_features"] = []
cfg["plan_daily_limit"] = None
cfg["plan_is_subscription"] = False
cfg["plan_expires_at"] = 0
cfg["plan_period_end"] = 0
def activate(license_key: str, email: str) -> dict:
"""Attiva una licenza contro il server. Salva token su success.
@@ -231,6 +332,7 @@ def activate(license_key: str, email: str) -> dict:
cfg["license_token"] = token
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
cfg["last_validated_at"] = now
_store_plan_from_response(cfg, resp, token)
save_config(cfg)
return get_status(cfg)
@@ -260,6 +362,7 @@ def validate() -> dict:
# Server ha risposto 4xx -> token non piu valido
cfg["license_token"] = ""
cfg["last_validated_at"] = 0
_clear_plan(cfg)
save_config(cfg)
return get_status(cfg)
@@ -268,6 +371,7 @@ def validate() -> dict:
if new_token:
cfg["license_token"] = new_token
cfg["last_validated_at"] = _now()
_store_plan_from_response(cfg, resp, cfg.get("license_token", ""))
save_config(cfg)
return get_status(cfg)
@@ -298,5 +402,86 @@ def deactivate(release_remote: bool = True) -> dict:
cfg["license_token"] = ""
cfg["license_activated_at"] = 0
cfg["last_validated_at"] = 0
_clear_plan(cfg)
save_config(cfg)
return get_status(cfg)
# ============================================================
# Quota giornaliera
# ============================================================
def _http_json(method: str, path: str, body: Optional[dict] = None,
token: Optional[str] = None, timeout: int = 10) -> dict:
"""Helper unificato per GET/POST con Authorization Bearer."""
url = LICENSE_API_URL.rstrip("/") + path
data = json.dumps(body).encode("utf-8") if body is not None else None
headers = {
"User-Agent": f"MusicTools/{VERSION}",
"Accept": "application/json",
}
if data is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read().decode("utf-8")
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
try:
err_body = e.read().decode("utf-8")
err_data = json.loads(err_body) if err_body else {}
except Exception:
err_data = {}
err_data.setdefault("error", e.reason or f"HTTP {e.code}")
err_data["_status"] = e.code
return err_data
except urllib.error.URLError as e:
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
except (TimeoutError, OSError) as e:
raise LicenseNetworkError(str(e))
def get_quota_status() -> dict:
"""Legge lo stato quota dal server. Ritorna dict con plan/used/limit/remaining.
Solleva LicenseError se il server rifiuta (401/403); LicenseNetworkError
in caso di problemi di rete.
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
if not token:
raise LicenseError("Licenza non attivata.")
resp = _http_json("GET", "/api/usage/status", token=token)
if resp.get("_status") and resp["_status"] >= 400:
raise LicenseError(resp.get("error") or "Errore stato quota.")
return resp
def consume_quota(feature: str = "") -> dict:
"""Incrementa la quota giornaliera prima di un download.
Ritorna {allowed, used, limit, remaining, plan, day}.
Se il server risponde 429 -> allowed=False (l'utente ha raggiunto il limite).
Per i piani unlimited (annual), allowed=True sempre.
Solleva LicenseError per 401/403; LicenseNetworkError per problemi di rete.
"""
cfg = load_config()
token = (cfg.get("license_token") or "").strip()
if not token:
raise LicenseError("Licenza non attivata.")
body = {"feature": feature} if feature else {}
resp = _http_json("POST", "/api/usage/consume", body=body, token=token)
status_code = resp.pop("_status", 0)
if status_code == 401 or status_code == 403:
raise LicenseError(resp.get("error") or "Licenza non valida.")
if status_code == 429:
# Limite raggiunto, ma e' una risposta strutturata: ritorniamo
# il dict cosi' la UI puo' mostrare i numeri.
resp.setdefault("allowed", False)
return resp
if status_code and status_code >= 400:
raise LicenseError(resp.get("error") or f"Errore quota ({status_code}).")
resp.setdefault("allowed", True)
return resp
+80
View File
@@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
else:
name, tracks = get_playlist_info(token, item_id)
return "Playlist", name, tracks
# ---------------------------------------------------------------------------
# Fallback senza credenziali (solo per singoli brani)
# ---------------------------------------------------------------------------
class SpotifyAuthRequired(Exception):
"""Sollevata quando un URL Spotify richiede le API keys (album/playlist)."""
_OG_TITLE_RE = re.compile(
r'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE,
)
_OG_DESC_RE = re.compile(
r'<meta\s+property="og:description"\s+content="([^"]+)"', re.IGNORECASE,
)
# Tag HTML entities che ci interessano nelle og: (Spotify le scrive cosi').
_HTML_ENTITIES = {
"&amp;": "&", "&#x27;": "'", "&apos;": "'",
"&quot;": '"', "&lt;": "<", "&gt;": ">",
}
def _decode_entities(s: str) -> str:
for k, v in _HTML_ENTITIES.items():
s = s.replace(k, v)
return s
def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]:
"""Estrae name/artist da un URL Spotify SENZA credenziali API.
Funziona solo per brani singoli (open.spotify.com/track/<id>): legge
i meta tag og:title e og:description della pagina pubblica.
Per album/playlist non c'e' modo affidabile senza JS rendering -> alza
SpotifyAuthRequired.
Ritorna ("Brano", name, [{name, artist}]).
"""
kind, _ = detect_url_type(url)
if kind != "track":
raise SpotifyAuthRequired(
f"I link a {kind} richiedono le credenziali Spotify (gratuite). "
"Vai in Impostazioni -> Spotify API per configurarle in 2 minuti."
)
try:
resp = requests.get(
url,
headers={"User-Agent": "Mozilla/5.0 (MusicTools)"},
timeout=15,
)
resp.raise_for_status()
except requests.RequestException as e:
raise ValueError(f"Impossibile leggere il brano da Spotify: {e}") from e
html = resp.text
title_m = _OG_TITLE_RE.search(html)
desc_m = _OG_DESC_RE.search(html)
if not title_m:
raise ValueError("Risposta inattesa da Spotify (meta og:title mancante).")
name = _decode_entities(title_m.group(1)).strip()
artist = ""
if desc_m:
# Formato osservato: "Artist Name · Song Title · Song · YYYY"
# oppure con piu' artisti separati da ", ".
parts = [_decode_entities(p).strip() for p in desc_m.group(1).split("·")]
if parts:
artist = parts[0]
if not artist:
raise ValueError(
"Impossibile estrarre l'artista dal brano Spotify senza API keys. "
"Configura le credenziali in Impostazioni."
)
track = {"name": name, "artist": artist}
return "Brano", name, [track]
+137
View File
@@ -555,6 +555,115 @@ a { color: inherit; text-decoration: none; }
margin: 14px 0 0;
}
/* Grid 4 colonne per i piani */
.price-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 32px;
align-items: stretch;
}
@media (max-width: 1100px) {
.price-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
.price-grid { grid-template-columns: 1fr; }
}
.price-card-sm {
margin: 0;
max-width: none;
padding: 32px 22px 24px;
display: flex;
flex-direction: column;
box-shadow: none;
border: 1px solid var(--border);
}
.price-card-sm .price-amount { font-size: 44px; }
.price-card-sm .price-currency { font-size: 20px; }
.price-card-sm .price-once { font-size: 13px; }
.price-card-sm .price-features { margin-bottom: 22px; flex: 1; }
.price-card-sm .price-features li { padding: 7px 0; font-size: 13.5px; }
.price-card-featured {
border-color: var(--accent);
box-shadow: 0 18px 48px rgba(29, 185, 84, 0.18);
}
.price-badge-gold {
background: linear-gradient(135deg, #f8c44a, #f4a51c);
color: #0a0a0a;
}
.price-shared {
max-width: 720px;
margin: 0 auto;
text-align: center;
}
/* Box informativo sulle chiavi API Spotify */
.spotify-note {
max-width: 900px;
margin: 48px auto 0;
padding: 28px 32px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
}
.spotify-note h3 {
margin: 0 0 10px;
font-size: 19px;
font-weight: 800;
}
.spotify-note > p {
margin: 0 0 18px;
color: var(--text-muted);
font-size: 15px;
line-height: 1.55;
}
.spotify-note-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin: 18px 0 14px;
}
@media (max-width: 640px) {
.spotify-note-grid { grid-template-columns: 1fr; }
}
.sn-col {
padding: 18px 20px;
border-radius: 12px;
border: 1px solid var(--border);
}
.sn-with {
background: rgba(29, 185, 84, 0.08);
border-color: rgba(29, 185, 84, 0.3);
}
.sn-without {
background: rgba(120, 120, 120, 0.06);
}
.sn-title {
font-weight: 800;
font-size: 14.5px;
margin-bottom: 8px;
color: var(--text);
}
.sn-col ul {
margin: 0;
padding-left: 18px;
color: var(--text-muted);
font-size: 14px;
line-height: 1.55;
}
.sn-col li { margin-bottom: 4px; }
.sn-col strong { color: var(--text); }
.spotify-note-fine {
margin-top: 12px;
font-size: 12.5px;
color: var(--text-dim);
text-align: center;
}
/* ============================================================
FAQ
============================================================ */
@@ -775,3 +884,31 @@ a { color: inherit; text-decoration: none; }
}
.price-consent a { color: var(--text-muted); text-decoration: underline; }
.price-consent a:hover { color: var(--accent); }
/* ============================================================
"Hai gia acquistato? Scarica" — link discreti
============================================================ */
.hero-customer {
margin: 18px 0 0;
font-size: 13.5px;
color: var(--text-dim);
}
.hero-customer a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.hero-customer a:hover { text-decoration: underline; }
.price-already {
margin: 10px 0 0 !important;
font-size: 13px !important;
color: var(--text-muted) !important;
}
.price-already a {
color: var(--accent);
font-weight: 700;
text-decoration: none;
}
.price-already a:hover { text-decoration: underline; }
+270
View File
@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scarica MusicTools</title>
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
<link rel="stylesheet" href="css/style.css" />
<style>
.dl-page {
padding: 60px 24px 100px;
max-width: 900px;
margin: 0 auto;
}
.dl-head {
text-align: center;
margin-bottom: 48px;
}
.dl-head h1 {
font-size: clamp(28px, 4vw, 42px);
font-weight: 900;
letter-spacing: -0.02em;
margin: 12px 0 10px;
}
.dl-head p {
color: var(--text-muted);
font-size: 17px;
margin: 0;
}
.dl-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 40px;
}
@media (max-width: 700px) {
.dl-grid { grid-template-columns: 1fr; }
}
.dl-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 32px 28px;
text-align: center;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
}
.dl-card:hover {
transform: translateY(-3px);
border-color: #383838;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
}
.dl-os { font-size: 64px; line-height: 1; margin-bottom: 12px; }
.dl-card h2 { margin: 0 0 6px; font-size: 22px; font-weight: 800; }
.dl-card .meta { color: var(--text-dim); font-size: 13px; margin: 0 0 24px; }
.dl-form {
max-width: 460px;
margin: 0 auto 32px;
padding: 28px 30px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
}
.dl-form h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 800;
}
.dl-form p { margin: 0 0 18px; color: var(--text-muted); font-size: 14px; }
.dl-form .field { margin-bottom: 12px; }
.dl-form .field-label { font-size: 12px; color: var(--text-muted); font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 6px; }
.dl-form .input { width: 100%; padding: 12px 16px; border-radius: 999px; background: #0d0d0d; border: 1px solid var(--border); color: var(--text); font-size: 14px; font-family: inherit; box-sizing: border-box; }
.dl-form .input:focus { outline: none; border-color: var(--accent); }
.dl-form .submit { width: 100%; margin-top: 12px; }
.dl-steps {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 18px;
padding: 28px 32px;
margin-bottom: 18px;
}
.dl-steps h2 { font-size: 18px; font-weight: 800; margin: 0 0 14px; }
.dl-steps ol { margin: 0; padding-left: 22px; color: var(--text-muted); font-size: 14.5px; line-height: 1.7; }
.dl-steps li { margin-bottom: 4px; }
.dl-steps strong { color: var(--text); }
.dl-steps code { background: var(--bg-card-2); padding: 2px 7px; border-radius: 5px; font-size: 13px; font-family: ui-monospace, monospace; color: var(--accent); }
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
.dl-support a { color: var(--accent); }
.dl-status {
max-width: 460px;
margin: -16px auto 24px;
padding: 12px 18px;
text-align: center;
background: rgba(29, 185, 84, 0.08);
border: 1px solid rgba(29, 185, 84, 0.25);
color: var(--accent);
border-radius: 12px;
font-size: 14px;
font-weight: 600;
}
.dl-status.err {
background: rgba(239, 68, 68, 0.08);
border-color: rgba(239, 68, 68, 0.3);
color: #f87171;
}
</style>
</head>
<body>
<header class="nav">
<div class="container nav-row">
<a href="/" class="brand">
<span class="brand-logo">♫</span>
<span class="brand-name">MusicTools</span>
</a>
<nav class="nav-links">
<a href="/">Home</a>
<a href="/#features">Funzioni</a>
<a href="/#faq">FAQ</a>
</nav>
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Supporto</a>
</div>
</header>
<main class="dl-page">
<div class="dl-head">
<span class="eyebrow">SCARICA</span>
<h1>Scarica MusicTools</h1>
<p>Scegli il sistema operativo e procedi con l'installazione</p>
</div>
<!-- Form mostrato quando NON arrivi dall'email (no key/email in URL) -->
<div class="dl-form" id="dlForm">
<h2>Inserisci le tue credenziali</h2>
<p>Hai gia' acquistato? Inserisci l'email e la chiave per scaricare.</p>
<div class="field">
<label class="field-label" for="dlEmail">Email dell'acquisto</label>
<input type="email" id="dlEmail" class="input" autocomplete="email" />
</div>
<div class="field">
<label class="field-label" for="dlKey">Chiave di licenza</label>
<input type="text" id="dlKey" class="input" autocomplete="off" placeholder="XXXX-XXXX-XXXX-XXXX" />
</div>
<button class="btn btn-primary btn-lg submit" id="dlSubmitBtn">Mostra i link di download</button>
</div>
<!-- Cards di download (popolate dopo inserimento credenziali o da URL) -->
<div class="dl-grid" id="dlGrid" hidden>
<article class="dl-card">
<div class="dl-os">🍎</div>
<h2>macOS</h2>
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlMacBtn" data-platform="macos">
Scarica per Mac
</a>
</article>
<article class="dl-card">
<div class="dl-os">🪟</div>
<h2>Windows</h2>
<p class="meta">Windows 10 e 11 · 64-bit</p>
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlWinBtn" data-platform="windows">
Scarica per Windows
</a>
</article>
</div>
<p id="dlStatus" class="dl-status" hidden></p>
<div class="dl-steps">
<h2>Installazione macOS</h2>
<ol>
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
<li>Doppio click su <strong>Fix Gatekeeper.command</strong> (dentro il DMG): autorizza l'app in 1 click. Si chiude da sola appena finito.</li>
<li>Apri MusicTools dalle Applicazioni, inserisci email e chiave per attivare</li>
</ol>
</div>
<div class="dl-steps">
<h2>Installazione Windows</h2>
<ol>
<li>Estrai il file <code>.zip</code> in una cartella di tua scelta</li>
<li>Doppio click su <strong>MusicTools.exe</strong></li>
<li>Se Windows mostra "Windows ha protetto il PC": click su <strong>Ulteriori informazioni</strong> → <strong>Esegui comunque</strong></li>
<li>Inserisci email e chiave per attivare</li>
</ol>
</div>
<div class="dl-support">
Problemi con l'installazione o non hai ricevuto l'email con la chiave?<br>
Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> — risposta entro 24 ore.
</div>
</main>
<script>
function getParam(name) {
const url = new URL(window.location.href);
return url.searchParams.get(name) || "";
}
// Stato corrente — popolato da URL o dal form submit.
const dl = { email: "", key: "" };
function setStatus(text, isError) {
const el = document.getElementById("dlStatus");
if (!text) { el.hidden = true; el.textContent = ""; return; }
el.textContent = text;
el.classList.toggle("err", !!isError);
el.hidden = false;
}
function showDownloadCards(email, key) {
dl.email = email;
dl.key = key;
document.getElementById("dlGrid").hidden = false;
document.getElementById("dlForm").hidden = true;
}
// Click sui bottoni: naviga sulla stessa tab verso /api/download/:platform.
// Il server fa 302 con Content-Disposition: attachment -> il browser
// scarica il file e la pagina /download resta visibile.
document.querySelectorAll(".dl-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
if (!dl.email || !dl.key) {
setStatus("Inserisci email e chiave prima di scaricare.", true);
return;
}
const platform = btn.dataset.platform;
const q = "key=" + encodeURIComponent(dl.key) +
"&email=" + encodeURIComponent(dl.email);
setStatus("Avvio del download in corso… il file si apre fra qualche secondo.");
window.location.href = "/api/download/" + platform + "?" + q;
});
});
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni.
const urlKey = getParam("key");
const urlEmail = getParam("email");
if (urlKey && urlEmail) {
showDownloadCards(urlEmail, urlKey);
}
// Form submit
document.getElementById("dlSubmitBtn").addEventListener("click", () => {
const email = document.getElementById("dlEmail").value.trim();
const key = document.getElementById("dlKey").value.trim();
if (!email || !key) {
setStatus("Inserisci email e chiave di licenza.", true);
return;
}
setStatus("");
showDownloadCards(email, key);
});
// Enter dentro i campi
["dlEmail", "dlKey"].forEach(id => {
document.getElementById(id).addEventListener("keydown", (e) => {
if (e.key === "Enter") document.getElementById("dlSubmitBtn").click();
});
});
</script>
</body>
</html>
+181 -37
View File
@@ -4,10 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. 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: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:url" content="https://musictools.djluza.com" />
@@ -26,10 +26,11 @@
<nav class="nav-links">
<a href="#features">Funzioni</a>
<a href="#how">Come funziona</a>
<a href="#pricing">Prezzo</a>
<a href="#pricing">Piani</a>
<a href="#faq">FAQ</a>
<a href="/download">Download</a>
</nav>
<a href="#pricing" class="btn btn-primary btn-sm">Compra — 39,90 €</a>
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
</div>
</header>
@@ -37,7 +38,7 @@
<section class="hero" id="top">
<div class="container hero-grid">
<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>
<p class="lead">
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.
</p>
<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>
</div>
<ul class="hero-bullets">
<li>✓ Acquisto una tantum, nessun abbonamento</li>
<li>✓ Aggiornamenti gratuiti a vita</li>
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
<li>✓ Aggiornamenti gratuiti inclusi</li>
<li>✓ Funziona offline · Su 3 dispositivi</li>
</ul>
<p class="hero-customer">
Hai già acquistato? <a href="/download">Scarica l'app →</a>
</p>
</div>
<div class="hero-art" aria-hidden="true">
@@ -170,39 +175,146 @@
<!-- ============== PRICING ============== -->
<section class="section pricing" id="pricing">
<div class="container">
<span class="eyebrow center">PREZZO</span>
<h2 class="section-title">Una licenza. Per sempre.</h2>
<p class="section-lead">Niente abbonamenti. Niente sorprese. Compri una volta, la usi per sempre.</p>
<span class="eyebrow center">PIANI</span>
<h2 class="section-title">Scegli il piano giusto per te</h2>
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
<div class="price-card">
<div class="price-badge">Più popolare</div>
<div class="price-name">MusicTools Personal</div>
<div class="price-row">
<span class="price-amount">39,90</span>
<span class="price-currency">€</span>
<span class="price-once">una tantum</span>
<div class="price-grid">
<!-- BASIC -->
<div class="price-card price-card-sm">
<div class="price-name">Basic</div>
<div class="price-row">
<span class="price-amount">2,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
<li>✓ <strong>Fino a 10 al giorno</strong></li>
<li>✓ MP3 fino a 320kbps</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
<li>✗ Download video</li>
<li>✗ Registrazione, metadati, upgrade libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Inizia con Basic
</a>
</div>
<ul class="price-features">
<li>✓ <strong>Licenza a vita</strong> — paghi una volta, mai più</li>
<li>✓ <strong>3 dispositivi</strong> — Mac e Windows mixati</li>
<li>✓ <strong>Aggiornamenti gratuiti</strong> per tutte le versioni future</li>
<li>✓ <strong>Tutte le 7 tab</strong> sbloccate</li>
<li>✓ <strong>Supporto via email</strong> a info@djluza.com</li>
<li>✓ <strong>Garanzia 14 giorni</strong> soddisfatti o rimborsati</li>
</ul>
<a href="#"
class="btn btn-primary btn-lg btn-block"
data-lemonsqueezy-url="REPLACE_WITH_LS_PRODUCT_URL"
id="buyBtn">
Acquista ora — 39,90 €
</a>
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa</p>
<!-- PRO -->
<div class="price-card price-card-sm">
<div class="price-badge">Più scelto</div>
<div class="price-name">Pro</div>
<div class="price-row">
<span class="price-amount">5,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Basic</strong></li>
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
<li>✓ <strong>Fino a 30 al giorno</strong></li>
<li>✓ Registrazione audio (BlackHole / loopback)</li>
<li>✓ Editor metadati + copertine</li>
<li>✓ Upgrade qualità libreria</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
Scegli Pro
</a>
</div>
<!-- PREMIUM -->
<div class="price-card price-card-sm">
<div class="price-name">Premium</div>
<div class="price-row">
<span class="price-amount">9,99</span>
<span class="price-currency">€</span>
<span class="price-once">/mese</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutto di Pro</strong></li>
<li>✓ <strong>Fino a 150 al giorno</strong></li>
<li>✓ Priorità nel supporto via email</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
Passa a Premium
</a>
</div>
<!-- ANNUAL ONE-TIME -->
<div class="price-card price-card-sm price-card-featured">
<div class="price-badge price-badge-gold">Miglior valore</div>
<div class="price-name">Annual</div>
<div class="price-row">
<span class="price-amount">49,90</span>
<span class="price-currency">€</span>
<span class="price-once">/anno</span>
</div>
<ul class="price-features">
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
<li>✓ <strong>Nessun limite giornaliero</strong></li>
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
<li>✓ Una tantum, niente rinnovo automatico</li>
<li>✓ 3 dispositivi (Mac + Win)</li>
</ul>
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
id="buyBtn">
Acquista — 49,90 €
</a>
</div>
</div>
<div class="price-shared">
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
<p class="price-consent">
Procedendo con l'acquisto dichiari di aver letto e di accettare i
<a href="/legal/terms.html">Termini di servizio</a>,
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
<a href="/legal/refund.html">Politica rimborsi</a>.
</p>
<p class="price-already">
Hai già una licenza? <a href="/download">Scarica l'app →</a>
</p>
</div>
<div class="spotify-note">
<h3>Per sfruttare al massimo MusicTools serve un account Spotify</h3>
<p>
L'app raggiunge il <strong>100% delle sue funzioni</strong> quando colleghi
le tue <strong>chiavi API Spotify</strong>. Sono <strong>gratuite</strong>:
basta un account Spotify (anche <strong>quello gratis</strong>, niente
Premium richiesto) e bastano <strong>2 minuti</strong> per generarle dalla
Developer Dashboard. La guida passo-passo e' integrata nell'app.
</p>
<div class="spotify-note-grid">
<div class="sn-col sn-with">
<div class="sn-title">✓ Con chiavi API Spotify (consigliato)</div>
<ul>
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
<li>Tutte le altre fonti restano sempre attive</li>
</ul>
</div>
<div class="sn-col sn-without">
<div class="sn-title">Senza chiavi (modalita' base)</div>
<ul>
<li>Da Spotify scarichi <strong>un brano alla volta</strong></li>
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
</ul>
</div>
</div>
<p class="spotify-note-fine">
MusicTools non e' affiliato a Spotify. Le chiavi API restano sul tuo
dispositivo, non passano per i nostri server.
</p>
</div>
<div class="use-policy">
@@ -240,12 +352,27 @@
<details class="faq-item">
<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 class="faq-item">
<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 class="faq-item">
@@ -253,6 +380,22 @@
<p>No. Per Spotify ti basta un account gratuito (serve solo per ottenere le credenziali API dal loro dashboard, è gratis). YouTube non richiede account.</p>
</details>
<details class="faq-item">
<summary>Cosa posso fare senza configurare le chiavi API Spotify?</summary>
<p>
<strong>Da YouTube, SoundCloud, TikTok, Instagram, Facebook</strong>:
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
</p>
<p>
<strong>Da Spotify</strong>: senza chiavi puoi scaricare <strong>un
brano alla volta</strong> incollando il link al singolo brano (l'app
estrae artista e titolo dai metadati pubblici di Spotify e cerca su
YouTube). Per <strong>album e playlist</strong> servono le chiavi API
— sono gratis (anche con account Spotify free) e la procedura guidata
nell'app ci mette 2 minuti.
</p>
</details>
<details class="faq-item">
<summary>Posso registrare l'audio di Spotify mentre suona?</summary>
<p>Sì, con BlackHole (macOS) o la scheda audio virtuale di Windows. La tab Registra ti guida nel setup passo-passo.</p>
@@ -300,8 +443,8 @@
<section class="section cta-final">
<div class="container cta-box">
<h2>Pronto a semplificarti la vita?</h2>
<p>Una sola app per tutto quello che fai con la musica.</p>
<a href="#pricing" class="btn btn-primary btn-lg">Compra ora — 39,90 €</a>
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
</div>
</section>
@@ -314,6 +457,7 @@
<span class="footer-by">by LuZa</span>
</div>
<nav class="footer-links">
<a href="/download">Download</a>
<a href="mailto:info@djluza.com">Supporto</a>
<a href="/legal/terms.html">Termini</a>
<a href="/legal/privacy.html">Privacy</a>
+3 -19
View File
@@ -8,25 +8,9 @@
if (el) el.textContent = String(new Date().getFullYear());
})();
// Lemon Squeezy overlay
// Lo script di Lemon (lemon.js) intercetta automaticamente i link con
// data-lemonsqueezy-url e apre l'overlay. Qui ci limitiamo a impedire
// 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.
});
});
// Lemon Squeezy overlay: lemon.js intercetta automaticamente i link con
// data-lemonsqueezy-url e apre l'overlay di checkout. Niente codice nostro
// necessario qui — basta che lemon.js sia caricato (vedi <script> in index.html).
// Header che diventa piu' opaco mentre scorri (effetto subtle)
const nav = document.querySelector(".nav");
@@ -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).
* Usa fetch nativo di Node >= 20 — nessuna dipendenza.
* Invio email transazionali via Resend.
*
* sendLicenseEmail(to, key, { plan, expiresAt, periodEnd })
* - plan: codice piano ("basic" | "pro" | "premium" | "annual")
* - expiresAt: epoch seconds (annual one-time) o null
* - periodEnd: epoch seconds (subscription mensile) o null
*
* Variabili env richieste:
* RESEND_API_KEY dal dashboard Resend
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
*/
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) {
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
return;
}
const { plan: planCode, expiresAt, periodEnd } = opts;
const planInfo = describePlan(planCode, expiresAt, periodEnd);
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
const macUrl = `${BASE}/api/download/macos?${q}`;
const winUrl = `${BASE}/api/download/windows?${q}`;
const html = `
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
<p style="font-size:15px;line-height:1.55;margin:0 0 14px">
${planInfo.summary}
</p>
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
Ecco la tua chiave di licenza. Conservala con cura &mdash; ti servira' per attivare l'app.
</p>
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
${licenseKey}
</p>
<p style="font-size:15px;margin:0 0 12px"><strong>Scarica l'app:</strong></p>
<table cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px">
<tr>
<td style="padding-right:10px">
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
Scarica per Mac
</a>
</td>
<td>
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
Scarica per Windows
</a>
</td>
</tr>
</table>
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
<li>Scarica MusicTools per <a href="https://musictools.djluza.com#pricing">Mac o Windows</a></li>
<li>Apri l'app: alla prima schermata ti chiedera' email e chiave</li>
<li>Inserisci questa email (<code>${to}</code>) e la chiave qui sopra</li>
<li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
<li>Installa l'app (su Mac: tasto destro &rarr; Apri &rarr; Apri; su Windows: Esegui comunque)</li>
<li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
</ol>
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
<p style="font-size:13px;color:#888;margin:8px 0 0">
Hai bisogno di riscaricare in futuro? Vai su <a href="${BASE}/download" style="color:#1db954">${BASE.replace(/^https?:\/\//, "")}/download</a> e inserisci email + chiave.
</p>
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
</div>
`;
const subject = planInfo.name === "MusicTools"
? "La tua licenza MusicTools"
: `La tua licenza MusicTools ${planInfo.name}`;
const resp = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
@@ -45,7 +129,7 @@ export async function sendLicenseEmail(to, licenseKey) {
body: JSON.stringify({
from: FROM,
to: [to],
subject: "La tua licenza MusicTools",
subject,
html,
}),
});
+253 -38
View File
@@ -1,13 +1,26 @@
/**
* Webhook Lemon Squeezy.
* 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 { one, exec } from "./db.js";
import { generateLicenseKey } from "./license.js";
import { sendLicenseEmail } from "./email.js";
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
const now = () => Math.floor(Date.now() / 1000);
@@ -21,8 +34,210 @@ function timingSafe(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) {
// express.raw() salva il body come Buffer in req.body
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
const sig = req.get("X-Signature") || "";
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
@@ -39,45 +254,45 @@ export async function webhook(req, res) {
const eventName = payload?.meta?.event_name || "";
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();
if (eventName === "order_created") {
const key = generateLicenseKey();
try {
await exec(
`INSERT INTO licenses
(license_key, email, status, source, order_id, created_at, updated_at)
VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`,
[key, email, orderId, t, t],
);
} catch (e) {
// duplicate webhook delivery
if (e?.code === "ER_DUP_ENTRY") {
return res.json({ ok: true, duplicate: true });
}
throw e;
let out;
try {
switch (eventName) {
case "order_created":
out = await handleOrderCreated(payload, attrs, t);
break;
case "order_refunded":
out = await handleOrderRefunded(payload, attrs, t);
break;
case "subscription_created":
out = await handleSubscriptionCreated(payload, attrs, t);
break;
case "subscription_updated":
case "subscription_resumed":
case "subscription_unpaused":
out = await handleSubscriptionUpdated(payload, attrs, t);
break;
case "subscription_payment_success":
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
break;
case "subscription_expired":
out = await handleSubscriptionExpired(payload, attrs, t);
break;
case "subscription_cancelled":
case "subscription_paused":
case "subscription_payment_failed":
case "subscription_payment_refunded":
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
out = { status: 200, body: { ok: true, ignored: eventName } };
break;
default:
out = { status: 200, body: { ok: true, ignored: eventName } };
}
try {
await sendLicenseEmail(email, key);
} catch (e) {
console.error("[email] send failed:", e?.message || e);
}
return res.json({ ok: true });
} catch (e) {
console.error("[ls] handler error", eventName, e?.message || e);
return res.status(500).json({ error: "Internal error" });
}
if (eventName === "order_refunded") {
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 });
return res.status(out.status).json(out.body);
}
+84 -12
View File
@@ -1,6 +1,7 @@
import crypto from "node:crypto";
import { one, exec } from "./db.js";
import { signJwt, verifyJwt } from "./jwt.js";
import { getPlan, planSnapshot } from "./plans.js";
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
@@ -9,18 +10,54 @@ const now = () => Math.floor(Date.now() / 1000);
const normEmail = (s) => String(s || "").trim().toLowerCase();
const normKey = (s) => String(s || "").trim().toUpperCase();
/**
* Verifica che una licenza sia ancora "utilizzabile" oggi.
* Ritorna { ok: true } oppure { ok: false, error: "..." }.
*
* Casi gestiti:
* - annual one-time scaduto (expires_at < now)
* - subscription scaduta (current_period_end < now)
*/
function checkLicenseValidity(license, t = now()) {
if (license.status !== "active") {
return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." };
}
if (license.expires_at && Number(license.expires_at) < t) {
return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." };
}
if (license.current_period_end && Number(license.current_period_end) < t) {
return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." };
}
return { ok: true };
}
async function issueToken(license, deviceId) {
const t = now();
return signJwt({
const plan = planSnapshot(license.plan);
const claims = {
sub: String(license.id),
key_id: license.license_key,
email: license.email,
device_id: deviceId,
iat: t,
exp: t + TOKEN_TTL_DAYS * 86400,
}, 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) {
const { key, email, device_id, device_name, app_version } = req.body || {};
const K = normKey(key), E = normEmail(email);
@@ -30,15 +67,15 @@ export async function activate(req, res) {
}
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],
);
if (!license) {
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
}
if (license.status !== "active") {
return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." });
}
const check = checkLicenseValidity(license);
if (!check.ok) return res.status(403).json({ error: check.error });
const t = now();
const existing = await one(
@@ -71,7 +108,15 @@ export async function activate(req, res) {
}
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) {
@@ -85,12 +130,14 @@ export async function validate(req, res) {
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
const license = await one(
"SELECT id, license_key, email, status FROM licenses WHERE id=?",
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
[claims.sub],
);
if (!license || license.status !== "active") {
return res.status(401).json({ error: "Licenza non attiva" });
}
if (!license) return res.status(401).json({ error: "Licenza non trovata" });
const check = checkLicenseValidity(license);
if (!check.ok) return res.status(401).json({ error: check.error });
const act = await one(
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
[license.id, D],
@@ -105,7 +152,14 @@ export async function validate(req, res) {
);
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) {
@@ -126,6 +180,24 @@ export async function deactivate(req, res) {
res.json({ ok: true });
}
/**
* Helper interno: dato un token JWT valido, ritorna la riga licenze
* fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se
* il token e' invalido o la licenza non e' piu' attiva.
*/
export async function licenseFromToken(token) {
const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET);
if (!claims) return null;
const lic = await one(
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
[claims.sub],
);
if (!lic) return null;
const check = checkLicenseValidity(lic);
if (!check.ok) return null;
return { license: lic, claims };
}
// Esposto per uso interno (es. webhook genera chiave nuova)
export function generateLicenseKey() {
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+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 updates from "./updates.js";
import * as ls from "./lemonsqueezy.js";
import * as dl from "./downloads.js";
import * as usage from "./usage.js";
const app = express();
@@ -41,10 +43,17 @@ app.post("/api/license/activate", license.activate);
app.post("/api/license/validate", license.validate);
app.post("/api/license/deactivate", license.deactivate);
// Quota giornaliera
app.get("/api/usage/status", usage.status);
app.post("/api/usage/consume", usage.consume);
// Aggiornamenti + download firmato
app.get("/api/latest", updates.latest);
app.get("/api/download", updates.download);
// 1-click download dall'email (key+email come query -> redirect signed)
app.get("/api/download/:platform", dl.downloadByKey);
// 404 JSON solo per /api/*
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
+7 -1
View File
@@ -89,7 +89,13 @@ export async function download(req, res) {
}
const name = path.basename(abs);
res.setHeader("Content-Type", "application/zip");
const ext = path.extname(name).toLowerCase();
const contentType = ext === ".dmg"
? "application/x-apple-diskimage"
: ext === ".exe"
? "application/vnd.microsoft.portable-executable"
: "application/zip";
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
fs.createReadStream(abs).pipe(res);
}
+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);
}
/* 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;
margin: 0;
@@ -194,6 +226,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
margin-top: 2px;
}
/* ===============================
QUOTA BOX (sidebar)
=============================== */
.quota-box {
margin: 10px 8px 0;
padding: 10px 12px;
background: rgba(29, 185, 84, 0.08);
border: 1px solid rgba(29, 185, 84, 0.25);
border-radius: 10px;
text-align: center;
}
.quota-box[hidden] { display: none; }
.quota-label {
font-size: 10px;
font-weight: 700;
letter-spacing: 1px;
color: var(--text-3);
text-transform: uppercase;
}
.quota-value {
font-size: 22px;
font-weight: 800;
color: var(--text);
margin: 2px 0;
}
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
.quota-plan {
font-size: 11px;
color: var(--green);
font-weight: 700;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.quota-upgrade {
margin-top: 8px;
background: var(--green);
border: none;
color: #000;
font-weight: 700;
font-size: 11px;
padding: 6px 12px;
border-radius: 999px;
cursor: pointer;
width: 100%;
}
.quota-upgrade[hidden] { display: none; }
.quota-upgrade:hover { filter: brightness(1.1); }
/* Modal Upgrade — testo prose, non monospace */
#upgradeModal .modal-body {
font-family: inherit;
font-size: 14px;
color: var(--text);
white-space: normal;
}
#upgradeModal .modal-body p { margin: 0 0 10px; line-height: 1.5; }
#upgradeModal .modal-body .modal-hint {
color: var(--text-2);
font-size: 13px;
}
/* ===============================
MAIN
=============================== */
@@ -1433,13 +1526,13 @@ input[type="number"]::-webkit-inner-spin-button {
.rec-error-log {
margin-top: 12px;
background: #0d0d0d;
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 14px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #c0c0c0;
color: var(--text-2);
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
+35 -5
View File
@@ -21,23 +21,23 @@
<div class="divider"></div>
<nav class="nav">
<button class="nav-item active" data-view="download">
<button class="nav-item active" data-view="download" data-feature="audio">
<span class="nav-icon">⬇</span>
<span>Scarica</span>
</button>
<button class="nav-item" data-view="video">
<button class="nav-item" data-view="video" data-feature="video">
<span class="nav-icon">🎬</span>
<span>Video</span>
</button>
<button class="nav-item" data-view="upgrade">
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
<span class="nav-icon">⚡</span>
<span>Upgrade</span>
</button>
<button class="nav-item" data-view="meta">
<button class="nav-item" data-view="meta" data-feature="metadata">
<span class="nav-icon">🏷</span>
<span>Metadati</span>
</button>
<button class="nav-item" data-view="record">
<button class="nav-item" data-view="record" data-feature="record">
<span class="nav-icon">●</span>
<span>Registra</span>
</button>
@@ -51,6 +51,19 @@
</button>
</nav>
<div class="quota-box" id="quotaBox" hidden>
<div class="quota-label">Oggi</div>
<div class="quota-value">
<span id="quotaUsed">0</span>
<span class="quota-sep">/</span>
<span id="quotaLimit">10</span>
</div>
<div class="quota-plan" id="quotaPlanName">Basic</div>
<button type="button" class="quota-upgrade" id="quotaUpgradeBtn" hidden>
Upgrade
</button>
</div>
<div class="footer">
<div class="footer-by">by LuZa</div>
<div class="footer-version" id="footerVersion">v1.0</div>
@@ -892,6 +905,23 @@
</div>
</div>
<!-- ============== MODAL: Upgrade richiesto ============== -->
<div class="modal-backdrop" id="upgradeModal" hidden>
<div class="modal">
<div class="modal-head">
<h2 id="upgradeModalTitle">Passa a un piano superiore</h2>
<button class="modal-close" id="upgradeCloseBtn">×</button>
</div>
<div class="modal-body" id="upgradeModalBody">
<!-- popolato dinamicamente -->
</div>
<div class="modal-foot">
<button class="btn btn-primary pill" id="upgradeOpenBtn">Vedi i piani</button>
<button class="btn btn-ghost pill" id="upgradeCloseBtn2">Chiudi</button>
</div>
</div>
</div>
<!-- ============== TOAST ============== -->
<div class="toast" id="toast"></div>
+160 -6
View File
@@ -80,7 +80,24 @@ function renderLicenseStatus() {
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
return;
}
const plan = lic.plan || {};
const planLine = plan.code
? `<div class="lic-row"><span>Piano</span><strong>${plan.name || plan.code}</strong></div>`
: "";
const limitLine = plan.daily_limit
? `<div class="lic-row"><span>Limite giornaliero</span><strong>${plan.daily_limit}</strong></div>`
: (plan.code === "annual"
? `<div class="lic-row"><span>Limite giornaliero</span><strong>Illimitato</strong></div>`
: "");
const expiryLine = plan.expires_at
? `<div class="lic-row"><span>Scade il</span><strong>${fmtDate(plan.expires_at)}</strong></div>`
: (plan.period_end
? `<div class="lic-row"><span>Prossimo rinnovo</span><strong>${fmtDate(plan.period_end)}</strong></div>`
: "");
box.innerHTML = `
${planLine}
${limitLine}
${expiryLine}
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
@@ -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() {
const email = $("#actEmail").value.trim();
const key = $("#actKey").value.trim();
@@ -109,6 +229,8 @@ async function activateLicense() {
state.license = res.license;
applyLicenseGate();
renderLicenseStatus();
applyPlanGate();
refreshQuota();
toast("Licenza attivata. Benvenuto!", "success");
} else {
err.textContent = res.error || "Errore di attivazione.";
@@ -143,6 +265,7 @@ window.bridge = {
const bridgeHandlers = {
"log": ({ view, msg }) => appendLog(view, msg),
"quota:update": (q) => renderQuotaBox(q),
"download:progress": (p) => {
if (typeof p.overall === "number") {
@@ -317,6 +440,11 @@ function showView(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) => {
btn.addEventListener("click", () => showView(btn.dataset.view));
});
@@ -336,6 +464,8 @@ async function init() {
applyLicenseGate();
renderLicenseStatus();
applyPlanGate();
refreshQuota();
// Populate Settings fields
$("#clientIdInput").value = state.config.client_id || "";
@@ -345,6 +475,7 @@ async function init() {
$("#cookiesInput").value = state.config.cookies_path || "";
$("#outputInput").value = state.config.output_dir || "";
$("#themeSelect").value = state.config.theme || "dark";
applyTheme(state.config.theme);
// Download tab — usa output_dir come default
state.dlOutputDir = state.config.output_dir || "";
@@ -471,7 +602,7 @@ $("#downloadBtn").addEventListener("click", async () => {
subfolder: "",
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
return;
@@ -484,7 +615,7 @@ $("#downloadBtn").addEventListener("click", async () => {
}
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
});
@@ -541,7 +672,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
audio_only: videoMode === "audio",
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["video:done"]();
}
});
@@ -602,7 +733,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
threshold: parseInt($("#upThreshold").value, 10) || 310,
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
bridgeHandlers["upgrade:done"]();
}
});
@@ -694,7 +825,7 @@ $("#recStartBtn").addEventListener("click", async () => {
bitrate: $("#recBitrate").value,
});
if (!res.ok) {
toast(res.error || "Errore", "error");
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
}
});
@@ -876,7 +1007,7 @@ $("#metaSaveBtn").addEventListener("click", async () => {
await loadMetaFile(state.meta.path);
} else {
status.textContent = "";
toast("Errore: " + (res.error || ""), "error");
if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error");
}
});
@@ -902,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
if (path) $("#outputInput").value = path;
});
$("#themeSelect").addEventListener("change", (e) => {
applyTheme(e.target.value);
});
$("#saveBtn").addEventListener("click", async () => {
const payload = {
client_id: $("#clientIdInput").value,
@@ -912,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
output_dir: $("#outputInput").value,
theme: $("#themeSelect").value,
};
applyTheme(payload.theme);
const res = await window.pywebview.api.save_settings(payload);
if (res.ok) {
state.config = payload;
@@ -1003,6 +1139,8 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
if (res.ok) {
state.license = res.license;
renderLicenseStatus();
applyPlanGate();
refreshQuota();
if (res.license.licensed) {
toast("Licenza verificata", "success");
} else {
@@ -1016,6 +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
// ============================================================