Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8349fb79f | ||
|
|
1414c5199c | ||
|
|
6e96e8c6b7 | ||
|
|
c2599a72a1 | ||
|
|
2cc6c5e9c6 | ||
|
|
891562dc88 | ||
|
|
9d21ca051f | ||
|
|
05ea4c6181 | ||
|
|
30be62af78 | ||
|
|
a82f6f420c | ||
|
|
61459099f9 | ||
|
|
bcd4f22b1e |
No files matched your search
@@ -33,17 +33,38 @@ jobs:
|
||||
- name: Build app
|
||||
run: python build_macos.py
|
||||
|
||||
- name: Package .app -> zip
|
||||
- name: Create DMG
|
||||
run: |
|
||||
cd dist
|
||||
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
|
||||
ls -lh ../MusicTools-macOS.zip
|
||||
STAGING="dmg_staging"
|
||||
rm -rf "$STAGING"
|
||||
mkdir -p "$STAGING"
|
||||
cp -R dist/MusicTools.app "$STAGING/"
|
||||
cp "assets/Fix Gatekeeper.command" "$STAGING/"
|
||||
chmod +x "$STAGING/Fix Gatekeeper.command"
|
||||
ln -s /Applications "$STAGING/Applications"
|
||||
cat > "$STAGING/LEGGIMI.txt" <<'EOF'
|
||||
INSTALLAZIONE MusicTools
|
||||
========================
|
||||
|
||||
1) Trascina MusicTools.app sulla cartella "Applicazioni"
|
||||
(l'alias e' qui dentro nel DMG)
|
||||
|
||||
2) Doppio click su "Fix Gatekeeper.command"
|
||||
Rimuove la quarantena macOS Gatekeeper (una sola volta).
|
||||
|
||||
3) Apri MusicTools dalle Applicazioni e attiva con email + chiave.
|
||||
|
||||
Problemi? scrivici a info@djluza.com
|
||||
EOF
|
||||
hdiutil create -volname "MusicTools" -srcfolder "$STAGING" \
|
||||
-ov -format UDZO MusicTools-macOS.dmg
|
||||
ls -lh MusicTools-macOS.dmg
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: MusicTools-macOS
|
||||
path: MusicTools-macOS.zip
|
||||
path: MusicTools-macOS.dmg
|
||||
retention-days: 14
|
||||
|
||||
# =========================================================
|
||||
@@ -66,6 +87,14 @@ jobs:
|
||||
- name: Build exe
|
||||
run: python build_windows.py
|
||||
|
||||
- name: Unblock files (rimuovi MOTW pre-zip)
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Per ridurre l'incidenza del bug "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize" causato da Mark of the Web.
|
||||
Get-ChildItem -Path dist/MusicTools -Recurse -Include *.dll,*.exe,*.pyd `
|
||||
| Unblock-File
|
||||
|
||||
- name: Package -> zip
|
||||
shell: pwsh
|
||||
run: |
|
||||
@@ -107,5 +136,5 @@ jobs:
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
artifacts/macos/MusicTools-macOS.zip
|
||||
artifacts/macos/MusicTools-macOS.dmg
|
||||
artifacts/windows/MusicTools-Windows.zip
|
||||
@@ -20,7 +20,12 @@ from core.downloader import (
|
||||
reset_stop as reset_download_stop,
|
||||
is_stopped as download_is_stopped,
|
||||
)
|
||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
||||
from core.spotify_client import (
|
||||
get_access_token,
|
||||
resolve_spotify_url,
|
||||
resolve_spotify_track_no_auth,
|
||||
SpotifyAuthRequired,
|
||||
)
|
||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||
from core.recorder import (
|
||||
list_input_devices,
|
||||
@@ -37,7 +42,15 @@ from core.upgrader import (
|
||||
|
||||
|
||||
SPOTIFY_GUIDE_TEXT = """\
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
||||
NOTE: Spotify oggi consente di creare le chiavi API SOLO con un account
|
||||
Spotify Premium attivo. Senza Premium puoi comunque scaricare:
|
||||
- YouTube, SoundCloud, TikTok, Instagram, Facebook (senza setup)
|
||||
- Brani Spotify SINGOLI (incolla il link e basta, l'app fa il resto)
|
||||
Le chiavi Spotify servono solo per playlist e album da Spotify.
|
||||
|
||||
Procedura (se hai Spotify Premium):
|
||||
|
||||
1) Vai su https://developer.spotify.com/dashboard e accedi.
|
||||
2) Clicca "Create app".
|
||||
3) Compila:
|
||||
- App name: MusicTools (o quello che vuoi)
|
||||
@@ -50,7 +63,7 @@ SPOTIFY_GUIDE_TEXT = """\
|
||||
- Client Secret (clicca "View client secret")
|
||||
5) Incollali qui sotto e premi "Salva".
|
||||
|
||||
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
|
||||
Limite tecnico Spotify: 100 richieste/minuto.
|
||||
"""
|
||||
|
||||
|
||||
@@ -123,6 +136,7 @@ class Api:
|
||||
"config": safe_cfg,
|
||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||
"license": license_status,
|
||||
"plan": license_mod.get_plan(cfg),
|
||||
"purchase_url": "https://musictools.djluza.com",
|
||||
}
|
||||
|
||||
@@ -181,6 +195,63 @@ class Api:
|
||||
webbrowser.open("https://musictools.djluza.com")
|
||||
return {"ok": True}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quota giornaliera
|
||||
# ------------------------------------------------------------------
|
||||
def get_quota_status(self) -> dict:
|
||||
"""Ritorna lo stato quota corrente (lettura senza incremento)."""
|
||||
try:
|
||||
data = license_mod.get_quota_status()
|
||||
return {"ok": True, "quota": data}
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
return {"ok": False, "offline": True, "error": str(e)}
|
||||
except license_mod.LicenseError as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _gate(self, feature: str) -> Optional[dict]:
|
||||
"""Verifica feature + quota prima di un'azione.
|
||||
|
||||
Ritorna None se l'azione e' permessa; altrimenti un dict di
|
||||
errore pronto per la UI (con eventuale info quota per il modal
|
||||
"limite raggiunto").
|
||||
"""
|
||||
# 1) Feature gate locale (dai claims JWT in config)
|
||||
if not license_mod.has_feature(feature):
|
||||
plan = license_mod.get_plan()
|
||||
plan_name = plan.get("name") or "il tuo piano"
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "feature_not_in_plan",
|
||||
"feature": feature,
|
||||
"error": (
|
||||
f"Questa funzione non e' inclusa in {plan_name}. "
|
||||
"Passa a un piano superiore per sbloccarla."
|
||||
),
|
||||
"plan": plan,
|
||||
}
|
||||
# 2) Quota gate server-side
|
||||
try:
|
||||
res = license_mod.consume_quota(feature)
|
||||
except license_mod.LicenseNetworkError as e:
|
||||
return {
|
||||
"ok": False, "reason": "offline", "offline": True,
|
||||
"error": f"Impossibile contattare il server: {e}",
|
||||
}
|
||||
except license_mod.LicenseError as e:
|
||||
return {"ok": False, "reason": "license_invalid", "error": str(e)}
|
||||
|
||||
if not res.get("allowed", True):
|
||||
return {
|
||||
"ok": False,
|
||||
"reason": "quota_exceeded",
|
||||
"feature": feature,
|
||||
"error": res.get("error") or "Limite giornaliero raggiunto.",
|
||||
"quota": res,
|
||||
}
|
||||
# OK: notifico la UI dello stato quota aggiornato
|
||||
self._emit("quota:update", res)
|
||||
return None
|
||||
|
||||
def check_update(self) -> dict:
|
||||
"""Controlla aggiornamenti interrogando l'API djluza.com.
|
||||
|
||||
@@ -326,6 +397,10 @@ class Api:
|
||||
if not os.path.exists(path):
|
||||
return {"ok": False, "error": "File non trovato"}
|
||||
|
||||
gate = self._gate("metadata")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
data = payload.get("data") or {}
|
||||
cover_path = (payload.get("cover_path") or "").strip() or None
|
||||
remove_cover = bool(payload.get("remove_cover", False))
|
||||
@@ -360,6 +435,10 @@ class Api:
|
||||
if not filename.lower().endswith(".mp3"):
|
||||
filename += ".mp3"
|
||||
|
||||
gate = self._gate("record")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
out_path = os.path.join(output_dir, filename)
|
||||
|
||||
def cb(status, payload_evt):
|
||||
@@ -412,6 +491,10 @@ class Api:
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._download_worker,
|
||||
args=(list(urls), output_dir),
|
||||
@@ -441,6 +524,10 @@ class Api:
|
||||
if safe:
|
||||
output_dir = os.path.join(output_dir, safe)
|
||||
|
||||
gate = self._gate("audio")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._download_thread = threading.Thread(
|
||||
target=self._tracks_worker,
|
||||
args=(list(tracks), output_dir),
|
||||
@@ -516,14 +603,11 @@ class Api:
|
||||
view = "download"
|
||||
|
||||
token = None
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
has_credentials = bool(client_id and client_secret)
|
||||
has_spotify = any("spotify.com" in u for u in urls)
|
||||
if has_spotify:
|
||||
client_id = cfg.get("client_id", "")
|
||||
client_secret = cfg.get("client_secret", "")
|
||||
if not client_id or not client_secret:
|
||||
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
if has_spotify and has_credentials:
|
||||
self._log(view, "[INFO] Autenticazione Spotify...")
|
||||
try:
|
||||
token = get_access_token(client_id, client_secret)
|
||||
@@ -531,6 +615,13 @@ class Api:
|
||||
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
||||
self._emit("download:done", {"ok": False})
|
||||
return
|
||||
elif has_spotify:
|
||||
self._log(
|
||||
view,
|
||||
"[INFO] Spotify API non configurate: i singoli brani "
|
||||
"verranno comunque scaricati via metadata pubblico. "
|
||||
"Per album/playlist servono le credenziali (gratuite, 2 min)."
|
||||
)
|
||||
|
||||
for url_idx, url in enumerate(urls):
|
||||
if download_is_stopped():
|
||||
@@ -590,12 +681,19 @@ class Api:
|
||||
if is_spotify:
|
||||
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
||||
try:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
if token:
|
||||
label, name, tracks = resolve_spotify_url(token, url)
|
||||
else:
|
||||
# Senza API keys: fallback solo per singoli brani.
|
||||
label, name, tracks = resolve_spotify_track_no_auth(url)
|
||||
except SpotifyAuthRequired as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
except Exception as e:
|
||||
self._log(view, f"[ERRORE] {e}")
|
||||
continue
|
||||
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
||||
dest_dir = os.path.join(output_dir, name)
|
||||
dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir
|
||||
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
||||
else:
|
||||
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
||||
@@ -627,6 +725,10 @@ class Api:
|
||||
if not directory:
|
||||
return {"ok": False, "error": "Cartella non impostata"}
|
||||
|
||||
gate = self._gate("upgrade")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
cfg = load_config()
|
||||
cookies_path = cfg.get("cookies_path", "")
|
||||
|
||||
@@ -700,6 +802,11 @@ class Api:
|
||||
if not output_dir:
|
||||
return {"ok": False, "error": "Cartella output non impostata"}
|
||||
|
||||
# Anche "audio_only" video count come video: usa codec/yt-dlp diversi
|
||||
gate = self._gate("video")
|
||||
if gate:
|
||||
return gate
|
||||
|
||||
self._video_thread = threading.Thread(
|
||||
target=self._video_worker,
|
||||
args=(list(urls), output_dir, quality, audio_only),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# Rimuove la quarantena Gatekeeper da MusicTools.app
|
||||
# Doppio click in macOS apre Terminal ed esegue questo script.
|
||||
|
||||
set -e
|
||||
|
||||
APP_NAME="MusicTools.app"
|
||||
|
||||
# Cerca l'app in /Applications, poi nella stessa cartella del DMG montato.
|
||||
if [ -d "/Applications/${APP_NAME}" ]; then
|
||||
APP="/Applications/${APP_NAME}"
|
||||
elif [ -d "$(dirname "$0")/${APP_NAME}" ]; then
|
||||
APP="$(dirname "$0")/${APP_NAME}"
|
||||
else
|
||||
osascript -e "display alert \"${APP_NAME} non trovata\" message \"Trascina MusicTools.app nella cartella Applicazioni prima di eseguire questo script.\" as critical"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Rimuovo la quarantena da: ${APP}"
|
||||
xattr -dr com.apple.quarantine "${APP}" 2>/dev/null || true
|
||||
|
||||
# Notifica visuale
|
||||
osascript -e "display notification \"MusicTools puo' essere aperta normalmente con doppio click.\" with title \"Fix completato\""
|
||||
|
||||
echo ""
|
||||
echo "Fatto. Ora puoi aprire MusicTools normalmente con doppio click."
|
||||
echo "Puoi chiudere questa finestra."
|
||||
@@ -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 |
@@ -238,6 +238,7 @@ def run_pyinstaller():
|
||||
app_path = DIST_DIR / "MusicTools.app"
|
||||
if app_path.exists():
|
||||
log(f"Build completata: {app_path}")
|
||||
patch_info_plist(app_path)
|
||||
# Mostra dimensione
|
||||
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||
@@ -249,6 +250,48 @@ def run_pyinstaller():
|
||||
log(f"Trovata directory: {alt}")
|
||||
|
||||
|
||||
def patch_info_plist(app_path):
|
||||
"""Aggiunge le chiavi TCC (Transparency, Consent and Control) richieste
|
||||
da macOS nel bundle .app, altrimenti macOS nega silenziosamente l'accesso
|
||||
al microfono e l'app NON compare in Impostazioni > Privacy e sicurezza.
|
||||
|
||||
Senza NSMicrophoneUsageDescription, qualsiasi tentativo di ffmpeg di
|
||||
aprire un dispositivo audio (incluso BlackHole) viene rifiutato dal
|
||||
sistema e non viene mostrato nessun prompt all'utente.
|
||||
"""
|
||||
info_plist = app_path / "Contents" / "Info.plist"
|
||||
if not info_plist.exists():
|
||||
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
|
||||
return
|
||||
|
||||
pb = "/usr/libexec/PlistBuddy"
|
||||
|
||||
# Helper: aggiungi una chiave; se gia presente sovrascrivila.
|
||||
def add_or_set(key, value, ptype="string"):
|
||||
# Tenta Add; se esiste, fa Set.
|
||||
r = subprocess.run(
|
||||
[pb, "-c", f'Add :{key} {ptype} "{value}"', str(info_plist)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
if r.returncode != 0 and "already exists" in (r.stderr + r.stdout).lower():
|
||||
subprocess.run(
|
||||
[pb, "-c", f'Set :{key} "{value}"', str(info_plist)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
log("Patching Info.plist con le chiavi TCC...")
|
||||
add_or_set(
|
||||
"NSMicrophoneUsageDescription",
|
||||
"MusicTools usa il microfono per registrare l'audio del sistema "
|
||||
"(ad esempio tramite BlackHole o un altro dispositivo loopback).",
|
||||
)
|
||||
# Sicurezza extra: dichiarare bundle identifier stabile aiuta il
|
||||
# database TCC a riconoscere coerentemente l'app tra le sessioni.
|
||||
add_or_set("CFBundleIdentifier", "com.djluza.musictools")
|
||||
add_or_set("LSApplicationCategoryType", "public.app-category.music")
|
||||
log("Info.plist aggiornato.")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
|
||||
@@ -106,6 +106,9 @@ def run_pyinstaller():
|
||||
"--windowed",
|
||||
"--onedir",
|
||||
"--noconfirm",
|
||||
# Forza rebuild pulita (senza, residui di build vecchi possono
|
||||
# confondere il bundling di pythonnet/clr_loader).
|
||||
"--clean",
|
||||
]
|
||||
if icon_path.exists():
|
||||
cmd.extend(["--icon", str(icon_path)])
|
||||
@@ -115,11 +118,28 @@ def run_pyinstaller():
|
||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||
"--hidden-import", "webview",
|
||||
"--hidden-import", "webview.platforms.edgechromium",
|
||||
"--hidden-import", "webview.platforms.winforms",
|
||||
"--hidden-import", "clr",
|
||||
"--hidden-import", "clr_loader",
|
||||
"--hidden-import", "clr_loader.netfx",
|
||||
"--hidden-import", "clr_loader.types",
|
||||
"--hidden-import", "pythonnet",
|
||||
"--hidden-import", "requests",
|
||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
||||
# Collegamenti completi: webview, pythonnet, clr_loader (porta
|
||||
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
|
||||
# altrimenti winforms.py fallisce con "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize ...")
|
||||
"--collect-all", "webview",
|
||||
"--collect-all", "pythonnet",
|
||||
"--collect-all", "clr_loader",
|
||||
"--collect-all", "mutagen",
|
||||
# collect-data prende anche i file .json/.dll non-Python che
|
||||
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
|
||||
# in particolare).
|
||||
"--collect-data", "pythonnet",
|
||||
"--collect-data", "clr_loader",
|
||||
"--copy-metadata", "pythonnet",
|
||||
"--copy-metadata", "clr_loader",
|
||||
# Binari bundled
|
||||
*add_binaries,
|
||||
# Entry point
|
||||
|
||||
@@ -5,7 +5,7 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VERSION = "v1.6.0"
|
||||
VERSION = "v1.7.9"
|
||||
|
||||
|
||||
APP_NAME = "MusicTools"
|
||||
@@ -77,6 +77,14 @@ DEFAULTS = {
|
||||
"license_activated_at": 0, # epoch della prima attivazione
|
||||
"last_validated_at": 0, # epoch dell'ultima revalidate online riuscita
|
||||
"device_id": "", # UUID generato al primo avvio
|
||||
# ---- Piano (snapshot dei claims JWT) ----
|
||||
"plan_code": "", # "basic" | "pro" | "premium" | "annual"
|
||||
"plan_name": "", # nome user-facing
|
||||
"plan_features": [], # ["audio", "video", "record", "metadata", "upgrade"]
|
||||
"plan_daily_limit": None, # None = unlimited (annual), altrimenti int
|
||||
"plan_is_subscription": False,
|
||||
"plan_expires_at": 0, # solo per annual one-time
|
||||
"plan_period_end": 0, # solo per subscription mensili
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import json
|
||||
import platform
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from typing import Optional
|
||||
@@ -140,12 +141,42 @@ def _http_post(path: str, body: dict) -> dict:
|
||||
# ============================================================
|
||||
# API pubblica
|
||||
# ============================================================
|
||||
def _plan_snapshot(cfg: dict) -> dict:
|
||||
"""Estrae i dati del piano dal config (popolati da activate/validate)."""
|
||||
code = (cfg.get("plan_code") or "").strip()
|
||||
if not code:
|
||||
return {}
|
||||
return {
|
||||
"code": code,
|
||||
"name": (cfg.get("plan_name") or "").strip() or code.title(),
|
||||
"features": list(cfg.get("plan_features") or []),
|
||||
"daily_limit": cfg.get("plan_daily_limit"), # puo' essere None
|
||||
"is_subscription": bool(cfg.get("plan_is_subscription")),
|
||||
"expires_at": int(cfg.get("plan_expires_at") or 0),
|
||||
"period_end": int(cfg.get("plan_period_end") or 0),
|
||||
}
|
||||
|
||||
|
||||
def has_feature(name: str, config: Optional[dict] = None) -> bool:
|
||||
"""True se il piano corrente include la feature richiesta."""
|
||||
cfg = config or load_config()
|
||||
plan = _plan_snapshot(cfg)
|
||||
if not plan:
|
||||
return False
|
||||
return name in (plan.get("features") or [])
|
||||
|
||||
|
||||
def get_plan(config: Optional[dict] = None) -> dict:
|
||||
"""Ritorna il dict del piano corrente, oppure {} se non noto."""
|
||||
return _plan_snapshot(config or load_config())
|
||||
|
||||
|
||||
def get_status(config: Optional[dict] = None) -> dict:
|
||||
"""Ritorna lo stato corrente della licenza per la UI.
|
||||
|
||||
Chiavi: licensed (bool), reason (str), email, key, activated_at,
|
||||
last_validated_at, days_since_validation, expires_soon (bool),
|
||||
needs_revalidation (bool).
|
||||
needs_revalidation (bool), plan (dict).
|
||||
"""
|
||||
cfg = config or load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
@@ -153,6 +184,7 @@ def get_status(config: Optional[dict] = None) -> dict:
|
||||
email = (cfg.get("license_email") or "").strip()
|
||||
activated = int(cfg.get("license_activated_at") or 0)
|
||||
last_val = int(cfg.get("last_validated_at") or 0)
|
||||
plan = _plan_snapshot(cfg)
|
||||
|
||||
if not token or not key:
|
||||
return {
|
||||
@@ -164,9 +196,27 @@ def get_status(config: Optional[dict] = None) -> dict:
|
||||
"last_validated_at": 0,
|
||||
"days_since_validation": 0,
|
||||
"needs_revalidation": False,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
days_since = (_now() - last_val) // 86400 if last_val else 999
|
||||
# Scadenza locale derivata dai claims (best-effort: la verita' e' sul server).
|
||||
t = _now()
|
||||
expires_at = plan.get("expires_at") or 0
|
||||
period_end = plan.get("period_end") or 0
|
||||
if expires_at and expires_at < t:
|
||||
return {
|
||||
"licensed": False, "reason": "plan_expired", "email": email, "key": key,
|
||||
"activated_at": activated, "last_validated_at": last_val,
|
||||
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
|
||||
}
|
||||
if period_end and period_end < t:
|
||||
return {
|
||||
"licensed": False, "reason": "subscription_expired", "email": email, "key": key,
|
||||
"activated_at": activated, "last_validated_at": last_val,
|
||||
"days_since_validation": 0, "needs_revalidation": True, "plan": plan,
|
||||
}
|
||||
|
||||
days_since = (t - last_val) // 86400 if last_val else 999
|
||||
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
|
||||
grace_expired = days_since >= LICENSE_GRACE_DAYS
|
||||
|
||||
@@ -180,6 +230,7 @@ def get_status(config: Optional[dict] = None) -> dict:
|
||||
"last_validated_at": last_val,
|
||||
"days_since_validation": days_since,
|
||||
"needs_revalidation": True,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -191,6 +242,7 @@ def get_status(config: Optional[dict] = None) -> dict:
|
||||
"last_validated_at": last_val,
|
||||
"days_since_validation": days_since,
|
||||
"needs_revalidation": needs_reval,
|
||||
"plan": plan,
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +251,55 @@ def is_licensed() -> bool:
|
||||
return get_status()["licensed"]
|
||||
|
||||
|
||||
def _store_plan_from_response(cfg: dict, resp: dict, token: str) -> None:
|
||||
"""Aggiorna il config con i campi del piano estratti dalla risposta API.
|
||||
|
||||
Server risposta puo' contenere:
|
||||
- plan: {code, name, daily_limit, features, is_subscription}
|
||||
- expires_at: epoch (annual)
|
||||
- period_end: epoch (subscription)
|
||||
Come fallback decodifica i claims dal JWT.
|
||||
"""
|
||||
plan = resp.get("plan") if isinstance(resp.get("plan"), dict) else None
|
||||
claims = _decode_jwt_claims(token) if token else {}
|
||||
|
||||
code = ""
|
||||
name = ""
|
||||
features = []
|
||||
daily_limit = None
|
||||
is_sub = False
|
||||
if plan:
|
||||
code = (plan.get("code") or "").strip()
|
||||
name = (plan.get("name") or "").strip()
|
||||
features = list(plan.get("features") or [])
|
||||
daily_limit = plan.get("daily_limit")
|
||||
is_sub = bool(plan.get("is_subscription"))
|
||||
else:
|
||||
code = (claims.get("plan") or "").strip()
|
||||
name = (claims.get("plan_name") or "").strip()
|
||||
features = list(claims.get("features") or [])
|
||||
daily_limit = claims.get("daily_limit")
|
||||
is_sub = bool(claims.get("is_subscription"))
|
||||
|
||||
cfg["plan_code"] = code
|
||||
cfg["plan_name"] = name
|
||||
cfg["plan_features"] = features
|
||||
cfg["plan_daily_limit"] = daily_limit # None ammesso (unlimited)
|
||||
cfg["plan_is_subscription"] = is_sub
|
||||
cfg["plan_expires_at"] = int(resp.get("expires_at") or claims.get("expires_at") or 0)
|
||||
cfg["plan_period_end"] = int(resp.get("period_end") or claims.get("period_end") or 0)
|
||||
|
||||
|
||||
def _clear_plan(cfg: dict) -> None:
|
||||
cfg["plan_code"] = ""
|
||||
cfg["plan_name"] = ""
|
||||
cfg["plan_features"] = []
|
||||
cfg["plan_daily_limit"] = None
|
||||
cfg["plan_is_subscription"] = False
|
||||
cfg["plan_expires_at"] = 0
|
||||
cfg["plan_period_end"] = 0
|
||||
|
||||
|
||||
def activate(license_key: str, email: str) -> dict:
|
||||
"""Attiva una licenza contro il server. Salva token su success.
|
||||
|
||||
@@ -231,6 +332,7 @@ def activate(license_key: str, email: str) -> dict:
|
||||
cfg["license_token"] = token
|
||||
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
|
||||
cfg["last_validated_at"] = now
|
||||
_store_plan_from_response(cfg, resp, token)
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
@@ -260,6 +362,7 @@ def validate() -> dict:
|
||||
# Server ha risposto 4xx -> token non piu valido
|
||||
cfg["license_token"] = ""
|
||||
cfg["last_validated_at"] = 0
|
||||
_clear_plan(cfg)
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
@@ -268,6 +371,7 @@ def validate() -> dict:
|
||||
if new_token:
|
||||
cfg["license_token"] = new_token
|
||||
cfg["last_validated_at"] = _now()
|
||||
_store_plan_from_response(cfg, resp, cfg.get("license_token", ""))
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
@@ -298,5 +402,86 @@ def deactivate(release_remote: bool = True) -> dict:
|
||||
cfg["license_token"] = ""
|
||||
cfg["license_activated_at"] = 0
|
||||
cfg["last_validated_at"] = 0
|
||||
_clear_plan(cfg)
|
||||
save_config(cfg)
|
||||
return get_status(cfg)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Quota giornaliera
|
||||
# ============================================================
|
||||
def _http_json(method: str, path: str, body: Optional[dict] = None,
|
||||
token: Optional[str] = None, timeout: int = 10) -> dict:
|
||||
"""Helper unificato per GET/POST con Authorization Bearer."""
|
||||
url = LICENSE_API_URL.rstrip("/") + path
|
||||
data = json.dumps(body).encode("utf-8") if body is not None else None
|
||||
headers = {
|
||||
"User-Agent": f"MusicTools/{VERSION}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return json.loads(raw) if raw else {}
|
||||
except urllib.error.HTTPError as e:
|
||||
try:
|
||||
err_body = e.read().decode("utf-8")
|
||||
err_data = json.loads(err_body) if err_body else {}
|
||||
except Exception:
|
||||
err_data = {}
|
||||
err_data.setdefault("error", e.reason or f"HTTP {e.code}")
|
||||
err_data["_status"] = e.code
|
||||
return err_data
|
||||
except urllib.error.URLError as e:
|
||||
raise LicenseNetworkError(str(e.reason) if hasattr(e, "reason") else str(e))
|
||||
except (TimeoutError, OSError) as e:
|
||||
raise LicenseNetworkError(str(e))
|
||||
|
||||
|
||||
def get_quota_status() -> dict:
|
||||
"""Legge lo stato quota dal server. Ritorna dict con plan/used/limit/remaining.
|
||||
|
||||
Solleva LicenseError se il server rifiuta (401/403); LicenseNetworkError
|
||||
in caso di problemi di rete.
|
||||
"""
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
if not token:
|
||||
raise LicenseError("Licenza non attivata.")
|
||||
resp = _http_json("GET", "/api/usage/status", token=token)
|
||||
if resp.get("_status") and resp["_status"] >= 400:
|
||||
raise LicenseError(resp.get("error") or "Errore stato quota.")
|
||||
return resp
|
||||
|
||||
|
||||
def consume_quota(feature: str = "") -> dict:
|
||||
"""Incrementa la quota giornaliera prima di un download.
|
||||
|
||||
Ritorna {allowed, used, limit, remaining, plan, day}.
|
||||
Se il server risponde 429 -> allowed=False (l'utente ha raggiunto il limite).
|
||||
Per i piani unlimited (annual), allowed=True sempre.
|
||||
Solleva LicenseError per 401/403; LicenseNetworkError per problemi di rete.
|
||||
"""
|
||||
cfg = load_config()
|
||||
token = (cfg.get("license_token") or "").strip()
|
||||
if not token:
|
||||
raise LicenseError("Licenza non attivata.")
|
||||
body = {"feature": feature} if feature else {}
|
||||
resp = _http_json("POST", "/api/usage/consume", body=body, token=token)
|
||||
status_code = resp.pop("_status", 0)
|
||||
if status_code == 401 or status_code == 403:
|
||||
raise LicenseError(resp.get("error") or "Licenza non valida.")
|
||||
if status_code == 429:
|
||||
# Limite raggiunto, ma e' una risposta strutturata: ritorniamo
|
||||
# il dict cosi' la UI puo' mostrare i numeri.
|
||||
resp.setdefault("allowed", False)
|
||||
return resp
|
||||
if status_code and status_code >= 400:
|
||||
raise LicenseError(resp.get("error") or f"Errore quota ({status_code}).")
|
||||
resp.setdefault("allowed", True)
|
||||
return resp
|
||||
@@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
|
||||
else:
|
||||
name, tracks = get_playlist_info(token, item_id)
|
||||
return "Playlist", name, tracks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback senza credenziali (solo per singoli brani)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class SpotifyAuthRequired(Exception):
|
||||
"""Sollevata quando un URL Spotify richiede le API keys (album/playlist)."""
|
||||
|
||||
|
||||
_OG_TITLE_RE = re.compile(
|
||||
r'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE,
|
||||
)
|
||||
_OG_DESC_RE = re.compile(
|
||||
r'<meta\s+property="og:description"\s+content="([^"]+)"', re.IGNORECASE,
|
||||
)
|
||||
# Tag HTML entities che ci interessano nelle og: (Spotify le scrive cosi').
|
||||
_HTML_ENTITIES = {
|
||||
"&": "&", "'": "'", "'": "'",
|
||||
""": '"', "<": "<", ">": ">",
|
||||
}
|
||||
|
||||
|
||||
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]
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -91,6 +91,13 @@ a { color: inherit; text-decoration: none; }
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Quando il logo e' un'immagine, niente gradient ne' radius forzato */
|
||||
.brand-logo-img {
|
||||
background: none;
|
||||
border-radius: 0;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 26px;
|
||||
@@ -555,6 +562,115 @@ a { color: inherit; text-decoration: none; }
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
|
||||
/* Grid 4 colonne per i piani */
|
||||
.price-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
align-items: stretch;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.price-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.price-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.price-card-sm {
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
padding: 32px 22px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: none;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.price-card-sm .price-amount { font-size: 44px; }
|
||||
.price-card-sm .price-currency { font-size: 20px; }
|
||||
.price-card-sm .price-once { font-size: 13px; }
|
||||
.price-card-sm .price-features { margin-bottom: 22px; flex: 1; }
|
||||
.price-card-sm .price-features li { padding: 7px 0; font-size: 13.5px; }
|
||||
|
||||
.price-card-featured {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 18px 48px rgba(29, 185, 84, 0.18);
|
||||
}
|
||||
|
||||
.price-badge-gold {
|
||||
background: linear-gradient(135deg, #f8c44a, #f4a51c);
|
||||
color: #0a0a0a;
|
||||
}
|
||||
|
||||
.price-shared {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Box informativo sulle chiavi API Spotify */
|
||||
.spotify-note {
|
||||
max-width: 900px;
|
||||
margin: 48px auto 0;
|
||||
padding: 28px 32px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
}
|
||||
.spotify-note h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.spotify-note > p {
|
||||
margin: 0 0 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.spotify-note-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
margin: 18px 0 14px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.spotify-note-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
.sn-col {
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.sn-with {
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
border-color: rgba(29, 185, 84, 0.3);
|
||||
}
|
||||
.sn-without {
|
||||
background: rgba(120, 120, 120, 0.06);
|
||||
}
|
||||
.sn-title {
|
||||
font-weight: 800;
|
||||
font-size: 14.5px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sn-col ul {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sn-col li { margin-bottom: 4px; }
|
||||
.sn-col strong { color: var(--text); }
|
||||
.spotify-note-fine {
|
||||
margin-top: 12px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FAQ
|
||||
============================================================ */
|
||||
@@ -775,3 +891,31 @@ a { color: inherit; text-decoration: none; }
|
||||
}
|
||||
.price-consent a { color: var(--text-muted); text-decoration: underline; }
|
||||
.price-consent a:hover { color: var(--accent); }
|
||||
|
||||
/* ============================================================
|
||||
"Hai gia acquistato? Scarica" — link discreti
|
||||
============================================================ */
|
||||
.hero-customer {
|
||||
margin: 18px 0 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hero-customer a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.hero-customer a:hover { text-decoration: underline; }
|
||||
|
||||
.price-already {
|
||||
margin: 10px 0 0 !important;
|
||||
font-size: 13px !important;
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
.price-already a {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.price-already a:hover { text-decoration: underline; }
|
||||
@@ -0,0 +1,303 @@
|
||||
<!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-code {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
margin: 8px 0 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--accent);
|
||||
user-select: all;
|
||||
word-break: break-all;
|
||||
}
|
||||
.dl-sub {
|
||||
margin: 8px 0 0;
|
||||
padding-left: 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 14.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dl-sub li { margin-bottom: 4px; }
|
||||
.dl-sub strong { color: var(--text); }
|
||||
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
|
||||
|
||||
.dl-status {
|
||||
max-width: 460px;
|
||||
margin: -16px auto 24px;
|
||||
padding: 12px 18px;
|
||||
text-align: center;
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
border: 1px solid rgba(29, 185, 84, 0.25);
|
||||
color: var(--accent);
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dl-status.err {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="/" class="brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<span class="brand-name">MusicTools</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/#features">Funzioni</a>
|
||||
<a href="/#faq">FAQ</a>
|
||||
</nav>
|
||||
<a href="mailto:info@djluza.com" class="btn btn-ghost btn-sm">Supporto</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="dl-page">
|
||||
<div class="dl-head">
|
||||
<span class="eyebrow">SCARICA</span>
|
||||
<h1>Scarica MusicTools</h1>
|
||||
<p>Scegli il sistema operativo e procedi con l'installazione</p>
|
||||
</div>
|
||||
|
||||
<!-- Form mostrato quando NON arrivi dall'email (no key/email in URL) -->
|
||||
<div class="dl-form" id="dlForm">
|
||||
<h2>Inserisci le tue credenziali</h2>
|
||||
<p>Hai gia' acquistato? Inserisci l'email e la chiave per scaricare.</p>
|
||||
<div class="field">
|
||||
<label class="field-label" for="dlEmail">Email dell'acquisto</label>
|
||||
<input type="email" id="dlEmail" class="input" autocomplete="email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label" for="dlKey">Chiave di licenza</label>
|
||||
<input type="text" id="dlKey" class="input" autocomplete="off" placeholder="XXXX-XXXX-XXXX-XXXX" />
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg submit" id="dlSubmitBtn">Mostra i link di download</button>
|
||||
</div>
|
||||
|
||||
<!-- Cards di download (popolate dopo inserimento credenziali o da URL) -->
|
||||
<div class="dl-grid" id="dlGrid" hidden>
|
||||
<article class="dl-card">
|
||||
<div class="dl-os">🍎</div>
|
||||
<h2>macOS</h2>
|
||||
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
|
||||
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlMacBtn" data-platform="macos">
|
||||
Scarica per Mac
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<article class="dl-card">
|
||||
<div class="dl-os">🪟</div>
|
||||
<h2>Windows</h2>
|
||||
<p class="meta">Windows 10 e 11 · 64-bit</p>
|
||||
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlWinBtn" data-platform="windows">
|
||||
Scarica per Windows
|
||||
</a>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<p id="dlStatus" class="dl-status" hidden></p>
|
||||
|
||||
<div class="dl-steps">
|
||||
<h2>Installazione macOS</h2>
|
||||
<ol>
|
||||
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
||||
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
||||
<li>
|
||||
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
|
||||
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio → "Terminale"), incolla questa riga e premi Invio:
|
||||
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
|
||||
<strong>In alternativa</strong> (senza Terminale):
|
||||
<ul class="dl-sub">
|
||||
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG → <strong>Apri</strong> → <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
|
||||
<li>Apri <strong>Impostazioni di Sistema</strong> → <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="dl-steps">
|
||||
<h2>Installazione Windows</h2>
|
||||
<ol>
|
||||
<li>Estrai il file <code>.zip</code> in una cartella di tua scelta</li>
|
||||
<li>Doppio click su <strong>MusicTools.exe</strong></li>
|
||||
<li>Se Windows mostra "Windows ha protetto il PC": click su <strong>Ulteriori informazioni</strong> → <strong>Esegui comunque</strong></li>
|
||||
<li>Inserisci email e chiave per attivare</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="dl-support">
|
||||
Problemi con l'installazione o non hai ricevuto l'email con la chiave?<br>
|
||||
Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> — risposta entro 24 ore.
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
function getParam(name) {
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get(name) || "";
|
||||
}
|
||||
|
||||
// Stato corrente — popolato da URL o dal form submit.
|
||||
const dl = { email: "", key: "" };
|
||||
|
||||
function setStatus(text, isError) {
|
||||
const el = document.getElementById("dlStatus");
|
||||
if (!text) { el.hidden = true; el.textContent = ""; return; }
|
||||
el.textContent = text;
|
||||
el.classList.toggle("err", !!isError);
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function showDownloadCards(email, key) {
|
||||
dl.email = email;
|
||||
dl.key = key;
|
||||
document.getElementById("dlGrid").hidden = false;
|
||||
document.getElementById("dlForm").hidden = true;
|
||||
}
|
||||
|
||||
// Click sui bottoni: naviga sulla stessa tab verso /api/download/:platform.
|
||||
// Il server fa 302 con Content-Disposition: attachment -> il browser
|
||||
// scarica il file e la pagina /download resta visibile.
|
||||
document.querySelectorAll(".dl-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (!dl.email || !dl.key) {
|
||||
setStatus("Inserisci email e chiave prima di scaricare.", true);
|
||||
return;
|
||||
}
|
||||
const platform = btn.dataset.platform;
|
||||
const q = "key=" + encodeURIComponent(dl.key) +
|
||||
"&email=" + encodeURIComponent(dl.email);
|
||||
setStatus("Avvio del download in corso… il file si apre fra qualche secondo.");
|
||||
window.location.href = "/api/download/" + platform + "?" + q;
|
||||
});
|
||||
});
|
||||
|
||||
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni.
|
||||
const urlKey = getParam("key");
|
||||
const urlEmail = getParam("email");
|
||||
if (urlKey && urlEmail) {
|
||||
showDownloadCards(urlEmail, urlKey);
|
||||
}
|
||||
|
||||
// Form submit
|
||||
document.getElementById("dlSubmitBtn").addEventListener("click", () => {
|
||||
const email = document.getElementById("dlEmail").value.trim();
|
||||
const key = document.getElementById("dlKey").value.trim();
|
||||
if (!email || !key) {
|
||||
setStatus("Inserisci email e chiave di licenza.", true);
|
||||
return;
|
||||
}
|
||||
setStatus("");
|
||||
showDownloadCards(email, key);
|
||||
});
|
||||
|
||||
// Enter dentro i campi
|
||||
["dlEmail", "dlKey"].forEach(id => {
|
||||
document.getElementById(id).addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") document.getElementById("dlSubmitBtn").click();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,14 +4,16 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MusicTools — Scarica, modifica e registra la tua musica</title>
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Una licenza a vita, 39,90 €." />
|
||||
<meta name="description" content="App desktop per Mac e Windows. Scarica brani da Spotify e YouTube in MP3 320K, registra l'audio del sistema, modifica i tag. Piani da 2,99 €/mese." />
|
||||
|
||||
<meta property="og:title" content="MusicTools — La toolbox della tua musica" />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Licenza a vita 39,90 €." />
|
||||
<meta property="og:description" content="Scarica, modifica e registra. Tutto in un'app per Mac e Windows. Piani da 2,99 €/mese o 49,90 €/anno una tantum." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://musictools.djluza.com" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="assets/favicon-192.png" />
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -20,16 +22,17 @@
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a href="#top" class="brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span class="brand-name">MusicTools</span>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Funzioni</a>
|
||||
<a href="#how">Come funziona</a>
|
||||
<a href="#pricing">Prezzo</a>
|
||||
<a href="#pricing">Piani</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
<a href="/download">Download</a>
|
||||
</nav>
|
||||
<a href="#pricing" class="btn btn-primary btn-sm">Compra — 39,90 €</a>
|
||||
<a href="#pricing" class="btn btn-primary btn-sm">Vedi i piani</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -37,7 +40,7 @@
|
||||
<section class="hero" id="top">
|
||||
<div class="container hero-grid">
|
||||
<div class="hero-text">
|
||||
<span class="eyebrow">Per Mac e Windows · Licenza a vita</span>
|
||||
<span class="eyebrow">Per Mac e Windows · Da 2,99 €/mese</span>
|
||||
<h1>La tua musica, ovunque la trovi.<br/><span class="accent">In un'unica app.</span></h1>
|
||||
<p class="lead">
|
||||
Scarica brani da Spotify, YouTube, SoundCloud e altri 1000 siti in MP3 320K.
|
||||
@@ -45,14 +48,18 @@
|
||||
Modifica i metadati e le copertine. Tutto offline, senza account.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Compra ora — 39,90 €</a>
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||
<a href="#features" class="btn btn-ghost btn-lg">Scopri di più</a>
|
||||
</div>
|
||||
<ul class="hero-bullets">
|
||||
<li>✓ Acquisto una tantum, nessun abbonamento</li>
|
||||
<li>✓ Aggiornamenti gratuiti a vita</li>
|
||||
<li>✓ Mensili da 2,99 € o annuale 49,90 € una tantum</li>
|
||||
<li>✓ Aggiornamenti gratuiti inclusi</li>
|
||||
<li>✓ Funziona offline · Su 3 dispositivi</li>
|
||||
</ul>
|
||||
|
||||
<p class="hero-customer">
|
||||
Hai già acquistato? <a href="/download">Scarica l'app →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-art" aria-hidden="true">
|
||||
@@ -170,39 +177,148 @@
|
||||
<!-- ============== PRICING ============== -->
|
||||
<section class="section pricing" id="pricing">
|
||||
<div class="container">
|
||||
<span class="eyebrow center">PREZZO</span>
|
||||
<h2 class="section-title">Una licenza. Per sempre.</h2>
|
||||
<p class="section-lead">Niente abbonamenti. Niente sorprese. Compri una volta, la usi per sempre.</p>
|
||||
<span class="eyebrow center">PIANI</span>
|
||||
<h2 class="section-title">Scegli il piano giusto per te</h2>
|
||||
<p class="section-lead">Tre abbonamenti mensili o l'opzione una-tantum annuale senza limiti. Tutti i piani includono aggiornamenti gratuiti.</p>
|
||||
|
||||
<div class="price-card">
|
||||
<div class="price-badge">Più popolare</div>
|
||||
<div class="price-name">MusicTools Personal</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">39,90</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">una tantum</span>
|
||||
<div class="price-grid">
|
||||
|
||||
<!-- BASIC -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-name">Basic</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">2,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Download brani</strong> da Spotify e YouTube</li>
|
||||
<li>✓ <strong>Fino a 10 al giorno</strong></li>
|
||||
<li>✓ MP3 fino a 320kbps</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
<li>✗ Download video</li>
|
||||
<li>✗ Registrazione, metadati, upgrade libreria</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/8aca9e44-4775-424b-8ce3-583714044ac9?embed=1"
|
||||
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||
Inizia con Basic
|
||||
</a>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Licenza a vita</strong> — paghi una volta, mai più</li>
|
||||
<li>✓ <strong>3 dispositivi</strong> — Mac e Windows mixati</li>
|
||||
<li>✓ <strong>Aggiornamenti gratuiti</strong> per tutte le versioni future</li>
|
||||
<li>✓ <strong>Tutte le 7 tab</strong> sbloccate</li>
|
||||
<li>✓ <strong>Supporto via email</strong> a info@djluza.com</li>
|
||||
<li>✓ <strong>Garanzia 14 giorni</strong> soddisfatti o rimborsati</li>
|
||||
</ul>
|
||||
<a href="#"
|
||||
class="btn btn-primary btn-lg btn-block"
|
||||
data-lemonsqueezy-url="REPLACE_WITH_LS_PRODUCT_URL"
|
||||
id="buyBtn">
|
||||
Acquista ora — 39,90 €
|
||||
</a>
|
||||
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa</p>
|
||||
|
||||
<!-- PRO -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-badge">Più scelto</div>
|
||||
<div class="price-name">Pro</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">5,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutto di Basic</strong></li>
|
||||
<li>✓ <strong>Download video</strong> (YT, TikTok, IG, FB)</li>
|
||||
<li>✓ <strong>Fino a 30 al giorno</strong></li>
|
||||
<li>✓ Registrazione audio (BlackHole / loopback)</li>
|
||||
<li>✓ Editor metadati + copertine</li>
|
||||
<li>✓ Upgrade qualità libreria</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/89cb5ab5-9d39-4828-8c44-b67a651aeb46?embed=1"
|
||||
class="btn btn-primary btn-lg btn-block lemonsqueezy-button">
|
||||
Scegli Pro
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- PREMIUM -->
|
||||
<div class="price-card price-card-sm">
|
||||
<div class="price-name">Premium</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">9,99</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/mese</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutto di Pro</strong></li>
|
||||
<li>✓ <strong>Fino a 150 al giorno</strong></li>
|
||||
<li>✓ Priorità nel supporto via email</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/5e027bbf-30ba-4ef3-bb23-5e7ea4aa7e7e?embed=1"
|
||||
class="btn btn-ghost btn-lg btn-block lemonsqueezy-button">
|
||||
Passa a Premium
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ANNUAL ONE-TIME -->
|
||||
<div class="price-card price-card-sm price-card-featured">
|
||||
<div class="price-badge price-badge-gold">Miglior valore</div>
|
||||
<div class="price-name">Annual</div>
|
||||
<div class="price-row">
|
||||
<span class="price-amount">49,90</span>
|
||||
<span class="price-currency">€</span>
|
||||
<span class="price-once">/anno</span>
|
||||
</div>
|
||||
<ul class="price-features">
|
||||
<li>✓ <strong>Tutte le funzioni sbloccate</strong></li>
|
||||
<li>✓ <strong>Nessun limite giornaliero</strong></li>
|
||||
<li>✓ Valido <strong>12 mesi</strong> dall'acquisto</li>
|
||||
<li>✓ Una tantum, niente rinnovo automatico</li>
|
||||
<li>✓ 3 dispositivi (Mac + Win)</li>
|
||||
</ul>
|
||||
<a href="https://luza.lemonsqueezy.com/checkout/buy/85e91098-a93c-4008-a155-954b3456a299?embed=1"
|
||||
class="btn btn-primary btn-lg btn-block lemonsqueezy-button"
|
||||
id="buyBtn">
|
||||
Acquista — 49,90 €
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="price-shared">
|
||||
<p class="price-note">Pagamento sicuro · Carta, PayPal, Apple Pay · Fattura inclusa · IVA gestita da Lemon Squeezy</p>
|
||||
<p class="price-consent">
|
||||
Procedendo con l'acquisto dichiari di aver letto e di accettare i
|
||||
<a href="/legal/terms.html">Termini di servizio</a>,
|
||||
l'<a href="/legal/privacy.html">Informativa privacy</a> e la
|
||||
<a href="/legal/refund.html">Politica rimborsi</a>.
|
||||
</p>
|
||||
<p class="price-already">
|
||||
Hai già una licenza? <a href="/download">Scarica l'app →</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="spotify-note">
|
||||
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
|
||||
<p>
|
||||
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
|
||||
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
|
||||
(anche con account Spotify gratuito).<br>
|
||||
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
|
||||
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
|
||||
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
|
||||
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
|
||||
</p>
|
||||
<div class="spotify-note-grid">
|
||||
<div class="sn-col sn-with">
|
||||
<div class="sn-title">✓ Con Spotify Premium + chiavi API</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 Premium (modalita' base)</div>
|
||||
<ul>
|
||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
|
||||
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
|
||||
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="spotify-note-fine">
|
||||
MusicTools non e' affiliato a Spotify. Le chiavi API restano sul tuo
|
||||
dispositivo, non passano per i nostri server.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="use-policy">
|
||||
@@ -240,17 +356,54 @@
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Su quanti dispositivi posso installarla?</summary>
|
||||
<p>Una licenza copre fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
<p>Tutti i piani coprono fino a <strong>3 dispositivi</strong>, anche misti (Mac + Windows). Se ne cambi uno, puoi disattivare dall'app stessa o scriverci e ne liberiamo uno.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funziona il limite giornaliero?</summary>
|
||||
<p>Sui piani mensili (Basic, Pro, Premium) c'è un limite di download al giorno (10, 30 o 150 a seconda del piano). Il contatore si resetta a <strong>mezzanotte ora di Roma</strong>. Sull'annuale (49,90 €) non c'è nessun limite per 12 mesi.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso passare a un piano superiore?</summary>
|
||||
<p>Sì, in qualsiasi momento. Su Lemon Squeezy puoi cambiare piano dalla tua dashboard; la licenza si aggiorna in automatico al successivo controllo dell'app.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Cosa succede quando scade l'abbonamento annuale?</summary>
|
||||
<p>Il piano Annual è una-tantum: vale 12 mesi dall'acquisto, non si rinnova automaticamente. Allo scadere ricomprilo (o scegli un mensile) e tutto torna a funzionare.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Come funzionano gli aggiornamenti?</summary>
|
||||
<p>Sono <strong>gratis a vita</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
<p>Sono <strong>inclusi in tutti i piani</strong>. L'app controlla in automatico le nuove versioni dal menu Impostazioni → Aggiornamenti. Un click per scaricare e installare.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Devo creare un account Spotify Premium o YouTube Premium?</summary>
|
||||
<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>
|
||||
<summary>Mi serve un abbonamento Spotify Premium?</summary>
|
||||
<p>
|
||||
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
|
||||
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
|
||||
solo agli account Premium. Con un account Spotify gratuito (o senza
|
||||
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
|
||||
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
|
||||
</p>
|
||||
<p>YouTube non richiede alcun account.</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Cosa posso fare senza configurare le chiavi API Spotify?</summary>
|
||||
<p>
|
||||
<strong>Da YouTube, SoundCloud, TikTok, Instagram, Facebook</strong>:
|
||||
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Da Spotify</strong>: 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, che Spotify rilascia solo
|
||||
agli account Premium.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
@@ -300,8 +453,8 @@
|
||||
<section class="section cta-final">
|
||||
<div class="container cta-box">
|
||||
<h2>Pronto a semplificarti la vita?</h2>
|
||||
<p>Una sola app per tutto quello che fai con la musica.</p>
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Compra ora — 39,90 €</a>
|
||||
<p>Una sola app per tutto quello che fai con la musica. Inizia con Basic a 2,99 € al mese.</p>
|
||||
<a href="#pricing" class="btn btn-primary btn-lg">Vedi i piani</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -309,11 +462,12 @@
|
||||
<footer class="footer">
|
||||
<div class="container footer-row">
|
||||
<div class="footer-brand">
|
||||
<span class="brand-logo">♫</span>
|
||||
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||
<span>MusicTools</span>
|
||||
<span class="footer-by">by LuZa</span>
|
||||
</div>
|
||||
<nav class="footer-links">
|
||||
<a href="/download">Download</a>
|
||||
<a href="mailto:info@djluza.com">Supporto</a>
|
||||
<a href="/legal/terms.html">Termini</a>
|
||||
<a href="/legal/privacy.html">Privacy</a>
|
||||
|
||||
@@ -8,25 +8,9 @@
|
||||
if (el) el.textContent = String(new Date().getFullYear());
|
||||
})();
|
||||
|
||||
// Lemon Squeezy overlay
|
||||
// Lo script di Lemon (lemon.js) intercetta automaticamente i link con
|
||||
// data-lemonsqueezy-url e apre l'overlay. Qui ci limitiamo a impedire
|
||||
// che il click navighi via se l'URL non e' ancora stato configurato.
|
||||
document.querySelectorAll("[data-lemonsqueezy-url]").forEach((el) => {
|
||||
el.addEventListener("click", (e) => {
|
||||
const url = el.getAttribute("data-lemonsqueezy-url");
|
||||
if (!url || url === "REPLACE_WITH_LS_PRODUCT_URL") {
|
||||
e.preventDefault();
|
||||
alert(
|
||||
"Il prodotto Lemon Squeezy non e' ancora configurato.\n" +
|
||||
"Crea il prodotto su Lemon Squeezy e incolla l'URL dentro " +
|
||||
"landing/index.html in data-lemonsqueezy-url."
|
||||
);
|
||||
}
|
||||
// Se la pagina e' aperta via file:// lemon.js non riesce a inizializzare:
|
||||
// in quel caso lasciamo che il link navighi via.
|
||||
});
|
||||
});
|
||||
// Lemon Squeezy overlay: lemon.js intercetta automaticamente i link con
|
||||
// data-lemonsqueezy-url e apre l'overlay di checkout. Niente codice nostro
|
||||
// necessario qui — basta che lemon.js sia caricato (vedi <script> in index.html).
|
||||
|
||||
// Header che diventa piu' opaco mentre scorri (effetto subtle)
|
||||
const nav = document.querySelector(".nav");
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Informativa Privacy</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||
@@ -30,24 +30,26 @@
|
||||
<ul>
|
||||
<li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li>
|
||||
<li><strong>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li>
|
||||
<li><strong>Piano sottoscritto, id ordine e id sottoscrizione Lemon Squeezy, data scadenza/rinnovo</strong>: necessari per applicare correttamente le funzioni del piano (Basic/Pro/Premium/Annual) e gestire rinnovi, disdette, rimborsi.</li>
|
||||
<li><strong>Contatore download giornaliero</strong>: numero aggregato di download effettuati ogni giorno (azzerato a mezzanotte ora di Roma). Serve esclusivamente ad applicare i limiti del piano (10/30/150 al giorno). Non vengono registrati URL ne' titoli dei brani scaricati.</li>
|
||||
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Finalita' e base giuridica</h2>
|
||||
<ul>
|
||||
<li>Esecuzione del contratto (consegna della licenza, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Esecuzione del contratto (consegna della licenza, gestione del piano sottoscritto, rinnovi, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
||||
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
<li>Sicurezza del software e applicazione dei limiti del piano (verifica anti-abuso delle attivazioni, contatore giornaliero) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Conservazione</h2>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni.</p>
|
||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
|
||||
|
||||
<h2>5. Terze parti</h2>
|
||||
<ul>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li>
|
||||
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali.</li>
|
||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
|
||||
<li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</li>
|
||||
<li><strong>Provider di hosting self-managed</strong> — i dati di licenza e il backend API sono ospitati su server gestiti dall'Editore. I dati non vengono condivisi con altri fornitori cloud.</li>
|
||||
</ul>
|
||||
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
||||
|
||||
|
||||
@@ -21,20 +21,31 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Politica rimborsi</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<h2>Garanzia 14 giorni</h2>
|
||||
<p>Hai 14 giorni di tempo dall'acquisto per chiedere il rimborso completo, senza fornire motivazioni. La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
<h2>Garanzia 14 giorni sul primo acquisto</h2>
|
||||
<p>Hai <strong>14 giorni</strong> di tempo dal <strong>primo acquisto</strong> per chiedere il rimborso completo, senza fornire motivazioni. Vale per tutti i piani: Basic, Pro, Premium e Annual.</p>
|
||||
<p>La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||
|
||||
<h2>Cosa succede dopo</h2>
|
||||
<h2>Differenze per piano</h2>
|
||||
<ul>
|
||||
<li><strong>Basic / Pro / Premium</strong> (mensili): puoi disdire l'abbonamento <strong>in qualsiasi momento</strong> dalla dashboard di Lemon Squeezy o scrivendoci. La disdetta interrompe il rinnovo successivo; la licenza resta attiva fino alla scadenza del periodo gia pagato. Il rimborso integrale e' previsto solo entro 14 giorni dal primo addebito. <strong>I rinnovi successivi al primo non sono rimborsabili</strong> ma sono interrompibili con disdetta.</li>
|
||||
<li><strong>Annual</strong> (una tantum, 49,90 € per 12 mesi): rimborso integrale entro 14 giorni dall'acquisto. Oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</li>
|
||||
</ul>
|
||||
|
||||
<h2>Cosa succede dopo la richiesta</h2>
|
||||
<ol>
|
||||
<li>Riceverai una risposta entro 48 ore lavorative.</li>
|
||||
<li>Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento usato per l'acquisto. Tempi tipici: 5-10 giorni lavorativi.</li>
|
||||
<li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Come disdire un abbonamento mensile</h2>
|
||||
<p>Se sei oltre i 14 giorni e vuoi solo non rinnovare, accedi alla tua dashboard Lemon Squeezy (link nella ricevuta) e clicca "Cancel subscription". In alternativa scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a> e la disattiviamo per te.</p>
|
||||
|
||||
<h2>Limitazioni</h2>
|
||||
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Trascorsi 14 giorni dal primo acquisto non e' piu' possibile chiedere il rimborso integrale, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||
<p>Spotify potrebbe modificare in qualsiasi momento le proprie API o le condizioni di accesso (es. richiedere un account Premium per generare le credenziali). Tali modifiche di terze parti non costituiscono titolo per il rimborso del piano MusicTools acquistato. La sezione "Esclusione di garanzie" dei <a href="/legal/terms.html">Termini di servizio</a> si applica integralmente.</p>
|
||||
|
||||
<h2>Domande?</h2>
|
||||
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<main class="legal-doc">
|
||||
<h1>Termini di servizio e Licenza d'uso</h1>
|
||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||
|
||||
<div class="warn-box">
|
||||
<p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p>
|
||||
@@ -163,6 +163,7 @@
|
||||
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
|
||||
</ul>
|
||||
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
|
||||
<p><strong>Credenziali Spotify Web API:</strong> per scaricare playlist e album interi da Spotify il Software richiede che l'Utente generi proprie chiavi API (Client ID e Client Secret) dalla Spotify Developer Dashboard e le configuri all'interno del Software. Spotify, in seguito ad aggiornamenti della propria policy, consente la creazione di tali credenziali esclusivamente agli utenti con <strong>account Spotify Premium</strong> attivo. L'Editore non e' in alcun modo affiliato a Spotify, non fornisce, vende o rivende account Spotify, non puo' garantire l'accessibilita' o il funzionamento delle API Spotify per gli account Free, e non e' responsabile per limitazioni, sospensioni o ban applicati da Spotify alle credenziali dell'Utente. I download di singoli brani da Spotify, basati su metadati pubblici, non richiedono credenziali API ne' un abbonamento Premium.</p>
|
||||
|
||||
<h2 id="s10">10. Limitazione di responsabilita</h2>
|
||||
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
|
||||
@@ -185,14 +186,25 @@
|
||||
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
||||
</ul>
|
||||
|
||||
<h2 id="s12">12. Pagamenti e fatturazione</h2>
|
||||
<h2 id="s12">12. Piani, prezzi e fatturazione</h2>
|
||||
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
|
||||
<p>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</p>
|
||||
<p>Il Software e' commercializzato in quattro piani, ciascuno con prezzi indicati IVA inclusa secondo paese di residenza dell'Utente:</p>
|
||||
<ul>
|
||||
<li><strong>Basic</strong> — 2,99 € / mese (abbonamento). Include il download di brani audio fino a 10 al giorno; non include video, registrazione, editor metadati, upgrade libreria.</li>
|
||||
<li><strong>Pro</strong> — 5,99 € / mese (abbonamento). Include audio + video + registrazione + editor metadati + upgrade libreria, fino a 30 download al giorno.</li>
|
||||
<li><strong>Premium</strong> — 9,99 € / mese (abbonamento). Stesse funzioni del Pro con limite di 150 download al giorno.</li>
|
||||
<li><strong>Annual</strong> — 49,90 € una tantum. Tutte le funzioni sbloccate, nessun limite giornaliero, validita' 12 (dodici) mesi dalla data di acquisto. Non si rinnova automaticamente.</li>
|
||||
</ul>
|
||||
<p><strong>Limiti giornalieri:</strong> il contatore si azzera ogni giorno a mezzanotte ora di Roma (Europe/Rome). I download non utilizzati non si cumulano.</p>
|
||||
<p><strong>Abbonamenti mensili (Basic, Pro, Premium):</strong> si rinnovano automaticamente alla data di addebito mensile fino a disdetta dell'Utente, che puo' essere effettuata in qualsiasi momento dalla dashboard Lemon Squeezy o scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a>. La disdetta interrompe il rinnovo successivo; la Licenza resta attiva fino alla scadenza del periodo gia pagato.</p>
|
||||
<p><strong>Piano Annual (una tantum):</strong> il pagamento e' singolo e copre 12 mesi consecutivi. Allo scadere, la Licenza diventa inattiva e per continuare a utilizzare il Software l'Utente puo' acquistare nuovamente un piano a sua scelta.</p>
|
||||
<p>L'Utente puo' passare a un piano superiore (upgrade) o inferiore (downgrade) in qualsiasi momento. Il prorata viene gestito secondo le regole di Lemon Squeezy.</p>
|
||||
|
||||
<h2 id="s13">13. Diritto di recesso e rimborsi</h2>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dall'acquisto, senza fornire motivazioni.</p>
|
||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dal primo acquisto, senza fornire motivazioni. Il diritto di recesso si applica al primo addebito; non si applica ai rinnovi successivi di un abbonamento mensile, per i quali resta possibile la disdetta come previsto al §12.</p>
|
||||
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
|
||||
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
|
||||
<p>L'Utente non puo' richiedere il rimborso dell'importo gia pagato per i mesi di abbonamento gia consumati. Il piano Annual, in caso di recesso entro 14 giorni, viene rimborsato interamente; oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</p>
|
||||
|
||||
<h2 id="s14">14. Aggiornamenti e supporto</h2>
|
||||
<p>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p>
|
||||
|
||||
@@ -4,6 +4,46 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
|
||||
# webview. Con PyInstaller il lazy-loader fallisce con
|
||||
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
|
||||
# Python.Runtime.dll viene bundlata ma il loader non sa risolverla
|
||||
# senza l'inizializzazione esplicita. Fix conferma:
|
||||
# https://github.com/pythonnet/pythonnet/issues/2178
|
||||
if sys.platform == "win32":
|
||||
# ============================================================
|
||||
# Fix Windows: rimuovi Mark of the Web (MOTW) dalle DLL bundled.
|
||||
# Quando l'utente scarica il .zip dal browser, Windows aggiunge
|
||||
# uno stream NTFS Zone.Identifier a TUTTI i file estratti.
|
||||
# .NET Framework 4.x rifiuta silenziosamente di risolvere i
|
||||
# symbol export degli assembly managed con MOTW -> il loader
|
||||
# netfx di pythonnet fallisce con "Failed to resolve
|
||||
# Python.Runtime.Loader.Initialize".
|
||||
# Soluzione: cancelliamo lo stream :Zone.Identifier da ogni
|
||||
# .dll/.exe del bundle al primo avvio (operazione idempotente
|
||||
# e sicura: l'utente ha gia eseguito l'exe esplicitamente).
|
||||
# Vedi: https://github.com/r0x0r/pywebview/issues/1215
|
||||
# ============================================================
|
||||
if getattr(sys, "frozen", False):
|
||||
try:
|
||||
import ctypes
|
||||
_kernel32 = ctypes.windll.kernel32
|
||||
_bundle = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||
for _f in _bundle.rglob("*"):
|
||||
if _f.suffix.lower() in (".dll", ".exe", ".pyd"):
|
||||
_kernel32.DeleteFileW(f"{_f}:Zone.Identifier")
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] MOTW cleanup failed: {_e}")
|
||||
|
||||
try:
|
||||
if getattr(sys, "frozen", False):
|
||||
os.environ["PYTHONNET_RUNTIME"] = "netfx"
|
||||
from pythonnet import load as _pythonnet_load
|
||||
_pythonnet_load("netfx")
|
||||
import clr # noqa: F401 - inizializza il loader
|
||||
except Exception as _e:
|
||||
print(f"[bootstrap] preload pythonnet failed: {_e}")
|
||||
|
||||
import webview
|
||||
|
||||
from api.bridge import Api
|
||||
|
||||
@@ -5,4 +5,5 @@ mutagen>=1.47.0
|
||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||
pythonnet>=3.0 ; sys_platform == "win32"
|
||||
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||
@@ -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).
|
||||
* Usa fetch nativo di Node >= 20 — nessuna dipendenza.
|
||||
* Invio email transazionali via Resend.
|
||||
*
|
||||
* sendLicenseEmail(to, key, { plan, expiresAt, periodEnd })
|
||||
* - plan: codice piano ("basic" | "pro" | "premium" | "annual")
|
||||
* - expiresAt: epoch seconds (annual one-time) o null
|
||||
* - periodEnd: epoch seconds (subscription mensile) o null
|
||||
*
|
||||
* Variabili env richieste:
|
||||
* RESEND_API_KEY dal dashboard Resend
|
||||
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
|
||||
*/
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
||||
import { getPlan } from "./plans.js";
|
||||
|
||||
export async function sendLicenseEmail(to, licenseKey) {
|
||||
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
||||
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||
|
||||
function fmtItalianDate(epochSeconds) {
|
||||
if (!epochSeconds) return "";
|
||||
const d = new Date(Number(epochSeconds) * 1000);
|
||||
return new Intl.DateTimeFormat("it-IT", {
|
||||
timeZone: "Europe/Rome",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function describePlan(planCode, expiresAt, periodEnd) {
|
||||
const p = getPlan(planCode);
|
||||
if (!p) {
|
||||
return {
|
||||
name: "MusicTools",
|
||||
summary: "La tua licenza e' attiva.",
|
||||
};
|
||||
}
|
||||
const features = {
|
||||
audio: "download brani audio",
|
||||
video: "download video",
|
||||
record: "registrazione audio",
|
||||
metadata: "editor metadati",
|
||||
upgrade: "upgrade qualita' libreria",
|
||||
};
|
||||
const featList = p.features.map((f) => features[f] || f).join(", ");
|
||||
const limit = p.daily_limit === null
|
||||
? "senza limiti giornalieri"
|
||||
: `fino a ${p.daily_limit} download al giorno`;
|
||||
let validity = "";
|
||||
if (planCode === "annual" && expiresAt) {
|
||||
validity = `Validita': 1 anno, scade il <strong>${fmtItalianDate(expiresAt)}</strong>.`;
|
||||
} else if (p.is_subscription && periodEnd) {
|
||||
validity = `Prossimo rinnovo: <strong>${fmtItalianDate(periodEnd)}</strong>.`;
|
||||
}
|
||||
return {
|
||||
name: p.name,
|
||||
summary: `Piano <strong>${p.name}</strong> — ${limit}. Funzioni incluse: ${featList}. ${validity}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendLicenseEmail(to, licenseKey, opts = {}) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
||||
return;
|
||||
}
|
||||
|
||||
const { plan: planCode, expiresAt, periodEnd } = opts;
|
||||
const planInfo = describePlan(planCode, expiresAt, periodEnd);
|
||||
|
||||
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
|
||||
const macUrl = `${BASE}/api/download/macos?${q}`;
|
||||
const winUrl = `${BASE}/api/download/windows?${q}`;
|
||||
|
||||
const html = `
|
||||
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
||||
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 14px">
|
||||
${planInfo.summary}
|
||||
</p>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
|
||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||
</p>
|
||||
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
|
||||
${licenseKey}
|
||||
</p>
|
||||
|
||||
<p style="font-size:15px;margin:0 0 12px"><strong>Scarica l'app:</strong></p>
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px">
|
||||
<tr>
|
||||
<td style="padding-right:10px">
|
||||
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||
Scarica per Mac
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||
Scarica per Windows
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
|
||||
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
|
||||
<li>Scarica MusicTools per <a href="https://musictools.djluza.com#pricing">Mac o Windows</a></li>
|
||||
<li>Apri l'app: alla prima schermata ti chiedera' email e chiave</li>
|
||||
<li>Inserisci questa email (<code>${to}</code>) e la chiave qui sopra</li>
|
||||
<li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
|
||||
<li>Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)</li>
|
||||
<li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
|
||||
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
|
||||
</ol>
|
||||
<p style="font-size:14px;color:#555;margin:18px 0 0">Puoi attivare la licenza fino a 3 dispositivi (Mac e Windows mixati).</p>
|
||||
<p style="font-size:13px;color:#888;margin:8px 0 0">
|
||||
Hai bisogno di riscaricare in futuro? Vai su <a href="${BASE}/download" style="color:#1db954">${BASE.replace(/^https?:\/\//, "")}/download</a> e inserisci email + chiave.
|
||||
</p>
|
||||
<hr style="border:none;border-top:1px solid #eee;margin:28px 0"/>
|
||||
<p style="color:#666;font-size:12px;margin:0">Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const subject = planInfo.name === "MusicTools"
|
||||
? "La tua licenza MusicTools"
|
||||
: `La tua licenza MusicTools ${planInfo.name}`;
|
||||
|
||||
const resp = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -45,7 +129,7 @@ export async function sendLicenseEmail(to, licenseKey) {
|
||||
body: JSON.stringify({
|
||||
from: FROM,
|
||||
to: [to],
|
||||
subject: "La tua licenza MusicTools",
|
||||
subject,
|
||||
html,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* Webhook Lemon Squeezy.
|
||||
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||
* Eventi gestiti: order_created, order_refunded.
|
||||
*
|
||||
* Eventi gestiti:
|
||||
* - order_created : crea licenza ANNUAL (one-time) con expires_at = now+365d.
|
||||
* Per le subscription ignora: il record nasce da subscription_created.
|
||||
* - order_refunded : status -> refunded.
|
||||
* - subscription_created : crea licenza con plan, subscription_id, current_period_end.
|
||||
* - subscription_updated : aggiorna current_period_end e plan se cambiato.
|
||||
* - subscription_payment_success : estende current_period_end al nuovo renews_at.
|
||||
* - subscription_payment_failed : (no-op qui) lasciamo scadere current_period_end.
|
||||
* - subscription_cancelled : niente, il periodo corrente resta valido fino a current_period_end.
|
||||
* - subscription_expired : status -> revoked, l'utente perde l'accesso.
|
||||
*
|
||||
* Tutto idempotente: ogni evento si puo' rispedire senza spaccare nulla.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { generateLicenseKey } from "./license.js";
|
||||
import { sendLicenseEmail } from "./email.js";
|
||||
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -21,8 +34,249 @@ function timingSafe(a, b) {
|
||||
return crypto.timingSafeEqual(A, B);
|
||||
}
|
||||
|
||||
/** Converte una data ISO (es. "2026-07-09T13:24:00Z") in epoch seconds, o null. */
|
||||
function isoToEpoch(iso) {
|
||||
if (!iso) return null;
|
||||
const t = Date.parse(String(iso));
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.floor(t / 1000);
|
||||
}
|
||||
|
||||
/** Estrae variant_id dal payload, gestendo le diverse forme degli eventi. */
|
||||
function extractVariantId(payload, attrs) {
|
||||
// subscription_*: attrs.variant_id (diretto)
|
||||
if (attrs.variant_id != null) return String(attrs.variant_id);
|
||||
// order_created: attrs.first_order_item.variant_id
|
||||
const item = attrs.first_order_item;
|
||||
if (item && item.variant_id != null) return String(item.variant_id);
|
||||
// alcune forme passano da relationships
|
||||
const rel = payload?.data?.relationships?.variant?.data?.id;
|
||||
if (rel) return String(rel);
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Email cliente: nei subscription_* puo' essere customer_email. */
|
||||
function extractEmail(attrs) {
|
||||
const e = attrs.user_email || attrs.customer_email || attrs.email || "";
|
||||
return String(e).trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ---- Handlers per famiglia evento -----------------------------------------
|
||||
|
||||
async function handleOrderCreated(payload, attrs, t) {
|
||||
const email = extractEmail(attrs);
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!email || !orderId) {
|
||||
return { status: 400, body: { error: "Missing email or order_id" } };
|
||||
}
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
const plan = planCode ? getPlan(planCode) : null;
|
||||
|
||||
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
|
||||
// non duplichiamo licenza ne' email — ack 200 e basta.
|
||||
const existing = await one(
|
||||
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
|
||||
[orderId],
|
||||
);
|
||||
if (existing) {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
|
||||
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
|
||||
// i limiti esatti del piano comprato).
|
||||
const planForRecord = plan?.code || "annual";
|
||||
const planMeta = plan || getPlan("annual");
|
||||
const limit = planMeta?.daily_limit ?? null;
|
||||
|
||||
// Annual one-time: settiamo expires_at = now + 365d.
|
||||
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
|
||||
// subscription_created/subscription_payment_success quando arriva.
|
||||
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
|
||||
// dashboard), la licenza resta utilizzabile (current_period_end NULL
|
||||
// = no scadenza imposta lato server: la sottoscrizione vivra' o
|
||||
// morira' su LS, e i webhook successivi aggiorneranno lo status).
|
||||
const expiresAt = planMeta?.is_subscription ? null : 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: 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.
|
||||
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 } };
|
||||
}
|
||||
|
||||
// Caso normale ora: la licenza e' gia stata creata da order_created
|
||||
// (con subscription_id NULL). La aggiorno con subscription_id e
|
||||
// current_period_end senza generare una nuova chiave ne' una nuova email.
|
||||
const byOrder = orderId
|
||||
? await one(
|
||||
`SELECT id FROM licenses
|
||||
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
|
||||
[orderId],
|
||||
)
|
||||
: null;
|
||||
if (byOrder) {
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
|
||||
updated_at=?
|
||||
WHERE id=?`,
|
||||
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
|
||||
);
|
||||
return { status: 200, body: { ok: true, attached_subscription: subId } };
|
||||
}
|
||||
|
||||
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
|
||||
// (puo' succedere se order_created non e' arrivato per qualche motivo).
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit,
|
||||
source, order_id, subscription_id, current_period_end,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?, ?)`,
|
||||
[key, email, planCode, plan.daily_limit, orderId || null, subId, periodEnd, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, { plan: planCode, periodEnd });
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
return { status: 200, body: { ok: true, plan: planCode } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(payload, attrs, t) {
|
||||
const subId = String(payload?.data?.id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||
|
||||
if (planCode) {
|
||||
const plan = getPlan(planCode);
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||
WHERE subscription_id=?`,
|
||||
[planCode, plan.daily_limit, periodEnd, t, subId],
|
||||
);
|
||||
} else if (periodEnd) {
|
||||
await exec(
|
||||
`UPDATE licenses SET current_period_end=?, updated_at=? WHERE subscription_id=?`,
|
||||
[periodEnd, t, subId],
|
||||
);
|
||||
}
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionPaymentSuccess(payload, attrs, t) {
|
||||
// L'evento di pagamento riferisce alla subscription via attrs.subscription_id
|
||||
const subId = String(attrs.subscription_id || payload?.data?.id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
|
||||
// Quando un rinnovo va a buon fine LS sposta avanti renews_at.
|
||||
// Per essere sicuri leggiamo l'evento PIU' recente (subscription_updated
|
||||
// viene inviato a stretto giro), oppure deduciamo: +30 giorni.
|
||||
const periodEnd = isoToEpoch(attrs.renews_at)
|
||||
|| isoToEpoch(attrs.created_at)
|
||||
|| (t + 30 * 24 * 3600);
|
||||
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET status='active', current_period_end=?, updated_at=?
|
||||
WHERE subscription_id=?`,
|
||||
[periodEnd, t, subId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionExpired(payload, attrs, t) {
|
||||
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
await exec(
|
||||
`UPDATE licenses SET status='revoked', updated_at=? WHERE subscription_id=?`,
|
||||
[t, subId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
// ---- Entry point ----------------------------------------------------------
|
||||
|
||||
export async function webhook(req, res) {
|
||||
// express.raw() salva il body come Buffer in req.body
|
||||
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
|
||||
const sig = req.get("X-Signature") || "";
|
||||
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
|
||||
@@ -39,45 +293,45 @@ export async function webhook(req, res) {
|
||||
|
||||
const eventName = payload?.meta?.event_name || "";
|
||||
const attrs = payload?.data?.attributes || {};
|
||||
const email = String(attrs.user_email || "").trim().toLowerCase();
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!email || !orderId) {
|
||||
return res.status(400).json({ error: "Missing email or order_id" });
|
||||
}
|
||||
|
||||
const t = now();
|
||||
|
||||
if (eventName === "order_created") {
|
||||
const key = generateLicenseKey();
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, source, order_id, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`,
|
||||
[key, email, orderId, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
// duplicate webhook delivery
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return res.json({ ok: true, duplicate: true });
|
||||
}
|
||||
throw e;
|
||||
let out;
|
||||
try {
|
||||
switch (eventName) {
|
||||
case "order_created":
|
||||
out = await handleOrderCreated(payload, attrs, t);
|
||||
break;
|
||||
case "order_refunded":
|
||||
out = await handleOrderRefunded(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_created":
|
||||
out = await handleSubscriptionCreated(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_updated":
|
||||
case "subscription_resumed":
|
||||
case "subscription_unpaused":
|
||||
out = await handleSubscriptionUpdated(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_payment_success":
|
||||
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_expired":
|
||||
out = await handleSubscriptionExpired(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_cancelled":
|
||||
case "subscription_paused":
|
||||
case "subscription_payment_failed":
|
||||
case "subscription_payment_refunded":
|
||||
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
|
||||
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||
break;
|
||||
default:
|
||||
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||
}
|
||||
try {
|
||||
await sendLicenseEmail(email, key);
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("[ls] handler error", eventName, e?.message || e);
|
||||
return res.status(500).json({ error: "Internal error" });
|
||||
}
|
||||
|
||||
if (eventName === "order_refunded") {
|
||||
await exec(
|
||||
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
|
||||
[t, orderId],
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.json({ ok: true, ignored: eventName });
|
||||
return res.status(out.status).json(out.body);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { signJwt, verifyJwt } from "./jwt.js";
|
||||
import { getPlan, planSnapshot } from "./plans.js";
|
||||
|
||||
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
|
||||
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
|
||||
@@ -9,18 +10,54 @@ const now = () => Math.floor(Date.now() / 1000);
|
||||
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
||||
const normKey = (s) => String(s || "").trim().toUpperCase();
|
||||
|
||||
/**
|
||||
* Verifica che una licenza sia ancora "utilizzabile" oggi.
|
||||
* Ritorna { ok: true } oppure { ok: false, error: "..." }.
|
||||
*
|
||||
* Casi gestiti:
|
||||
* - annual one-time scaduto (expires_at < now)
|
||||
* - subscription scaduta (current_period_end < now)
|
||||
*/
|
||||
function checkLicenseValidity(license, t = now()) {
|
||||
if (license.status !== "active") {
|
||||
return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." };
|
||||
}
|
||||
if (license.expires_at && Number(license.expires_at) < t) {
|
||||
return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." };
|
||||
}
|
||||
if (license.current_period_end && Number(license.current_period_end) < t) {
|
||||
return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function issueToken(license, deviceId) {
|
||||
const t = now();
|
||||
return signJwt({
|
||||
const plan = planSnapshot(license.plan);
|
||||
const claims = {
|
||||
sub: String(license.id),
|
||||
key_id: license.license_key,
|
||||
email: license.email,
|
||||
device_id: deviceId,
|
||||
iat: t,
|
||||
exp: t + TOKEN_TTL_DAYS * 86400,
|
||||
}, process.env.JWT_SECRET);
|
||||
};
|
||||
if (plan) {
|
||||
claims.plan = plan.code;
|
||||
claims.plan_name = plan.name;
|
||||
claims.daily_limit = plan.daily_limit;
|
||||
claims.features = plan.features;
|
||||
claims.is_subscription = plan.is_subscription;
|
||||
}
|
||||
if (license.expires_at) claims.expires_at = Number(license.expires_at);
|
||||
if (license.current_period_end) claims.period_end = Number(license.current_period_end);
|
||||
return signJwt(claims, process.env.JWT_SECRET);
|
||||
}
|
||||
|
||||
const LICENSE_COLUMNS =
|
||||
"id, license_key, email, status, plan, daily_limit, " +
|
||||
"subscription_id, current_period_end, expires_at";
|
||||
|
||||
export async function activate(req, res) {
|
||||
const { key, email, device_id, device_name, app_version } = req.body || {};
|
||||
const K = normKey(key), E = normEmail(email);
|
||||
@@ -30,15 +67,15 @@ export async function activate(req, res) {
|
||||
}
|
||||
|
||||
const license = await one(
|
||||
"SELECT id, license_key, email, status FROM licenses WHERE license_key=? AND email=? LIMIT 1",
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses
|
||||
WHERE license_key=? AND email=? LIMIT 1`,
|
||||
[K, E],
|
||||
);
|
||||
if (!license) {
|
||||
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
|
||||
}
|
||||
if (license.status !== "active") {
|
||||
return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." });
|
||||
}
|
||||
const check = checkLicenseValidity(license);
|
||||
if (!check.ok) return res.status(403).json({ error: check.error });
|
||||
|
||||
const t = now();
|
||||
const existing = await one(
|
||||
@@ -71,7 +108,15 @@ export async function activate(req, res) {
|
||||
}
|
||||
|
||||
const token = await issueToken(license, D);
|
||||
res.json({ token, activated_at: t, email: license.email });
|
||||
const plan = planSnapshot(license.plan);
|
||||
res.json({
|
||||
token,
|
||||
activated_at: t,
|
||||
email: license.email,
|
||||
plan,
|
||||
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validate(req, res) {
|
||||
@@ -85,12 +130,14 @@ export async function validate(req, res) {
|
||||
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
||||
|
||||
const license = await one(
|
||||
"SELECT id, license_key, email, status FROM licenses WHERE id=?",
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||
[claims.sub],
|
||||
);
|
||||
if (!license || license.status !== "active") {
|
||||
return res.status(401).json({ error: "Licenza non attiva" });
|
||||
}
|
||||
if (!license) return res.status(401).json({ error: "Licenza non trovata" });
|
||||
|
||||
const check = checkLicenseValidity(license);
|
||||
if (!check.ok) return res.status(401).json({ error: check.error });
|
||||
|
||||
const act = await one(
|
||||
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
|
||||
[license.id, D],
|
||||
@@ -105,7 +152,14 @@ export async function validate(req, res) {
|
||||
);
|
||||
|
||||
const fresh = await issueToken(license, D);
|
||||
res.json({ token: fresh, email: license.email });
|
||||
const plan = planSnapshot(license.plan);
|
||||
res.json({
|
||||
token: fresh,
|
||||
email: license.email,
|
||||
plan,
|
||||
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivate(req, res) {
|
||||
@@ -126,6 +180,24 @@ export async function deactivate(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper interno: dato un token JWT valido, ritorna la riga licenze
|
||||
* fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se
|
||||
* il token e' invalido o la licenza non e' piu' attiva.
|
||||
*/
|
||||
export async function licenseFromToken(token) {
|
||||
const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET);
|
||||
if (!claims) return null;
|
||||
const lic = await one(
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||
[claims.sub],
|
||||
);
|
||||
if (!lic) return null;
|
||||
const check = checkLicenseValidity(lic);
|
||||
if (!check.ok) return null;
|
||||
return { license: lic, claims };
|
||||
}
|
||||
|
||||
// Esposto per uso interno (es. webhook genera chiave nuova)
|
||||
export function generateLicenseKey() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
@@ -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 updates from "./updates.js";
|
||||
import * as ls from "./lemonsqueezy.js";
|
||||
import * as dl from "./downloads.js";
|
||||
import * as usage from "./usage.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -41,10 +43,17 @@ app.post("/api/license/activate", license.activate);
|
||||
app.post("/api/license/validate", license.validate);
|
||||
app.post("/api/license/deactivate", license.deactivate);
|
||||
|
||||
// Quota giornaliera
|
||||
app.get("/api/usage/status", usage.status);
|
||||
app.post("/api/usage/consume", usage.consume);
|
||||
|
||||
// Aggiornamenti + download firmato
|
||||
app.get("/api/latest", updates.latest);
|
||||
app.get("/api/download", updates.download);
|
||||
|
||||
// 1-click download dall'email (key+email come query -> redirect signed)
|
||||
app.get("/api/download/:platform", dl.downloadByKey);
|
||||
|
||||
// 404 JSON solo per /api/*
|
||||
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
|
||||
|
||||
|
||||
@@ -89,7 +89,13 @@ export async function download(req, res) {
|
||||
}
|
||||
|
||||
const name = path.basename(abs);
|
||||
res.setHeader("Content-Type", "application/zip");
|
||||
const ext = path.extname(name).toLowerCase();
|
||||
const contentType = ext === ".dmg"
|
||||
? "application/x-apple-diskimage"
|
||||
: ext === ".exe"
|
||||
? "application/vnd.microsoft.portable-executable"
|
||||
: "application/zip";
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||
fs.createReadStream(abs).pipe(res);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* Tema chiaro: override delle custom properties via data-theme="light"
|
||||
sull'elemento <html>. I colori brand (verde, blu, viola, ecc.) restano
|
||||
uguali per non perdere identita'. */
|
||||
html[data-theme="light"] {
|
||||
--bg: #FFFFFF;
|
||||
--bg-elev: #F7F7F8;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-card-2: #F2F3F5;
|
||||
--bg-input: #EDEEF1;
|
||||
--bg-input-hover: #E2E4E8;
|
||||
|
||||
--border: #E2E4E8;
|
||||
--divider: #ECEDF0;
|
||||
|
||||
--text: #111315;
|
||||
--text-2: #4A4D52;
|
||||
--text-3: #8A8E94;
|
||||
|
||||
--green-dim: #C8F0D5;
|
||||
--blue-dim: #D6E6FB;
|
||||
--purple-dim: #E9D8FD;
|
||||
--pink-dim: #FBCFE8;
|
||||
--amber-dim: #FDE68A;
|
||||
--rec-red-dim: #FECACA;
|
||||
--indigo-dim: #C7D2FE;
|
||||
|
||||
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@@ -194,6 +226,67 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
QUOTA BOX (sidebar)
|
||||
=============================== */
|
||||
.quota-box {
|
||||
margin: 10px 8px 0;
|
||||
padding: 10px 12px;
|
||||
background: rgba(29, 185, 84, 0.08);
|
||||
border: 1px solid rgba(29, 185, 84, 0.25);
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.quota-box[hidden] { display: none; }
|
||||
.quota-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-3);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.quota-value {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
margin: 2px 0;
|
||||
}
|
||||
.quota-sep { color: var(--text-3); margin: 0 4px; font-weight: 400; }
|
||||
.quota-plan {
|
||||
font-size: 11px;
|
||||
color: var(--green);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.quota-upgrade {
|
||||
margin-top: 8px;
|
||||
background: var(--green);
|
||||
border: none;
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
.quota-upgrade[hidden] { display: none; }
|
||||
.quota-upgrade:hover { filter: brightness(1.1); }
|
||||
|
||||
/* Modal Upgrade — testo prose, non monospace */
|
||||
#upgradeModal .modal-body {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
white-space: normal;
|
||||
}
|
||||
#upgradeModal .modal-body p { margin: 0 0 10px; line-height: 1.5; }
|
||||
#upgradeModal .modal-body .modal-hint {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ===============================
|
||||
MAIN
|
||||
=============================== */
|
||||
@@ -780,11 +873,23 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||
sempre visibile mentre l'utente scrolla la tab Impostazioni. Niente
|
||||
ricerca del pulsante in coda. */
|
||||
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||
sempre visibile mentre l'utente scrolla la tab Impostazioni. */
|
||||
.save-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin: 20px 0 32px;
|
||||
position: sticky;
|
||||
bottom: -24px;
|
||||
padding: 16px 32px;
|
||||
margin: 24px -32px -32px;
|
||||
background: var(--bg);
|
||||
border-top: 1px solid var(--divider);
|
||||
box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.25);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.save-status {
|
||||
@@ -1433,13 +1538,13 @@ input[type="number"]::-webkit-inner-spin-button {
|
||||
|
||||
.rec-error-log {
|
||||
margin-top: 12px;
|
||||
background: #0d0d0d;
|
||||
background: var(--bg-card-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: #c0c0c0;
|
||||
color: var(--text-2);
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
|
||||
@@ -21,23 +21,23 @@
|
||||
<div class="divider"></div>
|
||||
|
||||
<nav class="nav">
|
||||
<button class="nav-item active" data-view="download">
|
||||
<button class="nav-item active" data-view="download" data-feature="audio">
|
||||
<span class="nav-icon">⬇</span>
|
||||
<span>Scarica</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="video">
|
||||
<button class="nav-item" data-view="video" data-feature="video">
|
||||
<span class="nav-icon">🎬</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="upgrade">
|
||||
<button class="nav-item" data-view="upgrade" data-feature="upgrade">
|
||||
<span class="nav-icon">⚡</span>
|
||||
<span>Upgrade</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="meta">
|
||||
<button class="nav-item" data-view="meta" data-feature="metadata">
|
||||
<span class="nav-icon">🏷</span>
|
||||
<span>Metadati</span>
|
||||
</button>
|
||||
<button class="nav-item" data-view="record">
|
||||
<button class="nav-item" data-view="record" data-feature="record">
|
||||
<span class="nav-icon">●</span>
|
||||
<span>Registra</span>
|
||||
</button>
|
||||
@@ -51,6 +51,19 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="quota-box" id="quotaBox" hidden>
|
||||
<div class="quota-label">Oggi</div>
|
||||
<div class="quota-value">
|
||||
<span id="quotaUsed">0</span>
|
||||
<span class="quota-sep">/</span>
|
||||
<span id="quotaLimit">10</span>
|
||||
</div>
|
||||
<div class="quota-plan" id="quotaPlanName">Basic</div>
|
||||
<button type="button" class="quota-upgrade" id="quotaUpgradeBtn" hidden>
|
||||
Upgrade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-by">by LuZa</div>
|
||||
<div class="footer-version" id="footerVersion">v1.0</div>
|
||||
@@ -892,6 +905,23 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== MODAL: Upgrade richiesto ============== -->
|
||||
<div class="modal-backdrop" id="upgradeModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="modal-head">
|
||||
<h2 id="upgradeModalTitle">Passa a un piano superiore</h2>
|
||||
<button class="modal-close" id="upgradeCloseBtn">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="upgradeModalBody">
|
||||
<!-- popolato dinamicamente -->
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<button class="btn btn-primary pill" id="upgradeOpenBtn">Vedi i piani</button>
|
||||
<button class="btn btn-ghost pill" id="upgradeCloseBtn2">Chiudi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============== TOAST ============== -->
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
|
||||
@@ -80,7 +80,24 @@ function renderLicenseStatus() {
|
||||
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
|
||||
return;
|
||||
}
|
||||
const plan = lic.plan || {};
|
||||
const planLine = plan.code
|
||||
? `<div class="lic-row"><span>Piano</span><strong>${plan.name || plan.code}</strong></div>`
|
||||
: "";
|
||||
const limitLine = plan.daily_limit
|
||||
? `<div class="lic-row"><span>Limite giornaliero</span><strong>${plan.daily_limit}</strong></div>`
|
||||
: (plan.code === "annual"
|
||||
? `<div class="lic-row"><span>Limite giornaliero</span><strong>Illimitato</strong></div>`
|
||||
: "");
|
||||
const expiryLine = plan.expires_at
|
||||
? `<div class="lic-row"><span>Scade il</span><strong>${fmtDate(plan.expires_at)}</strong></div>`
|
||||
: (plan.period_end
|
||||
? `<div class="lic-row"><span>Prossimo rinnovo</span><strong>${fmtDate(plan.period_end)}</strong></div>`
|
||||
: "");
|
||||
box.innerHTML = `
|
||||
${planLine}
|
||||
${limitLine}
|
||||
${expiryLine}
|
||||
<div class="lic-row"><span>Email</span><strong>${lic.email || "—"}</strong></div>
|
||||
<div class="lic-row"><span>Chiave</span><strong>${lic.key || "—"}</strong></div>
|
||||
<div class="lic-row"><span>Attivata il</span><strong>${fmtDate(lic.activated_at)}</strong></div>
|
||||
@@ -88,6 +105,109 @@ function renderLicenseStatus() {
|
||||
`;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Piano e quota giornaliera
|
||||
// ============================================================
|
||||
function applyPlanGate() {
|
||||
// Nasconde le tab non incluse nel piano corrente.
|
||||
const lic = state.license || {};
|
||||
const features = (lic.plan && lic.plan.features) || [];
|
||||
$$(".nav-item[data-feature]").forEach((btn) => {
|
||||
const f = btn.dataset.feature;
|
||||
const allowed = features.includes(f);
|
||||
btn.hidden = !allowed;
|
||||
if (!allowed && btn.classList.contains("active")) {
|
||||
// Fallback alla prima tab disponibile (audio o settings)
|
||||
const fallback = $$(".nav-item[data-feature]:not([hidden])")[0]
|
||||
|| $(".nav-item[data-view='settings']");
|
||||
if (fallback) showView(fallback.dataset.view);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderQuotaBox(q) {
|
||||
const box = $("#quotaBox");
|
||||
if (!box) return;
|
||||
const lic = state.license || {};
|
||||
const plan = (q && q.plan) || lic.plan || {};
|
||||
if (!plan.code || plan.code === "annual" || plan.daily_limit == null) {
|
||||
// Annual / unlimited -> niente contatore
|
||||
box.hidden = true;
|
||||
return;
|
||||
}
|
||||
box.hidden = false;
|
||||
$("#quotaUsed").textContent = String((q && q.used) ?? 0);
|
||||
$("#quotaLimit").textContent = String(plan.daily_limit);
|
||||
$("#quotaPlanName").textContent = plan.name || plan.code;
|
||||
const upBtn = $("#quotaUpgradeBtn");
|
||||
if (upBtn) upBtn.hidden = plan.code === "premium";
|
||||
}
|
||||
|
||||
async function refreshQuota() {
|
||||
const lic = state.license || {};
|
||||
const plan = lic.plan || {};
|
||||
if (!lic.licensed || !plan.code || plan.daily_limit == null) {
|
||||
renderQuotaBox(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await window.pywebview.api.get_quota_status();
|
||||
if (res && res.ok) {
|
||||
renderQuotaBox(res.quota);
|
||||
}
|
||||
} catch (_e) { /* offline: lascia stato precedente */ }
|
||||
}
|
||||
|
||||
function showUpgradeModal(reason, payload) {
|
||||
const modal = $("#upgradeModal");
|
||||
if (!modal) return;
|
||||
const title = $("#upgradeModalTitle");
|
||||
const body = $("#upgradeModalBody");
|
||||
if (reason === "feature_not_in_plan") {
|
||||
title.textContent = "Funzione non inclusa nel piano";
|
||||
body.innerHTML = `
|
||||
<p>${payload.error || "Questa funzione non e' inclusa nel tuo piano."}</p>
|
||||
<p class="modal-hint">Passa a un piano superiore per sbloccarla.</p>
|
||||
`;
|
||||
} else if (reason === "quota_exceeded") {
|
||||
const q = payload.quota || {};
|
||||
title.textContent = "Limite giornaliero raggiunto";
|
||||
body.innerHTML = `
|
||||
<p>${payload.error || "Hai raggiunto il limite giornaliero del tuo piano."}</p>
|
||||
<p class="modal-hint">Usati oggi: <strong>${q.used ?? "?"} / ${q.limit ?? "?"}</strong>. Il contatore si resetta a mezzanotte (ora di Roma).</p>
|
||||
`;
|
||||
} else if (reason === "license_invalid") {
|
||||
title.textContent = "Licenza non valida";
|
||||
body.innerHTML = `<p>${payload.error || "La tua licenza non e' piu' valida."}</p>`;
|
||||
} else if (reason === "offline") {
|
||||
title.textContent = "Connessione assente";
|
||||
body.innerHTML = `<p>${payload.error || "Impossibile contattare il server."}</p>`;
|
||||
} else {
|
||||
title.textContent = "Operazione bloccata";
|
||||
body.innerHTML = `<p>${payload.error || "L'operazione non e' stata avviata."}</p>`;
|
||||
}
|
||||
modal.hidden = false;
|
||||
}
|
||||
|
||||
function hideUpgradeModal() {
|
||||
const modal = $("#upgradeModal");
|
||||
if (modal) modal.hidden = true;
|
||||
}
|
||||
|
||||
/** Gestisce il dict di errore restituito dai metodi start_*: se e' un
|
||||
* gate failure (feature/quota/license/offline), mostra il modal e
|
||||
* ritorna true (= caller deve interrompere il flow). */
|
||||
function handleGateBlock(res) {
|
||||
if (!res || res.ok !== false) return false;
|
||||
const r = res.reason;
|
||||
if (r === "feature_not_in_plan" || r === "quota_exceeded"
|
||||
|| r === "license_invalid" || r === "offline") {
|
||||
showUpgradeModal(r, res);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function activateLicense() {
|
||||
const email = $("#actEmail").value.trim();
|
||||
const key = $("#actKey").value.trim();
|
||||
@@ -109,6 +229,8 @@ async function activateLicense() {
|
||||
state.license = res.license;
|
||||
applyLicenseGate();
|
||||
renderLicenseStatus();
|
||||
applyPlanGate();
|
||||
refreshQuota();
|
||||
toast("Licenza attivata. Benvenuto!", "success");
|
||||
} else {
|
||||
err.textContent = res.error || "Errore di attivazione.";
|
||||
@@ -143,6 +265,7 @@ window.bridge = {
|
||||
|
||||
const bridgeHandlers = {
|
||||
"log": ({ view, msg }) => appendLog(view, msg),
|
||||
"quota:update": (q) => renderQuotaBox(q),
|
||||
|
||||
"download:progress": (p) => {
|
||||
if (typeof p.overall === "number") {
|
||||
@@ -317,6 +440,11 @@ function showView(name) {
|
||||
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const t = theme === "light" ? "light" : "dark";
|
||||
document.documentElement.dataset.theme = t;
|
||||
}
|
||||
|
||||
$$(".nav-item").forEach((btn) => {
|
||||
btn.addEventListener("click", () => showView(btn.dataset.view));
|
||||
});
|
||||
@@ -336,6 +464,8 @@ async function init() {
|
||||
|
||||
applyLicenseGate();
|
||||
renderLicenseStatus();
|
||||
applyPlanGate();
|
||||
refreshQuota();
|
||||
|
||||
// Populate Settings fields
|
||||
$("#clientIdInput").value = state.config.client_id || "";
|
||||
@@ -345,6 +475,7 @@ async function init() {
|
||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||
$("#outputInput").value = state.config.output_dir || "";
|
||||
$("#themeSelect").value = state.config.theme || "dark";
|
||||
applyTheme(state.config.theme);
|
||||
|
||||
// Download tab — usa output_dir come default
|
||||
state.dlOutputDir = state.config.output_dir || "";
|
||||
@@ -471,7 +602,7 @@ $("#downloadBtn").addEventListener("click", async () => {
|
||||
subfolder: "",
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["download:done"]();
|
||||
}
|
||||
return;
|
||||
@@ -484,7 +615,7 @@ $("#downloadBtn").addEventListener("click", async () => {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["download:done"]();
|
||||
}
|
||||
});
|
||||
@@ -541,7 +672,7 @@ $("#videoDownloadBtn").addEventListener("click", async () => {
|
||||
audio_only: videoMode === "audio",
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["video:done"]();
|
||||
}
|
||||
});
|
||||
@@ -602,7 +733,7 @@ $("#upgradeBtn").addEventListener("click", async () => {
|
||||
threshold: parseInt($("#upThreshold").value, 10) || 310,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
bridgeHandlers["upgrade:done"]();
|
||||
}
|
||||
});
|
||||
@@ -694,7 +825,7 @@ $("#recStartBtn").addEventListener("click", async () => {
|
||||
bitrate: $("#recBitrate").value,
|
||||
});
|
||||
if (!res.ok) {
|
||||
toast(res.error || "Errore", "error");
|
||||
if (!handleGateBlock(res)) toast(res.error || "Errore", "error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -876,7 +1007,7 @@ $("#metaSaveBtn").addEventListener("click", async () => {
|
||||
await loadMetaFile(state.meta.path);
|
||||
} else {
|
||||
status.textContent = "";
|
||||
toast("Errore: " + (res.error || ""), "error");
|
||||
if (!handleGateBlock(res)) toast("Errore: " + (res.error || ""), "error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -902,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
|
||||
if (path) $("#outputInput").value = path;
|
||||
});
|
||||
|
||||
$("#themeSelect").addEventListener("change", (e) => {
|
||||
applyTheme(e.target.value);
|
||||
});
|
||||
|
||||
$("#saveBtn").addEventListener("click", async () => {
|
||||
const payload = {
|
||||
client_id: $("#clientIdInput").value,
|
||||
@@ -912,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
|
||||
output_dir: $("#outputInput").value,
|
||||
theme: $("#themeSelect").value,
|
||||
};
|
||||
applyTheme(payload.theme);
|
||||
const res = await window.pywebview.api.save_settings(payload);
|
||||
if (res.ok) {
|
||||
state.config = payload;
|
||||
@@ -1003,6 +1139,8 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
|
||||
if (res.ok) {
|
||||
state.license = res.license;
|
||||
renderLicenseStatus();
|
||||
applyPlanGate();
|
||||
refreshQuota();
|
||||
if (res.license.licensed) {
|
||||
toast("Licenza verificata", "success");
|
||||
} else {
|
||||
@@ -1016,6 +1154,22 @@ $("#revalidateLicenseBtn")?.addEventListener("click", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Modal Upgrade
|
||||
// ============================================================
|
||||
$("#upgradeCloseBtn")?.addEventListener("click", hideUpgradeModal);
|
||||
$("#upgradeCloseBtn2")?.addEventListener("click", hideUpgradeModal);
|
||||
$("#upgradeModal")?.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) hideUpgradeModal();
|
||||
});
|
||||
$("#upgradeOpenBtn")?.addEventListener("click", async () => {
|
||||
hideUpgradeModal();
|
||||
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||
});
|
||||
$("#quotaUpgradeBtn")?.addEventListener("click", async () => {
|
||||
try { await window.pywebview.api.open_purchase_page(); } catch (_e) {}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Boot
|
||||
// ============================================================
|
||||
|
||||