Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8272985459 | ||
|
|
f339197861 | ||
|
|
2d8290579b | ||
|
|
339dd4f3cc | ||
|
|
4e917fd9ef | ||
|
|
729f3cd8a0 | ||
|
|
4ebfbd472e | ||
|
|
5202b2c586 | ||
|
|
05a2a58ace | ||
|
|
8610f4d95c | ||
|
|
b47fdd1ab3 | ||
|
|
e56a975b77 | ||
|
|
52002f5184 | ||
|
|
878e024c13 |
No files matched your search
+15
@@ -34,3 +34,18 @@ MUSICA/
|
|||||||
# Claude
|
# Claude
|
||||||
.claude/
|
.claude/
|
||||||
Apri con claude-social.command
|
Apri con claude-social.command
|
||||||
|
|
||||||
|
# Playlist personali (non vanno nel repo)
|
||||||
|
Vibes_List.txt
|
||||||
|
Beatport Top 100*.txt
|
||||||
|
PlayList_2026.txt
|
||||||
|
PlayList_da_scaricare.txt
|
||||||
|
|
||||||
|
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
|
||||||
|
assets/icon.iconset/
|
||||||
|
|
||||||
|
# Backend (server/) build artifacts e secrets
|
||||||
|
server/node_modules/
|
||||||
|
server/.wrangler/
|
||||||
|
server/.dev.vars
|
||||||
|
server/dist/
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
1. Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)
|
|
||||||
2. Brunello, Vargas – Ghost Dance (Original Mix) (6:12)
|
|
||||||
3. Hardt Antoine – Raw (Original Mix) (5:25)
|
|
||||||
4. JUNO (DE) & Samm (BE) – Heart Spin (Original Mix) (5:56)
|
|
||||||
5. AIKON – To You (Original Mix) (6:12)
|
|
||||||
6. Adam Port, Keinemusik & SG Lewis – Be The One (Extended) (4:45)
|
|
||||||
7. HotLap – Recall (Extended Mix) (5:48)
|
|
||||||
8. JUNO (DE) & Samm (BE) – Muralli (Original Mix) (4:59)
|
|
||||||
9. Brunello – Science Fiction (Original Mix) (6:36)
|
|
||||||
10. Emanuel Satie, Tim Engelhardt, Maga, Sean Doron, Unfazed, Scenarios – A Gira (Extended Remix) (5:28)
|
|
||||||
11. DJ Koze – Spiralen (Original Mix) (7:17)
|
|
||||||
12. Joris Voorn, Taylr Renee & Roddy Lima – Machine (Original Mix) (5:02)
|
|
||||||
13. KREAM & Korolova – Annihilation (Extended Mix) (4:50)
|
|
||||||
14. Bedouin, Karl Williams, LUCH – The Only One (Original Mix) (6:49)
|
|
||||||
15. Massano & Silver Panda – Pa Ca (Original Mix) (4:47)
|
|
||||||
16. Moeaike, Arodes – Nobody Else (Extended Mix) (5:58)
|
|
||||||
17. RÜFÜS DU SOL – In the Moment (Adriatique Extended Remix) (5:12)
|
|
||||||
18. &ME, Black Coffee – The Rapture Pt.III (Original Mix) (8:02)
|
|
||||||
19. Liva K – Didn’t Miss You (Original Mix) (5:46)
|
|
||||||
20. Carlo Whale & TH;EN – Echoes of You (Original Mix) (5:22)
|
|
||||||
21. Magnus, JEWELS, YUMA., SOMMA, LE YORA – EVERYTHING IN ITS RIGHT PLACE (Original Mix) (7:14)
|
|
||||||
22. Andrea Oliva – Neon Hearts (Original Mix) (6:06)
|
|
||||||
23. Jackie Hollander – High On You (Extended Mix) (4:57)
|
|
||||||
24. KREAM, SCRIPT – Turn Up The Dose (Extended Mix) (4:20)
|
|
||||||
25. JOSEPH (CH) – B4U (Original Mix) (8:00)
|
|
||||||
26. Ajna (BE), Misha – Body Moving (Original Mix) (6:16)
|
|
||||||
27. Green Velvet & Harvard Bass – Lazer Beams (Adam Beyer & Massano Remix) (5:02)
|
|
||||||
28. Argy, Omiki & Lell Nahar – MANTRA (Extended Mix) (5:03)
|
|
||||||
29. Sasha & Franky Wah – Get On My Level (Original Mix) (8:00)
|
|
||||||
30. LUCH – UH HUH (Original Mix) (6:45)
|
|
||||||
31. Fahlberg – Feeling Good (Extended Mix) (6:49)
|
|
||||||
32. Ivory (IT) – Back Again (Original Mix) (5:42)
|
|
||||||
33. Massano, Y Do I – Lost My Mind (Original Mix) (5:23)
|
|
||||||
34. KOWSKI, Eric Reyes – Break Down! (Original Mix) (5:30)
|
|
||||||
35. Andrea Oliva – Spotlight (Original Mix) (6:21)
|
|
||||||
36. Tiga vs Meduza – You Gonna Want Me (I Know) (Extended Mix) (5:21)
|
|
||||||
37. Marsh – Beside (Extended Mix) (6:18)
|
|
||||||
38. Moderat, &ME, Rampa & Keinemusik – More Love (Rampa &ME Remix) (4:55)
|
|
||||||
39. Adriatique & Emmit Fenn – Closer (Extended Mix) (4:53)
|
|
||||||
40. Jimi Jules, Black Coffee – Trippy Yeah (Extended Mix) (6:40)
|
|
||||||
41. Liva K & OVEOUS – Blessings (Original Mix) (6:35)
|
|
||||||
42. Township Rebellion, Kasablanca – It’s Chemical (Original Mix) (6:28)
|
|
||||||
43. MAXI MERAKI, Sharing-DB.club – First Time (Original Mix) (5:19)
|
|
||||||
44. Pole Position – Touch Me (Hotlap Remix) (5:40)
|
|
||||||
45. Da Hool, Cassian & YOTTO – Love Parade (Extended Mix) (4:09)
|
|
||||||
46. Jody Wisternoff, Jamie McDermott – Citadel (Extended Mix) (5:17)
|
|
||||||
47. Super Flu, Dennis Louvra – Can’t Escape (Original Mix) (5:56)
|
|
||||||
48. Armin van Buuren, Argy, Marlo Rex – Like A Child (Extended Mix) (4:08)
|
|
||||||
49. Robert Babicz – The Wave (Original Mix) (5:08)
|
|
||||||
50. The Temper Trap – Sweet Disposition (John Summit & Silver Panda Extended Remix) (5:44)
|
|
||||||
51. Yotto, Tallac – Chemtrail Surfers (Extended Mix) (6:26)
|
|
||||||
52. HotLap – Waitin’ (Extended Mix) (6:35)
|
|
||||||
53. Miss Monique, Nicolas Taboada & FRANCO BA – Ghosts (Extended Mix) (5:28)
|
|
||||||
54. Picca & Mars & Moeaike – Move in Time (Original Mix) (5:39)
|
|
||||||
55. Hardt Antoine – Sing It Back (Extended Mix) (6:57)
|
|
||||||
56. Shakedown, Layton Giordani & Anyma (ofc) – At Night (Anyma x Layton Giordani Extended Remix) (4:35)
|
|
||||||
57. Acrobatik & whatlewisdoes – Lamazi (Extended Mix) (4:46)
|
|
||||||
58. Massano – Abraza (Original Mix) (5:13)
|
|
||||||
59. Alcatraz – Giv Me Luv (Jerome Isma-Ae Remix) (7:18)
|
|
||||||
60. Gespona – Komplex (Original Mix) (5:56)
|
|
||||||
61. Misha & Martim Rola – On Business (Extended Mix) (6:30)
|
|
||||||
62. Mind Against, Anakim & HUMAN404 – Late at Night (Extended Mix) (4:39)
|
|
||||||
63. Anyma (ofc), Argy & Son of Son – Voices In My Head (Extended Mix) (4:16)
|
|
||||||
64. Fred again.., Swedish House Mafia, Future – Turn On The Lights again.. (feat. Future) (Original Mix) (4:27)
|
|
||||||
65. Sezer Uysal & Axelax – Calling For More (Original Mix) (5:12)
|
|
||||||
66. LUCH, Marten Lou – Hideaway (Extended Version) (5:57)
|
|
||||||
67. Giuseppe Ottaviani, ARTBAT & Conor Ross – Break The Loop (Original Mix) (5:22)
|
|
||||||
68. Rhodes, CamelPhat, ARTBAT – For a Feeling (Adam Sellouk & Y do I Remix – Extended) (4:49)
|
|
||||||
69. Monolink, Artbat – Return to Oz (ARTBAT Remix) (8:00)
|
|
||||||
70. Lisa & Anyma (ofc) – Bad Angel (with Lisa) (Extended Mix) (4:08)
|
|
||||||
71. Florist (BE) – Unite (Original Mix) (6:25)
|
|
||||||
72. Moderat – Eating Hooks (Deep Dish Extended Remix) (7:13)
|
|
||||||
73. CamelPhat & Arodes – Cycles (Extended Mix) (6:52)
|
|
||||||
74. Interplanetary Criminal, Kettama – Yosemite (Extended) (6:02)
|
|
||||||
75. Leblanc (ofc) – Strings Anthem (Original Mix) (5:22)
|
|
||||||
76. Mind Against & Jos & Eli – Overture (Original Mix) (5:21)
|
|
||||||
77. Tripolism & Kingstarlight – Hurricane (Extended Mix) (4:49)
|
|
||||||
78. Luke Alexander – Last Night (feat. Y4MI) (Original Mix) (3:30)
|
|
||||||
79. Braxton – There’s Still Time (Extended Mix) (6:31)
|
|
||||||
80. Township Rebellion – Birds Fly First Class (Original Mix) (6:19)
|
|
||||||
81. Agents Of Time & Miss Monique – Rajada (Original Mix) (5:00)
|
|
||||||
82. Fancy Inc – Hypnotic (Extended Mix) (5:09)
|
|
||||||
83. Boys Noize, &ME, Rampa, Adam Port, Keinemusik, Vinson – Crazy For It (feat. Vinson) (Original Mix) (6:01)
|
|
||||||
84. Space Motion & Luca Belluco – Baila Conmigo (Original Mix) (4:38)
|
|
||||||
85. Tom Westy & Dansyn – Feel Your Soul (Extended) (4:33)
|
|
||||||
86. CamelPhat & Josh Gigante – The One (Extended Mix) (5:32)
|
|
||||||
87. Argy & Omnya – Aria (Extended Mix) (5:14)
|
|
||||||
88. Air & Tur, blou., Airsand, TuraniQa – Teach Me (Extended Mix) (4:35)
|
|
||||||
89. Dilby, Martin Ka – Moving Trough (Extened Mix) (6:16)
|
|
||||||
90. Pablo Say – Repique (Original Mix) (4:48)
|
|
||||||
91. Orsay & Kotiēr – 1886 (Original Mix) (5:46)
|
|
||||||
92. Adriatique & Elderbrook – Lost In The Woods (Extended Mix) (5:01)
|
|
||||||
93. Ajna (BE) & Sentin – Bringing The Noise (Original Mix) (5:45)
|
|
||||||
94. Eric Prydz – Pjanoo (Club Mix) (7:30)
|
|
||||||
95. Eelke Kleijn – Feel It, See It (Extended Mix) (6:07)
|
|
||||||
96. Aera – Litoral (Original Mix) (6:10)
|
|
||||||
97. Julian Koerndl – Horses (Original Mix) (6:09)
|
|
||||||
98. Supermode – Tell Me Why (MEDUZA Extended Remix) (8:28)
|
|
||||||
99. Laherte – Pump Up The Jam (Extended Mix) (3:58)
|
|
||||||
100. M.O.S. – Changes (Extended Mix) (6:43)
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
|
|
||||||
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
|
|
||||||
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
|
|
||||||
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
|
|
||||||
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
|
|
||||||
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
|
|
||||||
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
|
|
||||||
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
|
|
||||||
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
|
|
||||||
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
|
|
||||||
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
|
|
||||||
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
|
|
||||||
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
|
|
||||||
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
|
|
||||||
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
|
|
||||||
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
|
|
||||||
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
|
|
||||||
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
|
|
||||||
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
|
|
||||||
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
|
|
||||||
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
|
|
||||||
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
|
|
||||||
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
|
|
||||||
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
|
|
||||||
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
|
|
||||||
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
|
|
||||||
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
|
|
||||||
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
|
|
||||||
+83
-30
@@ -10,7 +10,8 @@ import time
|
|||||||
import webbrowser
|
import webbrowser
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from core.config import load_config, save_config, VERSION
|
from core.config import load_config, save_config, VERSION, LICENSE_API_URL
|
||||||
|
from core import license as license_mod
|
||||||
from core.downloader import (
|
from core.downloader import (
|
||||||
download_playlist,
|
download_playlist,
|
||||||
download_direct_url,
|
download_direct_url,
|
||||||
@@ -26,6 +27,7 @@ from core.recorder import (
|
|||||||
start_recording,
|
start_recording,
|
||||||
stop_recording as recorder_stop,
|
stop_recording as recorder_stop,
|
||||||
is_recording,
|
is_recording,
|
||||||
|
get_last_stderr as recorder_last_stderr,
|
||||||
)
|
)
|
||||||
from core.upgrader import (
|
from core.upgrader import (
|
||||||
upgrade_folder,
|
upgrade_folder,
|
||||||
@@ -113,10 +115,15 @@ class Api:
|
|||||||
def get_init_data(self) -> dict:
|
def get_init_data(self) -> dict:
|
||||||
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
"""Chiamato dal frontend all'avvio per popolare lo stato iniziale."""
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
|
# Non spediamo il token raw al frontend (e' sensibile e non serve in UI)
|
||||||
|
safe_cfg = {k: v for k, v in cfg.items() if k != "license_token"}
|
||||||
|
license_status = license_mod.get_status(cfg)
|
||||||
return {
|
return {
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"config": cfg,
|
"config": safe_cfg,
|
||||||
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
"spotify_guide": SPOTIFY_GUIDE_TEXT,
|
||||||
|
"license": license_status,
|
||||||
|
"purchase_url": "https://musictools.djluza.com",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -128,7 +135,10 @@ class Api:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
threshold = 310
|
threshold = 310
|
||||||
|
|
||||||
config = {
|
# Merge sul config esistente per preservare i campi licenza
|
||||||
|
# (license_key/token/email/...), device_id, ecc.
|
||||||
|
config = load_config()
|
||||||
|
config.update({
|
||||||
"client_id": (payload.get("client_id") or "").strip(),
|
"client_id": (payload.get("client_id") or "").strip(),
|
||||||
"client_secret": (payload.get("client_secret") or "").strip(),
|
"client_secret": (payload.get("client_secret") or "").strip(),
|
||||||
"bitrate": payload.get("bitrate", "320K"),
|
"bitrate": payload.get("bitrate", "320K"),
|
||||||
@@ -136,40 +146,73 @@ class Api:
|
|||||||
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
"cookies_path": (payload.get("cookies_path") or "").strip(),
|
||||||
"output_dir": (payload.get("output_dir") or "").strip(),
|
"output_dir": (payload.get("output_dir") or "").strip(),
|
||||||
"theme": payload.get("theme", "dark"),
|
"theme": payload.get("theme", "dark"),
|
||||||
}
|
})
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
def check_update(self) -> dict:
|
# ------------------------------------------------------------------
|
||||||
"""Controlla aggiornamenti interrogando l'API GitHub Releases."""
|
# Licenza
|
||||||
import platform
|
# ------------------------------------------------------------------
|
||||||
import requests
|
def get_license_status(self) -> dict:
|
||||||
|
return license_mod.get_status()
|
||||||
|
|
||||||
API_URL = "https://api.github.com/repos/luzadev/musicdownload/releases/latest"
|
def activate_license(self, payload: dict) -> dict:
|
||||||
try:
|
try:
|
||||||
resp = requests.get(API_URL, headers={
|
status = license_mod.activate(
|
||||||
"User-Agent": "MusicTools",
|
payload.get("key") or "",
|
||||||
"Accept": "application/vnd.github+json",
|
payload.get("email") or "",
|
||||||
}, timeout=10)
|
)
|
||||||
resp.raise_for_status()
|
return {"ok": True, "license": status}
|
||||||
data = resp.json()
|
except license_mod.LicenseNetworkError as e:
|
||||||
|
return {"ok": False, "error": f"Impossibile contattare il server: {e}"}
|
||||||
|
except license_mod.LicenseError as e:
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
remote = data.get("tag_name", "")
|
def deactivate_license(self) -> dict:
|
||||||
notes = data.get("body", "") or ""
|
status = license_mod.deactivate(release_remote=True)
|
||||||
html_url = data.get("html_url", "")
|
return {"ok": True, "license": status}
|
||||||
|
|
||||||
# Trova l'asset giusto per la piattaforma
|
def revalidate_license(self) -> dict:
|
||||||
assets = data.get("assets", []) or []
|
"""Revalidate in foreground (chiamabile dalla UI 'Verifica ora')."""
|
||||||
|
status = license_mod.validate()
|
||||||
|
return {"ok": True, "license": status}
|
||||||
|
|
||||||
|
def open_purchase_page(self) -> dict:
|
||||||
|
webbrowser.open("https://musictools.djluza.com")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
def check_update(self) -> dict:
|
||||||
|
"""Controlla aggiornamenti interrogando l'API djluza.com.
|
||||||
|
|
||||||
|
Il server identifica la piattaforma e restituisce un URL di
|
||||||
|
download firmato a tempo. Senza un token di licenza valido
|
||||||
|
ritorna comunque la versione corrente ma con download_url vuoto.
|
||||||
|
"""
|
||||||
|
import platform
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
token = (cfg.get("license_token") or "").strip()
|
||||||
is_macos = platform.system() == "Darwin"
|
is_macos = platform.system() == "Darwin"
|
||||||
keyword = "macos" if is_macos else "windows"
|
plat = "macos" if is_macos else "windows"
|
||||||
download_url = ""
|
|
||||||
for a in assets:
|
url = f"{LICENSE_API_URL.rstrip('/')}/api/latest?platform={plat}¤t={VERSION}"
|
||||||
name = (a.get("name") or "").lower()
|
headers = {
|
||||||
if keyword in name and name.endswith(".zip"):
|
"User-Agent": f"MusicTools/{VERSION}",
|
||||||
download_url = a.get("browser_download_url", "")
|
"Accept": "application/json",
|
||||||
break
|
}
|
||||||
if not download_url:
|
if token:
|
||||||
download_url = html_url # fallback: pagina release
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, headers=headers, method="GET")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
remote = data.get("version", "")
|
||||||
|
notes = data.get("notes", "") or ""
|
||||||
|
download_url = data.get("download_url", "") or ""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
@@ -177,8 +220,13 @@ class Api:
|
|||||||
"remote": remote,
|
"remote": remote,
|
||||||
"is_new": bool(remote and remote != VERSION),
|
"is_new": bool(remote and remote != VERSION),
|
||||||
"download_url": download_url,
|
"download_url": download_url,
|
||||||
"notes": notes[:500] if notes else "", # tronca per UI
|
"notes": notes[:500] if notes else "",
|
||||||
|
"requires_license": not bool(download_url) and not token,
|
||||||
}
|
}
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"ok": False, "error": f"HTTP {e.code}: {e.reason}", "current": VERSION}
|
||||||
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
||||||
|
return {"ok": False, "error": f"Connessione fallita: {e}", "current": VERSION}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"ok": False, "error": str(e), "current": VERSION}
|
return {"ok": False, "error": str(e), "current": VERSION}
|
||||||
|
|
||||||
@@ -329,6 +377,11 @@ class Api:
|
|||||||
def is_audio_recording(self) -> dict:
|
def is_audio_recording(self) -> dict:
|
||||||
return {"recording": is_recording()}
|
return {"recording": is_recording()}
|
||||||
|
|
||||||
|
def get_recorder_log(self) -> dict:
|
||||||
|
"""Ritorna le ultime righe di stderr di ffmpeg dell'ultima sessione
|
||||||
|
di registrazione. Utile per diagnosticare un errore."""
|
||||||
|
return {"ok": True, "lines": recorder_last_stderr(80)}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Audio folder scan (Upgrade tab)
|
# Audio folder scan (Upgrade tab)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Genera l'icona dell'app (PNG/ICNS/ICO) in modo riproducibile.
|
||||||
|
|
||||||
|
Stile: squircle (Apple-style rounded rect) con gradient verde Spotify
|
||||||
|
verso nero, nota musicale bianca al centro con leggero glow, highlight
|
||||||
|
sottile in alto.
|
||||||
|
|
||||||
|
Uso:
|
||||||
|
python3 assets/generate_icon.py
|
||||||
|
|
||||||
|
Produce:
|
||||||
|
assets/icon.png (1024x1024, master)
|
||||||
|
assets/icon.icns (macOS, generato via iconutil)
|
||||||
|
assets/icon.ico (Windows, multi-size)
|
||||||
|
assets/icon.iconset/... (intermedio macOS)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
ROOT = HERE.parent
|
||||||
|
|
||||||
|
SIZE = 1024
|
||||||
|
RADIUS_RATIO = 0.225 # ~22% del lato, Apple iOS-like squircle
|
||||||
|
PADDING_RATIO = 0.085 # margine fra bordo immagine e squircle
|
||||||
|
|
||||||
|
# Palette (coerente col tema della UI)
|
||||||
|
GREEN_TOP = (30, 215, 96) # verde Spotify lucido
|
||||||
|
GREEN_BOTTOM = (15, 59, 34) # verde scuro profondo
|
||||||
|
WHITE = (255, 255, 255, 255)
|
||||||
|
HIGHLIGHT = (255, 255, 255, 38) # bianco semitrasparente per il riflesso
|
||||||
|
|
||||||
|
|
||||||
|
def vertical_gradient(size: int, top: Tuple[int, int, int],
|
||||||
|
bottom: Tuple[int, int, int]) -> Image.Image:
|
||||||
|
"""Crea un gradient verticale top -> bottom."""
|
||||||
|
img = Image.new("RGB", (size, size), top)
|
||||||
|
px = img.load()
|
||||||
|
for y in range(size):
|
||||||
|
t = y / (size - 1)
|
||||||
|
# ease (quad) per ammorbidire la transizione
|
||||||
|
t = t * t
|
||||||
|
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(size):
|
||||||
|
px[x, y] = (r, g, b)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def squircle_mask(size: int, radius: int, padding: int) -> Image.Image:
|
||||||
|
"""Maschera alpha per un rounded rect centrato con padding."""
|
||||||
|
mask = Image.new("L", (size, size), 0)
|
||||||
|
draw = ImageDraw.Draw(mask)
|
||||||
|
draw.rounded_rectangle(
|
||||||
|
(padding, padding, size - padding, size - padding),
|
||||||
|
radius=radius, fill=255,
|
||||||
|
)
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def find_music_font() -> str:
|
||||||
|
"""Trova un font che contenga il glifo della nota musicale (♫)."""
|
||||||
|
candidates = [
|
||||||
|
"/System/Library/Fonts/Apple Symbols.ttf",
|
||||||
|
"/System/Library/Fonts/Symbol.ttf",
|
||||||
|
"/System/Library/Fonts/SFNS.ttf",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
]
|
||||||
|
for f in candidates:
|
||||||
|
if Path(f).exists():
|
||||||
|
return f
|
||||||
|
raise RuntimeError("Nessun font Unicode con simboli trovato")
|
||||||
|
|
||||||
|
|
||||||
|
def draw_note(img: Image.Image, glyph: str = "♫") -> None:
|
||||||
|
"""Stampa la nota musicale centrata, in bianco con leggero glow."""
|
||||||
|
font_path = find_music_font()
|
||||||
|
target_pt = int(SIZE * 0.62)
|
||||||
|
font = ImageFont.truetype(font_path, target_pt)
|
||||||
|
|
||||||
|
cx, cy = SIZE // 2, SIZE // 2 - int(SIZE * 0.01)
|
||||||
|
|
||||||
|
# Glow: stessa nota disegnata sfocata sotto in bianco semitrasparente
|
||||||
|
glow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||||
|
glow_draw = ImageDraw.Draw(glow_layer)
|
||||||
|
glow_draw.text((cx, cy), glyph, font=font, fill=(255, 255, 255, 110),
|
||||||
|
anchor="mm")
|
||||||
|
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=24))
|
||||||
|
img.alpha_composite(glow_layer)
|
||||||
|
|
||||||
|
# Testo nitido sopra
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
draw.text((cx, cy), glyph, font=font, fill=WHITE, anchor="mm")
|
||||||
|
|
||||||
|
|
||||||
|
def make_master() -> Image.Image:
|
||||||
|
"""Genera l'immagine master 1024x1024 RGBA con tutto."""
|
||||||
|
padding = int(SIZE * PADDING_RATIO)
|
||||||
|
radius = int(SIZE * RADIUS_RATIO)
|
||||||
|
|
||||||
|
# Background gradient verde
|
||||||
|
gradient = vertical_gradient(SIZE, GREEN_TOP, GREEN_BOTTOM)
|
||||||
|
|
||||||
|
# Squircle: prendiamo il gradient mascherato come forma
|
||||||
|
mask = squircle_mask(SIZE, radius, padding)
|
||||||
|
base = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
base.paste(gradient, (0, 0), mask)
|
||||||
|
|
||||||
|
# Highlight in alto: banda sottile (tipo "shine" iOS), molto trasparente
|
||||||
|
hl_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
hl_draw = ImageDraw.Draw(hl_layer)
|
||||||
|
hl_top = padding + int(SIZE * 0.04)
|
||||||
|
hl_bot = padding + int(SIZE * 0.16)
|
||||||
|
hl_left = padding + int(SIZE * 0.10)
|
||||||
|
hl_right = SIZE - padding - int(SIZE * 0.10)
|
||||||
|
hl_draw.ellipse(
|
||||||
|
(hl_left, hl_top, hl_right, hl_bot),
|
||||||
|
fill=(255, 255, 255, 30),
|
||||||
|
)
|
||||||
|
hl_layer = hl_layer.filter(ImageFilter.GaussianBlur(radius=18))
|
||||||
|
masked = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
masked.paste(hl_layer, (0, 0), mask)
|
||||||
|
base.alpha_composite(masked)
|
||||||
|
|
||||||
|
# Bordo interno sottile per dare definizione (stroke da 0.5%)
|
||||||
|
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
ring_draw = ImageDraw.Draw(ring)
|
||||||
|
ring_draw.rounded_rectangle(
|
||||||
|
(padding, padding, SIZE - padding, SIZE - padding),
|
||||||
|
radius=radius,
|
||||||
|
outline=(255, 255, 255, 28),
|
||||||
|
width=max(2, int(SIZE * 0.004)),
|
||||||
|
)
|
||||||
|
base.alpha_composite(ring)
|
||||||
|
|
||||||
|
# Nota musicale al centro
|
||||||
|
draw_note(base)
|
||||||
|
|
||||||
|
# Lieve ombra interna in basso per profondita (vignette)
|
||||||
|
shade = Image.new("L", (SIZE, SIZE), 0)
|
||||||
|
sd = ImageDraw.Draw(shade)
|
||||||
|
sd.rounded_rectangle(
|
||||||
|
(padding, padding, SIZE - padding, SIZE - padding),
|
||||||
|
radius=radius, outline=255, width=int(SIZE * 0.025),
|
||||||
|
)
|
||||||
|
shade = shade.filter(ImageFilter.GaussianBlur(radius=int(SIZE * 0.025)))
|
||||||
|
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
|
||||||
|
overlay.paste((0, 0, 0, 60), mask=shade)
|
||||||
|
overlay = Image.new("RGBA", overlay.size, (0, 0, 0, 0)).convert("RGBA")
|
||||||
|
# Skip vignette complicata — base e' gia' coerente
|
||||||
|
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def export_iconset(master: Image.Image, outdir: Path) -> None:
|
||||||
|
"""Esporta le dimensioni standard macOS in outdir."""
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
sizes = [
|
||||||
|
(16, "icon_16x16.png"),
|
||||||
|
(32, "icon_16x16@2x.png"),
|
||||||
|
(32, "icon_32x32.png"),
|
||||||
|
(64, "icon_32x32@2x.png"),
|
||||||
|
(128, "icon_128x128.png"),
|
||||||
|
(256, "icon_128x128@2x.png"),
|
||||||
|
(256, "icon_256x256.png"),
|
||||||
|
(512, "icon_256x256@2x.png"),
|
||||||
|
(512, "icon_512x512.png"),
|
||||||
|
(1024, "icon_512x512@2x.png"),
|
||||||
|
]
|
||||||
|
for px, name in sizes:
|
||||||
|
img = master.resize((px, px), Image.LANCZOS)
|
||||||
|
img.save(outdir / name, "PNG")
|
||||||
|
|
||||||
|
|
||||||
|
def build_icns(iconset_dir: Path, output_icns: Path) -> bool:
|
||||||
|
"""Usa iconutil di macOS per generare il .icns. Ritorna True se ok."""
|
||||||
|
if not shutil.which("iconutil"):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(output_icns)],
|
||||||
|
check=True, capture_output=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def build_ico(master: Image.Image, output_ico: Path) -> None:
|
||||||
|
"""Genera il .ico multi-size per Windows."""
|
||||||
|
sizes = [(16, 16), (24, 24), (32, 32), (48, 48),
|
||||||
|
(64, 64), (128, 128), (256, 256)]
|
||||||
|
master.save(output_ico, format="ICO",
|
||||||
|
sizes=sizes, append_images=[])
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print(f">>> Genero icona master {SIZE}x{SIZE}...")
|
||||||
|
master = make_master()
|
||||||
|
master_path = HERE / "icon.png"
|
||||||
|
master.save(master_path, "PNG")
|
||||||
|
print(f" {master_path}")
|
||||||
|
|
||||||
|
iconset_dir = HERE / "icon.iconset"
|
||||||
|
if iconset_dir.exists():
|
||||||
|
shutil.rmtree(iconset_dir)
|
||||||
|
print(">>> Esporto iconset macOS...")
|
||||||
|
export_iconset(master, iconset_dir)
|
||||||
|
|
||||||
|
icns_path = HERE / "icon.icns"
|
||||||
|
if build_icns(iconset_dir, icns_path):
|
||||||
|
print(f" {icns_path}")
|
||||||
|
else:
|
||||||
|
print(" SKIP icns (iconutil non disponibile)")
|
||||||
|
|
||||||
|
print(">>> Genero icon.ico per Windows...")
|
||||||
|
ico_path = HERE / "icon.ico"
|
||||||
|
build_ico(master, ico_path)
|
||||||
|
print(f" {ico_path}")
|
||||||
|
|
||||||
|
print("\nDone.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
+6
-1
@@ -208,12 +208,17 @@ def run_pyinstaller():
|
|||||||
if f.is_file():
|
if f.is_file():
|
||||||
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
add_binaries.extend(["--add-binary", f"{f}:lib"])
|
||||||
|
|
||||||
|
icon_path = ROOT / "assets" / "icon.icns"
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-m", "PyInstaller",
|
sys.executable, "-m", "PyInstaller",
|
||||||
"--name", "MusicTools",
|
"--name", "MusicTools",
|
||||||
"--windowed",
|
"--windowed",
|
||||||
"--onedir",
|
"--onedir",
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
|
]
|
||||||
|
if icon_path.exists():
|
||||||
|
cmd.extend(["--icon", str(icon_path)])
|
||||||
|
cmd.extend([
|
||||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||||
"--add-data", f"{ROOT / 'webui'}:webui",
|
"--add-data", f"{ROOT / 'webui'}:webui",
|
||||||
# Hidden imports per pywebview (WebKit su macOS)
|
# Hidden imports per pywebview (WebKit su macOS)
|
||||||
@@ -225,7 +230,7 @@ def run_pyinstaller():
|
|||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
str(ROOT / "main.py"),
|
str(ROOT / "main.py"),
|
||||||
]
|
])
|
||||||
|
|
||||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||||
|
|||||||
+6
-3
@@ -99,14 +99,17 @@ def run_pyinstaller():
|
|||||||
if f.is_file() and f.suffix in (".exe", ""):
|
if f.is_file() and f.suffix in (".exe", ""):
|
||||||
add_binaries.extend(["--add-binary", f"{f};."])
|
add_binaries.extend(["--add-binary", f"{f};."])
|
||||||
|
|
||||||
|
icon_path = ROOT / "assets" / "icon.ico"
|
||||||
cmd = [
|
cmd = [
|
||||||
sys.executable, "-m", "PyInstaller",
|
sys.executable, "-m", "PyInstaller",
|
||||||
"--name", "MusicTools",
|
"--name", "MusicTools",
|
||||||
"--windowed",
|
"--windowed",
|
||||||
"--onedir",
|
"--onedir",
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
# Icona (se presente)
|
]
|
||||||
# "--icon", str(ROOT / "icon.ico"),
|
if icon_path.exists():
|
||||||
|
cmd.extend(["--icon", str(icon_path)])
|
||||||
|
cmd.extend([
|
||||||
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
# Web UI assets (HTML/CSS/JS) — usati da pywebview
|
||||||
"--add-data", f"{ROOT / 'webui'};webui",
|
"--add-data", f"{ROOT / 'webui'};webui",
|
||||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||||
@@ -121,7 +124,7 @@ def run_pyinstaller():
|
|||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
str(ROOT / "main.py"),
|
str(ROOT / "main.py"),
|
||||||
]
|
])
|
||||||
|
|
||||||
log(f"Comando: {' '.join(cmd[:10])}...")
|
log(f"Comando: {' '.join(cmd[:10])}...")
|
||||||
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
subprocess.run(cmd, check=True, cwd=str(ROOT))
|
||||||
|
|||||||
+19
-1
@@ -5,12 +5,23 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.4.0"
|
VERSION = "v1.6.0"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
_LEGACY_NAME = "MusicDownload"
|
_LEGACY_NAME = "MusicDownload"
|
||||||
|
|
||||||
|
# Endpoint dell'API di licenza/aggiornamenti. Cambialo qui per puntare
|
||||||
|
# a un ambiente di staging.
|
||||||
|
LICENSE_API_URL = "https://musictools.djluza.com"
|
||||||
|
|
||||||
|
# Quanti giorni puo' restare l'app offline prima di richiedere una
|
||||||
|
# nuova validazione contro il server.
|
||||||
|
LICENSE_GRACE_DAYS = 14
|
||||||
|
|
||||||
|
# Ogni quanti giorni l'app rivalida la licenza in background quando online.
|
||||||
|
LICENSE_REVALIDATE_DAYS = 7
|
||||||
|
|
||||||
|
|
||||||
def _get_config_dir() -> Path:
|
def _get_config_dir() -> Path:
|
||||||
"""Ritorna la directory per config.json.
|
"""Ritorna la directory per config.json.
|
||||||
@@ -59,6 +70,13 @@ DEFAULTS = {
|
|||||||
"cookies_path": str(_project_dir / "cookies.txt"),
|
"cookies_path": str(_project_dir / "cookies.txt"),
|
||||||
"output_dir": str(_project_dir / "MUSICA"),
|
"output_dir": str(_project_dir / "MUSICA"),
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
|
# ---- Licenza ----
|
||||||
|
"license_key": "", # chiave fornita all'utente via email
|
||||||
|
"license_email": "", # email associata all'acquisto
|
||||||
|
"license_token": "", # JWT firmato dal server (claims offline)
|
||||||
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+11
-6
@@ -9,7 +9,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ytdlp, find_ffmpeg_dir
|
from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
|
||||||
|
|
||||||
|
|
||||||
# Flag globale per interruzione
|
# Flag globale per interruzione
|
||||||
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
|
|||||||
if cookies_path and Path(cookies_path).exists():
|
if cookies_path and Path(cookies_path).exists():
|
||||||
cmd.extend(["--cookies", cookies_path])
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
|
||||||
|
|
||||||
@@ -146,8 +146,10 @@ def download_playlist(
|
|||||||
progress_callback(i, total, "", "stopped", 0)
|
progress_callback(i, total, "", "stopped", 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
query = f"{track['name']} - {track['artist']}"
|
artist = (track.get('artist') or "").strip()
|
||||||
track_only = track['name']
|
name = (track.get('name') or "").strip()
|
||||||
|
query = f"{name} - {artist}" if artist else name
|
||||||
|
track_only = name
|
||||||
|
|
||||||
# Salta brani gia scaricati in precedenza (file di tracking)
|
# Salta brani gia scaricati in precedenza (file di tracking)
|
||||||
if query in done_set:
|
if query in done_set:
|
||||||
@@ -204,6 +206,7 @@ def download_playlist(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
@@ -278,7 +281,7 @@ def download_direct_url(
|
|||||||
probe_cmd.extend(["--cookies", cookies_path])
|
probe_cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError(result.stderr.strip())
|
raise ValueError(result.stderr.strip())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -356,6 +359,7 @@ def download_direct_url(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
@@ -443,7 +447,7 @@ def download_video(
|
|||||||
probe_cmd.extend(["--cookies", cookies_path])
|
probe_cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60)
|
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError(result.stderr.strip())
|
raise ValueError(result.stderr.strip())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -522,6 +526,7 @@ def download_video(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
|
|||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
"""Gestione licenze MusicTools.
|
||||||
|
|
||||||
|
Flusso:
|
||||||
|
1. L'utente compra su musictools.djluza.com -> riceve license_key via email.
|
||||||
|
2. Al primo avvio l'app mostra schermata bloccante: chiede email + key.
|
||||||
|
3. activate() chiama POST {LICENSE_API_URL}/api/license/activate
|
||||||
|
passando key, email, device_id (+ device_name). Il server:
|
||||||
|
- verifica che la key esista e non abbia superato max attivazioni;
|
||||||
|
- registra il device_id come attivo;
|
||||||
|
- ritorna un JWT con claims {sub: license_id, exp, email, key_id}.
|
||||||
|
4. Il token viene salvato in config.json. Il client lo considera valido
|
||||||
|
per LICENSE_REVALIDATE_DAYS senza ricontrollare il server; oltre
|
||||||
|
LICENSE_GRACE_DAYS chiede sempre nuova validazione online.
|
||||||
|
5. validate() chiama POST /api/license/validate con {token, device_id}
|
||||||
|
in background. Il server puo' revocare (refund, abuse) ritornando
|
||||||
|
401 -> il client cancella il token e torna a schermata attivazione.
|
||||||
|
|
||||||
|
Nota: non verifichiamo la firma JWT lato client (servirebbe la public
|
||||||
|
key). Ci fidiamo del token perche' e' uscito dal server al momento
|
||||||
|
dell'attivazione, e ogni N giorni lo facciamo rivalidare. Per i refund
|
||||||
|
non immediati basta il check periodico.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import platform
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from core.config import (
|
||||||
|
LICENSE_API_URL,
|
||||||
|
LICENSE_GRACE_DAYS,
|
||||||
|
LICENSE_REVALIDATE_DAYS,
|
||||||
|
VERSION,
|
||||||
|
load_config,
|
||||||
|
save_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_HTTP_TIMEOUT = 15 # secondi
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Errori
|
||||||
|
# ============================================================
|
||||||
|
class LicenseError(Exception):
|
||||||
|
"""Errore di attivazione/validazione licenza."""
|
||||||
|
|
||||||
|
|
||||||
|
class LicenseNetworkError(LicenseError):
|
||||||
|
"""Impossibile raggiungere il server (offline, DNS, ecc.)."""
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Utility
|
||||||
|
# ============================================================
|
||||||
|
def _now() -> int:
|
||||||
|
return int(time.time())
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_device_id(config: dict) -> str:
|
||||||
|
"""Garantisce che config abbia un device_id stabile e univoco."""
|
||||||
|
did = (config.get("device_id") or "").strip()
|
||||||
|
if not did:
|
||||||
|
did = str(uuid.uuid4())
|
||||||
|
config["device_id"] = did
|
||||||
|
save_config(config)
|
||||||
|
return did
|
||||||
|
|
||||||
|
|
||||||
|
def _device_name() -> str:
|
||||||
|
"""Nome leggibile per identificare il device lato server (UI utente)."""
|
||||||
|
try:
|
||||||
|
return f"{platform.node() or 'device'} ({platform.system()})"
|
||||||
|
except Exception:
|
||||||
|
return "device"
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_jwt_claims(token: str) -> dict:
|
||||||
|
"""Decodifica i claims di un JWT senza verificare la firma.
|
||||||
|
|
||||||
|
Ritorna {} se il token e' malformato. La verifica vera e' fatta
|
||||||
|
dal server quando rivalidiamo online; qui ci serve solo leggere
|
||||||
|
exp/email per la UI.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
parts = token.split(".")
|
||||||
|
if len(parts) != 3:
|
||||||
|
return {}
|
||||||
|
payload = parts[1]
|
||||||
|
# base64url -> base64 standard (padding)
|
||||||
|
payload += "=" * (-len(payload) % 4)
|
||||||
|
raw = base64.urlsafe_b64decode(payload.encode("ascii"))
|
||||||
|
data = json.loads(raw.decode("utf-8"))
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _http_post(path: str, body: dict) -> dict:
|
||||||
|
"""POST JSON verso LICENSE_API_URL{path}. Ritorna il JSON di risposta.
|
||||||
|
|
||||||
|
Solleva LicenseNetworkError per problemi di rete e LicenseError
|
||||||
|
per risposte HTTP 4xx/5xx (con messaggio dal server quando disponibile).
|
||||||
|
"""
|
||||||
|
url = LICENSE_API_URL.rstrip("/") + path
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url, data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": f"MusicTools/{VERSION}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=_HTTP_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)
|
||||||
|
msg = err_data.get("error") or err_data.get("message") or e.reason
|
||||||
|
except Exception:
|
||||||
|
msg = e.reason or f"HTTP {e.code}"
|
||||||
|
raise LicenseError(str(msg))
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API pubblica
|
||||||
|
# ============================================================
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
cfg = config or load_config()
|
||||||
|
token = (cfg.get("license_token") or "").strip()
|
||||||
|
key = (cfg.get("license_key") or "").strip()
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not token or not key:
|
||||||
|
return {
|
||||||
|
"licensed": False,
|
||||||
|
"reason": "not_activated",
|
||||||
|
"email": "",
|
||||||
|
"key": "",
|
||||||
|
"activated_at": 0,
|
||||||
|
"last_validated_at": 0,
|
||||||
|
"days_since_validation": 0,
|
||||||
|
"needs_revalidation": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
days_since = (_now() - last_val) // 86400 if last_val else 999
|
||||||
|
needs_reval = days_since >= LICENSE_REVALIDATE_DAYS
|
||||||
|
grace_expired = days_since >= LICENSE_GRACE_DAYS
|
||||||
|
|
||||||
|
if grace_expired:
|
||||||
|
return {
|
||||||
|
"licensed": False,
|
||||||
|
"reason": "grace_expired",
|
||||||
|
"email": email,
|
||||||
|
"key": key,
|
||||||
|
"activated_at": activated,
|
||||||
|
"last_validated_at": last_val,
|
||||||
|
"days_since_validation": days_since,
|
||||||
|
"needs_revalidation": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"licensed": True,
|
||||||
|
"reason": "ok",
|
||||||
|
"email": email,
|
||||||
|
"key": key,
|
||||||
|
"activated_at": activated,
|
||||||
|
"last_validated_at": last_val,
|
||||||
|
"days_since_validation": days_since,
|
||||||
|
"needs_revalidation": needs_reval,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def is_licensed() -> bool:
|
||||||
|
"""Helper rapido per gating delle azioni."""
|
||||||
|
return get_status()["licensed"]
|
||||||
|
|
||||||
|
|
||||||
|
def activate(license_key: str, email: str) -> dict:
|
||||||
|
"""Attiva una licenza contro il server. Salva token su success.
|
||||||
|
|
||||||
|
Ritorna dict con le chiavi di get_status(). Solleva LicenseError
|
||||||
|
o LicenseNetworkError in caso di fallimento.
|
||||||
|
"""
|
||||||
|
key = (license_key or "").strip()
|
||||||
|
mail = (email or "").strip().lower()
|
||||||
|
if not key or not mail:
|
||||||
|
raise LicenseError("Inserisci email e chiave di licenza.")
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
device_id = _ensure_device_id(cfg)
|
||||||
|
|
||||||
|
resp = _http_post("/api/license/activate", {
|
||||||
|
"key": key,
|
||||||
|
"email": mail,
|
||||||
|
"device_id": device_id,
|
||||||
|
"device_name": _device_name(),
|
||||||
|
"app_version": VERSION,
|
||||||
|
})
|
||||||
|
|
||||||
|
token = (resp.get("token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise LicenseError(resp.get("error") or "Risposta server non valida.")
|
||||||
|
|
||||||
|
now = _now()
|
||||||
|
cfg["license_key"] = key
|
||||||
|
cfg["license_email"] = mail
|
||||||
|
cfg["license_token"] = token
|
||||||
|
cfg["license_activated_at"] = int(resp.get("activated_at") or now)
|
||||||
|
cfg["last_validated_at"] = now
|
||||||
|
save_config(cfg)
|
||||||
|
return get_status(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def validate() -> dict:
|
||||||
|
"""Rivalida il token corrente contro il server (background).
|
||||||
|
|
||||||
|
Aggiorna last_validated_at su success. Su 401 (revoca/refund)
|
||||||
|
azzera il token cosi' la prossima get_status ritorna not_activated.
|
||||||
|
Su errori di rete non fa nulla (resta valido fino al grace).
|
||||||
|
"""
|
||||||
|
cfg = load_config()
|
||||||
|
token = (cfg.get("license_token") or "").strip()
|
||||||
|
if not token:
|
||||||
|
return get_status(cfg)
|
||||||
|
|
||||||
|
device_id = _ensure_device_id(cfg)
|
||||||
|
try:
|
||||||
|
resp = _http_post("/api/license/validate", {
|
||||||
|
"token": token,
|
||||||
|
"device_id": device_id,
|
||||||
|
"app_version": VERSION,
|
||||||
|
})
|
||||||
|
except LicenseNetworkError:
|
||||||
|
return get_status(cfg)
|
||||||
|
except LicenseError:
|
||||||
|
# Server ha risposto 4xx -> token non piu valido
|
||||||
|
cfg["license_token"] = ""
|
||||||
|
cfg["last_validated_at"] = 0
|
||||||
|
save_config(cfg)
|
||||||
|
return get_status(cfg)
|
||||||
|
|
||||||
|
# Server puo' inviare un token rinnovato (rotazione)
|
||||||
|
new_token = (resp.get("token") or "").strip()
|
||||||
|
if new_token:
|
||||||
|
cfg["license_token"] = new_token
|
||||||
|
cfg["last_validated_at"] = _now()
|
||||||
|
save_config(cfg)
|
||||||
|
return get_status(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def deactivate(release_remote: bool = True) -> dict:
|
||||||
|
"""Disattiva la licenza su questo device.
|
||||||
|
|
||||||
|
Se release_remote, notifica il server cosi' libera lo slot
|
||||||
|
di attivazione (utile per spostare l'app su un altro device).
|
||||||
|
Sempre azzera i campi licenza locali, anche se il server e' offline.
|
||||||
|
"""
|
||||||
|
cfg = load_config()
|
||||||
|
token = (cfg.get("license_token") or "").strip()
|
||||||
|
device_id = (cfg.get("device_id") or "").strip()
|
||||||
|
|
||||||
|
if release_remote and token and device_id:
|
||||||
|
try:
|
||||||
|
_http_post("/api/license/deactivate", {
|
||||||
|
"token": token,
|
||||||
|
"device_id": device_id,
|
||||||
|
})
|
||||||
|
except (LicenseError, LicenseNetworkError):
|
||||||
|
# Lo facciamo local-only se il server non risponde.
|
||||||
|
pass
|
||||||
|
|
||||||
|
cfg["license_key"] = ""
|
||||||
|
cfg["license_email"] = ""
|
||||||
|
cfg["license_token"] = ""
|
||||||
|
cfg["license_activated_at"] = 0
|
||||||
|
cfg["last_validated_at"] = 0
|
||||||
|
save_config(cfg)
|
||||||
|
return get_status(cfg)
|
||||||
@@ -48,6 +48,19 @@ def _exe(name: str) -> str:
|
|||||||
return name
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def subprocess_flags() -> dict:
|
||||||
|
"""Kwargs per subprocess.run/Popen che nascondono la console su Windows.
|
||||||
|
|
||||||
|
Senza CREATE_NO_WINDOW, ogni subprocess (yt-dlp, ffmpeg, ffprobe) lanciato
|
||||||
|
da un'app GUI PyInstaller apre una finestra cmd nera per la durata del
|
||||||
|
processo. Su macOS/Linux il flag non esiste, quindi ritorniamo dict vuoto.
|
||||||
|
"""
|
||||||
|
if _IS_WINDOWS:
|
||||||
|
# 0x08000000 == CREATE_NO_WINDOW
|
||||||
|
return {"creationflags": 0x08000000}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def find_ytdlp() -> str:
|
def find_ytdlp() -> str:
|
||||||
"""Trova il path di yt-dlp.
|
"""Trova il path di yt-dlp.
|
||||||
|
|
||||||
|
|||||||
+58
-16
@@ -15,7 +15,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ffmpeg
|
from core.paths import find_ffmpeg, subprocess_flags
|
||||||
|
|
||||||
|
|
||||||
_IS_MAC = sys.platform == "darwin"
|
_IS_MAC = sys.platform == "darwin"
|
||||||
@@ -79,6 +79,7 @@ def _list_macos(ffmpeg: str) -> list[dict]:
|
|||||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
||||||
"-list_devices", "true", "-i", ""],
|
"-list_devices", "true", "-i", ""],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@@ -116,6 +117,7 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
|||||||
[ffmpeg, "-hide_banner", "-f", "dshow",
|
[ffmpeg, "-hide_banner", "-f", "dshow",
|
||||||
"-list_devices", "true", "-i", "dummy"],
|
"-list_devices", "true", "-i", "dummy"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
@@ -247,6 +249,7 @@ def start_recording(
|
|||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=False,
|
text=False,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
drain_t = threading.Thread(target=_drain_stderr,
|
drain_t = threading.Thread(target=_drain_stderr,
|
||||||
@@ -332,39 +335,78 @@ def start_recording(
|
|||||||
def _extract_friendly_error(rc: int) -> str:
|
def _extract_friendly_error(rc: int) -> str:
|
||||||
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
||||||
Riconosce i casi tipici: permessi microfono, device occupato,
|
Riconosce i casi tipici: permessi microfono, device occupato,
|
||||||
device non disponibile, ..."""
|
device non disponibile, clock drift di BlackHole, ecc.
|
||||||
|
|
||||||
|
Fallback: se non riconosce il pattern, ritorna le ultime righe
|
||||||
|
significative dello stderr cosi' l'utente puo' diagnosticare
|
||||||
|
o aprire un ticket.
|
||||||
|
"""
|
||||||
with _stderr_lock:
|
with _stderr_lock:
|
||||||
lines = list(_stderr_buf)
|
lines = list(_stderr_buf)
|
||||||
|
|
||||||
text = "\n".join(lines).lower()
|
text = "\n".join(lines).lower()
|
||||||
|
is_mac = sys.platform == "darwin"
|
||||||
|
|
||||||
if "abort" in text and rc == -6 and sys.platform == "darwin":
|
# ---- Errori specifici (priorita' alta) ----
|
||||||
|
|
||||||
|
if "input/output error" in text or "errno 22" in text:
|
||||||
return (
|
return (
|
||||||
"ffmpeg abortito (errore -6): probabilmente l'app non ha il "
|
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
|
||||||
"permesso 'Microfono' di macOS. Vai su Preferenze di Sistema "
|
"Spesso si risolve cosi':\n"
|
||||||
"→ Privacy e sicurezza → Microfono e abilita Terminal/Python. "
|
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
|
||||||
"Poi riavvia l'app."
|
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
|
||||||
|
" 3) Verifica che nessun'altra app stia gia' registrando il device"
|
||||||
)
|
)
|
||||||
|
|
||||||
if any(s in text for s in ("input/output error", "errno 22", "device not configured")):
|
if "device not configured" in text or "device not available" in text:
|
||||||
return "Dispositivo non disponibile (riavvia il servizio audio o ricontrolla la selezione)."
|
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
|
||||||
|
|
||||||
if "permission" in text or "not authorized" in text or "not permitted" in text:
|
if "permission" in text or "not authorized" in text or "not permitted" in text \
|
||||||
return "Permesso microfono negato da macOS. Abilita Python/MusicTools in Privacy → Microfono."
|
or "tcc" in text or "denied" in text:
|
||||||
|
return _permission_message()
|
||||||
|
|
||||||
if "no such device" in text or "no such audio device" in text:
|
if "no such device" in text or "no such audio device" in text or "invalid device" in text:
|
||||||
return "Dispositivo non trovato. Premi '↻ Aggiorna' e riseleziona."
|
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona dalla lista."
|
||||||
|
|
||||||
# Restituisci l'ultima riga utile dello stderr (esclude le righe di banner/progress)
|
# ---- Macro per rc=-6 (SIGABRT) su macOS ----
|
||||||
|
# Su macOS rc=-6 e' quasi sempre il sintomo di un crash di ffmpeg
|
||||||
|
# dovuto al permesso Microfono mancante. La parola 'abort' non
|
||||||
|
# sempre compare nello stderr di ffmpeg recenti.
|
||||||
|
if rc == -6 and is_mac:
|
||||||
|
return _permission_message()
|
||||||
|
|
||||||
|
# ---- Fallback: cerca le ultime righe significative ----
|
||||||
for line in reversed(lines):
|
for line in reversed(lines):
|
||||||
l = line.strip()
|
l = line.strip()
|
||||||
if not l:
|
if not l:
|
||||||
continue
|
continue
|
||||||
|
low = l.lower()
|
||||||
|
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
|
||||||
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
||||||
return l
|
return l
|
||||||
if "error" in l.lower() or "fail" in l.lower() or "denied" in l.lower():
|
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
|
||||||
return l
|
return l
|
||||||
return f"ffmpeg exit {rc}"
|
|
||||||
|
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
|
||||||
|
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||||
|
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||||
|
|
||||||
|
|
||||||
|
def _permission_message() -> str:
|
||||||
|
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
|
||||||
|
return (
|
||||||
|
"Permesso Microfono mancante. Su macOS:\n"
|
||||||
|
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||||
|
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
|
||||||
|
" (se usi l'app installata). Poi riavvia l'app."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||||
|
"""Espone le ultime righe di stderr ai fini diagnostici.
|
||||||
|
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
|
||||||
|
with _stderr_lock:
|
||||||
|
return list(_stderr_buf[-max_lines:])
|
||||||
|
|
||||||
|
|
||||||
def stop_recording() -> dict:
|
def stop_recording() -> dict:
|
||||||
|
|||||||
+6
-3
@@ -9,7 +9,7 @@ import threading
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Callable, Optional
|
||||||
|
|
||||||
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe
|
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
|
||||||
|
|
||||||
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
data = json.loads(result.stdout)
|
data = json.loads(result.stdout)
|
||||||
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
|
bit_rate = int(data.get("format", {}).get("bit_rate", 0))
|
||||||
@@ -94,7 +95,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
|
|||||||
if cookies_path and Path(cookies_path).exists():
|
if cookies_path and Path(cookies_path).exists():
|
||||||
cmd.extend(["--cookies", cookies_path])
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError("Ricerca fallita")
|
raise ValueError("Ricerca fallita")
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ def update_cover_only(
|
|||||||
if cookies_path and Path(cookies_path).exists():
|
if cookies_path and Path(cookies_path).exists():
|
||||||
cmd.extend(["--cookies", cookies_path])
|
cmd.extend(["--cookies", cookies_path])
|
||||||
|
|
||||||
subprocess.run(cmd, capture_output=True, timeout=60)
|
subprocess.run(cmd, capture_output=True, timeout=60, **subprocess_flags())
|
||||||
|
|
||||||
cover_files = list(temp_dir.glob("cover*.jpg"))
|
cover_files = list(temp_dir.glob("cover*.jpg"))
|
||||||
if not cover_files:
|
if not cover_files:
|
||||||
@@ -152,6 +153,7 @@ def update_cover_only(
|
|||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=60,
|
timeout=60,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
_cleanup_temp(temp_dir)
|
_cleanup_temp(temp_dir)
|
||||||
@@ -284,6 +286,7 @@ def upgrade_folder(
|
|||||||
with _process_lock:
|
with _process_lock:
|
||||||
_current_process = subprocess.Popen(
|
_current_process = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||||||
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
for line in _current_process.stdout:
|
for line in _current_process.stdout:
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# MusicTools — Landing page
|
||||||
|
|
||||||
|
Sito vetrina + checkout per MusicTools, deployato su Cloudflare Pages al dominio `musictools.djluza.com`.
|
||||||
|
|
||||||
|
## Struttura
|
||||||
|
|
||||||
|
```
|
||||||
|
landing/
|
||||||
|
index.html # landing principale
|
||||||
|
css/style.css # tema dark coerente con l'app
|
||||||
|
js/main.js # FAQ accordion, animazioni scroll, hook Lemon Squeezy
|
||||||
|
assets/ # favicon, immagini eventuali
|
||||||
|
legal/
|
||||||
|
terms.html # termini di servizio
|
||||||
|
privacy.html # informativa privacy GDPR
|
||||||
|
refund.html # politica rimborsi
|
||||||
|
_headers # security + cache (Cloudflare Pages syntax)
|
||||||
|
_redirects # redirect rules
|
||||||
|
```
|
||||||
|
|
||||||
|
Nessun build step. Tutto e' HTML/CSS/JS statico servito così com'è.
|
||||||
|
|
||||||
|
## Anteprima locale
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd landing
|
||||||
|
python3 -m http.server 8080
|
||||||
|
# apri http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
Su `file://` Lemon Squeezy overlay non funziona — ma il sito si vede tutto.
|
||||||
|
|
||||||
|
## Deploy su Cloudflare Pages
|
||||||
|
|
||||||
|
**Una volta sola:**
|
||||||
|
|
||||||
|
1. Vai su <https://dash.cloudflare.com> → **Workers & Pages** → **Create application** → **Pages** → **Connect to Git**.
|
||||||
|
2. Scegli il repo `luzadev/musicdownload`.
|
||||||
|
3. Configura:
|
||||||
|
- **Production branch**: `main`
|
||||||
|
- **Build command**: *(lascia vuoto)*
|
||||||
|
- **Build output directory**: `landing`
|
||||||
|
- **Root directory**: *(lascia vuoto)*
|
||||||
|
4. Salva. La build parte automatica — l'URL iniziale sara' `<project>.pages.dev`.
|
||||||
|
5. Una volta verde, vai su **Custom domains** → **Set up a custom domain** → digita `musictools.djluza.com` → conferma.
|
||||||
|
6. Cloudflare crea automaticamente il CNAME se il dominio e' gia su Cloudflare DNS.
|
||||||
|
|
||||||
|
Ogni `git push origin main` rilancia la build di Pages — push-to-deploy gratis.
|
||||||
|
|
||||||
|
## Routing /api/* vs landing
|
||||||
|
|
||||||
|
Il Worker di `server/` deve essere associato alla route `musictools.djluza.com/api/*` (vedi `server/README.md`).
|
||||||
|
Pages serve tutto il resto. Verifica nel dashboard:
|
||||||
|
|
||||||
|
- **Workers & Pages** → `musictools-api` → **Settings** → **Triggers**:
|
||||||
|
- Aggiungi route `musictools.djluza.com/api/*`
|
||||||
|
- **Pages** → `musictools-landing` (o nome che scegli) → **Custom domains**:
|
||||||
|
- `musictools.djluza.com`
|
||||||
|
|
||||||
|
Cloudflare applica prima la regola del Worker (path-specific) e poi quella di Pages (catch-all).
|
||||||
|
|
||||||
|
## Da fare prima del lancio
|
||||||
|
|
||||||
|
- [ ] Sostituisci `REPLACE_WITH_LS_PRODUCT_URL` in `index.html` con l'URL prodotto Lemon Squeezy
|
||||||
|
- [ ] Crea immagine OG 1200x630 e referenziarla nei meta `og:image`
|
||||||
|
- [ ] Aggiungi screenshot reali (sostituiscono il mockup CSS nell'hero se preferisci)
|
||||||
|
- [ ] Verifica i tre documenti legali con un consulente fiscale se vendi in regime di forfettario / partita IVA italiana
|
||||||
|
- [ ] Aggiungi Google Search Console (verification meta) per indicizzazione
|
||||||
|
|
||||||
|
## Analytics
|
||||||
|
|
||||||
|
Puoi aggiungere Cloudflare Web Analytics (gratuito, no cookie) dal dashboard Pages → Analytics. Si attiva con un click — niente codice da incollare.
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Sicurezza + cache base.
|
||||||
|
# Sintassi Cloudflare Pages: https://developers.cloudflare.com/pages/configuration/headers/
|
||||||
|
|
||||||
|
/*
|
||||||
|
Strict-Transport-Security: max-age=31536000; includeSubDomains
|
||||||
|
X-Content-Type-Options: nosniff
|
||||||
|
Referrer-Policy: strict-origin-when-cross-origin
|
||||||
|
Permissions-Policy: interest-cohort=()
|
||||||
|
|
||||||
|
/css/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/js/*
|
||||||
|
Cache-Control: public, max-age=31536000, immutable
|
||||||
|
|
||||||
|
/assets/*
|
||||||
|
Cache-Control: public, max-age=2592000
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Redirects (sintassi Cloudflare Pages, Netlify-style).
|
||||||
|
# Niente regola per /api/* qui: lo gestisce il Worker (route definita
|
||||||
|
# nel dashboard Cloudflare). Pages serve il resto.
|
||||||
|
|
||||||
|
# Quando implementeremo /api/download (stream firmato R2->client), abilitare:
|
||||||
|
# /download/macos /api/download/macos 302
|
||||||
|
# /download/windows /api/download/windows 302
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1db954"/>
|
||||||
|
<stop offset="100%" stop-color="#137a37"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="14" fill="url(#g)"/>
|
||||||
|
<text x="32" y="44" font-family="-apple-system, Segoe UI, sans-serif" font-weight="800" font-size="36" text-anchor="middle" fill="#fff">♫</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 466 B |
@@ -0,0 +1,777 @@
|
|||||||
|
/* ============================================================
|
||||||
|
MusicTools — Landing page
|
||||||
|
Palette coerente con l'app: tema scuro Spotify-like.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0a0a0a;
|
||||||
|
--bg-2: #060606;
|
||||||
|
--bg-card: #161616;
|
||||||
|
--bg-card-2: #1e1e1e;
|
||||||
|
--border: #262626;
|
||||||
|
--divider: #1f1f1f;
|
||||||
|
--text: #ffffff;
|
||||||
|
--text-muted: #a3a3a3;
|
||||||
|
--text-dim: #707070;
|
||||||
|
--accent: #1db954;
|
||||||
|
--accent-dark: #137a37;
|
||||||
|
--accent-glow: rgba(29, 185, 84, 0.35);
|
||||||
|
|
||||||
|
--pink: #f472b6;
|
||||||
|
--blue: #60a5fa;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--red: #f87171;
|
||||||
|
--purple: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
*::before, *::after { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--bg);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
img, svg { max-width: 100%; display: block; }
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
NAV
|
||||||
|
============================================================ */
|
||||||
|
.nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(10, 10, 10, 0.82);
|
||||||
|
backdrop-filter: saturate(180%) blur(14px);
|
||||||
|
-webkit-backdrop-filter: saturate(180%) blur(14px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 64px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 26px;
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.nav-links a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BUTTONS
|
||||||
|
============================================================ */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 12px 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:active { transform: scale(0.98); }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0a0a;
|
||||||
|
box-shadow: 0 8px 24px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: #25d365; }
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.btn-ghost:hover { background: var(--bg-card); border-color: #3a3a3a; }
|
||||||
|
|
||||||
|
.btn-sm { padding: 8px 16px; font-size: 13px; }
|
||||||
|
.btn-lg { padding: 16px 32px; font-size: 16px; }
|
||||||
|
.btn-block { width: 100%; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HERO
|
||||||
|
============================================================ */
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 80px 0 100px;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 60% 50% at 0% 0%, rgba(29, 185, 84, 0.18), transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 50% at 100% 100%, rgba(96, 165, 250, 0.12), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.05fr 1fr;
|
||||||
|
gap: 60px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 5px 11px;
|
||||||
|
border: 1px solid rgba(29, 185, 84, 0.35);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(29, 185, 84, 0.08);
|
||||||
|
}
|
||||||
|
.eyebrow.center {
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 18px 0 18px;
|
||||||
|
font-size: clamp(36px, 5.2vw, 58px);
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.accent { color: var(--accent); }
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-size: 17px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 520px;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-cta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-bullets {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 24px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.hero-bullets li { display: flex; align-items: center; gap: 6px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HERO MOCKUP
|
||||||
|
============================================================ */
|
||||||
|
.hero-art {
|
||||||
|
position: relative;
|
||||||
|
perspective: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 40px 100px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.02);
|
||||||
|
transform: rotate3d(0, 1, 0, -6deg) translateY(0);
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
.mockup:hover { transform: rotate3d(0, 1, 0, -3deg); }
|
||||||
|
|
||||||
|
.mockup-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: #111111;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.dot { width: 11px; height: 11px; border-radius: 50%; }
|
||||||
|
.dot.red { background: #ff5f57; }
|
||||||
|
.dot.yellow { background: #febc2e; }
|
||||||
|
.dot.green { background: #28c840; }
|
||||||
|
.mockup-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 168px 1fr;
|
||||||
|
min-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-side {
|
||||||
|
background: #111;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
padding: 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding: 0 6px;
|
||||||
|
}
|
||||||
|
.ms-brand span {
|
||||||
|
width: 22px; height: 22px; border-radius: 7px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||||
|
color: #fff; font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ms-nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.ms-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 7px 9px; border-radius: 8px;
|
||||||
|
font-size: 12.5px; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.ms-item.active { background: rgba(29, 185, 84, 0.14); color: #fff; }
|
||||||
|
.ms-item span { width: 18px; text-align: center; font-size: 13px; }
|
||||||
|
|
||||||
|
.mockup-main { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
.mockup-hero {
|
||||||
|
position: relative;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(135deg, #1db954 0%, #0e7a35 100%);
|
||||||
|
color: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mh-eyebrow { font-size: 9.5px; letter-spacing: 1.5px; font-weight: 800; opacity: 0.85; }
|
||||||
|
.mh-title { font-size: 17px; font-weight: 800; margin-top: 4px; }
|
||||||
|
.mh-sub { font-size: 11px; opacity: 0.85; margin-top: 3px; }
|
||||||
|
.mh-deco { position: absolute; right: 12px; top: 50%; transform: translateY(-50%); font-size: 36px; opacity: 0.3; }
|
||||||
|
|
||||||
|
.mockup-card {
|
||||||
|
background: var(--bg-card-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.mc-label { font-size: 9.5px; letter-spacing: 0.8px; color: var(--text-dim); text-transform: uppercase; font-weight: 700; }
|
||||||
|
.mc-input {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mockup-actions { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.mc-btn.primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 9px 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mc-progress {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.mc-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--accent), #25d365);
|
||||||
|
border-radius: inherit;
|
||||||
|
animation: progress-pulse 2.5s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-pulse {
|
||||||
|
0% { width: 40%; }
|
||||||
|
100% { width: 78%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SECTIONS
|
||||||
|
============================================================ */
|
||||||
|
.section {
|
||||||
|
padding: 100px 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: clamp(28px, 3.8vw, 42px);
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 14px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-lead {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0 auto 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FEATURES
|
||||||
|
============================================================ */
|
||||||
|
.features { background: var(--bg-2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.feat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feat {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px 22px;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.25s ease;
|
||||||
|
}
|
||||||
|
.feat:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
border-color: #383838;
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feat-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.feat.green .feat-icon { background: rgba(29, 185, 84, 0.16); color: var(--accent); }
|
||||||
|
.feat.pink .feat-icon { background: rgba(244, 114, 182, 0.16); color: var(--pink); }
|
||||||
|
.feat.blue .feat-icon { background: rgba(96, 165, 250, 0.16); color: var(--blue); }
|
||||||
|
.feat.amber .feat-icon { background: rgba(251, 191, 36, 0.16); color: var(--amber); }
|
||||||
|
.feat.red .feat-icon { background: rgba(248, 113, 113, 0.16); color: var(--red); }
|
||||||
|
.feat.purple .feat-icon { background: rgba(167, 139, 250, 0.16); color: var(--purple); }
|
||||||
|
|
||||||
|
.feat h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 800;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feat p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
HOW IT WORKS
|
||||||
|
============================================================ */
|
||||||
|
.how-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.how-step {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.how-num {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
box-shadow: 0 10px 24px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.how-step h3 {
|
||||||
|
font-size: 17px;
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.how-step p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
PRICING
|
||||||
|
============================================================ */
|
||||||
|
.pricing { background: var(--bg-2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.price-card {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 36px 32px 32px;
|
||||||
|
position: relative;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 30px 80px rgba(29, 185, 84, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -14px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
font-weight: 800;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-name {
|
||||||
|
font-size: 13px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 14px 0 24px;
|
||||||
|
}
|
||||||
|
.price-amount {
|
||||||
|
font-size: 64px;
|
||||||
|
font-weight: 900;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
}
|
||||||
|
.price-currency { font-size: 26px; font-weight: 700; color: var(--text-muted); }
|
||||||
|
.price-once { font-size: 14px; color: var(--text-muted); margin-left: 8px; }
|
||||||
|
|
||||||
|
.price-features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.price-features li {
|
||||||
|
padding: 9px 0;
|
||||||
|
border-bottom: 1px dashed var(--divider);
|
||||||
|
font-size: 14.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.price-features li:last-child { border-bottom: none; }
|
||||||
|
.price-features strong { color: var(--text); font-weight: 700; }
|
||||||
|
|
||||||
|
.price-note {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
margin: 14px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FAQ
|
||||||
|
============================================================ */
|
||||||
|
.faq-list {
|
||||||
|
max-width: 760px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faq-item summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 18px 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15.5px;
|
||||||
|
position: relative;
|
||||||
|
padding-right: 50px;
|
||||||
|
transition: background 0.18s ease;
|
||||||
|
}
|
||||||
|
.faq-item summary::-webkit-details-marker { display: none; }
|
||||||
|
.faq-item summary::after {
|
||||||
|
content: "+";
|
||||||
|
position: absolute;
|
||||||
|
right: 22px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 22px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 400;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
.faq-item[open] summary::after { content: "−"; }
|
||||||
|
.faq-item summary:hover { background: var(--bg-card-2); }
|
||||||
|
|
||||||
|
.faq-item p {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 22px 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.faq-item code {
|
||||||
|
background: var(--bg-card-2);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FINAL CTA
|
||||||
|
============================================================ */
|
||||||
|
.cta-final { padding: 60px 0 90px; }
|
||||||
|
|
||||||
|
.cta-box {
|
||||||
|
text-align: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at center, rgba(29, 185, 84, 0.2), transparent 70%),
|
||||||
|
linear-gradient(135deg, var(--bg-card) 0%, var(--bg-card-2) 100%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 22px;
|
||||||
|
padding: 56px 28px;
|
||||||
|
max-width: 880px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.cta-box h2 { font-size: clamp(24px, 3.2vw, 34px); font-weight: 900; margin: 0 0 8px; letter-spacing: -0.02em; }
|
||||||
|
.cta-box p { color: var(--text-muted); margin: 0 0 22px; font-size: 16px; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
FOOTER
|
||||||
|
============================================================ */
|
||||||
|
.footer {
|
||||||
|
background: var(--bg-2);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 36px 0 30px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.footer-by { color: var(--text-dim); font-weight: 400; }
|
||||||
|
|
||||||
|
.footer-links { display: flex; gap: 22px; }
|
||||||
|
.footer-links a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.footer-copy {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
RESPONSIVE
|
||||||
|
============================================================ */
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.hero { padding: 60px 0 70px; }
|
||||||
|
.hero-grid { grid-template-columns: 1fr; gap: 40px; }
|
||||||
|
.hero-art { display: none; } /* su mobile nascondiamo il mockup */
|
||||||
|
.nav-links { display: none; }
|
||||||
|
|
||||||
|
.section { padding: 70px 0; }
|
||||||
|
|
||||||
|
.how-grid { grid-template-columns: 1fr; }
|
||||||
|
.footer-row { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container { padding: 0 18px; }
|
||||||
|
.hero-cta .btn { width: 100%; }
|
||||||
|
.price-amount { font-size: 52px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
ANIMAZIONI SCROLL
|
||||||
|
============================================================ */
|
||||||
|
.nav.scrolled { box-shadow: 0 1px 0 var(--border), 0 8px 24px rgba(0,0,0,0.4); }
|
||||||
|
|
||||||
|
.feat, .how-step, .price-card, .faq-item {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px);
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
.feat.in-view, .how-step.in-view, .price-card.in-view, .faq-item.in-view {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.feat, .how-step, .price-card, .faq-item {
|
||||||
|
opacity: 1; transform: none; transition: none;
|
||||||
|
}
|
||||||
|
.mockup { transform: none !important; }
|
||||||
|
.mc-progress-fill { animation: none !important; width: 64% !important; }
|
||||||
|
html { scroll-behavior: auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
USE POLICY BOX
|
||||||
|
============================================================ */
|
||||||
|
.use-policy {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 50px auto 0;
|
||||||
|
padding: 26px 28px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-policy h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-policy p {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-points {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-points li {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.use-fineprint {
|
||||||
|
margin: 8px 0 0 !important;
|
||||||
|
font-size: 12.5px !important;
|
||||||
|
color: var(--text-dim) !important;
|
||||||
|
}
|
||||||
|
.use-fineprint a { color: var(--accent); }
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.use-points { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-consent {
|
||||||
|
font-size: 11.5px !important;
|
||||||
|
color: var(--text-dim) !important;
|
||||||
|
margin: 12px 0 0 !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
}
|
||||||
|
.price-consent a { color: var(--text-muted); text-decoration: underline; }
|
||||||
|
.price-consent a:hover { color: var(--accent); }
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<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 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: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="stylesheet" href="css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ============== NAV ============== -->
|
||||||
|
<header class="nav">
|
||||||
|
<div class="container nav-row">
|
||||||
|
<a href="#top" class="brand">
|
||||||
|
<span class="brand-logo">♫</span>
|
||||||
|
<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="#faq">FAQ</a>
|
||||||
|
</nav>
|
||||||
|
<a href="#pricing" class="btn btn-primary btn-sm">Compra — 39,90 €</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ============== HERO ============== -->
|
||||||
|
<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>
|
||||||
|
<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.
|
||||||
|
Registra in alta qualità l'audio che esce dal tuo computer.
|
||||||
|
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="#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>✓ Funziona offline · Su 3 dispositivi</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-art" aria-hidden="true">
|
||||||
|
<!-- Mockup dell'app -->
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-bar">
|
||||||
|
<span class="dot red"></span><span class="dot yellow"></span><span class="dot green"></span>
|
||||||
|
<span class="mockup-title">MusicTools</span>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-body">
|
||||||
|
<aside class="mockup-side">
|
||||||
|
<div class="ms-brand"><span>♫</span> MusicTools</div>
|
||||||
|
<div class="ms-nav">
|
||||||
|
<div class="ms-item active"><span>⬇</span> Scarica</div>
|
||||||
|
<div class="ms-item"><span>🎬</span> Video</div>
|
||||||
|
<div class="ms-item"><span>⚡</span> Upgrade</div>
|
||||||
|
<div class="ms-item"><span>🏷</span> Metadati</div>
|
||||||
|
<div class="ms-item"><span>●</span> Registra</div>
|
||||||
|
<div class="ms-item"><span>⚙</span> Impostazioni</div>
|
||||||
|
<div class="ms-item"><span>📖</span> Guida</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="mockup-main">
|
||||||
|
<div class="mockup-hero">
|
||||||
|
<div class="mh-eyebrow">DOWNLOAD</div>
|
||||||
|
<div class="mh-title">Scarica la tua musica</div>
|
||||||
|
<div class="mh-sub">Spotify · YouTube · SoundCloud</div>
|
||||||
|
<div class="mh-deco">♫</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-card">
|
||||||
|
<div class="mc-label">URL o titolo brano</div>
|
||||||
|
<div class="mc-input">https://open.spotify.com/playlist/…</div>
|
||||||
|
</div>
|
||||||
|
<div class="mockup-card mockup-actions">
|
||||||
|
<div class="mc-btn primary">▶ Avvia Download</div>
|
||||||
|
<div class="mc-progress">
|
||||||
|
<div class="mc-progress-fill" style="width: 64%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============== FEATURES ============== -->
|
||||||
|
<section class="section features" id="features">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow center">FUNZIONI</span>
|
||||||
|
<h2 class="section-title">Sette strumenti, un'unica app</h2>
|
||||||
|
<p class="section-lead">Tutto quello che ti serve per gestire la tua musica, senza saltare tra siti web e tool diversi.</p>
|
||||||
|
|
||||||
|
<div class="feat-grid">
|
||||||
|
<article class="feat green">
|
||||||
|
<div class="feat-icon">⬇</div>
|
||||||
|
<h3>Scarica</h3>
|
||||||
|
<p>Brani singoli, playlist Spotify, link YouTube, tracklist su file. Detect automatico, MP3 320K con copertina.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feat pink">
|
||||||
|
<div class="feat-icon">🎬</div>
|
||||||
|
<h3>Video</h3>
|
||||||
|
<p>YouTube, TikTok, Instagram, Facebook. Scegli la qualità (fino a 4K) o estrai solo l'audio in MP3.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feat blue">
|
||||||
|
<div class="feat-icon">⚡</div>
|
||||||
|
<h3>Upgrade</h3>
|
||||||
|
<p>Scansiona una cartella, trova i file a bitrate basso e li riscarica in alta qualità. Mantiene metadati e cover.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feat amber">
|
||||||
|
<div class="feat-icon">🏷</div>
|
||||||
|
<h3>Metadati</h3>
|
||||||
|
<p>Editor completo per MP3, M4A, FLAC, WAV. Cambia copertina, artista, album, BPM, key. Anche `kMDItemWhereFroms` su macOS.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feat red">
|
||||||
|
<div class="feat-icon">●</div>
|
||||||
|
<h3>Registra</h3>
|
||||||
|
<p>Cattura l'audio di qualunque app (Spotify, Apple Music, browser) via BlackHole o scheda interna. Output MP3 320K.</p>
|
||||||
|
</article>
|
||||||
|
<article class="feat purple">
|
||||||
|
<div class="feat-icon">📖</div>
|
||||||
|
<h3>Guida integrata</h3>
|
||||||
|
<p>Manuale completo dentro l'app: setup, troubleshooting, esempi. Niente schede del browser da cercare.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============== HOW ============== -->
|
||||||
|
<section class="section how" id="how">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow center">COME FUNZIONA</span>
|
||||||
|
<h2 class="section-title">Tre passi, due minuti</h2>
|
||||||
|
|
||||||
|
<div class="how-grid">
|
||||||
|
<div class="how-step">
|
||||||
|
<div class="how-num">1</div>
|
||||||
|
<h3>Compra la licenza</h3>
|
||||||
|
<p>Checkout sicuro tramite Lemon Squeezy. Ricevi la chiave via email entro pochi secondi.</p>
|
||||||
|
</div>
|
||||||
|
<div class="how-step">
|
||||||
|
<div class="how-num">2</div>
|
||||||
|
<h3>Scarica l'app</h3>
|
||||||
|
<p>Mac o Windows, link diretto nella stessa email. Installazione standard, niente dipendenze.</p>
|
||||||
|
</div>
|
||||||
|
<div class="how-step">
|
||||||
|
<div class="how-num">3</div>
|
||||||
|
<h3>Attiva e parti</h3>
|
||||||
|
<p>Inserisci email e chiave all'avvio. L'app si attiva e resta valida a vita su questo dispositivo.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============== 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="use-policy">
|
||||||
|
<h3>Uso responsabile</h3>
|
||||||
|
<p>
|
||||||
|
MusicTools e' uno strumento tecnico per l'automazione di download e registrazione audio,
|
||||||
|
paragonabile a un browser o a un registratore digitale.
|
||||||
|
<strong>Sei tu a scegliere quali contenuti acquisire</strong> e sei l'unico responsabile
|
||||||
|
del rispetto del diritto d'autore e dei Termini di servizio delle piattaforme di origine.
|
||||||
|
</p>
|
||||||
|
<ul class="use-points">
|
||||||
|
<li>✓ Backup dei brani che hai gia acquistato</li>
|
||||||
|
<li>✓ Contenuti tuoi o rilasciati con licenza libera</li>
|
||||||
|
<li>✗ Distribuzione, rivendita o uso commerciale dei contenuti scaricati</li>
|
||||||
|
<li>✗ Aggiramento di misure di protezione (DRM)</li>
|
||||||
|
</ul>
|
||||||
|
<p class="use-fineprint">
|
||||||
|
Leggi i <a href="/legal/terms.html">Termini di servizio completi</a> prima dell'acquisto.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============== FAQ ============== -->
|
||||||
|
<section class="section faq" id="faq">
|
||||||
|
<div class="container">
|
||||||
|
<span class="eyebrow center">DOMANDE FREQUENTI</span>
|
||||||
|
<h2 class="section-title">Tutto quello che devi sapere</h2>
|
||||||
|
|
||||||
|
<div class="faq-list">
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Su quali sistemi operativi funziona?</summary>
|
||||||
|
<p>macOS 11 (Big Sur) o successivi, e Windows 10/11. L'app non gira su Linux per ora.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</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>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Posso registrare l'audio di Spotify mentre suona?</summary>
|
||||||
|
<p>Sì, con BlackHole (macOS) o la scheda audio virtuale di Windows. La tab Registra ti guida nel setup passo-passo.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>L'app si installa senza problemi su Mac?</summary>
|
||||||
|
<p>Al primo avvio macOS chiede una conferma di sicurezza. Tasto destro sull'app → Apri → Apri. Lo fai una volta sola, poi parte normalmente. Nel DMG trovi anche uno script <code>Fix Gatekeeper.command</code> che lo fa per te.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>E su Windows?</summary>
|
||||||
|
<p>Al primo avvio Windows SmartScreen mostra "Windows protected your PC". Clicca su <strong>More info</strong> → <strong>Run anyway</strong>. È una procedura standard per app non firmate con cert EV.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Posso chiedere il rimborso se non mi piace?</summary>
|
||||||
|
<p>Sì, hai <strong>14 giorni</strong> per chiederlo senza fornire motivazioni. Scrivi a info@djluza.com.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Posso usare i brani scaricati commercialmente?</summary>
|
||||||
|
<p>No. La licenza copre <strong>esclusivamente l'uso personale</strong>. I contenuti scaricati restano soggetti al copyright dei rispettivi proprietari: sei tu responsabile di rispettare i loro termini d'uso e di non distribuirli, rivenderli o esibirli pubblicamente. Vedi <a href="/legal/terms.html#s5">§5 dei Termini</a>.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>MusicTools è collegato a Spotify, YouTube o altre piattaforme?</summary>
|
||||||
|
<p>No. MusicTools <strong>non è affiliato, sponsorizzato o autorizzato</strong> da nessuna di queste società. È uno strumento tecnico indipendente che usa librerie open-source per interagire con risorse pubbliche. I marchi citati sono dei rispettivi proprietari.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Sono titolare di un copyright e voglio segnalare un uso illegittimo</summary>
|
||||||
|
<p>Scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a> con prova della titolarità, identificazione del materiale e dichiarazione di buona fede. La procedura completa è descritta al <a href="/legal/terms.html#s8">§8 dei Termini</a>.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Quali metodi di pagamento sono accettati?</summary>
|
||||||
|
<p>Tramite Lemon Squeezy: carta di credito/debito, PayPal, Apple Pay, Google Pay. Riceverai una fattura per il pagamento.</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============== FINAL CTA ============== -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============== FOOTER ============== -->
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container footer-row">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<span class="brand-logo">♫</span>
|
||||||
|
<span>MusicTools</span>
|
||||||
|
<span class="footer-by">by LuZa</span>
|
||||||
|
</div>
|
||||||
|
<nav class="footer-links">
|
||||||
|
<a href="mailto:info@djluza.com">Supporto</a>
|
||||||
|
<a href="/legal/terms.html">Termini</a>
|
||||||
|
<a href="/legal/privacy.html">Privacy</a>
|
||||||
|
<a href="/legal/refund.html">Rimborsi</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="container footer-copy">
|
||||||
|
© <span id="year">2026</span> MusicTools — Luciano Zappacosta · Tutti i diritti riservati
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://assets.lemonsqueezy.com/lemon.js" defer></script>
|
||||||
|
<script src="js/main.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/* ======================================================
|
||||||
|
MusicTools landing — JS minimale
|
||||||
|
====================================================== */
|
||||||
|
|
||||||
|
// Anno corrente in footer
|
||||||
|
(function setYear() {
|
||||||
|
const el = document.getElementById("year");
|
||||||
|
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.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Header che diventa piu' opaco mentre scorri (effetto subtle)
|
||||||
|
const nav = document.querySelector(".nav");
|
||||||
|
function onScroll() {
|
||||||
|
if (!nav) return;
|
||||||
|
if (window.scrollY > 24) nav.classList.add("scrolled");
|
||||||
|
else nav.classList.remove("scrolled");
|
||||||
|
}
|
||||||
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
onScroll();
|
||||||
|
|
||||||
|
// Reveal on scroll: aggiunge la classe .in-view alle sezioni
|
||||||
|
// quando entrano in viewport. Niente librerie esterne.
|
||||||
|
const io = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.classList.add("in-view");
|
||||||
|
io.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.12 });
|
||||||
|
|
||||||
|
document.querySelectorAll(".feat, .how-step, .price-card, .faq-item").forEach((el) => {
|
||||||
|
io.observe(el);
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Privacy · MusicTools</title>
|
||||||
|
<link rel="stylesheet" href="../css/style.css" />
|
||||||
|
<style>
|
||||||
|
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
|
||||||
|
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||||
|
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
|
||||||
|
.legal-doc h2 { font-size: 20px; margin-top: 32px; font-weight: 800; }
|
||||||
|
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
|
||||||
|
.legal-doc a { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="nav"><div class="container nav-row">
|
||||||
|
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
|
||||||
|
</div></header>
|
||||||
|
|
||||||
|
<main class="legal-doc">
|
||||||
|
<h1>Informativa Privacy</h1>
|
||||||
|
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
||||||
|
|
||||||
|
<h2>1. Titolare del trattamento</h2>
|
||||||
|
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||||
|
|
||||||
|
<h2>2. Dati trattati</h2>
|
||||||
|
<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>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>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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</ul>
|
||||||
|
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
||||||
|
|
||||||
|
<h2>6. Diritti dell'interessato</h2>
|
||||||
|
<p>L'utente ha diritto di accedere, rettificare, cancellare i propri dati e di ottenere portabilita' o limitazione del trattamento. Per esercitare questi diritti scrivi a <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||||
|
|
||||||
|
<h2>7. Cookie</h2>
|
||||||
|
<p>Questo sito non usa cookie di tracciamento. Lemon Squeezy puo' impostare i propri cookie funzionali durante il checkout secondo la sua informativa.</p>
|
||||||
|
|
||||||
|
<h2>8. Reclami</h2>
|
||||||
|
<p>In caso di violazioni puoi rivolgerti al Garante per la protezione dei dati personali (<a href="https://www.garanteprivacy.it">garanteprivacy.it</a>).</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Politica rimborsi · MusicTools</title>
|
||||||
|
<link rel="stylesheet" href="../css/style.css" />
|
||||||
|
<style>
|
||||||
|
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
|
||||||
|
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||||
|
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
|
||||||
|
.legal-doc h2 { font-size: 20px; margin-top: 32px; font-weight: 800; }
|
||||||
|
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
|
||||||
|
.legal-doc a { color: var(--accent); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="nav"><div class="container nav-row">
|
||||||
|
<a href="/" class="brand"><span class="brand-logo">♫</span><span class="brand-name">MusicTools</span></a>
|
||||||
|
</div></header>
|
||||||
|
|
||||||
|
<main class="legal-doc">
|
||||||
|
<h1>Politica rimborsi</h1>
|
||||||
|
<p class="updated">Ultimo aggiornamento: 8 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>Cosa succede dopo</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>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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="it">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Termini di servizio · MusicTools</title>
|
||||||
|
<link rel="stylesheet" href="../css/style.css" />
|
||||||
|
<style>
|
||||||
|
.legal-doc { max-width: 760px; margin: 0 auto; padding: 60px 24px 100px; }
|
||||||
|
.legal-doc h1 { font-size: 36px; font-weight: 900; letter-spacing: -0.02em; margin-bottom: 8px; }
|
||||||
|
.legal-doc .updated { color: var(--text-dim); font-size: 13px; margin-bottom: 36px; }
|
||||||
|
.legal-doc h2 { font-size: 20px; margin-top: 36px; font-weight: 800; }
|
||||||
|
.legal-doc h3 { font-size: 16px; margin-top: 22px; font-weight: 700; color: var(--text); }
|
||||||
|
.legal-doc p, .legal-doc li { color: var(--text-muted); font-size: 15px; line-height: 1.65; }
|
||||||
|
.legal-doc strong { color: var(--text); }
|
||||||
|
.legal-doc a { color: var(--accent); }
|
||||||
|
.legal-doc .toc {
|
||||||
|
background: var(--bg-card); border: 1px solid var(--border);
|
||||||
|
border-radius: 12px; padding: 18px 22px; margin-bottom: 32px;
|
||||||
|
}
|
||||||
|
.legal-doc .toc ol { margin: 0; padding-left: 22px; }
|
||||||
|
.legal-doc .toc li { margin: 4px 0; }
|
||||||
|
.legal-doc .toc a { color: var(--text-muted); font-size: 14px; }
|
||||||
|
.legal-doc .toc a:hover { color: var(--accent); }
|
||||||
|
.legal-doc .warn-box {
|
||||||
|
background: rgba(248, 113, 113, 0.08);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.4);
|
||||||
|
border-left: 4px solid var(--red);
|
||||||
|
padding: 16px 20px; border-radius: 10px; margin: 22px 0;
|
||||||
|
}
|
||||||
|
.legal-doc .warn-box p { margin: 0; color: var(--text); }
|
||||||
|
</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>
|
||||||
|
</div></header>
|
||||||
|
|
||||||
|
<main class="legal-doc">
|
||||||
|
<h1>Termini di servizio e Licenza d'uso</h1>
|
||||||
|
<p class="updated">Ultimo aggiornamento: 8 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toc">
|
||||||
|
<strong>Indice</strong>
|
||||||
|
<ol>
|
||||||
|
<li><a href="#s1">Definizioni</a></li>
|
||||||
|
<li><a href="#s2">Accettazione dei termini</a></li>
|
||||||
|
<li><a href="#s3">Licenza d'uso (EULA)</a></li>
|
||||||
|
<li><a href="#s4">Restrizioni d'uso</a></li>
|
||||||
|
<li><a href="#s5">Acceptable Use Policy</a></li>
|
||||||
|
<li><a href="#s6">Copyright e responsabilita dell'utente</a></li>
|
||||||
|
<li><a href="#s7">Marchi e affiliazioni con terze parti</a></li>
|
||||||
|
<li><a href="#s8">Procedura DMCA / segnalazioni</a></li>
|
||||||
|
<li><a href="#s9">Esclusione di garanzie</a></li>
|
||||||
|
<li><a href="#s10">Limitazione di responsabilita</a></li>
|
||||||
|
<li><a href="#s11">Manleva (indemnification)</a></li>
|
||||||
|
<li><a href="#s12">Pagamenti e fatturazione</a></li>
|
||||||
|
<li><a href="#s13">Diritto di recesso e rimborsi</a></li>
|
||||||
|
<li><a href="#s14">Aggiornamenti e supporto</a></li>
|
||||||
|
<li><a href="#s15">Risoluzione</a></li>
|
||||||
|
<li><a href="#s16">Modifiche ai termini</a></li>
|
||||||
|
<li><a href="#s17">Legge applicabile e foro competente</a></li>
|
||||||
|
<li><a href="#s18">Contatti</a></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 id="s1">1. Definizioni</h2>
|
||||||
|
<p>Nel presente documento:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>"Editore"</strong> — Luciano Zappacosta, in qualita' di sviluppatore e distributore del software, contattabile a <a href="mailto:info@djluza.com">info@djluza.com</a>.</li>
|
||||||
|
<li><strong>"Software" o "MusicTools"</strong> — l'applicazione desktop denominata MusicTools, comprensiva di codice eseguibile, asset, documentazione e aggiornamenti.</li>
|
||||||
|
<li><strong>"Utente"</strong> — la persona fisica o giuridica che acquista, installa o utilizza il Software.</li>
|
||||||
|
<li><strong>"Licenza"</strong> — il diritto d'uso personale concesso all'Utente dietro pagamento, come descritto al §3.</li>
|
||||||
|
<li><strong>"Contenuti"</strong> — file audio, video, immagini, metadati o altri materiali che l'Utente sceglie di scaricare, registrare o modificare tramite il Software.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="s2">2. Accettazione dei termini</h2>
|
||||||
|
<p>L'installazione, l'attivazione o l'uso del Software costituiscono accettazione integrale e incondizionata dei presenti Termini. Se l'Utente non accetta una qualsiasi disposizione, deve interrompere l'uso del Software, disinstallarlo e richiedere il rimborso (vedi §13) entro i termini previsti.</p>
|
||||||
|
|
||||||
|
<h2 id="s3">3. Licenza d'uso (EULA)</h2>
|
||||||
|
<h3>3.1 Concessione</h3>
|
||||||
|
<p>L'Editore concede all'Utente una licenza <strong>personale, non esclusiva, non trasferibile, non sublicenziabile e revocabile</strong> per:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Installare il Software su un massimo di <strong>3 (tre) dispositivi</strong> di proprieta' o sotto il controllo esclusivo dell'Utente;</li>
|
||||||
|
<li>Utilizzare il Software per finalita' strettamente <strong>personali e non commerciali</strong>;</li>
|
||||||
|
<li>Ricevere gli aggiornamenti rilasciati dall'Editore secondo §14.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>3.2 Durata</h3>
|
||||||
|
<p>La Licenza e' a tempo indeterminato ("a vita") salvo i casi di risoluzione descritti al §15.</p>
|
||||||
|
|
||||||
|
<h3>3.3 Proprieta' intellettuale</h3>
|
||||||
|
<p>Il Software resta di esclusiva proprieta' intellettuale dell'Editore. La Licenza concede solo un diritto d'uso e non trasferisce alcun diritto di proprieta' all'Utente. Marchi, design, codice sorgente e documentazione sono protetti dalle leggi sul diritto d'autore italiane e internazionali.</p>
|
||||||
|
|
||||||
|
<h2 id="s4">4. Restrizioni d'uso</h2>
|
||||||
|
<p>L'Utente <strong>NON</strong> puo':</p>
|
||||||
|
<ul>
|
||||||
|
<li>Copiare, distribuire, vendere, affittare, prestare, sublicenziare o rendere altrimenti disponibile il Software a terzi;</li>
|
||||||
|
<li>Decompilare, disassemblare, fare reverse-engineering o tentare di ricavare il codice sorgente, salvo nei limiti consentiti da norme inderogabili (es. art. 64-quater l.d.a.);</li>
|
||||||
|
<li>Rimuovere, alterare od oscurare avvisi di copyright, marchi o note legali presenti nel Software;</li>
|
||||||
|
<li>Aggirare, disattivare o manomettere i meccanismi di attivazione della Licenza;</li>
|
||||||
|
<li>Condividere la propria chiave di licenza o farne uso simultaneo su un numero di dispositivi superiore a quello consentito;</li>
|
||||||
|
<li>Usare il Software per scopi illeciti o per agevolare la violazione di diritti di terzi.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="s5">5. Acceptable Use Policy</h2>
|
||||||
|
<div class="warn-box">
|
||||||
|
<p><strong>MusicTools e' uno strumento tecnico di automazione.</strong> Funziona allo stesso modo di un browser o di un registratore audio: e' l'Utente a scegliere quali contenuti acquisire e da quali fonti.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>5.1 Usi consentiti</h3>
|
||||||
|
<p>L'Utente puo' utilizzare il Software esclusivamente per:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Scaricare, registrare o modificare Contenuti <strong>di cui possiede i diritti</strong> (es. opere proprie, contenuti acquistati legalmente, materiale di pubblico dominio, contenuti rilasciati con licenza permissiva come Creative Commons);</li>
|
||||||
|
<li>Effettuare copie di backup di Contenuti gia' in suo legittimo possesso, nei limiti previsti dalla legge (es. copia privata, art. 71-sexies l.d.a.);</li>
|
||||||
|
<li>Fini di studio, ricerca o uso personale non commerciale, sempre nel rispetto del diritto d'autore.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>5.2 Usi vietati</h3>
|
||||||
|
<p>E' espressamente vietato usare il Software per:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Scaricare o registrare Contenuti coperti da copyright senza l'autorizzazione del titolare dei diritti;</li>
|
||||||
|
<li>Aggirare misure tecnologiche di protezione (DRM) imposte sui Contenuti, anche quando il Software permettesse tecnicamente di farlo;</li>
|
||||||
|
<li>Distribuire, rivendere, esibire pubblicamente o monetizzare Contenuti acquisiti tramite il Software;</li>
|
||||||
|
<li>Violare i Termini di servizio delle piattaforme di origine (es. YouTube ToS, Spotify ToS, ecc.);</li>
|
||||||
|
<li>Qualsiasi attivita' che configuri reato o illecito civile secondo la legge italiana o secondo quella della giurisdizione dell'Utente.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="s6">6. Copyright e responsabilita dell'utente</h2>
|
||||||
|
<p>L'Editore <strong>non possiede, non ospita, non distribuisce alcun contenuto audio o video</strong>. Il Software accede esclusivamente alle risorse pubbliche o private a cui l'Utente decide di puntare.</p>
|
||||||
|
<p>L'<strong>Utente e' l'unico responsabile</strong>:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Della scelta dei Contenuti da scaricare, registrare o modificare;</li>
|
||||||
|
<li>Del rispetto dei diritti d'autore, dei diritti connessi e dei diritti di immagine relativi a tali Contenuti;</li>
|
||||||
|
<li>Del rispetto dei Termini di servizio delle piattaforme di origine;</li>
|
||||||
|
<li>Dell'uso che fa dei Contenuti dopo averli acquisiti tramite il Software.</li>
|
||||||
|
</ul>
|
||||||
|
<p>L'Editore non puo' essere ritenuto responsabile in alcuna forma per l'uso che l'Utente fa del Software o dei Contenuti. Eventuali violazioni del diritto d'autore commesse dall'Utente restano di sua esclusiva responsabilita' civile, penale e amministrativa.</p>
|
||||||
|
|
||||||
|
<h2 id="s7">7. Marchi e affiliazioni con terze parti</h2>
|
||||||
|
<p>I marchi <strong>Spotify</strong>, <strong>YouTube</strong>, <strong>SoundCloud</strong>, <strong>TikTok</strong>, <strong>Instagram</strong>, <strong>Facebook</strong>, <strong>Apple Music</strong> e qualsiasi altro marchio menzionato sono di proprieta' dei rispettivi titolari.</p>
|
||||||
|
<p>MusicTools <strong>non e' affiliato, sponsorizzato, autorizzato, supportato</strong> ne' in alcun modo connesso ad alcuna di queste societa'. Le menzioni di tali marchi nel sito, nella documentazione e nell'interfaccia del Software hanno scopo esclusivamente descrittivo (indicare la compatibilita' tecnica del Software con tali servizi).</p>
|
||||||
|
|
||||||
|
<h2 id="s8">8. Procedura DMCA / segnalazioni</h2>
|
||||||
|
<p>Sebbene l'Editore non distribuisca contenuti, accoglie eventuali segnalazioni ai sensi della normativa internazionale sul copyright (incluso il Digital Millennium Copyright Act statunitense). Le richieste vanno inviate a <a href="mailto:info@djluza.com">info@djluza.com</a> con:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Identificazione del titolare dei diritti e prova della titolarita';</li>
|
||||||
|
<li>Identificazione del materiale ritenuto in violazione;</li>
|
||||||
|
<li>Dichiarazione di buona fede e firma elettronica.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="s9">9. Esclusione di garanzie</h2>
|
||||||
|
<p>Il Software e' fornito <strong>"COSI' COM'E'" e "COME DISPONIBILE"</strong> ("AS IS" e "AS AVAILABLE"), senza alcuna garanzia espressa o implicita di qualsiasi tipo, ivi incluse, a titolo non esaustivo, le garanzie di:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Idoneita' a uno scopo specifico;</li>
|
||||||
|
<li>Commerciabilita';</li>
|
||||||
|
<li>Non violazione di diritti di terzi;</li>
|
||||||
|
<li>Funzionamento ininterrotto, privo di errori o privo di malware;</li>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<ul>
|
||||||
|
<li>Danni diretti, indiretti, incidentali, speciali, consequenziali, esemplari o punitivi;</li>
|
||||||
|
<li>Perdita di dati, profitti, ricavi, opportunita' commerciali, avviamento, reputazione;</li>
|
||||||
|
<li>Interruzione dell'attivita';</li>
|
||||||
|
<li>Costi di sostituzione di beni o servizi;</li>
|
||||||
|
<li>Pretese o azioni di terzi conseguenti all'uso del Software da parte dell'Utente.</li>
|
||||||
|
</ul>
|
||||||
|
<p>La responsabilita' complessiva dell'Editore, per qualsiasi titolo o causa, e' in ogni caso limitata all'<strong>importo effettivamente corrisposto dall'Utente per la Licenza nei 12 (dodici) mesi precedenti all'evento dannoso</strong>.</p>
|
||||||
|
<p>Le limitazioni sopra riportate non si applicano nei casi di dolo o colpa grave, ne' nei casi in cui la legge applicabile non consenta tale limitazione (es. art. 1229 c.c.).</p>
|
||||||
|
|
||||||
|
<h2 id="s11">11. Manleva (indemnification)</h2>
|
||||||
|
<p>L'Utente si impegna a manlevare, difendere e tenere indenne l'Editore (e i suoi eventuali collaboratori, fornitori, agenti) da qualsiasi pretesa, azione, richiesta di risarcimento, costo, spesa legale, sanzione o danno avanzati da terzi e derivanti da:</p>
|
||||||
|
<ul>
|
||||||
|
<li>L'uso del Software da parte dell'Utente in violazione dei presenti Termini;</li>
|
||||||
|
<li>La violazione, da parte dell'Utente, di diritti d'autore, diritti connessi, diritti di immagine, marchi o altri diritti di terzi nell'acquisizione o nell'uso di Contenuti;</li>
|
||||||
|
<li>La violazione dei Termini di servizio delle piattaforme di origine;</li>
|
||||||
|
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 id="s12">12. Pagamenti 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>
|
||||||
|
|
||||||
|
<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>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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<p>Il supporto e' fornito via email a <a href="mailto:info@djluza.com">info@djluza.com</a>, con risposta entro tempi ragionevoli e in lingua italiana o inglese.</p>
|
||||||
|
|
||||||
|
<h2 id="s15">15. Risoluzione</h2>
|
||||||
|
<p>L'Editore puo' risolvere immediatamente la Licenza, senza preavviso e senza obbligo di rimborso, qualora l'Utente:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Violi una qualsiasi disposizione dei presenti Termini;</li>
|
||||||
|
<li>Usi il Software per attivita' illecite o per violare diritti di terzi;</li>
|
||||||
|
<li>Tenti di aggirare i meccanismi di attivazione o condivida la propria chiave;</li>
|
||||||
|
<li>Inoltri un chargeback non giustificato successivamente al periodo di recesso.</li>
|
||||||
|
</ul>
|
||||||
|
<p>A seguito della risoluzione, l'Utente deve cessare l'uso del Software e disinstallarlo. Le disposizioni di cui ai §6, §9, §10, §11 sopravvivono alla risoluzione.</p>
|
||||||
|
|
||||||
|
<h2 id="s16">16. Modifiche ai termini</h2>
|
||||||
|
<p>L'Editore puo' aggiornare i presenti Termini in qualsiasi momento per ragioni tecniche, normative o operative. La versione vigente e' sempre consultabile a questa URL. Per modifiche sostanziali sara' inviata notifica via email agli Utenti registrati con almeno 30 giorni di preavviso.</p>
|
||||||
|
|
||||||
|
<h2 id="s17">17. Legge applicabile e foro competente</h2>
|
||||||
|
<p>I presenti Termini sono regolati dalla legge italiana. Per qualunque controversia il foro competente e' quello del luogo di residenza dell'Utente consumatore, ove inderogabile per legge; in caso contrario, foro esclusivo del Tribunale di Chieti, Italia.</p>
|
||||||
|
<p>Resta salva la possibilita' per l'Utente consumatore residente nell'Unione Europea di rivolgersi alla piattaforma ODR della Commissione Europea: <a href="https://ec.europa.eu/consumers/odr" target="_blank" rel="noopener">ec.europa.eu/consumers/odr</a>.</p>
|
||||||
|
|
||||||
|
<h2 id="s18">18. Contatti</h2>
|
||||||
|
<p>Per qualsiasi richiesta relativa ai presenti Termini scrivere a <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||||
|
|
||||||
|
<p style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--border); color: var(--text-dim); font-size: 13px;">
|
||||||
|
<strong>Disclaimer.</strong> Il presente documento e' un modello orientativo. L'Editore raccomanda all'Utente professionale di consultare un legale qualificato per verificare la conformita' a normative specifiche del proprio settore o paese.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Copia questo file in `.env` sul server (non committarlo!) e compila.
|
||||||
|
|
||||||
|
# ---- Server ----
|
||||||
|
PORT=4002
|
||||||
|
HOST=127.0.0.1
|
||||||
|
LATEST_VERSION=v1.5.2
|
||||||
|
PUBLIC_BASE_URL=https://musictools.djluza.com
|
||||||
|
|
||||||
|
# ---- DB (MariaDB locale) ----
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_NAME=musictools_licenses
|
||||||
|
DB_USER=musictools
|
||||||
|
DB_PASS=cambiami
|
||||||
|
|
||||||
|
# ---- Licenze ----
|
||||||
|
JWT_SECRET=cambiami_con_openssl_rand_base64_32
|
||||||
|
MAX_ACTIVATIONS=3
|
||||||
|
TOKEN_TTL_DAYS=30
|
||||||
|
DOWNLOAD_URL_TTL_SECONDS=300
|
||||||
|
|
||||||
|
# ---- Lemon Squeezy ----
|
||||||
|
# Dal dashboard LS: Settings > Webhooks > Signing Secret
|
||||||
|
LEMONSQUEEZY_SIGNING_SECRET=cambiami
|
||||||
|
|
||||||
|
# ---- Resend ----
|
||||||
|
# Dal dashboard Resend: API Keys
|
||||||
|
RESEND_API_KEY=cambiami
|
||||||
|
EMAIL_FROM=MusicTools <noreply@djluza.com>
|
||||||
|
|
||||||
|
# ---- Storage builds ----
|
||||||
|
# Dove conservi i binari macOS/Windows da servire agli utenti.
|
||||||
|
# Default: ../builds rispetto a server/. Puoi metterlo dove vuoi
|
||||||
|
# purche' il processo Node abbia accesso lettura.
|
||||||
|
BUILDS_DIR=/home/musictools/builds
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
dist/
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
# MusicTools — License & Update API
|
||||||
|
|
||||||
|
Backend Node.js per gestire attivazione licenze, validazione e distribuzione binari MusicTools.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Node.js 20 + Express 4**
|
||||||
|
- **MariaDB 10.11** (locale, localhost:3306)
|
||||||
|
- **PM2** per process management
|
||||||
|
- **Apache 2.4** come reverse proxy verso `127.0.0.1:4002`
|
||||||
|
- **Resend** per email transazionali
|
||||||
|
- **Lemon Squeezy** per i pagamenti (webhook -> `/api/webhook/lemonsqueezy`)
|
||||||
|
|
||||||
|
Tutto gira sull'hosting esistente (`musictools@musictools.djluza.com`), zero costi aggiuntivi.
|
||||||
|
|
||||||
|
## Struttura
|
||||||
|
|
||||||
|
```
|
||||||
|
server/
|
||||||
|
src/
|
||||||
|
server.js # Express app
|
||||||
|
db.js # pool mysql2
|
||||||
|
license.js # activate / validate / deactivate
|
||||||
|
updates.js # /api/latest + /api/download
|
||||||
|
lemonsqueezy.js # webhook ordini
|
||||||
|
email.js # Resend client
|
||||||
|
jwt.js # JWT HS256 senza dipendenze
|
||||||
|
migrate.js # runner SQL idempotente
|
||||||
|
migrations/
|
||||||
|
0001_init.sql # schema licenses / activations / releases
|
||||||
|
ecosystem.config.cjs # PM2
|
||||||
|
.env.example
|
||||||
|
package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup iniziale sul server
|
||||||
|
|
||||||
|
Una volta sola, da SSH `musictools@musictools.djluza.com`:
|
||||||
|
|
||||||
|
### 1) Crea database e utente MariaDB
|
||||||
|
|
||||||
|
Sul server (richiede root o l'utente master DB del tuo hosting — di solito Virtualmin lo crea per te dal pannello "Edit Databases"):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE musictools_licenses CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
CREATE USER 'musictools'@'localhost' IDENTIFIED BY 'PASSWORD_FORTE';
|
||||||
|
GRANT ALL PRIVILEGES ON musictools_licenses.* TO 'musictools'@'localhost';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
```
|
||||||
|
|
||||||
|
In alternativa via Virtualmin: **Edit Databases → Create a new database**, poi tab **Manage** → crea utente con password.
|
||||||
|
|
||||||
|
### 2) Clona o sincronizza il codice
|
||||||
|
|
||||||
|
Due opzioni:
|
||||||
|
|
||||||
|
**A. Via git** (consigliato):
|
||||||
|
```bash
|
||||||
|
cd ~
|
||||||
|
git clone https://github.com/luzadev/musicdownload.git app-src
|
||||||
|
ln -s app-src/server api
|
||||||
|
cd api
|
||||||
|
npm install --production
|
||||||
|
```
|
||||||
|
|
||||||
|
**B. Via rsync da locale** (più semplice se preferisci non lasciare codice client sul server):
|
||||||
|
```bash
|
||||||
|
# Da Mac:
|
||||||
|
rsync -avz --exclude node_modules /Users/luciano/Downloads/Progetti2026/MusicDownload/server/ musictools@musictools.djluza.com:~/api/
|
||||||
|
# Poi SSH e:
|
||||||
|
cd ~/api && npm install --production
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Configura .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/api
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # compila tutti i valori — vedi sotto
|
||||||
|
```
|
||||||
|
|
||||||
|
Per generare `JWT_SECRET`:
|
||||||
|
```bash
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) Migrate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/api
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Output atteso:
|
||||||
|
```
|
||||||
|
[migrate] applico 0001_init.sql
|
||||||
|
[migrate] ok 0001_init.sql
|
||||||
|
[migrate] done
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5) Avvia con PM2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/api
|
||||||
|
mkdir -p logs
|
||||||
|
pm2 start ecosystem.config.cjs
|
||||||
|
pm2 save
|
||||||
|
# Test:
|
||||||
|
curl -s http://127.0.0.1:4002/api/health
|
||||||
|
# -> {"ok":true,"version":"v1.5.2"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6) Apache reverse proxy
|
||||||
|
|
||||||
|
Devi dire ad Apache che le richieste a `musictools.djluza.com/api/*` vanno a `127.0.0.1:4002`.
|
||||||
|
|
||||||
|
**Via Virtualmin** (consigliato):
|
||||||
|
|
||||||
|
1. Vai su **Webmin → Servers → Apache Webserver**
|
||||||
|
2. Clicca sul VirtualHost di `musictools.djluza.com` (porta 443)
|
||||||
|
3. Sezione **Aliases and redirects** o **Edit Directives**, aggiungi prima di `</VirtualHost>`:
|
||||||
|
|
||||||
|
```apache
|
||||||
|
# MusicTools API
|
||||||
|
ProxyPreserveHost On
|
||||||
|
ProxyRequests Off
|
||||||
|
|
||||||
|
# Webhook: passa raw body senza alterazioni
|
||||||
|
<Location /api/>
|
||||||
|
ProxyPass http://127.0.0.1:4002/api/
|
||||||
|
ProxyPassReverse http://127.0.0.1:4002/api/
|
||||||
|
</Location>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Apply changes.
|
||||||
|
|
||||||
|
**Verifica moduli Apache attivi** (da SSH se hai sudo):
|
||||||
|
```bash
|
||||||
|
apache2ctl -M | grep -E 'proxy|proxy_http'
|
||||||
|
# Devono comparire proxy_module e proxy_http_module
|
||||||
|
```
|
||||||
|
Se mancano, abilitali (sudo richiesto):
|
||||||
|
```bash
|
||||||
|
sudo a2enmod proxy proxy_http
|
||||||
|
sudo systemctl reload apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7) Verifica end-to-end
|
||||||
|
|
||||||
|
Da qualsiasi posto:
|
||||||
|
```bash
|
||||||
|
curl -s https://musictools.djluza.com/api/health
|
||||||
|
# -> {"ok":true,"version":"v1.5.2"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow pubblicazione release
|
||||||
|
|
||||||
|
1. GitHub Actions builda i due zip (gia in place).
|
||||||
|
2. Carica gli zip sul server in `~/builds/<version>/`:
|
||||||
|
```bash
|
||||||
|
ssh musictools@musictools.djluza.com 'mkdir -p ~/builds/v1.5.3'
|
||||||
|
scp MusicTools-macOS.zip musictools@musictools.djluza.com:~/builds/v1.5.3/
|
||||||
|
scp MusicTools-Windows.zip musictools@musictools.djluza.com:~/builds/v1.5.3/
|
||||||
|
```
|
||||||
|
3. Inserisci il record in MariaDB:
|
||||||
|
```bash
|
||||||
|
ssh musictools@musictools.djluza.com 'mariadb musictools_licenses' <<SQL
|
||||||
|
INSERT INTO releases (version, platform, file_path, size_bytes, sha256, notes, published_at)
|
||||||
|
VALUES ('v1.5.3', 'macos', 'v1.5.3/MusicTools-macOS.zip', 12345, '<sha256>', 'Note...', UNIX_TIMESTAMP()),
|
||||||
|
('v1.5.3', 'windows', 'v1.5.3/MusicTools-Windows.zip', 67890, '<sha256>', 'Note...', UNIX_TIMESTAMP());
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
4. Aggiorna `LATEST_VERSION` nel `.env` e `pm2 reload musictools-api`.
|
||||||
|
|
||||||
|
In futuro: script o workflow GitHub Actions che fa tutto in automatico.
|
||||||
|
|
||||||
|
## Operazioni quotidiane
|
||||||
|
|
||||||
|
| Cosa | Comando |
|
||||||
|
|---|---|
|
||||||
|
| Reload codice dopo deploy | `pm2 reload musictools-api` |
|
||||||
|
| Vedere log live | `pm2 logs musictools-api` |
|
||||||
|
| Stato | `pm2 status` |
|
||||||
|
| Errori recenti | `pm2 logs musictools-api --err --lines 100` |
|
||||||
|
| Restart hard | `pm2 restart musictools-api` |
|
||||||
|
| Stop | `pm2 stop musictools-api` |
|
||||||
|
| Console DB | `mariadb -u musictools -p musictools_licenses` |
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Metodo | Path | Auth | Descrizione |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/license/activate` | — | Attiva licenza, ritorna JWT |
|
||||||
|
| POST | `/api/license/validate` | — (token nel body) | Rivalida + ruota token |
|
||||||
|
| POST | `/api/license/deactivate` | — (token nel body) | Libera uno slot |
|
||||||
|
| GET | `/api/latest?platform=…` | Bearer token (opzionale) | Versione + URL download firmato |
|
||||||
|
| GET | `/api/download?file=…&exp=…&sig=…` | Firma HMAC | Stream del binario |
|
||||||
|
| POST | `/api/webhook/lemonsqueezy` | X-Signature HMAC | Crea licenza dopo ordine |
|
||||||
|
| GET | `/api/health` | — | Healthcheck |
|
||||||
|
|
||||||
|
## Backup
|
||||||
|
|
||||||
|
Aggiungi un cronjob daily:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# crontab -e
|
||||||
|
0 4 * * * mariadb-dump musictools_licenses | gzip > ~/backups/musictools-$(date +\%F).sql.gz && find ~/backups -name 'musictools-*.sql.gz' -mtime +30 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Costi
|
||||||
|
|
||||||
|
- **Hosting**: gia pagato (server condiviso esistente)
|
||||||
|
- **Lemon Squeezy**: 5% + $0.50 per transazione = ~5 EUR su un acquisto da 39,90 EUR
|
||||||
|
- **Resend**: free fino a 3k email/mese (= ~3k licenze/mese, oltre serve piano $20)
|
||||||
|
- **Totale fisso**: 0 EUR/mese
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* PM2 ecosystem per il backend MusicTools.
|
||||||
|
*
|
||||||
|
* Avvio: pm2 start ecosystem.config.cjs --env production
|
||||||
|
* Reload: pm2 reload musictools-api
|
||||||
|
* Logs: pm2 logs musictools-api
|
||||||
|
* Save: pm2 save && pm2 startup (al primo deploy, una sola volta)
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: "musictools-api",
|
||||||
|
script: "src/server.js",
|
||||||
|
cwd: __dirname,
|
||||||
|
exec_mode: "fork", // 1 processo, basta per i nostri volumi
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
max_restarts: 10,
|
||||||
|
max_memory_restart: "256M",
|
||||||
|
env: {
|
||||||
|
NODE_ENV: "production",
|
||||||
|
},
|
||||||
|
// Le env sensibili stanno in .env: dotenv le carica al boot
|
||||||
|
out_file: "logs/out.log",
|
||||||
|
error_file: "logs/err.log",
|
||||||
|
merge_logs: true,
|
||||||
|
time: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- Schema MariaDB iniziale per MusicTools licenze.
|
||||||
|
-- Tutto utf8mb4 + InnoDB con FK abilitate.
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS licenses (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
license_key VARCHAR(64) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
status ENUM('active','revoked','refunded') NOT NULL DEFAULT 'active',
|
||||||
|
source VARCHAR(32) NULL,
|
||||||
|
order_id VARCHAR(64) NULL,
|
||||||
|
created_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
updated_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_licenses_key (license_key),
|
||||||
|
KEY idx_licenses_email (email),
|
||||||
|
KEY idx_licenses_order_id (order_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activations (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
license_id INT UNSIGNED NOT NULL,
|
||||||
|
device_id VARCHAR(64) NOT NULL,
|
||||||
|
device_name VARCHAR(255) NULL,
|
||||||
|
app_version VARCHAR(32) NULL,
|
||||||
|
activated_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
last_seen_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
revoked_at BIGINT UNSIGNED NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_activations_lic_dev (license_id, device_id),
|
||||||
|
KEY idx_activations_license (license_id),
|
||||||
|
CONSTRAINT fk_activations_license
|
||||||
|
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS releases (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
version VARCHAR(32) NOT NULL,
|
||||||
|
platform ENUM('macos','windows') NOT NULL,
|
||||||
|
file_path VARCHAR(512) NOT NULL,
|
||||||
|
size_bytes BIGINT UNSIGNED NULL,
|
||||||
|
sha256 CHAR(64) NULL,
|
||||||
|
notes TEXT NULL,
|
||||||
|
published_at BIGINT UNSIGNED NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_releases_ver_plat (version, platform),
|
||||||
|
KEY idx_releases_platform_pub (platform, published_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "musictools-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "License + update API per MusicTools (Node.js + Express + MariaDB)",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"migrate": "node src/migrate.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"mysql2": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import mysql from "mysql2/promise";
|
||||||
|
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST || "127.0.0.1",
|
||||||
|
port: Number(process.env.DB_PORT || 3306),
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASS,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 5,
|
||||||
|
queueLimit: 0,
|
||||||
|
charset: "utf8mb4",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function query(sql, params = []) {
|
||||||
|
const [rows] = await pool.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function one(sql, params = []) {
|
||||||
|
const rows = await query(sql, params);
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exec(sql, params = []) {
|
||||||
|
const [result] = await pool.execute(sql, params);
|
||||||
|
return result; // { insertId, affectedRows, ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default pool;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Invio email transazionali via Resend (https://resend.com).
|
||||||
|
* Usa fetch nativo di Node >= 20 — nessuna dipendenza.
|
||||||
|
*
|
||||||
|
* 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>";
|
||||||
|
|
||||||
|
export async function sendLicenseEmail(to, licenseKey) {
|
||||||
|
if (!process.env.RESEND_API_KEY) {
|
||||||
|
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 18px">
|
||||||
|
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 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>
|
||||||
|
</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>
|
||||||
|
<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 resp = await fetch("https://api.resend.com/emails", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
from: FROM,
|
||||||
|
to: [to],
|
||||||
|
subject: "La tua licenza MusicTools",
|
||||||
|
html,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const txt = await resp.text();
|
||||||
|
throw new Error(`Resend ${resp.status}: ${txt}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* JWT HS256 minimale (niente dipendenze). Stessa logica del worker
|
||||||
|
* Cloudflare precedente: stesso token formato, stessa firma, stessa
|
||||||
|
* verifica. L'app desktop non distingue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
function b64url(buf) {
|
||||||
|
return Buffer.from(buf).toString("base64")
|
||||||
|
.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s) {
|
||||||
|
s = s.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
s += "=".repeat((4 - (s.length % 4)) % 4);
|
||||||
|
return Buffer.from(s, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hmac(secret, data) {
|
||||||
|
return crypto.createHmac("sha256", secret).update(data).digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signJwt(claims, secret) {
|
||||||
|
const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const body = b64url(JSON.stringify(claims));
|
||||||
|
const sig = b64url(hmac(secret, `${head}.${body}`));
|
||||||
|
return `${head}.${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyJwt(token, secret) {
|
||||||
|
if (typeof token !== "string") return null;
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) return null;
|
||||||
|
const [head, body, sig] = parts;
|
||||||
|
const expected = b64url(hmac(secret, `${head}.${body}`));
|
||||||
|
// confronto a tempo costante
|
||||||
|
if (sig.length !== expected.length) return null;
|
||||||
|
let diff = 0;
|
||||||
|
for (let i = 0; i < sig.length; i++) diff |= sig.charCodeAt(i) ^ expected.charCodeAt(i);
|
||||||
|
if (diff !== 0) return null;
|
||||||
|
try {
|
||||||
|
const claims = JSON.parse(b64urlDecode(body).toString("utf-8"));
|
||||||
|
if (typeof claims.exp === "number" && claims.exp < Math.floor(Date.now() / 1000)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return claims;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Webhook Lemon Squeezy.
|
||||||
|
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||||
|
* Eventi gestiti: order_created, order_refunded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { one, exec } from "./db.js";
|
||||||
|
import { generateLicenseKey } from "./license.js";
|
||||||
|
import { sendLicenseEmail } from "./email.js";
|
||||||
|
|
||||||
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
function hmacHex(secret, data) {
|
||||||
|
return crypto.createHmac("sha256", secret).update(data).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingSafe(a, b) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
const A = Buffer.from(a), B = Buffer.from(b);
|
||||||
|
return crypto.timingSafeEqual(A, B);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
if (!sig || !secret) return res.status(400).json({ error: "Missing signature" });
|
||||||
|
|
||||||
|
const expected = hmacHex(secret, raw);
|
||||||
|
if (!timingSafe(sig, expected)) {
|
||||||
|
return res.status(401).json({ error: "Invalid signature" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload;
|
||||||
|
try { payload = JSON.parse(raw); }
|
||||||
|
catch { return res.status(400).json({ error: "Invalid JSON" }); }
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await sendLicenseEmail(email, key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[email] send failed:", e?.message || e);
|
||||||
|
}
|
||||||
|
return res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { one, exec } from "./db.js";
|
||||||
|
import { signJwt, verifyJwt } from "./jwt.js";
|
||||||
|
|
||||||
|
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
|
||||||
|
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
|
||||||
|
|
||||||
|
const now = () => Math.floor(Date.now() / 1000);
|
||||||
|
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
||||||
|
const normKey = (s) => String(s || "").trim().toUpperCase();
|
||||||
|
|
||||||
|
async function issueToken(license, deviceId) {
|
||||||
|
const t = now();
|
||||||
|
return signJwt({
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function activate(req, res) {
|
||||||
|
const { key, email, device_id, device_name, app_version } = req.body || {};
|
||||||
|
const K = normKey(key), E = normEmail(email);
|
||||||
|
const D = String(device_id || "").trim();
|
||||||
|
if (!K || !E || !D) {
|
||||||
|
return res.status(400).json({ error: "Missing key, email or device_id" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = await one(
|
||||||
|
"SELECT id, license_key, email, status 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 t = now();
|
||||||
|
const existing = await one(
|
||||||
|
"SELECT id FROM activations WHERE license_id=? AND device_id=? LIMIT 1",
|
||||||
|
[license.id, D],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await exec(
|
||||||
|
`UPDATE activations SET app_version=?, device_name=?, last_seen_at=?, revoked_at=NULL
|
||||||
|
WHERE id=?`,
|
||||||
|
[app_version || null, device_name || null, t, existing.id],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const row = await one(
|
||||||
|
"SELECT COUNT(*) AS n FROM activations WHERE license_id=? AND revoked_at IS NULL",
|
||||||
|
[license.id],
|
||||||
|
);
|
||||||
|
if ((row?.n || 0) >= MAX_ACTIVATIONS) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: `Hai gia attivato la licenza su ${MAX_ACTIVATIONS} dispositivi. Disattivane uno per usarla qui.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await exec(
|
||||||
|
`INSERT INTO activations
|
||||||
|
(license_id, device_id, device_name, app_version, activated_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[license.id, D, device_name || null, app_version || null, t, t],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await issueToken(license, D);
|
||||||
|
res.json({ token, activated_at: t, email: license.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validate(req, res) {
|
||||||
|
const { token, device_id, app_version } = req.body || {};
|
||||||
|
const T = String(token || "").trim();
|
||||||
|
const D = String(device_id || "").trim();
|
||||||
|
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
|
||||||
|
|
||||||
|
const claims = verifyJwt(T, process.env.JWT_SECRET);
|
||||||
|
if (!claims) return res.status(401).json({ error: "Token invalido o scaduto" });
|
||||||
|
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=?",
|
||||||
|
[claims.sub],
|
||||||
|
);
|
||||||
|
if (!license || license.status !== "active") {
|
||||||
|
return res.status(401).json({ error: "Licenza non attiva" });
|
||||||
|
}
|
||||||
|
const act = await one(
|
||||||
|
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
|
||||||
|
[license.id, D],
|
||||||
|
);
|
||||||
|
if (!act || act.revoked_at !== null) {
|
||||||
|
return res.status(401).json({ error: "Attivazione non trovata o revocata" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await exec(
|
||||||
|
"UPDATE activations SET last_seen_at=?, app_version=? WHERE id=?",
|
||||||
|
[now(), app_version || null, act.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const fresh = await issueToken(license, D);
|
||||||
|
res.json({ token: fresh, email: license.email });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deactivate(req, res) {
|
||||||
|
const { token, device_id } = req.body || {};
|
||||||
|
const T = String(token || "").trim();
|
||||||
|
const D = String(device_id || "").trim();
|
||||||
|
if (!T || !D) return res.status(400).json({ error: "Missing token or device_id" });
|
||||||
|
|
||||||
|
const claims = verifyJwt(T, process.env.JWT_SECRET);
|
||||||
|
if (!claims) return res.status(401).json({ error: "Token invalido" });
|
||||||
|
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
||||||
|
|
||||||
|
await exec(
|
||||||
|
`UPDATE activations SET revoked_at=?
|
||||||
|
WHERE license_id=? AND device_id=? AND revoked_at IS NULL`,
|
||||||
|
[now(), claims.sub, D],
|
||||||
|
);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esposto per uso interno (es. webhook genera chiave nuova)
|
||||||
|
export function generateLicenseKey() {
|
||||||
|
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
|
const buf = crypto.randomBytes(16);
|
||||||
|
const chars = Array.from(buf, (b) => alphabet[b % alphabet.length]);
|
||||||
|
return [chars.slice(0,4), chars.slice(4,8), chars.slice(8,12), chars.slice(12,16)]
|
||||||
|
.map((g) => g.join("")).join("-");
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* Applica i file SQL in migrations/ in ordine alfabetico.
|
||||||
|
* Tiene traccia dei file gia eseguiti in una tabella `schema_migrations`.
|
||||||
|
*
|
||||||
|
* Run: npm run migrate
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "dotenv/config";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import pool, { query, exec } from "./db.js";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const MIG_DIR = path.resolve(__dirname, "../migrations");
|
||||||
|
|
||||||
|
async function ensureMigrationsTable() {
|
||||||
|
await exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
filename VARCHAR(255) NOT NULL PRIMARY KEY,
|
||||||
|
applied_at BIGINT UNSIGNED NOT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await ensureMigrationsTable();
|
||||||
|
const applied = new Set(
|
||||||
|
(await query("SELECT filename FROM schema_migrations")).map((r) => r.filename),
|
||||||
|
);
|
||||||
|
|
||||||
|
const files = fs.readdirSync(MIG_DIR)
|
||||||
|
.filter((f) => f.endsWith(".sql"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
for (const f of files) {
|
||||||
|
if (applied.has(f)) {
|
||||||
|
console.log(`[migrate] skip ${f} (gia applicato)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const sql = fs.readFileSync(path.join(MIG_DIR, f), "utf-8");
|
||||||
|
console.log(`[migrate] applico ${f}`);
|
||||||
|
// mysql2 supporta multipleStatements ma e' rischioso; splittiamo manualmente.
|
||||||
|
const statements = sql
|
||||||
|
.split(/;\s*\n/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0 && !s.startsWith("--"));
|
||||||
|
for (const stmt of statements) {
|
||||||
|
await exec(stmt);
|
||||||
|
}
|
||||||
|
await exec(
|
||||||
|
"INSERT INTO schema_migrations (filename, applied_at) VALUES (?, ?)",
|
||||||
|
[f, Math.floor(Date.now() / 1000)],
|
||||||
|
);
|
||||||
|
console.log(`[migrate] ok ${f}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.end();
|
||||||
|
console.log("[migrate] done");
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((e) => {
|
||||||
|
console.error("[migrate] errore:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import express from "express";
|
||||||
|
|
||||||
|
import * as license from "./license.js";
|
||||||
|
import * as updates from "./updates.js";
|
||||||
|
import * as ls from "./lemonsqueezy.js";
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// L'app sta dietro Apache reverse proxy: fidati di X-Forwarded-* dal localhost.
|
||||||
|
app.set("trust proxy", "loopback");
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
|
// CORS minimale (solo per /api/*)
|
||||||
|
app.use("/api", (req, res, next) => {
|
||||||
|
res.set("Access-Control-Allow-Origin", "*");
|
||||||
|
res.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||||
|
res.set("Access-Control-Allow-Headers", "Content-Type,Authorization");
|
||||||
|
if (req.method === "OPTIONS") return res.status(204).end();
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check (no body parser necessario)
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ ok: true, version: process.env.LATEST_VERSION || "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// WEBHOOK Lemon Squeezy: deve ricevere il body RAW per verificare la firma.
|
||||||
|
// Va registrato PRIMA del json parser globale.
|
||||||
|
app.post(
|
||||||
|
"/api/webhook/lemonsqueezy",
|
||||||
|
express.raw({ type: "application/json", limit: "1mb" }),
|
||||||
|
ls.webhook,
|
||||||
|
);
|
||||||
|
|
||||||
|
// JSON parser per tutti gli altri endpoint
|
||||||
|
app.use(express.json({ limit: "128kb" }));
|
||||||
|
|
||||||
|
// Licenze
|
||||||
|
app.post("/api/license/activate", license.activate);
|
||||||
|
app.post("/api/license/validate", license.validate);
|
||||||
|
app.post("/api/license/deactivate", license.deactivate);
|
||||||
|
|
||||||
|
// Aggiornamenti + download firmato
|
||||||
|
app.get("/api/latest", updates.latest);
|
||||||
|
app.get("/api/download", updates.download);
|
||||||
|
|
||||||
|
// 404 JSON solo per /api/*
|
||||||
|
app.use("/api", (_req, res) => res.status(404).json({ error: "Not found" }));
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err, _req, res, _next) => {
|
||||||
|
console.error("[unhandled]", err);
|
||||||
|
if (res.headersSent) return;
|
||||||
|
res.status(500).json({ error: "Internal error" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 4002);
|
||||||
|
const HOST = process.env.HOST || "127.0.0.1";
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
console.log(`[musictools-api] listening on ${HOST}:${PORT}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { one } from "./db.js";
|
||||||
|
import { verifyJwt } from "./jwt.js";
|
||||||
|
|
||||||
|
const BUILDS_DIR = process.env.BUILDS_DIR || path.resolve(process.cwd(), "../builds");
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDownloadUrl(filePath) {
|
||||||
|
const exp = Math.floor(Date.now() / 1000) + DOWNLOAD_TTL;
|
||||||
|
const sig = signPayload(`${filePath}.${exp}`, process.env.JWT_SECRET);
|
||||||
|
return `${PUBLIC_BASE}/api/download?file=${encodeURIComponent(filePath)}&exp=${exp}&sig=${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/latest?platform=macos|windows¤t=v1.5.2
|
||||||
|
export async function latest(req, res) {
|
||||||
|
const platform = String(req.query.platform || "").toLowerCase();
|
||||||
|
if (platform !== "macos" && platform !== "windows") {
|
||||||
|
return res.status(400).json({ error: "platform must be macos or windows" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = await one(
|
||||||
|
`SELECT version, platform, file_path, size_bytes, sha256, notes, published_at
|
||||||
|
FROM releases
|
||||||
|
WHERE platform=?
|
||||||
|
ORDER BY published_at DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
[platform],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.json({
|
||||||
|
version: process.env.LATEST_VERSION || "",
|
||||||
|
notes: "",
|
||||||
|
download_url: "",
|
||||||
|
requires_license: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth opzionale (Bearer): senza, niente download URL
|
||||||
|
const auth = req.get("Authorization") || "";
|
||||||
|
let licensed = false;
|
||||||
|
if (auth.startsWith("Bearer ")) {
|
||||||
|
const claims = verifyJwt(auth.slice(7).trim(), process.env.JWT_SECRET);
|
||||||
|
licensed = !!claims;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
version: row.version,
|
||||||
|
notes: row.notes || "",
|
||||||
|
sha256: row.sha256 || "",
|
||||||
|
size_bytes: Number(row.size_bytes || 0),
|
||||||
|
download_url: licensed ? buildDownloadUrl(row.file_path) : "",
|
||||||
|
requires_license: !licensed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/download?file=...&exp=...&sig=...
|
||||||
|
// Verifica firma e stream del file da disco.
|
||||||
|
export async function download(req, res) {
|
||||||
|
const file = String(req.query.file || "");
|
||||||
|
const exp = Number(req.query.exp || 0);
|
||||||
|
const sig = String(req.query.sig || "");
|
||||||
|
if (!file || !exp || !sig) return res.status(400).send("Missing params");
|
||||||
|
|
||||||
|
const expected = signPayload(`${file}.${exp}`, process.env.JWT_SECRET);
|
||||||
|
const a = Buffer.from(sig), b = Buffer.from(expected);
|
||||||
|
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
|
||||||
|
return res.status(401).send("Invalid signature");
|
||||||
|
}
|
||||||
|
if (exp < Math.floor(Date.now() / 1000)) {
|
||||||
|
return res.status(410).send("URL expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sicurezza path: il file deve trovarsi sotto BUILDS_DIR.
|
||||||
|
// 'file' arriva come "v1.5.3/MusicTools-macOS.zip"
|
||||||
|
const abs = path.resolve(BUILDS_DIR, file);
|
||||||
|
if (!abs.startsWith(path.resolve(BUILDS_DIR) + path.sep)) {
|
||||||
|
return res.status(403).send("Forbidden");
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(abs)) {
|
||||||
|
return res.status(404).send("File not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = path.basename(abs);
|
||||||
|
res.setHeader("Content-Type", "application/zip");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||||
|
fs.createReadStream(abs).pipe(res);
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@
|
|||||||
--rec-red-hover: #F87171;
|
--rec-red-hover: #F87171;
|
||||||
--rec-red-dim: #7F1D1D;
|
--rec-red-dim: #7F1D1D;
|
||||||
|
|
||||||
|
--indigo: #6366F1;
|
||||||
|
--indigo-hover: #818CF8;
|
||||||
|
--indigo-dim: #312E81;
|
||||||
|
|
||||||
--red: #E22134;
|
--red: #E22134;
|
||||||
--red-hover: #FF4055;
|
--red-hover: #FF4055;
|
||||||
|
|
||||||
@@ -249,6 +253,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
|
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-indigo {
|
||||||
|
background: linear-gradient(135deg, #4F46E5 0%, #312E81 60%, #1E1B4B 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
.hero-content { position: relative; z-index: 2; max-width: 70%; }
|
||||||
|
|
||||||
.hero-eyebrow {
|
.hero-eyebrow {
|
||||||
@@ -264,6 +272,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
|
|||||||
.hero-eyebrow.pink { color: var(--pink-hover); }
|
.hero-eyebrow.pink { color: var(--pink-hover); }
|
||||||
.hero-eyebrow.amber { color: var(--amber-hover); }
|
.hero-eyebrow.amber { color: var(--amber-hover); }
|
||||||
.hero-eyebrow.red { color: var(--rec-red-hover); }
|
.hero-eyebrow.red { color: var(--rec-red-hover); }
|
||||||
|
.hero-eyebrow.indigo { color: var(--indigo-hover); }
|
||||||
|
|
||||||
.hero-title {
|
.hero-title {
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
@@ -1038,3 +1047,401 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===============================
|
||||||
|
GUIDE
|
||||||
|
=============================== */
|
||||||
|
.guide-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 200px 1fr;
|
||||||
|
gap: 32px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-toc {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 16px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link {
|
||||||
|
color: var(--text-2);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background-color 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link:hover {
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toc-link.active {
|
||||||
|
background: var(--indigo-dim);
|
||||||
|
color: var(--indigo-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content {
|
||||||
|
min-width: 0; /* evita overflow horizontal */
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 24px 28px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
scroll-margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section h2 {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
margin-top: 22px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section h4 {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--indigo-hover);
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section p {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section ul,
|
||||||
|
.g-section ol {
|
||||||
|
padding-left: 22px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section strong { color: var(--text); }
|
||||||
|
|
||||||
|
.g-section code {
|
||||||
|
background: var(--bg-input);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: "SF Mono", Menlo, Consolas, monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--green-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section pre {
|
||||||
|
background: var(--bg-card-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin: 10px 0 14px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section pre code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section a {
|
||||||
|
color: var(--indigo-hover);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-section a:hover {
|
||||||
|
border-bottom-color: var(--indigo-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kbd {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--bg-input-hover);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-box {
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
margin: 14px 0;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-box.info {
|
||||||
|
background: rgba(99, 102, 241, 0.10);
|
||||||
|
border-left: 3px solid var(--indigo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-box.success {
|
||||||
|
background: rgba(29, 185, 84, 0.10);
|
||||||
|
border-left: 3px solid var(--green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-box.warn {
|
||||||
|
background: rgba(253, 203, 110, 0.10);
|
||||||
|
border-left: 3px solid #FDCB6E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-table th,
|
||||||
|
.g-table td {
|
||||||
|
padding: 10px 14px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-table th {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11.5px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: var(--bg-card-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.g-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.guide-layout { grid-template-columns: 1fr; }
|
||||||
|
.guide-toc {
|
||||||
|
position: static;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
SCHERMATA ATTIVAZIONE LICENZA
|
||||||
|
============================================================ */
|
||||||
|
.activate-screen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top, rgba(29, 185, 84, 0.18), transparent 60%),
|
||||||
|
linear-gradient(180deg, #0a0a0a 0%, #060606 100%);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-screen[hidden] { display: none; }
|
||||||
|
|
||||||
|
.activate-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.5);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-logo {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(135deg, #1db954 0%, #137a37 100%);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 36px;
|
||||||
|
box-shadow: 0 12px 30px rgba(29, 185, 84, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-sub {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-form {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-form .field {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-error {
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.4);
|
||||||
|
color: #ff6b7a;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 6px 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-foot {
|
||||||
|
margin-top: 24px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-foot a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activate-foot a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
IMPOSTAZIONI > LICENZA
|
||||||
|
============================================================ */
|
||||||
|
.license-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lic-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--divider);
|
||||||
|
font-size: 13.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lic-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.lic-row span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lic-row strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lic-warn {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(220, 53, 69, 0.12);
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.35);
|
||||||
|
color: #ff6b7a;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.license-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
REGISTRA: card errore con log tecnici
|
||||||
|
============================================================ */
|
||||||
|
.rec-error-card {
|
||||||
|
border-left: 4px solid var(--red, #f87171);
|
||||||
|
}
|
||||||
|
|
||||||
|
.micro-label.red {
|
||||||
|
color: #ff8a92;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-error-msg {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-error-log {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: #0d0d0d;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c0c0c0;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
+348
-2
@@ -45,6 +45,10 @@
|
|||||||
<span class="nav-icon">⚙</span>
|
<span class="nav-icon">⚙</span>
|
||||||
<span>Impostazioni</span>
|
<span>Impostazioni</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="guide">
|
||||||
|
<span class="nav-icon">📖</span>
|
||||||
|
<span>Guida</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
@@ -71,15 +75,22 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="field-label">
|
<label class="field-label">
|
||||||
URL
|
URL o titolo brano
|
||||||
<span class="badge" id="urlListBadge" hidden></span>
|
<span class="badge" id="urlListBadge" hidden></span>
|
||||||
</label>
|
</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" />
|
<input type="text" id="urlInput" class="input pill" placeholder="Link Spotify/YouTube… oppure Artista - Titolo · Titolo brano" />
|
||||||
<button class="btn btn-ghost pill" id="loadListBtn">
|
<button class="btn btn-ghost pill" id="loadListBtn">
|
||||||
<span>📂</span> Carica lista
|
<span>📂</span> Carica lista
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<div>
|
||||||
|
Puoi incollare un <strong>link</strong> (Spotify, YouTube, SoundCloud…) oppure scrivere direttamente
|
||||||
|
<strong>Artista - Titolo</strong> o solo il <strong>titolo del brano</strong>: l'app cerca il match migliore su YouTube e scarica in MP3.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -332,6 +343,16 @@
|
|||||||
<div class="micro-label">Ultima registrazione</div>
|
<div class="micro-label">Ultima registrazione</div>
|
||||||
<div class="rec-result" id="recResult"></div>
|
<div class="rec-result" id="recResult"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card rec-error-card" id="recErrorCard" hidden>
|
||||||
|
<div class="micro-label red">Errore registrazione</div>
|
||||||
|
<div class="rec-error-msg" id="recErrorMsg"></div>
|
||||||
|
<div class="row" style="gap: 10px; margin-top: 12px;">
|
||||||
|
<button class="btn btn-ghost pill btn-sm" id="recShowLogBtn">Mostra log tecnici</button>
|
||||||
|
<button class="btn btn-ghost pill btn-sm" id="recCopyLogBtn" hidden>Copia log</button>
|
||||||
|
</div>
|
||||||
|
<pre class="rec-error-log" id="recErrorLog" hidden></pre>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ===== VIEW: METADATI ===== -->
|
<!-- ===== VIEW: METADATI ===== -->
|
||||||
@@ -509,6 +530,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-label">Licenza</h2>
|
||||||
|
<div class="card">
|
||||||
|
<div class="license-box" id="licenseStatusBox"></div>
|
||||||
|
<div class="license-actions">
|
||||||
|
<button class="btn btn-ghost pill" id="revalidateLicenseBtn">Verifica ora</button>
|
||||||
|
<button class="btn btn-danger pill" id="deactivateLicenseBtn">Disattiva su questo device</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint">
|
||||||
|
<span class="hint-ico">ⓘ</span>
|
||||||
|
<div>
|
||||||
|
Disattivando liberi uno slot di attivazione, utile se sposti l'app su un altro Mac/PC.
|
||||||
|
Puoi riattivare in qualsiasi momento con la stessa email e chiave.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="section-label">Aggiornamenti</h2>
|
<h2 class="section-label">Aggiornamenti</h2>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="row spread">
|
<div class="row spread">
|
||||||
@@ -528,9 +565,318 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== VIEW: GUIDA ===== -->
|
||||||
|
<section class="view" id="view-guide">
|
||||||
|
<header class="hero hero-indigo">
|
||||||
|
<div class="hero-content">
|
||||||
|
<div class="hero-eyebrow indigo">GUIDA</div>
|
||||||
|
<h1 class="hero-title">Manuale d'uso</h1>
|
||||||
|
<p class="hero-subtitle">Come usare MusicTools al meglio · setup · troubleshooting</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-deco">📖</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="guide-layout">
|
||||||
|
<!-- TOC sticky -->
|
||||||
|
<nav class="guide-toc">
|
||||||
|
<div class="micro-label" style="margin-bottom: 8px;">Sommario</div>
|
||||||
|
<a href="#g-intro" class="toc-link">Introduzione</a>
|
||||||
|
<a href="#g-setup" class="toc-link">Setup iniziale</a>
|
||||||
|
<a href="#g-download" class="toc-link">Tab Scarica</a>
|
||||||
|
<a href="#g-video" class="toc-link">Tab Video</a>
|
||||||
|
<a href="#g-upgrade" class="toc-link">Tab Upgrade</a>
|
||||||
|
<a href="#g-meta" class="toc-link">Tab Metadati</a>
|
||||||
|
<a href="#g-record" class="toc-link">Tab Registra</a>
|
||||||
|
<a href="#g-settings" class="toc-link">Impostazioni</a>
|
||||||
|
<a href="#g-update" class="toc-link">Aggiornamenti</a>
|
||||||
|
<a href="#g-troubleshoot" class="toc-link">Troubleshooting</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<article class="guide-content">
|
||||||
|
|
||||||
|
<section id="g-intro" class="g-section">
|
||||||
|
<h2>Introduzione</h2>
|
||||||
|
<p><strong>MusicTools</strong> è una app desktop tutto-in-uno per chi lavora con la musica: scarica brani e video da decine di piattaforme, migliora la qualità dei file esistenti, modifica i tag/metadati e registra l'audio da qualsiasi ingresso del computer.</p>
|
||||||
|
<p>L'interfaccia è organizzata in 6 sezioni accessibili dalla barra laterale: <span class="kbd">⬇ Scarica</span>, <span class="kbd">🎬 Video</span>, <span class="kbd">⚡ Upgrade</span>, <span class="kbd">🏷 Metadati</span>, <span class="kbd">● Registra</span>, <span class="kbd">⚙ Impostazioni</span>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-setup" class="g-section">
|
||||||
|
<h2>Setup iniziale</h2>
|
||||||
|
<p>Prima di usare l'app, conviene fare 3 cose nelle <strong>Impostazioni</strong>:</p>
|
||||||
|
<ol>
|
||||||
|
<li><strong>Cartella output predefinita</strong> — dove vengono salvati tutti i download.</li>
|
||||||
|
<li><strong>Credenziali Spotify</strong> (solo se vuoi scaricare da link Spotify) — vedi il bottone "Come ottenere le credenziali?" nella tab Impostazioni.</li>
|
||||||
|
<li><strong>cookies.txt</strong> (opzionale) — necessario solo per scaricare contenuti privati Instagram/Facebook o aggirare rate-limit YouTube. Esportalo dal browser con l'estensione "Get cookies.txt LOCALLY".</li>
|
||||||
|
</ol>
|
||||||
|
<p>Tutti i campi vengono salvati automaticamente quando premi <strong>Salva Impostazioni</strong>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-download" class="g-section">
|
||||||
|
<h2>Tab ⬇ Scarica</h2>
|
||||||
|
<p>Scarica musica in MP3 al bitrate impostato in Impostazioni (default 320 kbps).</p>
|
||||||
|
|
||||||
|
<h3>1 · Da Spotify</h3>
|
||||||
|
<p>Incolla un link a una <strong>playlist, album o brano</strong> (es. <code>open.spotify.com/playlist/...</code>). L'app risolve i metadati via API Spotify e scarica i brani da YouTube. <strong>Richiede</strong> le credenziali API impostate.</p>
|
||||||
|
|
||||||
|
<h3>2 · Da YouTube, SoundCloud e altri</h3>
|
||||||
|
<p>Incolla qualsiasi URL supportato da yt-dlp (singolo video o playlist). Funziona anche con Bandcamp, Mixcloud, ecc.</p>
|
||||||
|
|
||||||
|
<h3>3 · Lista da file .txt</h3>
|
||||||
|
<p>Clicca <strong>📂 Carica lista</strong>. Il file può contenere:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Una URL per riga</strong> — l'app le scarica una dopo l'altra.</li>
|
||||||
|
<li><strong>Tracklist con titoli</strong> (es. Beatport Top 100): formato <code>1. Artista – Titolo (variant) (durata)</code>. L'app riconosce automaticamente il formato e cerca ogni brano su YouTube.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Nel caso tracklist, viene creata automaticamente una <strong>sottocartella</strong> col nome del file (es. <code>Beatport Top 100.txt</code> → <code>Beatport Top 100/</code>).</p>
|
||||||
|
|
||||||
|
<h3>4 · Skip duplicati</h3>
|
||||||
|
<p>Se riesegui la stessa playlist, i brani già scaricati vengono saltati in automatico. Il tracking funziona in due modi:</p>
|
||||||
|
<ul>
|
||||||
|
<li>File <code>.downloaded_tracks</code> dentro la cartella output.</li>
|
||||||
|
<li>Scan dei file presenti, con match dei nomi token-per-token (ignora parole come "Official Video", "Lyrics", "HD"...).</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-video" class="g-section">
|
||||||
|
<h2>Tab 🎬 Video</h2>
|
||||||
|
<p>Scarica video da <strong>YouTube, TikTok, Instagram, Facebook</strong> e oltre 1000 altri siti supportati da yt-dlp.</p>
|
||||||
|
|
||||||
|
<h3>Modalità</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>🎬 Video (MP4)</strong> — output video+audio in MP4, qualità a scelta (best / 1080p / 720p / 480p).</li>
|
||||||
|
<li><strong>🎵 Solo audio (MP3)</strong> — estrae solo l'audio in MP3 al bitrate impostato in Impostazioni, con thumbnail embedded come copertina.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Contenuti privati</h3>
|
||||||
|
<p>Per scaricare reel Instagram privati, post Facebook privati o aggirare rate-limit YouTube serve un file <strong><code>cookies.txt</code></strong> valido (formato Netscape). Esportalo dal browser e impostalo in <strong>Impostazioni → Percorsi</strong>.</p>
|
||||||
|
|
||||||
|
<div class="g-box info">
|
||||||
|
<strong>Esempio:</strong> per scaricare la <em>Storia</em> di un utente Instagram seguito, fai login al browser, esporta i cookies, riavvia l'app.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-upgrade" class="g-section">
|
||||||
|
<h2>Tab ⚡ Upgrade qualità</h2>
|
||||||
|
<p>Scansiona una cartella e <strong>riscarica i file sotto la soglia kbps</strong> impostata, sostituendoli con la versione a qualità maggiore.</p>
|
||||||
|
<ol>
|
||||||
|
<li>Seleziona la cartella con i file audio.</li>
|
||||||
|
<li>Imposta la <strong>Soglia HQ (kbps)</strong> — i file con bitrate inferiore verranno riprocessati. Default 310 kbps.</li>
|
||||||
|
<li>(Opzionale) Spunta <strong>Includi sottocartelle</strong> per la scansione ricorsiva.</li>
|
||||||
|
<li>Premi <strong>Avvia Upgrade</strong>.</li>
|
||||||
|
</ol>
|
||||||
|
<p>Per ogni file l'app cerca la versione migliore su YouTube e la sostituisce mantenendo nome originale. I file già processati vengono saltati grazie al tracking <code>.upgraded_tracks</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-meta" class="g-section">
|
||||||
|
<h2>Tab 🏷 Metadati</h2>
|
||||||
|
<p>Editor completo dei tag audio. Apri un file con <strong>📂 Apri file audio</strong>.</p>
|
||||||
|
|
||||||
|
<h3>Formati supportati</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>MP3</strong> — ID3v2.3</li>
|
||||||
|
<li><strong>M4A / AAC / MP4</strong> — Apple atoms</li>
|
||||||
|
<li><strong>FLAC</strong> — Vorbis comments</li>
|
||||||
|
<li><strong>WAV</strong> — ID3 embedded + fallback al chunk RIFF LIST/INFO (per file taggati da ffmpeg/Audacity)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Campi editabili</h3>
|
||||||
|
<p>Titolo, artista, album, album artist, anno, traccia (formato <code>1/12</code>), genere, BPM, key musicale, commento, copertina (JPG/PNG), e su macOS anche <strong>kMDItemWhereFroms</strong> (l'extended attribute che traccia l'origine del file scaricato).</p>
|
||||||
|
|
||||||
|
<h3>Copertina</h3>
|
||||||
|
<p>Anteprima 180×180 della copertina corrente. <strong>Cambia copertina</strong> ne carica una nuova; <strong>Rimuovi</strong> la marca per la rimozione al prossimo salvataggio.</p>
|
||||||
|
|
||||||
|
<h3>Annulla modifiche</h3>
|
||||||
|
<p>Premi <strong>↺ Ricarica</strong> per rileggere i tag dal file e scartare le modifiche non salvate.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-record" class="g-section">
|
||||||
|
<h2>Tab ● Registra</h2>
|
||||||
|
<p>Cattura audio in MP3 da qualsiasi ingresso del computer: <strong>mixer, scheda audio esterna, microfono, driver loopback</strong> (BlackHole/Soundflower/VB-Cable). Bitrate selezionabile 128–320 kbps.</p>
|
||||||
|
|
||||||
|
<h3>Setup standard (mixer/microfono)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Dropdown <strong>Dispositivo</strong>: seleziona la sorgente.</li>
|
||||||
|
<li>Imposta una cartella output e (opzionale) un nome file.</li>
|
||||||
|
<li>Premi <strong>● REC</strong> → registrazione, timer in tempo reale.</li>
|
||||||
|
<li><strong>◼ Stop</strong> → file MP3 finalizzato e linkato sotto.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>Registrare l'audio di sistema su macOS (Spotify, YouTube...)</h3>
|
||||||
|
<p>macOS non espone nativamente l'uscita audio. Serve un driver loopback gratuito, il più diffuso è <strong>BlackHole</strong>.</p>
|
||||||
|
|
||||||
|
<h4>Installazione</h4>
|
||||||
|
<p>Apri Terminale e lancia:</p>
|
||||||
|
<pre><code>brew install blackhole-2ch</code></pre>
|
||||||
|
<p>Se subito dopo l'install BlackHole non compare nel dropdown, riavvia il servizio audio:</p>
|
||||||
|
<pre><code>sudo killall coreaudiod</code></pre>
|
||||||
|
<p>(rilancia da solo in pochi secondi, nessun rischio)</p>
|
||||||
|
|
||||||
|
<h4>Permesso microfono</h4>
|
||||||
|
<p>macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation. Vai su <strong>Preferenze di Sistema → Privacy e sicurezza → Microfono</strong> e abilita Terminal/MusicTools. Se la voce non c'è, comparirà un popup la prima volta che premi REC.</p>
|
||||||
|
|
||||||
|
<h4>Setup A — minimo (non senti l'audio durante)</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Click icona altoparlante nella barra menu → <strong>Output → BlackHole 2ch</strong></li>
|
||||||
|
<li>Fai partire Spotify (non sentirai nulla — l'audio va a BlackHole)</li>
|
||||||
|
<li>Tab Registra → dispositivo <strong>🔄 BlackHole 2ch (loopback)</strong> → REC</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h4>Setup B — completo (registri E senti l'audio)</h4>
|
||||||
|
<ol>
|
||||||
|
<li>Apri <strong>Configurazione MIDI Audio</strong> (<code>Applicazioni → Utility</code>)</li>
|
||||||
|
<li>In basso a sinistra: <strong>+ → Crea dispositivo aggregato a uscita multipla</strong></li>
|
||||||
|
<li>Spunta sia <strong>BlackHole 2ch</strong> sia i tuoi <strong>Altoparlanti integrati</strong></li>
|
||||||
|
<li>Imposta <strong>BlackHole 2ch come Master Device</strong> (menu in alto)</li>
|
||||||
|
<li>Abilita <strong>Drift Correction</strong> sulla riga degli altoparlanti</li>
|
||||||
|
<li>(Opzionale) Rinomina in <code>Speakers + BlackHole</code></li>
|
||||||
|
<li>Preferenze di Sistema → Audio → Uscita → seleziona <code>Speakers + BlackHole</code></li>
|
||||||
|
<li>Nella tab Registra: scegli <strong>🔄 BlackHole 2ch (loopback)</strong>, non il dispositivo aggregato</li>
|
||||||
|
</ol>
|
||||||
|
<div class="g-box success">
|
||||||
|
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-settings" class="g-section">
|
||||||
|
<h2>Impostazioni</h2>
|
||||||
|
|
||||||
|
<h3>Spotify API</h3>
|
||||||
|
<p>Le credenziali API Spotify sono <strong>gratuite</strong> e non richiedono Premium. Vai su <a href="#" data-ext="https://developer.spotify.com/dashboard">Spotify Developer Dashboard</a>, "Create app", compila i campi (Redirect URI <code>http://localhost:8888/callback</code>, seleziona "Web API"), e copia Client ID + Client Secret nei campi corrispondenti.</p>
|
||||||
|
|
||||||
|
<h3>Download</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Bitrate</strong> — qualità target per gli MP3 (128/192/256/320 kbps).</li>
|
||||||
|
<li><strong>Soglia HQ</strong> — soglia kbps per la tab Upgrade. I file sotto questa soglia vengono riscaricati.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Percorsi</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>cookies.txt</strong> — per contenuti privati/protetti.</li>
|
||||||
|
<li><strong>Cartella output predefinita</strong> — viene proposta in tutte le tab.</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-update" class="g-section">
|
||||||
|
<h2>Aggiornamenti</h2>
|
||||||
|
<p>In <strong>Impostazioni → Aggiornamenti</strong> trovi il bottone <strong>Controlla aggiornamenti</strong>. L'app interroga la pagina release di GitHub e ti mostra se c'è una versione nuova.</p>
|
||||||
|
<p>Se è disponibile un aggiornamento, comparirà un secondo bottone <strong>Scarica aggiornamento</strong> che apre direttamente il browser sull'asset corretto (macOS o Windows) della release più recente.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="g-troubleshoot" class="g-section">
|
||||||
|
<h2>Troubleshooting</h2>
|
||||||
|
|
||||||
|
<table class="g-table">
|
||||||
|
<thead><tr><th>Sintomo</th><th>Causa probabile</th><th>Fix</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>BlackHole non compare nel dropdown della tab Registra</td>
|
||||||
|
<td>CoreAudio non l'ha ancora caricato dopo l'install</td>
|
||||||
|
<td><code>sudo killall coreaudiod</code> + premi <strong>↻ Aggiorna</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>"Permesso microfono mancante" o errore ffmpeg -6</td>
|
||||||
|
<td>macOS nega l'accesso al device</td>
|
||||||
|
<td>Privacy e sicurezza → Microfono → abilita Terminal/MusicTools</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>File MP3 della registrazione è muto</td>
|
||||||
|
<td>L'output di sistema non passa da BlackHole</td>
|
||||||
|
<td>Imposta output su BlackHole o Multi-Output Device (Setup A/B)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Spotify dice "Autenticazione fallita"</td>
|
||||||
|
<td>Client ID/Secret mancanti o errati</td>
|
||||||
|
<td>Impostazioni → Spotify API → ripeti la procedura</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Instagram/Facebook non scarica</td>
|
||||||
|
<td>Contenuto privato senza cookies validi</td>
|
||||||
|
<td>Esporta cookies.txt dal browser dopo login</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Brano scaricato è sbagliato (versione cover invece dell'originale)</td>
|
||||||
|
<td>Ricerca YouTube ha preso il primo risultato</td>
|
||||||
|
<td>Modifica la query nel file di tracklist (più dettagli artista/titolo)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Tag non vengono letti su un WAV</td>
|
||||||
|
<td>File usa solo chunk INFO non ID3 (raro)</td>
|
||||||
|
<td>L'app ora gestisce entrambi — controlla di avere v1.2.1+</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>Stop button non risponde durante il download</td>
|
||||||
|
<td>yt-dlp sta finalizzando il file</td>
|
||||||
|
<td>Attendi qualche secondo, il processo si chiude dopo l'I/O</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>Reset configurazione</h3>
|
||||||
|
<p>Se vuoi azzerare tutte le impostazioni, cancella il file <code>config.json</code>. Posizione:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>macOS</strong>: <code>~/Library/Application Support/MusicTools/config.json</code></li>
|
||||||
|
<li><strong>Windows</strong>: <code>%APPDATA%\MusicTools\config.json</code></li>
|
||||||
|
</ul>
|
||||||
|
<p>Verrà ricreato con valori di default al prossimo avvio.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="g-section">
|
||||||
|
<h2>Risorse</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload">Codice sorgente su GitHub</a></li>
|
||||||
|
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload/releases">Tutte le release</a></li>
|
||||||
|
<li><a href="#" data-ext="https://github.com/yt-dlp/yt-dlp/wiki/FAQ">FAQ di yt-dlp</a></li>
|
||||||
|
<li><a href="#" data-ext="https://existential.audio/blackhole/">BlackHole — driver loopback macOS</a></li>
|
||||||
|
</ul>
|
||||||
|
<p style="color: var(--text-3); font-size: 12px; margin-top: 20px;">MusicTools — by <strong style="color: var(--green);">LuZa</strong></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============== SCHERMATA ATTIVAZIONE LICENZA ============== -->
|
||||||
|
<div class="activate-screen" id="activateScreen" hidden>
|
||||||
|
<div class="activate-card">
|
||||||
|
<div class="activate-logo">♫</div>
|
||||||
|
<h1 class="activate-title">Benvenuto in MusicTools</h1>
|
||||||
|
<p class="activate-sub">
|
||||||
|
Inserisci la tua licenza per iniziare. Se non ne hai ancora una,
|
||||||
|
acquistala dal nostro sito ed entra in pochi secondi.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="activate-form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Email dell'acquisto</label>
|
||||||
|
<input type="email" id="actEmail" class="input pill" placeholder="tu@esempio.com" autocomplete="email" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="field-label">Chiave di licenza</label>
|
||||||
|
<input type="text" id="actKey" class="input pill" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activate-error" id="actError" hidden></div>
|
||||||
|
|
||||||
|
<div class="activate-actions">
|
||||||
|
<button class="btn btn-primary pill btn-lg" id="actActivateBtn">
|
||||||
|
<span class="ico">🔓</span> Attiva
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost pill btn-lg" id="actBuyBtn">
|
||||||
|
Compra una licenza
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="activate-foot">
|
||||||
|
Hai problemi? Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============== MODAL: Spotify Guide ============== -->
|
<!-- ============== MODAL: Spotify Guide ============== -->
|
||||||
<div class="modal-backdrop" id="guideModal" hidden>
|
<div class="modal-backdrop" id="guideModal" hidden>
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|||||||
+242
-5
@@ -7,6 +7,8 @@ const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
|||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
config: {},
|
config: {},
|
||||||
|
license: { licensed: false },
|
||||||
|
purchaseUrl: "https://musictools.djluza.com",
|
||||||
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
loaded: null, // { kind: "urls"|"tracks", urls?, tracks?, count }
|
||||||
dlOutputDir: "",
|
dlOutputDir: "",
|
||||||
upDir: "",
|
upDir: "",
|
||||||
@@ -27,6 +29,108 @@ function waitApi() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Riconosce se una stringa e un URL (incluso schema spotify:)
|
||||||
|
function looksLikeUrl(s) {
|
||||||
|
if (!s) return false;
|
||||||
|
if (/^(https?|spotify):/i.test(s)) return true;
|
||||||
|
if (s.includes("://")) return true;
|
||||||
|
// Domini comuni anche senza http (es. "youtube.com/...")
|
||||||
|
return /^(www\.)?(youtube\.com|youtu\.be|spotify\.com|soundcloud\.com|tiktok\.com|instagram\.com|facebook\.com|fb\.watch)\//i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trasforma una query libera in {name, artist}.
|
||||||
|
// Formati riconosciuti: "Artista - Titolo" (anche con – o —) oppure solo "Titolo".
|
||||||
|
function parseQueryToTrack(q) {
|
||||||
|
const s = (q || "").trim();
|
||||||
|
const m = s.match(/^(.+?)\s+[-–—]\s+(.+)$/);
|
||||||
|
if (m) return { artist: m[1].trim(), name: m[2].trim() };
|
||||||
|
return { artist: "", name: s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Licenza: gating della UI principale
|
||||||
|
// ============================================================
|
||||||
|
function applyLicenseGate() {
|
||||||
|
const lic = state.license || { licensed: false };
|
||||||
|
const screen = $("#activateScreen");
|
||||||
|
const appEl = document.querySelector(".app");
|
||||||
|
if (!lic.licensed) {
|
||||||
|
if (screen) screen.hidden = false;
|
||||||
|
if (appEl) appEl.style.display = "none";
|
||||||
|
} else {
|
||||||
|
if (screen) screen.hidden = true;
|
||||||
|
if (appEl) appEl.style.display = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(epoch) {
|
||||||
|
if (!epoch) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(epoch * 1000).toLocaleDateString("it-IT", {
|
||||||
|
day: "2-digit", month: "short", year: "numeric"
|
||||||
|
});
|
||||||
|
} catch (_e) { return "—"; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLicenseStatus() {
|
||||||
|
const lic = state.license || {};
|
||||||
|
const box = $("#licenseStatusBox");
|
||||||
|
if (!box) return; // panel non ancora montato
|
||||||
|
if (!lic.licensed) {
|
||||||
|
box.innerHTML = `<div class="lic-warn">Licenza non attiva</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
box.innerHTML = `
|
||||||
|
<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>
|
||||||
|
<div class="lic-row"><span>Ultima verifica</span><strong>${fmtDate(lic.last_validated_at)}</strong></div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activateLicense() {
|
||||||
|
const email = $("#actEmail").value.trim();
|
||||||
|
const key = $("#actKey").value.trim();
|
||||||
|
const err = $("#actError");
|
||||||
|
err.hidden = true;
|
||||||
|
err.textContent = "";
|
||||||
|
if (!email || !key) {
|
||||||
|
err.textContent = "Inserisci email e chiave di licenza.";
|
||||||
|
err.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const btn = $("#actActivateBtn");
|
||||||
|
btn.disabled = true;
|
||||||
|
const oldText = btn.innerHTML;
|
||||||
|
btn.innerHTML = "Attivazione in corso…";
|
||||||
|
try {
|
||||||
|
const res = await window.pywebview.api.activate_license({ email, key });
|
||||||
|
if (res.ok) {
|
||||||
|
state.license = res.license;
|
||||||
|
applyLicenseGate();
|
||||||
|
renderLicenseStatus();
|
||||||
|
toast("Licenza attivata. Benvenuto!", "success");
|
||||||
|
} else {
|
||||||
|
err.textContent = res.error || "Errore di attivazione.";
|
||||||
|
err.hidden = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = oldText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deactivateLicense() {
|
||||||
|
if (!confirm("Disattivare la licenza su questo dispositivo?")) return;
|
||||||
|
const res = await window.pywebview.api.deactivate_license();
|
||||||
|
if (res.ok) {
|
||||||
|
state.license = res.license;
|
||||||
|
applyLicenseGate();
|
||||||
|
renderLicenseStatus();
|
||||||
|
toast("Licenza disattivata", "info");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Bridge — eventi spinti dal backend
|
// Bridge — eventi spinti dal backend
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -118,6 +222,7 @@ const bridgeHandlers = {
|
|||||||
$("#recRefreshBtn").disabled = true;
|
$("#recRefreshBtn").disabled = true;
|
||||||
$("#recTimer").textContent = "00:00:00";
|
$("#recTimer").textContent = "00:00:00";
|
||||||
$("#recStatus").textContent = "● Registrazione in corso…";
|
$("#recStatus").textContent = "● Registrazione in corso…";
|
||||||
|
$("#recErrorCard").hidden = true; // pulisci eventuale errore precedente
|
||||||
} else if (status === "tick") {
|
} else if (status === "tick") {
|
||||||
state.rec.seconds = p.seconds || 0;
|
state.rec.seconds = p.seconds || 0;
|
||||||
$("#recTimer").textContent = formatHms(state.rec.seconds);
|
$("#recTimer").textContent = formatHms(state.rec.seconds);
|
||||||
@@ -143,7 +248,14 @@ const bridgeHandlers = {
|
|||||||
$("#recDevice").disabled = false;
|
$("#recDevice").disabled = false;
|
||||||
$("#recRefreshBtn").disabled = false;
|
$("#recRefreshBtn").disabled = false;
|
||||||
$("#recStatus").textContent = "";
|
$("#recStatus").textContent = "";
|
||||||
toast("Errore registrazione: " + (p.error || ""), "error");
|
const msg = p.error || "Errore sconosciuto";
|
||||||
|
$("#recErrorMsg").textContent = msg;
|
||||||
|
$("#recErrorCard").hidden = false;
|
||||||
|
$("#recErrorLog").hidden = true;
|
||||||
|
$("#recErrorLog").textContent = "";
|
||||||
|
$("#recCopyLogBtn").hidden = true;
|
||||||
|
$("#recShowLogBtn").textContent = "Mostra log tecnici";
|
||||||
|
toast("Errore registrazione", "error");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -216,10 +328,15 @@ async function init() {
|
|||||||
await waitApi();
|
await waitApi();
|
||||||
const data = await window.pywebview.api.get_init_data();
|
const data = await window.pywebview.api.get_init_data();
|
||||||
state.config = data.config || {};
|
state.config = data.config || {};
|
||||||
|
state.license = data.license || { licensed: false };
|
||||||
|
state.purchaseUrl = data.purchase_url || "https://musictools.djluza.com";
|
||||||
$("#footerVersion").textContent = data.version;
|
$("#footerVersion").textContent = data.version;
|
||||||
$("#currentVersionLabel").textContent = data.version;
|
$("#currentVersionLabel").textContent = data.version;
|
||||||
$("#guideBody").textContent = data.spotify_guide;
|
$("#guideBody").textContent = data.spotify_guide;
|
||||||
|
|
||||||
|
applyLicenseGate();
|
||||||
|
renderLicenseStatus();
|
||||||
|
|
||||||
// Populate Settings fields
|
// Populate Settings fields
|
||||||
$("#clientIdInput").value = state.config.client_id || "";
|
$("#clientIdInput").value = state.config.client_id || "";
|
||||||
$("#clientSecretInput").value = state.config.client_secret || "";
|
$("#clientSecretInput").value = state.config.client_secret || "";
|
||||||
@@ -340,11 +457,25 @@ $("#downloadBtn").addEventListener("click", async () => {
|
|||||||
if (!urls.length) {
|
if (!urls.length) {
|
||||||
const single = $("#urlInput").value.trim();
|
const single = $("#urlInput").value.trim();
|
||||||
if (!single) {
|
if (!single) {
|
||||||
toast("Inserisci un URL o carica una lista", "error");
|
toast("Inserisci un URL, un titolo o carica una lista", "error");
|
||||||
bridgeHandlers["download:done"]();
|
bridgeHandlers["download:done"]();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (looksLikeUrl(single)) {
|
||||||
urls = [single];
|
urls = [single];
|
||||||
|
} else {
|
||||||
|
const track = parseQueryToTrack(single);
|
||||||
|
res = await window.pywebview.api.start_tracks_download({
|
||||||
|
tracks: [track],
|
||||||
|
output_dir: state.dlOutputDir,
|
||||||
|
subfolder: "",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
toast(res.error || "Errore", "error");
|
||||||
|
bridgeHandlers["download:done"]();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
res = await window.pywebview.api.start_download({
|
res = await window.pywebview.api.start_download({
|
||||||
urls,
|
urls,
|
||||||
@@ -488,11 +619,55 @@ $("#recRefreshBtn").addEventListener("click", refreshRecDevices);
|
|||||||
|
|
||||||
$("#recOpenGuide").addEventListener("click", (e) => {
|
$("#recOpenGuide").addEventListener("click", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
window.pywebview.api.open_external_url(
|
showView("guide");
|
||||||
"https://github.com/luzadev/musicdownload#guida-rapida-registrazione-audio-di-sistema-macos"
|
setTimeout(() => {
|
||||||
);
|
const el = document.getElementById("g-record");
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}, 80);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GUIDE — link esterni + TOC active highlight
|
||||||
|
// ============================================================
|
||||||
|
document.querySelectorAll("#view-guide a[data-ext]").forEach((a) => {
|
||||||
|
a.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
window.pywebview.api.open_external_url(a.dataset.ext);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll(".toc-link").forEach((a) => {
|
||||||
|
a.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = a.getAttribute("href").slice(1);
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Aggiorna TOC active in base allo scroll della view
|
||||||
|
function setupGuideObserver() {
|
||||||
|
const sections = document.querySelectorAll(".g-section[id]");
|
||||||
|
const tocLinks = document.querySelectorAll(".toc-link");
|
||||||
|
if (!sections.length || !tocLinks.length) return;
|
||||||
|
|
||||||
|
const main = document.querySelector(".main");
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (!entry.isIntersecting) return;
|
||||||
|
const id = entry.target.id;
|
||||||
|
tocLinks.forEach((l) => {
|
||||||
|
l.classList.toggle("active", l.getAttribute("href") === "#" + id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ root: main, rootMargin: "-20% 0px -65% 0px", threshold: 0 }
|
||||||
|
);
|
||||||
|
sections.forEach((s) => observer.observe(s));
|
||||||
|
}
|
||||||
|
setupGuideObserver();
|
||||||
|
|
||||||
$("#recBrowseBtn").addEventListener("click", async () => {
|
$("#recBrowseBtn").addEventListener("click", async () => {
|
||||||
const path = await window.pywebview.api.browse_directory();
|
const path = await window.pywebview.api.browse_directory();
|
||||||
if (path) {
|
if (path) {
|
||||||
@@ -528,6 +703,32 @@ $("#recStopBtn").addEventListener("click", async () => {
|
|||||||
await window.pywebview.api.stop_audio_recording();
|
await window.pywebview.api.stop_audio_recording();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#recShowLogBtn")?.addEventListener("click", async () => {
|
||||||
|
const pre = $("#recErrorLog");
|
||||||
|
if (!pre.hidden) {
|
||||||
|
pre.hidden = true;
|
||||||
|
$("#recShowLogBtn").textContent = "Mostra log tecnici";
|
||||||
|
$("#recCopyLogBtn").hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await window.pywebview.api.get_recorder_log();
|
||||||
|
const lines = (res && res.lines) || [];
|
||||||
|
pre.textContent = lines.length ? lines.join("\n") : "(log vuoto)";
|
||||||
|
pre.hidden = false;
|
||||||
|
$("#recShowLogBtn").textContent = "Nascondi log tecnici";
|
||||||
|
$("#recCopyLogBtn").hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#recCopyLogBtn")?.addEventListener("click", async () => {
|
||||||
|
const text = $("#recErrorLog").textContent || "";
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast("Log copiato negli appunti", "success");
|
||||||
|
} catch {
|
||||||
|
toast("Impossibile copiare", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// METADATA editor
|
// METADATA editor
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -779,6 +980,42 @@ $("#checkUpdateBtn").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Licenza: listener dello schermo di attivazione + impostazioni
|
||||||
|
// ============================================================
|
||||||
|
$("#actActivateBtn")?.addEventListener("click", activateLicense);
|
||||||
|
$("#actBuyBtn")?.addEventListener("click", async () => {
|
||||||
|
await window.pywebview.api.open_purchase_page();
|
||||||
|
});
|
||||||
|
$("#actKey")?.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") activateLicense();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#deactivateLicenseBtn")?.addEventListener("click", deactivateLicense);
|
||||||
|
|
||||||
|
$("#revalidateLicenseBtn")?.addEventListener("click", async () => {
|
||||||
|
const btn = $("#revalidateLicenseBtn");
|
||||||
|
btn.disabled = true;
|
||||||
|
const old = btn.textContent;
|
||||||
|
btn.textContent = "Verifica in corso…";
|
||||||
|
try {
|
||||||
|
const res = await window.pywebview.api.revalidate_license();
|
||||||
|
if (res.ok) {
|
||||||
|
state.license = res.license;
|
||||||
|
renderLicenseStatus();
|
||||||
|
if (res.license.licensed) {
|
||||||
|
toast("Licenza verificata", "success");
|
||||||
|
} else {
|
||||||
|
applyLicenseGate();
|
||||||
|
toast("Licenza non piu valida", "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = old;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Boot
|
// Boot
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
Reference in new issue
Block a user