Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ea4c6181 | ||
|
|
30be62af78 | ||
|
|
a82f6f420c | ||
|
|
61459099f9 | ||
|
|
bcd4f22b1e |
No files matched your search
@@ -20,7 +20,12 @@ from core.downloader import (
|
|||||||
reset_stop as reset_download_stop,
|
reset_stop as reset_download_stop,
|
||||||
is_stopped as download_is_stopped,
|
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.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||||
from core.recorder import (
|
from core.recorder import (
|
||||||
list_input_devices,
|
list_input_devices,
|
||||||
@@ -123,6 +128,7 @@ class Api:
|
|||||||
"config": safe_cfg,
|
"config": safe_cfg,
|
||||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||||
"license": license_status,
|
"license": license_status,
|
||||||
|
"plan": license_mod.get_plan(cfg),
|
||||||
"purchase_url": "https://musictools.djluza.com",
|
"purchase_url": "https://musictools.djluza.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +187,63 @@ class Api:
|
|||||||
webbrowser.open("https://musictools.djluza.com")
|
webbrowser.open("https://musictools.djluza.com")
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Quota giornaliera
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def get_quota_status(self) -> dict:
|
||||||
|
"""Ritorna lo stato quota corrente (lettura senza incremento)."""
|
||||||
|
try:
|
||||||
|
data = license_mod.get_quota_status()
|
||||||
|
return {"ok": True, "quota": data}
|
||||||
|
except license_mod.LicenseNetworkError as e:
|
||||||
|
return {"ok": False, "offline": True, "error": str(e)}
|
||||||
|
except license_mod.LicenseError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
|
def _gate(self, feature: str) -> Optional[dict]:
|
||||||
|
"""Verifica feature + quota prima di un'azione.
|
||||||
|
|
||||||
|
Ritorna None se l'azione e' permessa; altrimenti un dict di
|
||||||
|
errore pronto per la UI (con eventuale info quota per il modal
|
||||||
|
"limite raggiunto").
|
||||||
|
"""
|
||||||
|
# 1) Feature gate locale (dai claims JWT in config)
|
||||||
|
if not license_mod.has_feature(feature):
|
||||||
|
plan = license_mod.get_plan()
|
||||||
|
plan_name = plan.get("name") or "il tuo piano"
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "feature_not_in_plan",
|
||||||
|
"feature": feature,
|
||||||
|
"error": (
|
||||||
|
f"Questa funzione non e' inclusa in {plan_name}. "
|
||||||
|
"Passa a un piano superiore per sbloccarla."
|
||||||
|
),
|
||||||
|
"plan": plan,
|
||||||
|
}
|
||||||
|
# 2) Quota gate server-side
|
||||||
|
try:
|
||||||
|
res = license_mod.consume_quota(feature)
|
||||||
|
except license_mod.LicenseNetworkError as e:
|
||||||
|
return {
|
||||||
|
"ok": False, "reason": "offline", "offline": True,
|
||||||
|
"error": f"Impossibile contattare il server: {e}",
|
||||||
|
}
|
||||||
|
except license_mod.LicenseError as e:
|
||||||
|
return {"ok": False, "reason": "license_invalid", "error": str(e)}
|
||||||
|
|
||||||
|
if not res.get("allowed", True):
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"reason": "quota_exceeded",
|
||||||
|
"feature": feature,
|
||||||
|
"error": res.get("error") or "Limite giornaliero raggiunto.",
|
||||||
|
"quota": res,
|
||||||
|
}
|
||||||
|
# OK: notifico la UI dello stato quota aggiornato
|
||||||
|
self._emit("quota:update", res)
|
||||||
|
return None
|
||||||
|
|
||||||
def check_update(self) -> dict:
|
def check_update(self) -> dict:
|
||||||
"""Controlla aggiornamenti interrogando l'API djluza.com.
|
"""Controlla aggiornamenti interrogando l'API djluza.com.
|
||||||
|
|
||||||
@@ -326,6 +389,10 @@ class Api:
|
|||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return {"ok": False, "error": "File non trovato"}
|
return {"ok": False, "error": "File non trovato"}
|
||||||
|
|
||||||
|
gate = self._gate("metadata")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
data = payload.get("data") or {}
|
data = payload.get("data") or {}
|
||||||
cover_path = (payload.get("cover_path") or "").strip() or None
|
cover_path = (payload.get("cover_path") or "").strip() or None
|
||||||
remove_cover = bool(payload.get("remove_cover", False))
|
remove_cover = bool(payload.get("remove_cover", False))
|
||||||
@@ -360,6 +427,10 @@ class Api:
|
|||||||
if not filename.lower().endswith(".mp3"):
|
if not filename.lower().endswith(".mp3"):
|
||||||
filename += ".mp3"
|
filename += ".mp3"
|
||||||
|
|
||||||
|
gate = self._gate("record")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
out_path = os.path.join(output_dir, filename)
|
out_path = os.path.join(output_dir, filename)
|
||||||
|
|
||||||
def cb(status, payload_evt):
|
def cb(status, payload_evt):
|
||||||
@@ -412,6 +483,10 @@ class Api:
|
|||||||
if not output_dir:
|
if not output_dir:
|
||||||
return {"ok": False, "error": "Cartella output non impostata"}
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
gate = self._gate("audio")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
self._download_thread = threading.Thread(
|
self._download_thread = threading.Thread(
|
||||||
target=self._download_worker,
|
target=self._download_worker,
|
||||||
args=(list(urls), output_dir),
|
args=(list(urls), output_dir),
|
||||||
@@ -441,6 +516,10 @@ class Api:
|
|||||||
if safe:
|
if safe:
|
||||||
output_dir = os.path.join(output_dir, safe)
|
output_dir = os.path.join(output_dir, safe)
|
||||||
|
|
||||||
|
gate = self._gate("audio")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
self._download_thread = threading.Thread(
|
self._download_thread = threading.Thread(
|
||||||
target=self._tracks_worker,
|
target=self._tracks_worker,
|
||||||
args=(list(tracks), output_dir),
|
args=(list(tracks), output_dir),
|
||||||
@@ -516,14 +595,11 @@ class Api:
|
|||||||
view = "download"
|
view = "download"
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
has_spotify = any("spotify.com" in u for u in urls)
|
|
||||||
if has_spotify:
|
|
||||||
client_id = cfg.get("client_id", "")
|
client_id = cfg.get("client_id", "")
|
||||||
client_secret = cfg.get("client_secret", "")
|
client_secret = cfg.get("client_secret", "")
|
||||||
if not client_id or not client_secret:
|
has_credentials = bool(client_id and client_secret)
|
||||||
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
|
has_spotify = any("spotify.com" in u for u in urls)
|
||||||
self._emit("download:done", {"ok": False})
|
if has_spotify and has_credentials:
|
||||||
return
|
|
||||||
self._log(view, "[INFO] Autenticazione Spotify...")
|
self._log(view, "[INFO] Autenticazione Spotify...")
|
||||||
try:
|
try:
|
||||||
token = get_access_token(client_id, client_secret)
|
token = get_access_token(client_id, client_secret)
|
||||||
@@ -531,6 +607,13 @@ class Api:
|
|||||||
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
||||||
self._emit("download:done", {"ok": False})
|
self._emit("download:done", {"ok": False})
|
||||||
return
|
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):
|
for url_idx, url in enumerate(urls):
|
||||||
if download_is_stopped():
|
if download_is_stopped():
|
||||||
@@ -590,12 +673,19 @@ class Api:
|
|||||||
if is_spotify:
|
if is_spotify:
|
||||||
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
||||||
try:
|
try:
|
||||||
|
if token:
|
||||||
label, name, tracks = resolve_spotify_url(token, url)
|
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:
|
except Exception as e:
|
||||||
self._log(view, f"[ERRORE] {e}")
|
self._log(view, f"[ERRORE] {e}")
|
||||||
continue
|
continue
|
||||||
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
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)
|
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
||||||
else:
|
else:
|
||||||
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
||||||
@@ -627,6 +717,10 @@ class Api:
|
|||||||
if not directory:
|
if not directory:
|
||||||
return {"ok": False, "error": "Cartella non impostata"}
|
return {"ok": False, "error": "Cartella non impostata"}
|
||||||
|
|
||||||
|
gate = self._gate("upgrade")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
cookies_path = cfg.get("cookies_path", "")
|
cookies_path = cfg.get("cookies_path", "")
|
||||||
|
|
||||||
@@ -700,6 +794,11 @@ class Api:
|
|||||||
if not output_dir:
|
if not output_dir:
|
||||||
return {"ok": False, "error": "Cartella output non impostata"}
|
return {"ok": False, "error": "Cartella output non impostata"}
|
||||||
|
|
||||||
|
# Anche "audio_only" video count come video: usa codec/yt-dlp diversi
|
||||||
|
gate = self._gate("video")
|
||||||
|
if gate:
|
||||||
|
return gate
|
||||||
|
|
||||||
self._video_thread = threading.Thread(
|
self._video_thread = threading.Thread(
|
||||||
target=self._video_worker,
|
target=self._video_worker,
|
||||||
args=(list(urls), output_dir, quality, audio_only),
|
args=(list(urls), output_dir, quality, audio_only),
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 207 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.6.0"
|
VERSION = "v1.7.3"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
@@ -77,6 +77,14 @@ DEFAULTS = {
|
|||||||
"license_activated_at": 0, # epoch della prima attivazione
|
"license_activated_at": 0, # epoch della prima attivazione
|
||||||
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
|
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
|
||||||
"device_id": "", # UUID generato al primo avvio
|
"device_id": "", # UUID generato al primo avvio
|
||||||
|
# ---- Piano (snapshot dei claims JWT) ----
|
||||||
|
"plan_code": "", # "basic" | "pro" | "premium" | "annual"
|
||||||
|
"plan_name": "", # nome user-facing
|
||||||
|
"plan_features": [], # ["audio", "video", "record", "metadata", "upgrade"]
|
||||||
|
"plan_daily_limit": None, # None = unlimited (annual), altrimenti int
|
||||||
|
"plan_is_subscription": False,
|
||||||
|
"plan_expires_at": 0, # solo per annual one-time
|
||||||
|
"plan_period_end": 0, # solo per subscription mensili
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import json
|
|||||||
import platform
|
import platform
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -140,12 +141,42 @@ def _http_post(path: str, body: dict) -> dict:
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# API pubblica
|
# API pubblica
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
def _plan_snapshot(cfg: dict) -> dict:
|
||||||
|
"""Estrae i dati del piano dal config (popolati da activate/validate)."""
|
||||||
|
code = (cfg.get("plan_code") or "").strip()
|
||||||
|
if not code:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"name": (cfg.get("plan_name") or "").strip() or code.title(),
|
||||||
|
"features": list(cfg.get("plan_features") or []),
|
||||||
|
"daily_limit": cfg.get("plan_daily_limit"), # puo' essere None
|
||||||
|
"is_subscription": bool(cfg.get("plan_is_subscription")),
|
||||||
|
"expires_at": int(cfg.get("plan_expires_at") or 0),
|
||||||
|
"period_end": int(cfg.get("plan_period_end") or 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def has_feature(name: str, config: Optional[dict] = None) -> bool:
|
||||||
|
"""True se il piano corrente include la feature richiesta."""
|
||||||
|
cfg = config or load_config()
|
||||||
|
plan = _plan_snapshot(cfg)
|
||||||
|
if not plan:
|
||||||
|
return False
|
||||||
|
return name in (plan.get("features") or [])
|
||||||
|
|
||||||
|
|
||||||
|
def get_plan(config: Optional[dict] = None) -> dict:
|
||||||
|
"""Ritorna il dict del piano corrente, oppure {} se non noto."""
|
||||||
|
return _plan_snapshot(config or load_config())
|
||||||
|
|
||||||
|
|
||||||
def get_status(config: Optional[dict] = None) -> dict:
|
def get_status(config: Optional[dict] = None) -> dict:
|
||||||
"""Ritorna lo stato corrente della licenza per la UI.
|
"""Ritorna lo stato corrente della licenza per la UI.
|
||||||
|
|
||||||
Chiavi: licensed (bool), reason (str), email, key, activated_at,
|
Chiavi: licensed (bool), reason (str), email, key, activated_at,
|
||||||
last_validated_at, days_since_validation, expires_soon (bool),
|
last_validated_at, days_since_validation, expires_soon (bool),
|
||||||
needs_revalidation (bool).
|
needs_revalidation (bool), plan (dict).
|
||||||
"""
|
"""
|
||||||
cfg = config or load_config()
|
cfg = config or load_config()
|
||||||
token = (cfg.get("license_token") or "").strip()
|
token = (cfg.get("license_token") or "").strip()
|
||||||
@@ -153,6 +184,7 @@ def get_status(config: Optional[dict] = None) -> dict:
|
|||||||
email = (cfg.get("license_email") or "").strip()
|
email = (cfg.get("license_email") or "").strip()
|
||||||
activated = int(cfg.get("license_activated_at") or 0)
|
activated = int(cfg.get("license_activated_at") or 0)
|
||||||
last_val = int(cfg.get("last_validated_at") or 0)
|
last_val = int(cfg.get("last_validated_at") or 0)
|
||||||
|
plan = _plan_snapshot(cfg)
|
||||||
|
|
||||||
if not token or not key:
|
if not token or not key:
|
||||||
return {
|
return {
|
||||||
@@ -164,9 +196,27 @@ def get_status(config: Optional[dict] = None) -> dict:
|
|||||||
"last_validated_at": 0,
|
"last_validated_at": 0,
|
||||||
"days_since_validation": 0,
|
"days_since_validation": 0,
|
||||||
"needs_revalidation": False,
|
"needs_revalidation": False,
|
||||||
|
"plan": plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
days_since = (_now() - last_val) // 86400 if last_val else 999
|
# Scadenza locale derivata dai claims (best-effort: la verita' e' sul server).
|
||||||
|
t = _now()
|
||||||
|
expires_at = plan.get("expires_at") or 0
|
||||||
|
period_end = plan.get("period_end") or 0
|
||||||
|
if expires_at and expires_at < t:
|
||||||
|
return {
|
||||||
|
"licensed": False, "reason": "plan_expired", "email": email, "key": key,
|
||||||
|
"activated_at": activated, "last_validated_at": last_val,
|
||||||
|
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
|
||||||
|
}
|
||||||
|
if period_end and period_end < t:
|
||||||
|
return {
|
||||||
|
"licensed": False, "reason": "subscription_expired", "email": email, "key": key,
|
||||||
|
"activated_at": activated, "last_validated_at": last_val,
|
||||||
|
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
days_since = (t - last_val) // 86400 if last_val else 999
|
||||||
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
|
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
|
||||||
grace_expired = days_since >= LICENSE_GRACE_DAYS
|
grace_expired = days_since >= LICENSE_GRACE_DAYS
|
||||||
|
|
||||||
@@ -180,6 +230,7 @@ def get_status(config: Optional[dict] = None) -> dict:
|
|||||||
"last_validated_at": last_val,
|
"last_validated_at": last_val,
|
||||||
"days_since_validation": days_since,
|
"days_since_validation": days_since,
|
||||||
"needs_revalidation": True,
|
"needs_revalidation": True,
|
||||||
|
"plan": plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -191,6 +242,7 @@ def get_status(config: Optional[dict] = None) -> dict:
|
|||||||
"last_validated_at": last_val,
|
"last_validated_at": last_val,
|
||||||
"days_since_validation": days_since,
|
"days_since_validation": days_since,
|
||||||
"needs_revalidation": needs_reval,
|
"needs_revalidation": needs_reval,
|
||||||
|
"plan": plan,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -199,6 +251,55 @@ def is_licensed() -> bool:
|
|||||||
return get_status()["licensed"]
|
return get_status()["licensed"]
|
||||||
|
|
||||||
|
|
||||||
|
def _store_plan_from_response(cfg: dict, resp: dict, token: str) -> None:
|
||||||
|
"""Aggiorna il config con i campi del piano estratti dalla risposta API.
|
||||||
|
|
||||||
|
Server risposta puo' contenere:
|
||||||
|
- plan: {code, name, daily_limit, features, is_subscription}
|
||||||
|
- expires_at: epoch (annual)
|
||||||
|
- period_end: epoch (subscription)
|
||||||
|
Come fallback decodifica i claims dal JWT.
|
||||||
|
"""
|
||||||
|
plan = resp.get("plan") if isinstance(resp.get("plan"), dict) else None
|
||||||
|
claims = _decode_jwt_claims(token) if token else {}
|
||||||
|
|
||||||
|
code = ""
|
||||||
|
name = ""
|
||||||
|
features = []
|
||||||
|
daily_limit = None
|
||||||
|
is_sub = False
|
||||||
|
if plan:
|
||||||
|
code = (plan.get("code") or "").strip()
|
||||||
|
name = (plan.get("name") or "").strip()
|
||||||
|
features = list(plan.get("features") or [])
|
||||||
|
daily_limit = plan.get("daily_limit")
|
||||||
|
is_sub = bool(plan.get("is_subscription"))
|
||||||
|
else:
|
||||||
|
code = (claims.get("plan") or "").strip()
|
||||||
|
name = (claims.get("plan_name") or "").strip()
|
||||||
|
features = list(claims.get("features") or [])
|
||||||
|
daily_limit = claims.get("daily_limit")
|
||||||
|
is_sub = bool(claims.get("is_subscription"))
|
||||||
|
|
||||||
|
cfg["plan_code"] = code
|
||||||
|
cfg["plan_name"] = name
|
||||||
|
cfg["plan_features"] = features
|
||||||
|
cfg["plan_daily_limit"] = daily_limit # None ammesso (unlimited)
|
||||||
|
cfg["plan_is_subscription"] = is_sub
|
||||||
|
cfg["plan_expires_at"] = int(resp.get("expires_at") or claims.get("expires_at") or 0)
|
||||||
|
cfg["plan_period_end"] = int(resp.get("period_end") or claims.get("period_end") or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_plan(cfg: dict) -> None:
|
||||||
|
cfg["plan_code"] = ""
|
||||||
|
cfg["plan_name"] = ""
|
||||||
|
cfg["plan_features"] = []
|
||||||
|
cfg["plan_daily_limit"] = None
|
||||||
|
cfg["plan_is_subscription"] = False
|
||||||
|
cfg["plan_expires_at"] = 0
|
||||||
|
cfg["plan_period_end"] = 0
|
||||||
|
|
||||||
|
|
||||||
def activate(license_key: str, email: str) -> dict:
|
def activate(license_key: str, email: str) -> dict:
|
||||||
"""Attiva una licenza contro il server. Salva token su success.
|
"""Attiva una licenza contro il server. Salva token su success.
|
||||||
|
|
||||||
@@ -231,6 +332,7 @@ def activate(license_key: str, email: str) -> dict:
|
|||||||
cfg["license_token"] = token
|
cfg["license_token"] = token
|
||||||
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
|
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
|
||||||
cfg["last_validated_at"] = now
|
cfg["last_validated_at"] = now
|
||||||
|
_store_plan_from_response(cfg, resp, token)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
return get_status(cfg)
|
return get_status(cfg)
|
||||||
|
|
||||||
@@ -260,6 +362,7 @@ def validate() -> dict:
|
|||||||
# Server ha risposto 4xx -> token non piu valido
|
# Server ha risposto 4xx -> token non piu valido
|
||||||
cfg["license_token"] = ""
|
cfg["license_token"] = ""
|
||||||
cfg["last_validated_at"] = 0
|
cfg["last_validated_at"] = 0
|
||||||
|
_clear_plan(cfg)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
return get_status(cfg)
|
return get_status(cfg)
|
||||||
|
|
||||||
@@ -268,6 +371,7 @@ def validate() -> dict:
|
|||||||
if new_token:
|
if new_token:
|
||||||
cfg["license_token"] = new_token
|
cfg["license_token"] = new_token
|
||||||
cfg["last_validated_at"] = _now()
|
cfg["last_validated_at"] = _now()
|
||||||
|
_store_plan_from_response(cfg, resp, cfg.get("license_token", ""))
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
return get_status(cfg)
|
return get_status(cfg)
|
||||||
|
|
||||||
@@ -298,5 +402,86 @@ def deactivate(release_remote: bool = True) -> dict:
|
|||||||
cfg["license_token"] = ""
|
cfg["license_token"] = ""
|
||||||
cfg["license_activated_at"] = 0
|
cfg["license_activated_at"] = 0
|
||||||
cfg["last_validated_at"] = 0
|
cfg["last_validated_at"] = 0
|
||||||
|
_clear_plan(cfg)
|
||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
return get_status(cfg)
|
return get_status(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Quota giornaliera
|
||||||
|
# ============================================================
|
||||||
|
def _http_json(method: str, path: str, body: Optional[dict] = None,
|
||||||
|
token: Optional[str] = None, timeout: int = 10) -> dict:
|
||||||
|
"""Helper unificato per GET/POST con Authorization Bearer."""
|
||||||
|
url = LICENSE_API_URL.rstrip("/") + path
|
||||||
|
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||||
|
headers = {
|
||||||
|
"User-Agent": f"MusicTools/{VERSION}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if data is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode("utf-8")
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
err_body = e.read().decode("utf-8")
|
||||||
|
err_data = json.loads(err_body) if err_body else {}
|
||||||
|
except Exception:
|
||||||
|
err_data = {}
|
||||||
|
err_data.setdefault("error", e.reason or f"HTTP {e.code}")
|
||||||
|
err_data["_status"] = e.code
|
||||||
|
return err_data
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
|
||||||
|
except (TimeoutError, OSError) as e:
|
||||||
|
raise LicenseNetworkError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def get_quota_status() -> dict:
|
||||||
|
"""Legge lo stato quota dal server. Ritorna dict con plan/used/limit/remaining.
|
||||||
|
|
||||||
|
Solleva LicenseError se il server rifiuta (401/403); LicenseNetworkError
|
||||||
|
in caso di problemi di rete.
|
||||||
|
"""
|
||||||
|
cfg = load_config()
|
||||||
|
token = (cfg.get("license_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise LicenseError("Licenza non attivata.")
|
||||||
|
resp = _http_json("GET", "/api/usage/status", token=token)
|
||||||
|
if resp.get("_status") and resp["_status"] >= 400:
|
||||||
|
raise LicenseError(resp.get("error") or "Errore stato quota.")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def consume_quota(feature: str = "") -> dict:
|
||||||
|
"""Incrementa la quota giornaliera prima di un download.
|
||||||
|
|
||||||
|
Ritorna {allowed, used, limit, remaining, plan, day}.
|
||||||
|
Se il server risponde 429 -> allowed=False (l'utente ha raggiunto il limite).
|
||||||
|
Per i piani unlimited (annual), allowed=True sempre.
|
||||||
|
Solleva LicenseError per 401/403; LicenseNetworkError per problemi di rete.
|
||||||
|
"""
|
||||||
|
cfg = load_config()
|
||||||
|
token = (cfg.get("license_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise LicenseError("Licenza non attivata.")
|
||||||
|
body = {"feature": feature} if feature else {}
|
||||||
|
resp = _http_json("POST", "/api/usage/consume", body=body, token=token)
|
||||||
|
status_code = resp.pop("_status", 0)
|
||||||
|
if status_code == 401 or status_code == 403:
|
||||||
|
raise LicenseError(resp.get("error") or "Licenza non valida.")
|
||||||
|
if status_code == 429:
|
||||||
|
# Limite raggiunto, ma e' una risposta strutturata: ritorniamo
|
||||||
|
# il dict cosi' la UI puo' mostrare i numeri.
|
||||||
|
resp.setdefault("allowed", False)
|
||||||
|
return resp
|
||||||
|
if status_code and status_code >= 400:
|
||||||
|
raise LicenseError(resp.get("error") or f"Errore quota ({status_code}).")
|
||||||
|
resp.setdefault("allowed", True)
|
||||||
|
return resp
|
||||||
@@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
|
|||||||
else:
|
else:
|
||||||
name, tracks = get_playlist_info(token, item_id)
|
name, tracks = get_playlist_info(token, item_id)
|
||||||
return "Playlist", name, tracks
|
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 = {
|
||||||
|
"&": "&", "'": "'", "'": "'",
|
||||||
|
""": '"', "<": "<", ">": ">",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
@@ -555,6 +555,115 @@ a { color: inherit; text-decoration: none; }
|
|||||||
margin: 14px 0 0;
|
margin: 14px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Grid 4 colonne per i piani */
|
||||||
|
.price-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 32px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.price-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.price-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-card-sm {
|
||||||
|
margin: 0;
|
||||||
|
max-width: none;
|
||||||
|
padding: 32px 22px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.price-card-sm .price-amount { font-size: 44px; }
|
||||||
|
.price-card-sm .price-currency { font-size: 20px; }
|
||||||
|
.price-card-sm .price-once { font-size: 13px; }
|
||||||
|
.price-card-sm .price-features { margin-bottom: 22px; flex: 1; }
|
||||||
|
.price-card-sm .price-features li { padding: 7px 0; font-size: 13.5px; }
|
||||||
|
|
||||||
|
.price-card-featured {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 18px 48px rgba(29, 185, 84, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-badge-gold {
|
||||||
|
background: linear-gradient(135deg, #f8c44a, #f4a51c);
|
||||||
|
color: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-shared {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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
|
FAQ
|
||||||
============================================================ */
|
============================================================ */
|
||||||
@@ -775,3 +884,31 @@ a { color: inherit; text-decoration: none; }
|
|||||||
}
|
}
|
||||||
.price-consent a { color: var(--text-muted); text-decoration: underline; }
|
.price-consent a { color: var(--text-muted); text-decoration: underline; }
|
||||||
.price-consent a:hover { color: var(--accent); }
|
.price-consent a:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
"Hai gia acquistato? Scarica" — link discreti
|
||||||
|
============================================================ */
|
||||||
|
.hero-customer {
|
||||||
|
margin: 18px 0 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-customer a {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.hero-customer a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.price-already {
|
||||||
|
margin: 10px 0 0 !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
.price-already a {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.price-already a:hover { text-decoration: underline; }
|
||||||
@@ -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>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li>
|
||||||
|
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li>
|
||||||
|
<li>Vai in Applicazioni, <strong>tasto destro</strong> su MusicTools → <strong>Apri</strong> → <strong>Apri</strong> (solo la prima volta, e' la procedura standard per app non firmate)</li>
|
||||||
|
<li>Inserisci email e chiave per attivare</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-steps">
|
||||||
|
<h2>Installazione Windows</h2>
|
||||||
|
<ol>
|
||||||
|
<li>Estrai il file <code>.zip</code> in una cartella di tua scelta</li>
|
||||||
|
<li>Doppio click su <strong>MusicTools.exe</strong></li>
|
||||||
|
<li>Se Windows mostra "Windows ha protetto il PC": click su <strong>Ulteriori informazioni</strong> → <strong>Esegui comunque</strong></li>
|
||||||
|
<li>Inserisci email e chiave per attivare</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dl-support">
|
||||||
|
Problemi con l'installazione o non hai ricevuto l'email con la chiave?<br>
|
||||||
|
Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> — risposta entro 24 ore.
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function getParam(name) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
return url.searchParams.get(name) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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>
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
|
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
|
||||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Una licenza a vita, 39,90 €." />
|
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Piani da 2,99 €/mese." />
|
||||||
|
|
||||||
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
|
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
|
||||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Licenza a vita 39,90 €." />
|
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Piani da 2,99 €/mese o 49,90 €/anno una tantum." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://musictools.djluza.com" />
|
<meta property="og:url" content="https://musictools.djluza.com" />
|
||||||
|
|
||||||
@@ -26,10 +26,11 @@
|
|||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a href="#features">Funzioni</a>
|
<a href="#features">Funzioni</a>
|
||||||
<a href="#how">Come funziona</a>
|
<a href="#how">Come funziona</a>
|
||||||
<a href="#pricing">Prezzo</a>
|
<a href="#pricing">Piani</a>
|
||||||
<a href="#faq">FAQ</a>
|
<a href="#faq">FAQ</a>
|
||||||
|
<a href="/download">Download</a>
|
||||||
</nav>
|
</nav>
|
||||||
<a href="#pricing" class="btn btn-primary btn-sm">Compra — 39,90 €</a>
|
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
<section class="hero" id="top">
|
<section class="hero" id="top">
|
||||||
<div class="container hero-grid">
|
<div class="container hero-grid">
|
||||||
<div class="hero-text">
|
<div class="hero-text">
|
||||||
<span class="eyebrow">Per Mac e Windows · Licenza a vita</span>
|
<span class="eyebrow">Per Mac e Windows · Da 2,99 €/mese</span>
|
||||||
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
|
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
|
||||||
<p class="lead">
|
<p class="lead">
|
||||||
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
|
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
|
||||||
@@ -45,14 +46,18 @@
|
|||||||
Modifica i metadati e le copertine. Tutto offline, senza account.
|
Modifica i metadati e le copertine. Tutto offline, senza account.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-cta">
|
<div class="hero-cta">
|
||||||
<a href="#pricing" class="btn btn-primary btn-lg">Compra ora — 39,90 €</a>
|
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||||
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
|
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
|
||||||
</div>
|
</div>
|
||||||
<ul class="hero-bullets">
|
<ul class="hero-bullets">
|
||||||
<li>✓ Acquisto una tantum, nessun abbonamento</li>
|
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
|
||||||
<li>✓ Aggiornamenti gratuiti a vita</li>
|
<li>✓ Aggiornamenti gratuiti inclusi</li>
|
||||||
<li>✓ Funziona offline · Su 3 dispositivi</li>
|
<li>✓ Funziona offline · Su 3 dispositivi</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<p class="hero-customer">
|
||||||
|
Hai già acquistato? <a href="/download">Scarica l'app →</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="hero-art" aria-hidden="true">
|
<div class="hero-art" aria-hidden="true">
|
||||||
@@ -170,39 +175,146 @@
|
|||||||
<!-- ============== PRICING ============== -->
|
<!-- ============== PRICING ============== -->
|
||||||
<section class="section pricing" id="pricing">
|
<section class="section pricing" id="pricing">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<span class="eyebrow center">PREZZO</span>
|
<span class="eyebrow center">PIANI</span>
|
||||||
<h2 class="section-title">Una licenza. Per sempre.</h2>
|
<h2 class="section-title">Scegli il piano giusto per te</h2>
|
||||||
<p class="section-lead">Niente abbonamenti. Niente sorprese. Compri una volta, la usi per sempre.</p>
|
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
|
||||||
|
|
||||||
<div class="price-card">
|
<div class="price-grid">
|
||||||
<div class="price-badge">Più popolare</div>
|
|
||||||
<div class="price-name">MusicTools Personal</div>
|
<!-- BASIC -->
|
||||||
|
<div class="price-card price-card-sm">
|
||||||
|
<div class="price-name">Basic</div>
|
||||||
<div class="price-row">
|
<div class="price-row">
|
||||||
<span class="price-amount">39,90</span>
|
<span class="price-amount">2,99</span>
|
||||||
<span class="price-currency">€</span>
|
<span class="price-currency">€</span>
|
||||||
<span class="price-once">una tantum</span>
|
<span class="price-once">/mese</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="price-features">
|
<ul class="price-features">
|
||||||
<li>✓ <strong>Licenza a vita</strong> — paghi una volta, mai più</li>
|
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
|
||||||
<li>✓ <strong>3 dispositivi</strong> — Mac e Windows mixati</li>
|
<li>✓ <strong>Fino a 10 al giorno</strong></li>
|
||||||
<li>✓ <strong>Aggiornamenti gratuiti</strong> per tutte le versioni future</li>
|
<li>✓ MP3 fino a 320kbps</li>
|
||||||
<li>✓ <strong>Tutte le 7 tab</strong> sbloccate</li>
|
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||||
<li>✓ <strong>Supporto via email</strong> a info@djluza.com</li>
|
<li>✗ Download video</li>
|
||||||
<li>✓ <strong>Garanzia 14 giorni</strong> soddisfatti o rimborsati</li>
|
<li>✗ Registrazione, metadati, upgrade libreria</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="#"
|
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
|
||||||
class="btn btn-primary btn-lg btn-block"
|
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||||
data-lemonsqueezy-url="REPLACE_WITH_LS_PRODUCT_URL"
|
Inizia con Basic
|
||||||
id="buyBtn">
|
|
||||||
Acquista ora — 39,90 €
|
|
||||||
</a>
|
</a>
|
||||||
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa</p>
|
</div>
|
||||||
|
|
||||||
|
<!-- PRO -->
|
||||||
|
<div class="price-card price-card-sm">
|
||||||
|
<div class="price-badge">Più scelto</div>
|
||||||
|
<div class="price-name">Pro</div>
|
||||||
|
<div class="price-row">
|
||||||
|
<span class="price-amount">5,99</span>
|
||||||
|
<span class="price-currency">€</span>
|
||||||
|
<span class="price-once">/mese</span>
|
||||||
|
</div>
|
||||||
|
<ul class="price-features">
|
||||||
|
<li>✓ <strong>Tutto di Basic</strong></li>
|
||||||
|
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
|
||||||
|
<li>✓ <strong>Fino a 30 al giorno</strong></li>
|
||||||
|
<li>✓ Registrazione audio (BlackHole / loopback)</li>
|
||||||
|
<li>✓ Editor metadati + copertine</li>
|
||||||
|
<li>✓ Upgrade qualità libreria</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
|
||||||
|
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
|
||||||
|
Scegli Pro
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PREMIUM -->
|
||||||
|
<div class="price-card price-card-sm">
|
||||||
|
<div class="price-name">Premium</div>
|
||||||
|
<div class="price-row">
|
||||||
|
<span class="price-amount">9,99</span>
|
||||||
|
<span class="price-currency">€</span>
|
||||||
|
<span class="price-once">/mese</span>
|
||||||
|
</div>
|
||||||
|
<ul class="price-features">
|
||||||
|
<li>✓ <strong>Tutto di Pro</strong></li>
|
||||||
|
<li>✓ <strong>Fino a 150 al giorno</strong></li>
|
||||||
|
<li>✓ Priorità nel supporto via email</li>
|
||||||
|
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
|
||||||
|
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||||
|
Passa a Premium
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ANNUAL ONE-TIME -->
|
||||||
|
<div class="price-card price-card-sm price-card-featured">
|
||||||
|
<div class="price-badge price-badge-gold">Miglior valore</div>
|
||||||
|
<div class="price-name">Annual</div>
|
||||||
|
<div class="price-row">
|
||||||
|
<span class="price-amount">49,90</span>
|
||||||
|
<span class="price-currency">€</span>
|
||||||
|
<span class="price-once">/anno</span>
|
||||||
|
</div>
|
||||||
|
<ul class="price-features">
|
||||||
|
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
|
||||||
|
<li>✓ <strong>Nessun limite giornaliero</strong></li>
|
||||||
|
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
|
||||||
|
<li>✓ Una tantum, niente rinnovo automatico</li>
|
||||||
|
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||||
|
</ul>
|
||||||
|
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
|
||||||
|
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
|
||||||
|
id="buyBtn">
|
||||||
|
Acquista — 49,90 €
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="price-shared">
|
||||||
|
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
|
||||||
<p class="price-consent">
|
<p class="price-consent">
|
||||||
Procedendo con l'acquisto dichiari di aver letto e di accettare i
|
Procedendo con l'acquisto dichiari di aver letto e di accettare i
|
||||||
<a href="/legal/terms.html">Termini di servizio</a>,
|
<a href="/legal/terms.html">Termini di servizio</a>,
|
||||||
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
|
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
|
||||||
<a href="/legal/refund.html">Politica rimborsi</a>.
|
<a href="/legal/refund.html">Politica rimborsi</a>.
|
||||||
</p>
|
</p>
|
||||||
|
<p class="price-already">
|
||||||
|
Hai già una licenza? <a href="/download">Scarica l'app →</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div 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>
|
||||||
|
|
||||||
<div class="use-policy">
|
<div class="use-policy">
|
||||||
@@ -240,12 +352,27 @@
|
|||||||
|
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Su quanti dispositivi posso installarla?</summary>
|
<summary>Su quanti dispositivi posso installarla?</summary>
|
||||||
<p>Una licenza copre fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
<p>Tutti i piani coprono fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Come funziona il limite giornaliero?</summary>
|
||||||
|
<p>Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a <strong>mezzanotte ora di Roma</strong>. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Posso passare a un piano superiore?</summary>
|
||||||
|
<p>Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Cosa succede quando scade l'abbonamento annuale?</summary>
|
||||||
|
<p>Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Come funzionano gli aggiornamenti?</summary>
|
<summary>Come funzionano gli aggiornamenti?</summary>
|
||||||
<p>Sono <strong>gratis a vita</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
<p>Sono <strong>inclusi in tutti i piani</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
@@ -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>
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<details class="faq-item">
|
||||||
<summary>Posso registrare l'audio di Spotify mentre suona?</summary>
|
<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>
|
<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">
|
<section class="section cta-final">
|
||||||
<div class="container cta-box">
|
<div class="container cta-box">
|
||||||
<h2>Pronto a semplificarti la vita?</h2>
|
<h2>Pronto a semplificarti la vita?</h2>
|
||||||
<p>Una sola app per tutto quello che fai con la musica.</p>
|
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
|
||||||
<a href="#pricing" class="btn btn-primary btn-lg">Compra ora — 39,90 €</a>
|
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -314,6 +457,7 @@
|
|||||||
<span class="footer-by">by LuZa</span>
|
<span class="footer-by">by LuZa</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="footer-links">
|
<nav class="footer-links">
|
||||||
|
<a href="/download">Download</a>
|
||||||
<a href="mailto:info@djluza.com">Supporto</a>
|
<a href="mailto:info@djluza.com">Supporto</a>
|
||||||
<a href="/legal/terms.html">Termini</a>
|
<a href="/legal/terms.html">Termini</a>
|
||||||
<a href="/legal/privacy.html">Privacy</a>
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
|||||||
@@ -8,25 +8,9 @@
|
|||||||
if (el) el.textContent = String(new Date().getFullYear());
|
if (el) el.textContent = String(new Date().getFullYear());
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// Lemon Squeezy overlay
|
// Lemon Squeezy overlay: lemon.js intercetta automaticamente i link con
|
||||||
// Lo script di Lemon (lemon.js) intercetta automaticamente i link con
|
// data-lemonsqueezy-url e apre l'overlay di checkout. Niente codice nostro
|
||||||
// data-lemonsqueezy-url e apre l'overlay. Qui ci limitiamo a impedire
|
// necessario qui — basta che lemon.js sia caricato (vedi <script> in index.html).
|
||||||
// che il click navighi via se l'URL non e' ancora stato configurato.
|
|
||||||
document.querySelectorAll("[data-lemonsqueezy-url]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (e) => {
|
|
||||||
const url = el.getAttribute("data-lemonsqueezy-url");
|
|
||||||
if (!url || url === "REPLACE_WITH_LS_PRODUCT_URL") {
|
|
||||||
e.preventDefault();
|
|
||||||
alert(
|
|
||||||
"Il prodotto Lemon Squeezy non e' ancora configurato.\n" +
|
|
||||||
"Crea il prodotto su Lemon Squeezy e incolla l'URL dentro " +
|
|
||||||
"landing/index.html in data-lemonsqueezy-url."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Se la pagina e' aperta via file:// lemon.js non riesce a inizializzare:
|
|
||||||
// in quel caso lasciamo che il link navighi via.
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Header che diventa piu' opaco mentre scorri (effetto subtle)
|
// Header che diventa piu' opaco mentre scorri (effetto subtle)
|
||||||
const nav = document.querySelector(".nav");
|
const nav = document.querySelector(".nav");
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
ALTER TABLE licenses
|
||||||
|
ADD COLUMN plan VARCHAR(16) NULL AFTER status,
|
||||||
|
ADD COLUMN daily_limit INT UNSIGNED NULL AFTER plan,
|
||||||
|
ADD COLUMN subscription_id VARCHAR(64) NULL AFTER daily_limit,
|
||||||
|
ADD COLUMN current_period_end BIGINT UNSIGNED NULL AFTER subscription_id,
|
||||||
|
ADD COLUMN expires_at BIGINT UNSIGNED NULL AFTER current_period_end;
|
||||||
|
|
||||||
|
UPDATE licenses
|
||||||
|
SET plan = 'annual',
|
||||||
|
daily_limit = NULL,
|
||||||
|
expires_at = NULL
|
||||||
|
WHERE plan IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE licenses
|
||||||
|
ADD KEY idx_licenses_plan (plan),
|
||||||
|
ADD KEY idx_licenses_sub (subscription_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||||
|
license_id INT UNSIGNED NOT NULL,
|
||||||
|
day CHAR(10) NOT NULL,
|
||||||
|
count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
updated_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (license_id, day),
|
||||||
|
CONSTRAINT fk_daily_usage_license
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,41 +1,125 @@
|
|||||||
/**
|
/**
|
||||||
* Invio email transazionali via Resend (https://resend.com).
|
* Invio email transazionali via Resend.
|
||||||
* Usa fetch nativo di Node >= 20 — nessuna dipendenza.
|
*
|
||||||
|
* sendLicenseEmail(to, key, { plan, expiresAt, periodEnd })
|
||||||
|
* - plan: codice piano ("basic" | "pro" | "premium" | "annual")
|
||||||
|
* - expiresAt: epoch seconds (annual one-time) o null
|
||||||
|
* - periodEnd: epoch seconds (subscription mensile) o null
|
||||||
*
|
*
|
||||||
* Variabili env richieste:
|
* Variabili env richieste:
|
||||||
* RESEND_API_KEY dal dashboard Resend
|
* RESEND_API_KEY dal dashboard Resend
|
||||||
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
|
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
import { getPlan } from "./plans.js";
|
||||||
|
|
||||||
export async function sendLicenseEmail(to, licenseKey) {
|
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
||||||
|
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||||
|
|
||||||
|
function fmtItalianDate(epochSeconds) {
|
||||||
|
if (!epochSeconds) return "";
|
||||||
|
const d = new Date(Number(epochSeconds) * 1000);
|
||||||
|
return new Intl.DateTimeFormat("it-IT", {
|
||||||
|
timeZone: "Europe/Rome",
|
||||||
|
day: "2-digit",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describePlan(planCode, expiresAt, periodEnd) {
|
||||||
|
const p = getPlan(planCode);
|
||||||
|
if (!p) {
|
||||||
|
return {
|
||||||
|
name: "MusicTools",
|
||||||
|
summary: "La tua licenza e' attiva.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const features = {
|
||||||
|
audio: "download brani audio",
|
||||||
|
video: "download video",
|
||||||
|
record: "registrazione audio",
|
||||||
|
metadata: "editor metadati",
|
||||||
|
upgrade: "upgrade qualita' libreria",
|
||||||
|
};
|
||||||
|
const featList = p.features.map((f) => features[f] || f).join(", ");
|
||||||
|
const limit = p.daily_limit === null
|
||||||
|
? "senza limiti giornalieri"
|
||||||
|
: `fino a ${p.daily_limit} download al giorno`;
|
||||||
|
let validity = "";
|
||||||
|
if (planCode === "annual" && expiresAt) {
|
||||||
|
validity = `Validita': 1 anno, scade il <strong>${fmtItalianDate(expiresAt)}</strong>.`;
|
||||||
|
} else if (p.is_subscription && periodEnd) {
|
||||||
|
validity = `Prossimo rinnovo: <strong>${fmtItalianDate(periodEnd)}</strong>.`;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: p.name,
|
||||||
|
summary: `Piano <strong>${p.name}</strong> — ${limit}. Funzioni incluse: ${featList}. ${validity}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendLicenseEmail(to, licenseKey, opts = {}) {
|
||||||
if (!process.env.RESEND_API_KEY) {
|
if (!process.env.RESEND_API_KEY) {
|
||||||
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { plan: planCode, expiresAt, periodEnd } = opts;
|
||||||
|
const planInfo = describePlan(planCode, expiresAt, periodEnd);
|
||||||
|
|
||||||
|
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
|
||||||
|
const macUrl = `${BASE}/api/download/macos?${q}`;
|
||||||
|
const winUrl = `${BASE}/api/download/windows?${q}`;
|
||||||
|
|
||||||
const html = `
|
const html = `
|
||||||
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
||||||
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
|
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
|
||||||
|
<p style="font-size:15px;line-height:1.55;margin:0 0 14px">
|
||||||
|
${planInfo.summary}
|
||||||
|
</p>
|
||||||
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
|
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
|
||||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||||
</p>
|
</p>
|
||||||
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
|
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
|
||||||
${licenseKey}
|
${licenseKey}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size:15px;margin:0 0 12px"><strong>Scarica l'app:</strong></p>
|
||||||
|
<table cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px">
|
||||||
|
<tr>
|
||||||
|
<td style="padding-right:10px">
|
||||||
|
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||||
|
Scarica per Mac
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||||
|
Scarica per Windows
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
|
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
|
||||||
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
|
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
|
||||||
<li>Scarica MusicTools per <a href="https://musictools.djluza.com#pricing">Mac o Windows</a></li>
|
<li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
|
||||||
<li>Apri l'app: alla prima schermata ti chiedera' email e chiave</li>
|
<li>Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)</li>
|
||||||
<li>Inserisci questa email (<code>${to}</code>) e la chiave qui sopra</li>
|
<li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
|
||||||
|
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
|
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
|
||||||
|
<p style="font-size:13px;color:#888;margin:8px 0 0">
|
||||||
|
Hai bisogno di riscaricare in futuro? Vai su <a href="${BASE}/download" style="color:#1db954">${BASE.replace(/^https?:\/\//, "")}/download</a> e inserisci email + chiave.
|
||||||
|
</p>
|
||||||
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
|
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
|
||||||
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
|
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const subject = planInfo.name === "MusicTools"
|
||||||
|
? "La tua licenza MusicTools"
|
||||||
|
: `La tua licenza MusicTools ${planInfo.name}`;
|
||||||
|
|
||||||
const resp = await fetch("https://api.resend.com/emails", {
|
const resp = await fetch("https://api.resend.com/emails", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
@@ -45,7 +129,7 @@ export async function sendLicenseEmail(to, licenseKey) {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to: [to],
|
to: [to],
|
||||||
subject: "La tua licenza MusicTools",
|
subject,
|
||||||
html,
|
html,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Webhook Lemon Squeezy.
|
* Webhook Lemon Squeezy.
|
||||||
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||||
* Eventi gestiti: order_created, order_refunded.
|
*
|
||||||
|
* Eventi gestiti:
|
||||||
|
* - order_created : crea licenza ANNUAL (one-time) con expires_at = now+365d.
|
||||||
|
* Per le subscription ignora: il record nasce da subscription_created.
|
||||||
|
* - order_refunded : status -> refunded.
|
||||||
|
* - subscription_created : crea licenza con plan, subscription_id, current_period_end.
|
||||||
|
* - subscription_updated : aggiorna current_period_end e plan se cambiato.
|
||||||
|
* - subscription_payment_success : estende current_period_end al nuovo renews_at.
|
||||||
|
* - subscription_payment_failed : (no-op qui) lasciamo scadere current_period_end.
|
||||||
|
* - subscription_cancelled : niente, il periodo corrente resta valido fino a current_period_end.
|
||||||
|
* - subscription_expired : status -> revoked, l'utente perde l'accesso.
|
||||||
|
*
|
||||||
|
* Tutto idempotente: ogni evento si puo' rispedire senza spaccare nulla.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { one, exec } from "./db.js";
|
import { one, exec } from "./db.js";
|
||||||
import { generateLicenseKey } from "./license.js";
|
import { generateLicenseKey } from "./license.js";
|
||||||
import { sendLicenseEmail } from "./email.js";
|
import { sendLicenseEmail } from "./email.js";
|
||||||
|
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
|
||||||
|
|
||||||
const now = () => Math.floor(Date.now() / 1000);
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
@@ -21,8 +34,210 @@ function timingSafe(a, b) {
|
|||||||
return crypto.timingSafeEqual(A, B);
|
return crypto.timingSafeEqual(A, B);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Converte una data ISO (es. "2026-07-09T13:24:00Z") in epoch seconds, o null. */
|
||||||
|
function isoToEpoch(iso) {
|
||||||
|
if (!iso) return null;
|
||||||
|
const t = Date.parse(String(iso));
|
||||||
|
if (Number.isNaN(t)) return null;
|
||||||
|
return Math.floor(t / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estrae variant_id dal payload, gestendo le diverse forme degli eventi. */
|
||||||
|
function extractVariantId(payload, attrs) {
|
||||||
|
// subscription_*: attrs.variant_id (diretto)
|
||||||
|
if (attrs.variant_id != null) return String(attrs.variant_id);
|
||||||
|
// order_created: attrs.first_order_item.variant_id
|
||||||
|
const item = attrs.first_order_item;
|
||||||
|
if (item && item.variant_id != null) return String(item.variant_id);
|
||||||
|
// alcune forme passano da relationships
|
||||||
|
const rel = payload?.data?.relationships?.variant?.data?.id;
|
||||||
|
if (rel) return String(rel);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Email cliente: nei subscription_* puo' essere customer_email. */
|
||||||
|
function extractEmail(attrs) {
|
||||||
|
const e = attrs.user_email || attrs.customer_email || attrs.email || "";
|
||||||
|
return String(e).trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Handlers per famiglia evento -----------------------------------------
|
||||||
|
|
||||||
|
async function handleOrderCreated(payload, attrs, t) {
|
||||||
|
const email = extractEmail(attrs);
|
||||||
|
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||||
|
if (!email || !orderId) {
|
||||||
|
return { status: 400, body: { error: "Missing email or order_id" } };
|
||||||
|
}
|
||||||
|
const variantId = extractVariantId(payload, attrs);
|
||||||
|
const planCode = planByVariantId(variantId);
|
||||||
|
const plan = planCode ? getPlan(planCode) : null;
|
||||||
|
|
||||||
|
// Subscriptions: il record vero arriva con subscription_created.
|
||||||
|
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
|
||||||
|
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
|
||||||
|
if (plan && plan.is_subscription) {
|
||||||
|
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annual one-time (o piano non mappato -> trattalo come annual di default
|
||||||
|
// cosi' non lasciamo l'utente senza licenza se ha pagato).
|
||||||
|
const planForRecord = plan?.code || "annual";
|
||||||
|
const limit = plan?.daily_limit ?? null;
|
||||||
|
const expiresAt = t + ANNUAL_TTL_SECONDS;
|
||||||
|
const key = generateLicenseKey();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exec(
|
||||||
|
`INSERT INTO licenses
|
||||||
|
(license_key, email, status, plan, daily_limit,
|
||||||
|
source, order_id, expires_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?)`,
|
||||||
|
[key, email, planForRecord, limit, orderId, expiresAt, t, t],
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === "ER_DUP_ENTRY") {
|
||||||
|
return { status: 200, body: { ok: true, duplicate: true } };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[email] send failed:", e?.message || e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 200, body: { ok: true, plan: planForRecord } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOrderRefunded(payload, attrs, t) {
|
||||||
|
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||||
|
if (!orderId) return { status: 400, body: { error: "Missing order_id" } };
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
|
||||||
|
[t, orderId],
|
||||||
|
);
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionCreated(payload, attrs, t) {
|
||||||
|
const email = extractEmail(attrs);
|
||||||
|
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||||
|
const variantId = extractVariantId(payload, attrs);
|
||||||
|
const planCode = planByVariantId(variantId);
|
||||||
|
if (!email || !subId) {
|
||||||
|
return { status: 400, body: { error: "Missing email or subscription_id" } };
|
||||||
|
}
|
||||||
|
if (!planCode) {
|
||||||
|
// Niente mapping -> non sappiamo che limiti applicare. Logghiamo e
|
||||||
|
// ritorniamo 200 cosi' LS non rispedisce all'infinito.
|
||||||
|
console.warn("[ls] subscription_created variant non mappato:", variantId, "email=", email);
|
||||||
|
return { status: 200, body: { ok: true, ignored: "unknown_variant" } };
|
||||||
|
}
|
||||||
|
const plan = getPlan(planCode);
|
||||||
|
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||||
|
const orderId = String(attrs.order_id || "");
|
||||||
|
const key = generateLicenseKey();
|
||||||
|
|
||||||
|
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
||||||
|
// riuso la licenza esistente (non ne creo un'altra).
|
||||||
|
const existing = await one(
|
||||||
|
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
||||||
|
[subId],
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses
|
||||||
|
SET status='active', plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
[planCode, plan.daily_limit, periodEnd, t, existing.id],
|
||||||
|
);
|
||||||
|
return { status: 200, body: { ok: true, duplicate: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await exec(
|
||||||
|
`INSERT INTO licenses
|
||||||
|
(license_key, email, status, plan, daily_limit,
|
||||||
|
source, order_id, subscription_id, current_period_end,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?, ?)`,
|
||||||
|
[key, email, planCode, plan.daily_limit, orderId || null, subId, periodEnd, t, t],
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.code === "ER_DUP_ENTRY") {
|
||||||
|
return { status: 200, body: { ok: true, duplicate: true } };
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendLicenseEmail(email, key, { plan: planCode, periodEnd });
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[email] send failed:", e?.message || e);
|
||||||
|
}
|
||||||
|
return { status: 200, body: { ok: true, plan: planCode } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionUpdated(payload, attrs, t) {
|
||||||
|
const subId = String(payload?.data?.id || "");
|
||||||
|
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||||
|
const variantId = extractVariantId(payload, attrs);
|
||||||
|
const planCode = planByVariantId(variantId);
|
||||||
|
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||||
|
|
||||||
|
if (planCode) {
|
||||||
|
const plan = getPlan(planCode);
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses
|
||||||
|
SET plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||||
|
WHERE subscription_id=?`,
|
||||||
|
[planCode, plan.daily_limit, periodEnd, t, subId],
|
||||||
|
);
|
||||||
|
} else if (periodEnd) {
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses SET current_period_end=?, updated_at=? WHERE subscription_id=?`,
|
||||||
|
[periodEnd, t, subId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionPaymentSuccess(payload, attrs, t) {
|
||||||
|
// L'evento di pagamento riferisce alla subscription via attrs.subscription_id
|
||||||
|
const subId = String(attrs.subscription_id || payload?.data?.id || "");
|
||||||
|
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||||
|
|
||||||
|
// Quando un rinnovo va a buon fine LS sposta avanti renews_at.
|
||||||
|
// Per essere sicuri leggiamo l'evento PIU' recente (subscription_updated
|
||||||
|
// viene inviato a stretto giro), oppure deduciamo: +30 giorni.
|
||||||
|
const periodEnd = isoToEpoch(attrs.renews_at)
|
||||||
|
|| isoToEpoch(attrs.created_at)
|
||||||
|
|| (t + 30 * 24 * 3600);
|
||||||
|
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses
|
||||||
|
SET status='active', current_period_end=?, updated_at=?
|
||||||
|
WHERE subscription_id=?`,
|
||||||
|
[periodEnd, t, subId],
|
||||||
|
);
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubscriptionExpired(payload, attrs, t) {
|
||||||
|
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||||
|
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses SET status='revoked', updated_at=? WHERE subscription_id=?`,
|
||||||
|
[t, subId],
|
||||||
|
);
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Entry point ----------------------------------------------------------
|
||||||
|
|
||||||
export async function webhook(req, res) {
|
export async function webhook(req, res) {
|
||||||
// express.raw() salva il body come Buffer in req.body
|
|
||||||
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
|
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
|
||||||
const sig = req.get("X-Signature") || "";
|
const sig = req.get("X-Signature") || "";
|
||||||
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
|
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
|
||||||
@@ -39,45 +254,45 @@ export async function webhook(req, res) {
|
|||||||
|
|
||||||
const eventName = payload?.meta?.event_name || "";
|
const eventName = payload?.meta?.event_name || "";
|
||||||
const attrs = payload?.data?.attributes || {};
|
const attrs = payload?.data?.attributes || {};
|
||||||
const email = String(attrs.user_email || "").trim().toLowerCase();
|
|
||||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
|
||||||
if (!email || !orderId) {
|
|
||||||
return res.status(400).json({ error: "Missing email or order_id" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = now();
|
const t = now();
|
||||||
|
|
||||||
if (eventName === "order_created") {
|
let out;
|
||||||
const key = generateLicenseKey();
|
|
||||||
try {
|
try {
|
||||||
await exec(
|
switch (eventName) {
|
||||||
`INSERT INTO licenses
|
case "order_created":
|
||||||
(license_key, email, status, source, order_id, created_at, updated_at)
|
out = await handleOrderCreated(payload, attrs, t);
|
||||||
VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`,
|
break;
|
||||||
[key, email, orderId, t, t],
|
case "order_refunded":
|
||||||
);
|
out = await handleOrderRefunded(payload, attrs, t);
|
||||||
|
break;
|
||||||
|
case "subscription_created":
|
||||||
|
out = await handleSubscriptionCreated(payload, attrs, t);
|
||||||
|
break;
|
||||||
|
case "subscription_updated":
|
||||||
|
case "subscription_resumed":
|
||||||
|
case "subscription_unpaused":
|
||||||
|
out = await handleSubscriptionUpdated(payload, attrs, t);
|
||||||
|
break;
|
||||||
|
case "subscription_payment_success":
|
||||||
|
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
|
||||||
|
break;
|
||||||
|
case "subscription_expired":
|
||||||
|
out = await handleSubscriptionExpired(payload, attrs, t);
|
||||||
|
break;
|
||||||
|
case "subscription_cancelled":
|
||||||
|
case "subscription_paused":
|
||||||
|
case "subscription_payment_failed":
|
||||||
|
case "subscription_payment_refunded":
|
||||||
|
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
|
||||||
|
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// duplicate webhook delivery
|
console.error("[ls] handler error", eventName, e?.message || e);
|
||||||
if (e?.code === "ER_DUP_ENTRY") {
|
return res.status(500).json({ error: "Internal error" });
|
||||||
return res.json({ ok: true, duplicate: true });
|
|
||||||
}
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await sendLicenseEmail(email, key);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[email] send failed:", e?.message || e);
|
|
||||||
}
|
|
||||||
return res.json({ ok: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (eventName === "order_refunded") {
|
return res.status(out.status).json(out.body);
|
||||||
await exec(
|
|
||||||
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
|
|
||||||
[t, orderId],
|
|
||||||
);
|
|
||||||
return res.json({ ok: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ ok: true, ignored: eventName });
|
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import { one, exec } from "./db.js";
|
import { one, exec } from "./db.js";
|
||||||
import { signJwt, verifyJwt } from "./jwt.js";
|
import { signJwt, verifyJwt } from "./jwt.js";
|
||||||
|
import { getPlan, planSnapshot } from "./plans.js";
|
||||||
|
|
||||||
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
|
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
|
||||||
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
|
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
|
||||||
@@ -9,17 +10,53 @@ const now = () => Math.floor(Date.now() / 1000);
|
|||||||
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
||||||
const normKey = (s) => String(s || "").trim().toUpperCase();
|
const normKey = (s) => String(s || "").trim().toUpperCase();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifica che una licenza sia ancora "utilizzabile" oggi.
|
||||||
|
* Ritorna { ok: true } oppure { ok: false, error: "..." }.
|
||||||
|
*
|
||||||
|
* Casi gestiti:
|
||||||
|
* - annual one-time scaduto (expires_at < now)
|
||||||
|
* - subscription scaduta (current_period_end < now)
|
||||||
|
*/
|
||||||
|
function checkLicenseValidity(license, t = now()) {
|
||||||
|
if (license.status !== "active") {
|
||||||
|
return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." };
|
||||||
|
}
|
||||||
|
if (license.expires_at && Number(license.expires_at) < t) {
|
||||||
|
return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." };
|
||||||
|
}
|
||||||
|
if (license.current_period_end && Number(license.current_period_end) < t) {
|
||||||
|
return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
async function issueToken(license, deviceId) {
|
async function issueToken(license, deviceId) {
|
||||||
const t = now();
|
const t = now();
|
||||||
return signJwt({
|
const plan = planSnapshot(license.plan);
|
||||||
|
const claims = {
|
||||||
sub: String(license.id),
|
sub: String(license.id),
|
||||||
key_id: license.license_key,
|
key_id: license.license_key,
|
||||||
email: license.email,
|
email: license.email,
|
||||||
device_id: deviceId,
|
device_id: deviceId,
|
||||||
iat: t,
|
iat: t,
|
||||||
exp: t + TOKEN_TTL_DAYS * 86400,
|
exp: t + TOKEN_TTL_DAYS * 86400,
|
||||||
}, process.env.JWT_SECRET);
|
};
|
||||||
|
if (plan) {
|
||||||
|
claims.plan = plan.code;
|
||||||
|
claims.plan_name = plan.name;
|
||||||
|
claims.daily_limit = plan.daily_limit;
|
||||||
|
claims.features = plan.features;
|
||||||
|
claims.is_subscription = plan.is_subscription;
|
||||||
}
|
}
|
||||||
|
if (license.expires_at) claims.expires_at = Number(license.expires_at);
|
||||||
|
if (license.current_period_end) claims.period_end = Number(license.current_period_end);
|
||||||
|
return signJwt(claims, process.env.JWT_SECRET);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LICENSE_COLUMNS =
|
||||||
|
"id, license_key, email, status, plan, daily_limit, " +
|
||||||
|
"subscription_id, current_period_end, expires_at";
|
||||||
|
|
||||||
export async function activate(req, res) {
|
export async function activate(req, res) {
|
||||||
const { key, email, device_id, device_name, app_version } = req.body || {};
|
const { key, email, device_id, device_name, app_version } = req.body || {};
|
||||||
@@ -30,15 +67,15 @@ export async function activate(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const license = await one(
|
const license = await one(
|
||||||
"SELECT id, license_key, email, status FROM licenses WHERE license_key=? AND email=? LIMIT 1",
|
`SELECT ${LICENSE_COLUMNS} FROM licenses
|
||||||
|
WHERE license_key=? AND email=? LIMIT 1`,
|
||||||
[K, E],
|
[K, E],
|
||||||
);
|
);
|
||||||
if (!license) {
|
if (!license) {
|
||||||
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
|
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
|
||||||
}
|
}
|
||||||
if (license.status !== "active") {
|
const check = checkLicenseValidity(license);
|
||||||
return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." });
|
if (!check.ok) return res.status(403).json({ error: check.error });
|
||||||
}
|
|
||||||
|
|
||||||
const t = now();
|
const t = now();
|
||||||
const existing = await one(
|
const existing = await one(
|
||||||
@@ -71,7 +108,15 @@ export async function activate(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const token = await issueToken(license, D);
|
const token = await issueToken(license, D);
|
||||||
res.json({ token, activated_at: t, email: license.email });
|
const plan = planSnapshot(license.plan);
|
||||||
|
res.json({
|
||||||
|
token,
|
||||||
|
activated_at: t,
|
||||||
|
email: license.email,
|
||||||
|
plan,
|
||||||
|
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||||
|
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validate(req, res) {
|
export async function validate(req, res) {
|
||||||
@@ -85,12 +130,14 @@ export async function validate(req, res) {
|
|||||||
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
||||||
|
|
||||||
const license = await one(
|
const license = await one(
|
||||||
"SELECT id, license_key, email, status FROM licenses WHERE id=?",
|
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||||
[claims.sub],
|
[claims.sub],
|
||||||
);
|
);
|
||||||
if (!license || license.status !== "active") {
|
if (!license) return res.status(401).json({ error: "Licenza non trovata" });
|
||||||
return res.status(401).json({ error: "Licenza non attiva" });
|
|
||||||
}
|
const check = checkLicenseValidity(license);
|
||||||
|
if (!check.ok) return res.status(401).json({ error: check.error });
|
||||||
|
|
||||||
const act = await one(
|
const act = await one(
|
||||||
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
|
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
|
||||||
[license.id, D],
|
[license.id, D],
|
||||||
@@ -105,7 +152,14 @@ export async function validate(req, res) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const fresh = await issueToken(license, D);
|
const fresh = await issueToken(license, D);
|
||||||
res.json({ token: fresh, email: license.email });
|
const plan = planSnapshot(license.plan);
|
||||||
|
res.json({
|
||||||
|
token: fresh,
|
||||||
|
email: license.email,
|
||||||
|
plan,
|
||||||
|
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||||
|
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate(req, res) {
|
export async function deactivate(req, res) {
|
||||||
@@ -126,6 +180,24 @@ export async function deactivate(req, res) {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper interno: dato un token JWT valido, ritorna la riga licenze
|
||||||
|
* fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se
|
||||||
|
* il token e' invalido o la licenza non e' piu' attiva.
|
||||||
|
*/
|
||||||
|
export async function licenseFromToken(token) {
|
||||||
|
const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET);
|
||||||
|
if (!claims) return null;
|
||||||
|
const lic = await one(
|
||||||
|
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||||
|
[claims.sub],
|
||||||
|
);
|
||||||
|
if (!lic) return null;
|
||||||
|
const check = checkLicenseValidity(lic);
|
||||||
|
if (!check.ok) return null;
|
||||||
|
return { license: lic, claims };
|
||||||
|
}
|
||||||
|
|
||||||
// Esposto per uso interno (es. webhook genera chiave nuova)
|
// Esposto per uso interno (es. webhook genera chiave nuova)
|
||||||
export function generateLicenseKey() {
|
export function generateLicenseKey() {
|
||||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -4,6 +4,8 @@ import express from "express";
|
|||||||
import * as license from "./license.js";
|
import * as license from "./license.js";
|
||||||
import * as updates from "./updates.js";
|
import * as updates from "./updates.js";
|
||||||
import * as ls from "./lemonsqueezy.js";
|
import * as ls from "./lemonsqueezy.js";
|
||||||
|
import * as dl from "./downloads.js";
|
||||||
|
import * as usage from "./usage.js";
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@@ -41,10 +43,17 @@ app.post("/api/license/activate", license.activate);
|
|||||||
app.post("/api/license/validate", license.validate);
|
app.post("/api/license/validate", license.validate);
|
||||||
app.post("/api/license/deactivate", license.deactivate);
|
app.post("/api/license/deactivate", license.deactivate);
|
||||||
|
|
||||||
|
// Quota giornaliera
|
||||||
|
app.get("/api/usage/status", usage.status);
|
||||||
|
app.post("/api/usage/consume", usage.consume);
|
||||||
|
|
||||||
// Aggiornamenti + download firmato
|
// Aggiornamenti + download firmato
|
||||||
app.get("/api/latest", updates.latest);
|
app.get("/api/latest", updates.latest);
|
||||||
app.get("/api/download", updates.download);
|
app.get("/api/download", updates.download);
|
||||||
|
|
||||||
|
// 1-click download dall'email (key+email come query -> redirect signed)
|
||||||
|
app.get("/api/download/:platform", dl.downloadByKey);
|
||||||
|
|
||||||
// 404 JSON solo per /api/*
|
// 404 JSON solo per /api/*
|
||||||
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
|
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -58,6 +58,38 @@
|
|||||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
|
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tema chiaro: override delle custom properties via data-theme="light"
|
||||||
|
sull'elemento <html>. I colori brand (verde, blu, viola, ecc.) restano
|
||||||
|
uguali per non perdere identita'. */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #FFFFFF;
|
||||||
|
--bg-elev: #F7F7F8;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--bg-card-2: #F2F3F5;
|
||||||
|
--bg-input: #EDEEF1;
|
||||||
|
--bg-input-hover: #E2E4E8;
|
||||||
|
|
||||||
|
--border: #E2E4E8;
|
||||||
|
--divider: #ECEDF0;
|
||||||
|
|
||||||
|
--text: #111315;
|
||||||
|
--text-2: #4A4D52;
|
||||||
|
--text-3: #8A8E94;
|
||||||
|
|
||||||
|
--green-dim: #C8F0D5;
|
||||||
|
--blue-dim: #D6E6FB;
|
||||||
|
--purple-dim: #E9D8FD;
|
||||||
|
--pink-dim: #FBCFE8;
|
||||||
|
--amber-dim: #FDE68A;
|
||||||
|
--rec-red-dim: #FECACA;
|
||||||
|
--indigo-dim: #C7D2FE;
|
||||||
|
|
||||||
|
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -194,6 +226,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
QUOTA BOX (sidebar)
|
||||||
|
=============================== */
|
||||||
|
.quota-box {
|
||||||
|
margin: 10px 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(29, 185, 84, 0.08);
|
||||||
|
border: 1px solid rgba(29, 185, 84, 0.25);
|
||||||
|
border-radius: 10px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.quota-box[hidden] { display: none; }
|
||||||
|
.quota-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--text-3);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.quota-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
|
||||||
|
.quota-plan {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--green);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.quota-upgrade {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--green);
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.quota-upgrade[hidden] { display: none; }
|
||||||
|
.quota-upgrade:hover { filter: brightness(1.1); }
|
||||||
|
|
||||||
|
/* Modal Upgrade — testo prose, non monospace */
|
||||||
|
#upgradeModal .modal-body {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
#upgradeModal .modal-body p { margin: 0 0 10px; line-height: 1.5; }
|
||||||
|
#upgradeModal .modal-body .modal-hint {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===============================
|
/* ===============================
|
||||||
MAIN
|
MAIN
|
||||||
=============================== */
|
=============================== */
|
||||||
@@ -1433,13 +1526,13 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
|
|
||||||
.rec-error-log {
|
.rec-error-log {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-card-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #c0c0c0;
|
color: var(--text-2);
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -21,23 +21,23 @@
|
|||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
|
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<button class="nav-item active" data-view="download">
|
<button class="nav-item active" data-view="download" data-feature="audio">
|
||||||
<span class="nav-icon">⬇</span>
|
<span class="nav-icon">⬇</span>
|
||||||
<span>Scarica</span>
|
<span>Scarica</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="video">
|
<button class="nav-item" data-view="video" data-feature="video">
|
||||||
<span class="nav-icon">🎬</span>
|
<span class="nav-icon">🎬</span>
|
||||||
<span>Video</span>
|
<span>Video</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="upgrade">
|
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||||
<span class="nav-icon">⚡</span>
|
<span class="nav-icon">⚡</span>
|
||||||
<span>Upgrade</span>
|
<span>Upgrade</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="meta">
|
<button class="nav-item" data-view="meta" data-feature="metadata">
|
||||||
<span class="nav-icon">🏷</span>
|
<span class="nav-icon">🏷</span>
|
||||||
<span>Metadati</span>
|
<span>Metadati</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-item" data-view="record">
|
<button class="nav-item" data-view="record" data-feature="record">
|
||||||
<span class="nav-icon">●</span>
|
<span class="nav-icon">●</span>
|
||||||
<span>Registra</span>
|
<span>Registra</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -51,6 +51,19 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div class="quota-box" id="quotaBox" hidden>
|
||||||
|
<div class="quota-label">Oggi</div>
|
||||||
|
<div class="quota-value">
|
||||||
|
<span id="quotaUsed">0</span>
|
||||||
|
<span class="quota-sep">/</span>
|
||||||
|
<span id="quotaLimit">10</span>
|
||||||
|
</div>
|
||||||
|
<div class="quota-plan" id="quotaPlanName">Basic</div>
|
||||||
|
<button type="button" class="quota-upgrade" id="quotaUpgradeBtn" hidden>
|
||||||
|
Upgrade
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="footer-by">by LuZa</div>
|
<div class="footer-by">by LuZa</div>
|
||||||
<div class="footer-version" id="footerVersion">v1.0</div>
|
<div class="footer-version" id="footerVersion">v1.0</div>
|
||||||
@@ -892,6 +905,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== MODAL: Upgrade richiesto ============== -->
|
||||||
|
<div class="modal-backdrop" id="upgradeModal" hidden>
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="upgradeModalTitle">Passa a un piano superiore</h2>
|
||||||
|
<button class="modal-close" id="upgradeCloseBtn">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="upgradeModalBody">
|
||||||
|
<!-- popolato dinamicamente -->
|
||||||
|
</div>
|
||||||
|
<div class="modal-foot">
|
||||||
|
<button class="btn btn-primary pill" id="upgradeOpenBtn">Vedi i piani</button>
|
||||||
|
<button class="btn btn-ghost pill" id="upgradeCloseBtn2">Chiudi</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============== TOAST ============== -->
|
<!-- ============== TOAST ============== -->
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,24 @@ function renderLicenseStatus() {
|
|||||||
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
|
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const plan = lic.plan || {};
|
||||||
|
const planLine = plan.code
|
||||||
|
? `<div class="lic-row"><span>Piano</span><strong>${plan.name || plan.code}</strong></div>`
|
||||||
|
: "";
|
||||||
|
const limitLine = plan.daily_limit
|
||||||
|
? `<div class="lic-row"><span>Limite giornaliero</span><strong>${plan.daily_limit}</strong></div>`
|
||||||
|
: (plan.code === "annual"
|
||||||
|
? `<div class="lic-row"><span>Limite giornaliero</span><strong>Illimitato</strong></div>`
|
||||||
|
: "");
|
||||||
|
const expiryLine = plan.expires_at
|
||||||
|
? `<div class="lic-row"><span>Scade il</span><strong>${fmtDate(plan.expires_at)}</strong></div>`
|
||||||
|
: (plan.period_end
|
||||||
|
? `<div class="lic-row"><span>Prossimo rinnovo</span><strong>${fmtDate(plan.period_end)}</strong></div>`
|
||||||
|
: "");
|
||||||
box.innerHTML = `
|
box.innerHTML = `
|
||||||
|
${planLine}
|
||||||
|
${limitLine}
|
||||||
|
${expiryLine}
|
||||||
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
|
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
|
||||||
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
|
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
|
||||||
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
|
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
|
||||||
@@ -88,6 +105,109 @@ function renderLicenseStatus() {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Piano e quota giornaliera
|
||||||
|
// ============================================================
|
||||||
|
function applyPlanGate() {
|
||||||
|
// Nasconde le tab non incluse nel piano corrente.
|
||||||
|
const lic = state.license || {};
|
||||||
|
const features = (lic.plan && lic.plan.features) || [];
|
||||||
|
$$(".nav-item[data-feature]").forEach((btn) => {
|
||||||
|
const f = btn.dataset.feature;
|
||||||
|
const allowed = features.includes(f);
|
||||||
|
btn.hidden = !allowed;
|
||||||
|
if (!allowed && btn.classList.contains("active")) {
|
||||||
|
// Fallback alla prima tab disponibile (audio o settings)
|
||||||
|
const fallback = $$(".nav-item[data-feature]:not([hidden])")[0]
|
||||||
|
|| $(".nav-item[data-view='settings']");
|
||||||
|
if (fallback) showView(fallback.dataset.view);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQuotaBox(q) {
|
||||||
|
const box = $("#quotaBox");
|
||||||
|
if (!box) return;
|
||||||
|
const lic = state.license || {};
|
||||||
|
const plan = (q && q.plan) || lic.plan || {};
|
||||||
|
if (!plan.code || plan.code === "annual" || plan.daily_limit == null) {
|
||||||
|
// Annual / unlimited -> niente contatore
|
||||||
|
box.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.hidden = false;
|
||||||
|
$("#quotaUsed").textContent = String((q && q.used) ?? 0);
|
||||||
|
$("#quotaLimit").textContent = String(plan.daily_limit);
|
||||||
|
$("#quotaPlanName").textContent = plan.name || plan.code;
|
||||||
|
const upBtn = $("#quotaUpgradeBtn");
|
||||||
|
if (upBtn) upBtn.hidden = plan.code === "premium";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshQuota() {
|
||||||
|
const lic = state.license || {};
|
||||||
|
const plan = lic.plan || {};
|
||||||
|
if (!lic.licensed || !plan.code || plan.daily_limit == null) {
|
||||||
|
renderQuotaBox(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await window.pywebview.api.get_quota_status();
|
||||||
|
if (res && res.ok) {
|
||||||
|
renderQuotaBox(res.quota);
|
||||||
|
}
|
||||||
|
} catch (_e) { /* offline: lascia stato precedente */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpgradeModal(reason, payload) {
|
||||||
|
const modal = $("#upgradeModal");
|
||||||
|
if (!modal) return;
|
||||||
|
const title = $("#upgradeModalTitle");
|
||||||
|
const body = $("#upgradeModalBody");
|
||||||
|
if (reason === "feature_not_in_plan") {
|
||||||
|
title.textContent = "Funzione non inclusa nel piano";
|
||||||
|
body.innerHTML = `
|
||||||
|
<p>${payload.error || "Questa funzione non e' inclusa nel tuo piano."}</p>
|
||||||
|
<p class="modal-hint">Passa a un piano superiore per sbloccarla.</p>
|
||||||
|
`;
|
||||||
|
} else if (reason === "quota_exceeded") {
|
||||||
|
const q = payload.quota || {};
|
||||||
|
title.textContent = "Limite giornaliero raggiunto";
|
||||||
|
body.innerHTML = `
|
||||||
|
<p>${payload.error || "Hai raggiunto il limite giornaliero del tuo piano."}</p>
|
||||||
|
<p class="modal-hint">Usati oggi: <strong>${q.used ?? "?"} / ${q.limit ?? "?"}</strong>. Il contatore si resetta a mezzanotte (ora di Roma).</p>
|
||||||
|
`;
|
||||||
|
} else if (reason === "license_invalid") {
|
||||||
|
title.textContent = "Licenza non valida";
|
||||||
|
body.innerHTML = `<p>${payload.error || "La tua licenza non e' piu' valida."}</p>`;
|
||||||
|
} else if (reason === "offline") {
|
||||||
|
title.textContent = "Connessione assente";
|
||||||
|
body.innerHTML = `<p>${payload.error || "Impossibile contattare il server."}</p>`;
|
||||||
|
} else {
|
||||||
|
title.textContent = "Operazione bloccata";
|
||||||
|
body.innerHTML = `<p>${payload.error || "L'operazione non e' stata avviata."}</p>`;
|
||||||
|
}
|
||||||
|
modal.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUpgradeModal() {
|
||||||
|
const modal = $("#upgradeModal");
|
||||||
|
if (modal) modal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gestisce il dict di errore restituito dai metodi start_*: se e' un
|
||||||
|
* gate failure (feature/quota/license/offline), mostra il modal e
|
||||||
|
* ritorna true (= caller deve interrompere il flow). */
|
||||||
|
function handleGateBlock(res) {
|
||||||
|
if (!res || res.ok !== false) return false;
|
||||||
|
const r = res.reason;
|
||||||
|
if (r === "feature_not_in_plan" || r === "quota_exceeded"
|
||||||
|
|| r === "license_invalid" || r === "offline") {
|
||||||
|
showUpgradeModal(r, res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function activateLicense() {
|
async function activateLicense() {
|
||||||
const email = $("#actEmail").value.trim();
|
const email = $("#actEmail").value.trim();
|
||||||
const key = $("#actKey").value.trim();
|
const key = $("#actKey").value.trim();
|
||||||
@@ -109,6 +229,8 @@ async function activateLicense() {
|
|||||||
state.license = res.license;
|
state.license = res.license;
|
||||||
applyLicenseGate();
|
applyLicenseGate();
|
||||||
renderLicenseStatus();
|
renderLicenseStatus();
|
||||||
|
applyPlanGate();
|
||||||
|
refreshQuota();
|
||||||
toast("Licenza attivata. Benvenuto!", "success");
|
toast("Licenza attivata. Benvenuto!", "success");
|
||||||
} else {
|
} else {
|
||||||
err.textContent = res.error || "Errore di attivazione.";
|
err.textContent = res.error || "Errore di attivazione.";
|
||||||
@@ -143,6 +265,7 @@ window.bridge = {
|
|||||||
|
|
||||||
const bridgeHandlers = {
|
const bridgeHandlers = {
|
||||||
"log": ({ view, msg }) => appendLog(view, msg),
|
"log": ({ view, msg }) => appendLog(view, msg),
|
||||||
|
"quota:update": (q) => renderQuotaBox(q),
|
||||||
|
|
||||||
"download:progress": (p) => {
|
"download:progress": (p) => {
|
||||||
if (typeof p.overall === "number") {
|
if (typeof p.overall === "number") {
|
||||||
@@ -317,6 +440,11 @@ function showView(name) {
|
|||||||
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const t = theme === "light" ? "light" : "dark";
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
}
|
||||||
|
|
||||||
$$(".nav-item").forEach((btn) => {
|
$$(".nav-item").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => showView(btn.dataset.view));
|
btn.addEventListener("click", () => showView(btn.dataset.view));
|
||||||
});
|
});
|
||||||
@@ -336,6 +464,8 @@ async function init() {
|
|||||||
|
|
||||||
applyLicenseGate();
|
applyLicenseGate();
|
||||||
renderLicenseStatus();
|
renderLicenseStatus();
|
||||||
|
applyPlanGate();
|
||||||
|
refreshQuota();
|
||||||
|
|
||||||
// Populate Settings fields
|
// Populate Settings fields
|
||||||
$("#clientIdInput").value = state.config.client_id || "";
|
$("#clientIdInput").value = state.config.client_id || "";
|
||||||
@@ -345,6 +475,7 @@ async function init() {
|
|||||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||||
$("#outputInput").value = state.config.output_dir || "";
|
$("#outputInput").value = state.config.output_dir || "";
|
||||||
$("#themeSelect").value = state.config.theme || "dark";
|
$("#themeSelect").value = state.config.theme || "dark";
|
||||||
|
applyTheme(state.config.theme);
|
||||||
|
|
||||||
// Download tab — usa output_dir come default
|
// Download tab — usa output_dir come default
|
||||||
state.dlOutputDir = state.config.output_dir || "";
|
state.dlOutputDir = state.config.output_dir || "";
|
||||||
@@ -471,7 +602,7 @@ $("#downloadBtn").addEventListener("click", async () => {
|
|||||||
subfolder: "",
|
subfolder: "",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||||
bridgeHandlers["download:done"]();
|
bridgeHandlers["download:done"]();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -484,7 +615,7 @@ $("#downloadBtn").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||||
bridgeHandlers["download:done"]();
|
bridgeHandlers["download:done"]();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -541,7 +672,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
|
|||||||
audio_only: videoMode === "audio",
|
audio_only: videoMode === "audio",
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||||
bridgeHandlers["video:done"]();
|
bridgeHandlers["video:done"]();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -602,7 +733,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
|
|||||||
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||||
bridgeHandlers["upgrade:done"]();
|
bridgeHandlers["upgrade:done"]();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -694,7 +825,7 @@ $("#recStartBtn").addEventListener("click", async () => {
|
|||||||
bitrate: $("#recBitrate").value,
|
bitrate: $("#recBitrate").value,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast(res.error || "Errore", "error");
|
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -876,7 +1007,7 @@ $("#metaSaveBtn").addEventListener("click", async () => {
|
|||||||
await loadMetaFile(state.meta.path);
|
await loadMetaFile(state.meta.path);
|
||||||
} else {
|
} else {
|
||||||
status.textContent = "";
|
status.textContent = "";
|
||||||
toast("Errore: " + (res.error || ""), "error");
|
if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -902,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
|
|||||||
if (path) $("#outputInput").value = path;
|
if (path) $("#outputInput").value = path;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#themeSelect").addEventListener("change", (e) => {
|
||||||
|
applyTheme(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
$("#saveBtn").addEventListener("click", async () => {
|
$("#saveBtn").addEventListener("click", async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
client_id: $("#clientIdInput").value,
|
client_id: $("#clientIdInput").value,
|
||||||
@@ -912,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
|
|||||||
output_dir: $("#outputInput").value,
|
output_dir: $("#outputInput").value,
|
||||||
theme: $("#themeSelect").value,
|
theme: $("#themeSelect").value,
|
||||||
};
|
};
|
||||||
|
applyTheme(payload.theme);
|
||||||
const res = await window.pywebview.api.save_settings(payload);
|
const res = await window.pywebview.api.save_settings(payload);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
state.config = payload;
|
state.config = payload;
|
||||||
@@ -1003,6 +1139,8 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
state.license = res.license;
|
state.license = res.license;
|
||||||
renderLicenseStatus();
|
renderLicenseStatus();
|
||||||
|
applyPlanGate();
|
||||||
|
refreshQuota();
|
||||||
if (res.license.licensed) {
|
if (res.license.licensed) {
|
||||||
toast("Licenza verificata", "success");
|
toast("Licenza verificata", "success");
|
||||||
} else {
|
} else {
|
||||||
@@ -1016,6 +1154,22 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Modal Upgrade
|
||||||
|
// ============================================================
|
||||||
|
$("#upgradeCloseBtn")?.addEventListener("click", hideUpgradeModal);
|
||||||
|
$("#upgradeCloseBtn2")?.addEventListener("click", hideUpgradeModal);
|
||||||
|
$("#upgradeModal")?.addEventListener("click", (e) => {
|
||||||
|
if (e.target === e.currentTarget) hideUpgradeModal();
|
||||||
|
});
|
||||||
|
$("#upgradeOpenBtn")?.addEventListener("click", async () => {
|
||||||
|
hideUpgradeModal();
|
||||||
|
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||||
|
});
|
||||||
|
$("#quotaUpgradeBtn")?.addEventListener("click", async () => {
|
||||||
|
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Boot
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||