Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e96e8c6b7 | ||
|
|
c2599a72a1 | ||
|
|
2cc6c5e9c6 | ||
|
|
891562dc88 | ||
|
|
9d21ca051f | ||
|
|
05ea4c6181 | ||
|
|
30be62af78 | ||
|
|
a82f6f420c |
No files matched your search
@@ -33,17 +33,38 @@ jobs:
|
|||||||
- name: Build app
|
- name: Build app
|
||||||
run: python build_macos.py
|
run: python build_macos.py
|
||||||
|
|
||||||
- name: Package .app -> zip
|
- name: Create DMG
|
||||||
run: |
|
run: |
|
||||||
cd dist
|
STAGING="dmg_staging"
|
||||||
ditto -c -k --sequesterRsrc --keepParent MusicTools.app ../MusicTools-macOS.zip
|
rm -rf "$STAGING"
|
||||||
ls -lh ../MusicTools-macOS.zip
|
mkdir -p "$STAGING"
|
||||||
|
cp -R dist/MusicTools.app "$STAGING/"
|
||||||
|
cp "assets/Fix Gatekeeper.command" "$STAGING/"
|
||||||
|
chmod +x "$STAGING/Fix Gatekeeper.command"
|
||||||
|
ln -s /Applications "$STAGING/Applications"
|
||||||
|
cat > "$STAGING/LEGGIMI.txt" <<'EOF'
|
||||||
|
INSTALLAZIONE MusicTools
|
||||||
|
========================
|
||||||
|
|
||||||
|
1) Trascina MusicTools.app sulla cartella "Applicazioni"
|
||||||
|
(l'alias e' qui dentro nel DMG)
|
||||||
|
|
||||||
|
2) Doppio click su "Fix Gatekeeper.command"
|
||||||
|
Rimuove la quarantena macOS Gatekeeper (una sola volta).
|
||||||
|
|
||||||
|
3) Apri MusicTools dalle Applicazioni e attiva con email + chiave.
|
||||||
|
|
||||||
|
Problemi? scrivici a info@djluza.com
|
||||||
|
EOF
|
||||||
|
hdiutil create -volname "MusicTools" -srcfolder "$STAGING" \
|
||||||
|
-ov -format UDZO MusicTools-macOS.dmg
|
||||||
|
ls -lh MusicTools-macOS.dmg
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: MusicTools-macOS
|
name: MusicTools-macOS
|
||||||
path: MusicTools-macOS.zip
|
path: MusicTools-macOS.dmg
|
||||||
retention-days: 14
|
retention-days: 14
|
||||||
|
|
||||||
# =========================================================
|
# =========================================================
|
||||||
@@ -66,6 +87,14 @@ jobs:
|
|||||||
- name: Build exe
|
- name: Build exe
|
||||||
run: python build_windows.py
|
run: python build_windows.py
|
||||||
|
|
||||||
|
- name: Unblock files (rimuovi MOTW pre-zip)
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
# Per ridurre l'incidenza del bug "Failed to resolve
|
||||||
|
# Python.Runtime.Loader.Initialize" causato da Mark of the Web.
|
||||||
|
Get-ChildItem -Path dist/MusicTools -Recurse -Include *.dll,*.exe,*.pyd `
|
||||||
|
| Unblock-File
|
||||||
|
|
||||||
- name: Package -> zip
|
- name: Package -> zip
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: |
|
run: |
|
||||||
@@ -107,5 +136,5 @@ jobs:
|
|||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
files: |
|
files: |
|
||||||
artifacts/macos/MusicTools-macOS.zip
|
artifacts/macos/MusicTools-macOS.dmg
|
||||||
artifacts/windows/MusicTools-Windows.zip
|
artifacts/windows/MusicTools-Windows.zip
|
||||||
@@ -20,7 +20,12 @@ from core.downloader import (
|
|||||||
reset_stop as reset_download_stop,
|
reset_stop as reset_download_stop,
|
||||||
is_stopped as download_is_stopped,
|
is_stopped as download_is_stopped,
|
||||||
)
|
)
|
||||||
from core.spotify_client import get_access_token, resolve_spotify_url
|
from core.spotify_client import (
|
||||||
|
get_access_token,
|
||||||
|
resolve_spotify_url,
|
||||||
|
resolve_spotify_track_no_auth,
|
||||||
|
SpotifyAuthRequired,
|
||||||
|
)
|
||||||
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
from core.metadata import read_metadata, write_metadata, SUPPORTED_EXTS
|
||||||
from core.recorder import (
|
from core.recorder import (
|
||||||
list_input_devices,
|
list_input_devices,
|
||||||
@@ -37,7 +42,15 @@ from core.upgrader import (
|
|||||||
|
|
||||||
|
|
||||||
SPOTIFY_GUIDE_TEXT = """\
|
SPOTIFY_GUIDE_TEXT = """\
|
||||||
1) Vai su https://developer.spotify.com/dashboard e accedi (anche con account free).
|
NOTE: Spotify oggi consente di creare le chiavi API SOLO con un account
|
||||||
|
Spotify Premium attivo. Senza Premium puoi comunque scaricare:
|
||||||
|
- YouTube, SoundCloud, TikTok, Instagram, Facebook (senza setup)
|
||||||
|
- Brani Spotify SINGOLI (incolla il link e basta, l'app fa il resto)
|
||||||
|
Le chiavi Spotify servono solo per playlist e album da Spotify.
|
||||||
|
|
||||||
|
Procedura (se hai Spotify Premium):
|
||||||
|
|
||||||
|
1) Vai su https://developer.spotify.com/dashboard e accedi.
|
||||||
2) Clicca "Create app".
|
2) Clicca "Create app".
|
||||||
3) Compila:
|
3) Compila:
|
||||||
- App name: MusicTools (o quello che vuoi)
|
- App name: MusicTools (o quello che vuoi)
|
||||||
@@ -50,7 +63,7 @@ SPOTIFY_GUIDE_TEXT = """\
|
|||||||
- Client Secret (clicca "View client secret")
|
- Client Secret (clicca "View client secret")
|
||||||
5) Incollali qui sotto e premi "Salva".
|
5) Incollali qui sotto e premi "Salva".
|
||||||
|
|
||||||
Note: credenziali gratuite, no Premium. Limite 100 richieste/minuto.
|
Limite tecnico Spotify: 100 richieste/minuto.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -590,14 +603,11 @@ class Api:
|
|||||||
view = "download"
|
view = "download"
|
||||||
|
|
||||||
token = None
|
token = None
|
||||||
has_spotify = any("spotify.com" in u for u in urls)
|
|
||||||
if has_spotify:
|
|
||||||
client_id = cfg.get("client_id", "")
|
client_id = cfg.get("client_id", "")
|
||||||
client_secret = cfg.get("client_secret", "")
|
client_secret = cfg.get("client_secret", "")
|
||||||
if not client_id or not client_secret:
|
has_credentials = bool(client_id and client_secret)
|
||||||
self._log(view, "[ERRORE] Configura Client ID e Client Secret nelle Impostazioni.")
|
has_spotify = any("spotify.com" in u for u in urls)
|
||||||
self._emit("download:done", {"ok": False})
|
if has_spotify and has_credentials:
|
||||||
return
|
|
||||||
self._log(view, "[INFO] Autenticazione Spotify...")
|
self._log(view, "[INFO] Autenticazione Spotify...")
|
||||||
try:
|
try:
|
||||||
token = get_access_token(client_id, client_secret)
|
token = get_access_token(client_id, client_secret)
|
||||||
@@ -605,6 +615,13 @@ class Api:
|
|||||||
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
self._log(view, f"[ERRORE] Autenticazione fallita: {e}")
|
||||||
self._emit("download:done", {"ok": False})
|
self._emit("download:done", {"ok": False})
|
||||||
return
|
return
|
||||||
|
elif has_spotify:
|
||||||
|
self._log(
|
||||||
|
view,
|
||||||
|
"[INFO] Spotify API non configurate: i singoli brani "
|
||||||
|
"verranno comunque scaricati via metadata pubblico. "
|
||||||
|
"Per album/playlist servono le credenziali (gratuite, 2 min)."
|
||||||
|
)
|
||||||
|
|
||||||
for url_idx, url in enumerate(urls):
|
for url_idx, url in enumerate(urls):
|
||||||
if download_is_stopped():
|
if download_is_stopped():
|
||||||
@@ -664,12 +681,19 @@ class Api:
|
|||||||
if is_spotify:
|
if is_spotify:
|
||||||
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
self._log(view, "[INFO] Recupero informazioni da Spotify...")
|
||||||
try:
|
try:
|
||||||
|
if token:
|
||||||
label, name, tracks = resolve_spotify_url(token, url)
|
label, name, tracks = resolve_spotify_url(token, url)
|
||||||
|
else:
|
||||||
|
# Senza API keys: fallback solo per singoli brani.
|
||||||
|
label, name, tracks = resolve_spotify_track_no_auth(url)
|
||||||
|
except SpotifyAuthRequired as e:
|
||||||
|
self._log(view, f"[ERRORE] {e}")
|
||||||
|
continue
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._log(view, f"[ERRORE] {e}")
|
self._log(view, f"[ERRORE] {e}")
|
||||||
continue
|
continue
|
||||||
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
self._log(view, f"[INFO] {label}: {name} ({len(tracks)} brani)")
|
||||||
dest_dir = os.path.join(output_dir, name)
|
dest_dir = os.path.join(output_dir, name) if len(tracks) > 1 else output_dir
|
||||||
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
download_playlist(tracks, dest_dir, bitrate, cookies_path, progress_cb)
|
||||||
else:
|
else:
|
||||||
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
self._log(view, f"[INFO] Download diretto via yt-dlp: {url}")
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Rimuove la quarantena Gatekeeper da MusicTools.app
|
||||||
|
# Doppio click in macOS apre Terminal ed esegue questo script.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
APP_NAME="MusicTools.app"
|
||||||
|
|
||||||
|
# Cerca l'app in /Applications, poi nella stessa cartella del DMG montato.
|
||||||
|
if [ -d "/Applications/${APP_NAME}" ]; then
|
||||||
|
APP="/Applications/${APP_NAME}"
|
||||||
|
elif [ -d "$(dirname "$0")/${APP_NAME}" ]; then
|
||||||
|
APP="$(dirname "$0")/${APP_NAME}"
|
||||||
|
else
|
||||||
|
osascript -e "display alert \"${APP_NAME} non trovata\" message \"Trascina MusicTools.app nella cartella Applicazioni prima di eseguire questo script.\" as critical"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Rimuovo la quarantena da: ${APP}"
|
||||||
|
xattr -dr com.apple.quarantine "${APP}" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Notifica visuale
|
||||||
|
osascript -e "display notification \"MusicTools puo' essere aperta normalmente con doppio click.\" with title \"Fix completato\""
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Fatto. Ora puoi aprire MusicTools normalmente con doppio click."
|
||||||
|
echo "Puoi chiudere questa finestra."
|
||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 207 KiB |
@@ -106,6 +106,9 @@ def run_pyinstaller():
|
|||||||
"--windowed",
|
"--windowed",
|
||||||
"--onedir",
|
"--onedir",
|
||||||
"--noconfirm",
|
"--noconfirm",
|
||||||
|
# Forza rebuild pulita (senza, residui di build vecchi possono
|
||||||
|
# confondere il bundling di pythonnet/clr_loader).
|
||||||
|
"--clean",
|
||||||
]
|
]
|
||||||
if icon_path.exists():
|
if icon_path.exists():
|
||||||
cmd.extend(["--icon", str(icon_path)])
|
cmd.extend(["--icon", str(icon_path)])
|
||||||
@@ -115,11 +118,28 @@ def run_pyinstaller():
|
|||||||
# Hidden imports per pywebview (EdgeChromium su Windows)
|
# Hidden imports per pywebview (EdgeChromium su Windows)
|
||||||
"--hidden-import", "webview",
|
"--hidden-import", "webview",
|
||||||
"--hidden-import", "webview.platforms.edgechromium",
|
"--hidden-import", "webview.platforms.edgechromium",
|
||||||
|
"--hidden-import", "webview.platforms.winforms",
|
||||||
"--hidden-import", "clr",
|
"--hidden-import", "clr",
|
||||||
|
"--hidden-import", "clr_loader",
|
||||||
|
"--hidden-import", "clr_loader.netfx",
|
||||||
|
"--hidden-import", "clr_loader.types",
|
||||||
|
"--hidden-import", "pythonnet",
|
||||||
"--hidden-import", "requests",
|
"--hidden-import", "requests",
|
||||||
# Collegamenti completi alle DLL .NET di pywebview/pythonnet
|
# Collegamenti completi: webview, pythonnet, clr_loader (porta
|
||||||
|
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
|
||||||
|
# altrimenti winforms.py fallisce con "Failed to resolve
|
||||||
|
# Python.Runtime.Loader.Initialize ...")
|
||||||
"--collect-all", "webview",
|
"--collect-all", "webview",
|
||||||
|
"--collect-all", "pythonnet",
|
||||||
|
"--collect-all", "clr_loader",
|
||||||
"--collect-all", "mutagen",
|
"--collect-all", "mutagen",
|
||||||
|
# collect-data prende anche i file .json/.dll non-Python che
|
||||||
|
# collect-all potrebbe saltare (Python.Runtime.runtimeconfig.json
|
||||||
|
# in particolare).
|
||||||
|
"--collect-data", "pythonnet",
|
||||||
|
"--collect-data", "clr_loader",
|
||||||
|
"--copy-metadata", "pythonnet",
|
||||||
|
"--copy-metadata", "clr_loader",
|
||||||
# Binari bundled
|
# Binari bundled
|
||||||
*add_binaries,
|
*add_binaries,
|
||||||
# Entry point
|
# Entry point
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.7.0"
|
VERSION = "v1.7.8"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
|
|||||||
@@ -175,3 +175,83 @@ def resolve_spotify_url(token: str, url: str) -> tuple[str, str, list[dict]]:
|
|||||||
else:
|
else:
|
||||||
name, tracks = get_playlist_info(token, item_id)
|
name, tracks = get_playlist_info(token, item_id)
|
||||||
return "Playlist", name, tracks
|
return "Playlist", name, tracks
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fallback senza credenziali (solo per singoli brani)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class SpotifyAuthRequired(Exception):
|
||||||
|
"""Sollevata quando un URL Spotify richiede le API keys (album/playlist)."""
|
||||||
|
|
||||||
|
|
||||||
|
_OG_TITLE_RE = re.compile(
|
||||||
|
r'<meta\s+property="og:title"\s+content="([^"]+)"', re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_OG_DESC_RE = re.compile(
|
||||||
|
r'<meta\s+property="og:description"\s+content="([^"]+)"', re.IGNORECASE,
|
||||||
|
)
|
||||||
|
# Tag HTML entities che ci interessano nelle og: (Spotify le scrive cosi').
|
||||||
|
_HTML_ENTITIES = {
|
||||||
|
"&": "&", "'": "'", "'": "'",
|
||||||
|
""": '"', "<": "<", ">": ">",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_entities(s: str) -> str:
|
||||||
|
for k, v in _HTML_ENTITIES.items():
|
||||||
|
s = s.replace(k, v)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_spotify_track_no_auth(url: str) -> tuple[str, str, list[dict]]:
|
||||||
|
"""Estrae name/artist da un URL Spotify SENZA credenziali API.
|
||||||
|
|
||||||
|
Funziona solo per brani singoli (open.spotify.com/track/<id>): legge
|
||||||
|
i meta tag og:title e og:description della pagina pubblica.
|
||||||
|
|
||||||
|
Per album/playlist non c'e' modo affidabile senza JS rendering -> alza
|
||||||
|
SpotifyAuthRequired.
|
||||||
|
|
||||||
|
Ritorna ("Brano", name, [{name, artist}]).
|
||||||
|
"""
|
||||||
|
kind, _ = detect_url_type(url)
|
||||||
|
if kind != "track":
|
||||||
|
raise SpotifyAuthRequired(
|
||||||
|
f"I link a {kind} richiedono le credenziali Spotify (gratuite). "
|
||||||
|
"Vai in Impostazioni -> Spotify API per configurarle in 2 minuti."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "Mozilla/5.0 (MusicTools)"},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
raise ValueError(f"Impossibile leggere il brano da Spotify: {e}") from e
|
||||||
|
|
||||||
|
html = resp.text
|
||||||
|
title_m = _OG_TITLE_RE.search(html)
|
||||||
|
desc_m = _OG_DESC_RE.search(html)
|
||||||
|
if not title_m:
|
||||||
|
raise ValueError("Risposta inattesa da Spotify (meta og:title mancante).")
|
||||||
|
|
||||||
|
name = _decode_entities(title_m.group(1)).strip()
|
||||||
|
artist = ""
|
||||||
|
if desc_m:
|
||||||
|
# Formato osservato: "Artist Name · Song Title · Song · YYYY"
|
||||||
|
# oppure con piu' artisti separati da ", ".
|
||||||
|
parts = [_decode_entities(p).strip() for p in desc_m.group(1).split("·")]
|
||||||
|
if parts:
|
||||||
|
artist = parts[0]
|
||||||
|
|
||||||
|
if not artist:
|
||||||
|
raise ValueError(
|
||||||
|
"Impossibile estrarre l'artista dal brano Spotify senza API keys. "
|
||||||
|
"Configura le credenziali in Impostazioni."
|
||||||
|
)
|
||||||
|
|
||||||
|
track = {"name": name, "artist": artist}
|
||||||
|
return "Brano", name, [track]
|
||||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 33 KiB |
@@ -91,6 +91,13 @@ a { color: inherit; text-decoration: none; }
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quando il logo e' un'immagine, niente gradient ne' radius forzato */
|
||||||
|
.brand-logo-img {
|
||||||
|
background: none;
|
||||||
|
border-radius: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-links {
|
.nav-links {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 26px;
|
gap: 26px;
|
||||||
@@ -601,6 +608,69 @@ a { color: inherit; text-decoration: none; }
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Box informativo sulle chiavi API Spotify */
|
||||||
|
.spotify-note {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 48px auto 0;
|
||||||
|
padding: 28px 32px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
.spotify-note h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.spotify-note > p {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.spotify-note-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
margin: 18px 0 14px;
|
||||||
|
}
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.spotify-note-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.sn-col {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.sn-with {
|
||||||
|
background: rgba(29, 185, 84, 0.08);
|
||||||
|
border-color: rgba(29, 185, 84, 0.3);
|
||||||
|
}
|
||||||
|
.sn-without {
|
||||||
|
background: rgba(120, 120, 120, 0.06);
|
||||||
|
}
|
||||||
|
.sn-title {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14.5px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.sn-col ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.sn-col li { margin-bottom: 4px; }
|
||||||
|
.sn-col strong { color: var(--text); }
|
||||||
|
.spotify-note-fine {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
FAQ
|
FAQ
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|||||||
@@ -90,6 +90,48 @@
|
|||||||
|
|
||||||
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
|
.dl-support { text-align: center; color: var(--text-muted); font-size: 14px; padding-top: 24px; border-top: 1px solid var(--divider); margin-top: 24px; }
|
||||||
.dl-support a { color: var(--accent); }
|
.dl-support a { color: var(--accent); }
|
||||||
|
|
||||||
|
.dl-code {
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 8px 0 14px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--accent);
|
||||||
|
user-select: all;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.dl-sub {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.dl-sub li { margin-bottom: 4px; }
|
||||||
|
.dl-sub strong { color: var(--text); }
|
||||||
|
.dl-sub code { background: var(--bg-card-2); padding: 1px 6px; border-radius: 4px; font-size: 12.5px; }
|
||||||
|
|
||||||
|
.dl-status {
|
||||||
|
max-width: 460px;
|
||||||
|
margin: -16px auto 24px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
text-align: center;
|
||||||
|
background: rgba(29, 185, 84, 0.08);
|
||||||
|
border: 1px solid rgba(29, 185, 84, 0.25);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dl-status.err {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
border-color: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -137,7 +179,7 @@
|
|||||||
<div class="dl-os">🍎</div>
|
<div class="dl-os">🍎</div>
|
||||||
<h2>macOS</h2>
|
<h2>macOS</h2>
|
||||||
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
|
<p class="meta">macOS 11 Big Sur o successivi · Apple Silicon e Intel</p>
|
||||||
<a class="btn btn-primary btn-lg btn-block" id="dlMacBtn" target="_blank" rel="noopener">
|
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlMacBtn" data-platform="macos">
|
||||||
Scarica per Mac
|
Scarica per Mac
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
@@ -146,19 +188,30 @@
|
|||||||
<div class="dl-os">🪟</div>
|
<div class="dl-os">🪟</div>
|
||||||
<h2>Windows</h2>
|
<h2>Windows</h2>
|
||||||
<p class="meta">Windows 10 e 11 · 64-bit</p>
|
<p class="meta">Windows 10 e 11 · 64-bit</p>
|
||||||
<a class="btn btn-primary btn-lg btn-block" id="dlWinBtn" target="_blank" rel="noopener">
|
<a class="btn btn-primary btn-lg btn-block dl-btn" id="dlWinBtn" data-platform="windows">
|
||||||
Scarica per Windows
|
Scarica per Windows
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p id="dlStatus" class="dl-status" hidden></p>
|
||||||
|
|
||||||
<div class="dl-steps">
|
<div class="dl-steps">
|
||||||
<h2>Installazione macOS</h2>
|
<h2>Installazione macOS</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Apri il file <code>.zip</code> scaricato — viene estratto in <code>MusicTools.app</code></li>
|
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
||||||
<li>Trascina <strong>MusicTools.app</strong> nella cartella <strong>Applicazioni</strong></li>
|
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
||||||
<li>Vai in Applicazioni, <strong>tasto destro</strong> su MusicTools → <strong>Apri</strong> → <strong>Apri</strong> (solo la prima volta, e' la procedura standard per app non firmate)</li>
|
<li>
|
||||||
<li>Inserisci email e chiave per attivare</li>
|
Autorizza l'app (una sola volta, perche' non e' firmata con Developer ID Apple).<br>
|
||||||
|
<strong>Modo piu' semplice:</strong> apri il <strong>Terminale</strong> (cmd+spazio → "Terminale"), incolla questa riga e premi Invio:
|
||||||
|
<p class="dl-code">xattr -dr com.apple.quarantine /Applications/MusicTools.app</p>
|
||||||
|
<strong>In alternativa</strong> (senza Terminale):
|
||||||
|
<ul class="dl-sub">
|
||||||
|
<li><strong>Tasto destro</strong> su <code>Fix Gatekeeper.command</code> nel DMG → <strong>Apri</strong> → <strong>Apri</strong>. Se compare il blocco "non e' in grado di verificare", clicca <strong>Annulla</strong> e procedi col passo successivo.</li>
|
||||||
|
<li>Apri <strong>Impostazioni di Sistema</strong> → <strong>Privacy e sicurezza</strong>. In fondo vedi "Fix Gatekeeper.command e' stato bloccato": clicca <strong>Apri comunque</strong>.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Apri <strong>MusicTools</strong> dalle Applicazioni, inserisci email e chiave per attivare</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -184,19 +237,47 @@
|
|||||||
return url.searchParams.get(name) || "";
|
return url.searchParams.get(name) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setDownloadLinks(email, key) {
|
// Stato corrente — popolato da URL o dal form submit.
|
||||||
const q = "key=" + encodeURIComponent(key) + "&email=" + encodeURIComponent(email);
|
const dl = { email: "", key: "" };
|
||||||
document.getElementById("dlMacBtn").href = "/api/download/macos?" + q;
|
|
||||||
document.getElementById("dlWinBtn").href = "/api/download/windows?" + q;
|
function setStatus(text, isError) {
|
||||||
|
const el = document.getElementById("dlStatus");
|
||||||
|
if (!text) { el.hidden = true; el.textContent = ""; return; }
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.toggle("err", !!isError);
|
||||||
|
el.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDownloadCards(email, key) {
|
||||||
|
dl.email = email;
|
||||||
|
dl.key = key;
|
||||||
document.getElementById("dlGrid").hidden = false;
|
document.getElementById("dlGrid").hidden = false;
|
||||||
document.getElementById("dlForm").hidden = true;
|
document.getElementById("dlForm").hidden = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni
|
// Click sui bottoni: naviga sulla stessa tab verso /api/download/:platform.
|
||||||
|
// Il server fa 302 con Content-Disposition: attachment -> il browser
|
||||||
|
// scarica il file e la pagina /download resta visibile.
|
||||||
|
document.querySelectorAll(".dl-btn").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!dl.email || !dl.key) {
|
||||||
|
setStatus("Inserisci email e chiave prima di scaricare.", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const platform = btn.dataset.platform;
|
||||||
|
const q = "key=" + encodeURIComponent(dl.key) +
|
||||||
|
"&email=" + encodeURIComponent(dl.email);
|
||||||
|
setStatus("Avvio del download in corso… il file si apre fra qualche secondo.");
|
||||||
|
window.location.href = "/api/download/" + platform + "?" + q;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se arrivi dall'email con ?key=&email= mostra subito i bottoni.
|
||||||
const urlKey = getParam("key");
|
const urlKey = getParam("key");
|
||||||
const urlEmail = getParam("email");
|
const urlEmail = getParam("email");
|
||||||
if (urlKey && urlEmail) {
|
if (urlKey && urlEmail) {
|
||||||
setDownloadLinks(urlEmail, urlKey);
|
showDownloadCards(urlEmail, urlKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form submit
|
// Form submit
|
||||||
@@ -204,10 +285,11 @@
|
|||||||
const email = document.getElementById("dlEmail").value.trim();
|
const email = document.getElementById("dlEmail").value.trim();
|
||||||
const key = document.getElementById("dlKey").value.trim();
|
const key = document.getElementById("dlKey").value.trim();
|
||||||
if (!email || !key) {
|
if (!email || !key) {
|
||||||
alert("Inserisci email e chiave di licenza.");
|
setStatus("Inserisci email e chiave di licenza.", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setDownloadLinks(email, key);
|
setStatus("");
|
||||||
|
showDownloadCards(email, key);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Enter dentro i campi
|
// Enter dentro i campi
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://musictools.djluza.com" />
|
<meta property="og:url" content="https://musictools.djluza.com" />
|
||||||
|
|
||||||
<link rel="icon" type="image/svg+xml" href="assets/favicon.svg" />
|
<link rel="icon" type="image/png" sizes="32x32" href="assets/favicon-32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="assets/favicon-192.png" />
|
||||||
|
<link rel="apple-touch-icon" sizes="192x192" href="assets/favicon-192.png" />
|
||||||
<link rel="stylesheet" href="css/style.css" />
|
<link rel="stylesheet" href="css/style.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
<header class="nav">
|
<header class="nav">
|
||||||
<div class="container nav-row">
|
<div class="container nav-row">
|
||||||
<a href="#top" class="brand">
|
<a href="#top" class="brand">
|
||||||
<span class="brand-logo">♫</span>
|
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||||
<span class="brand-name">MusicTools</span>
|
<span class="brand-name">MusicTools</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
@@ -284,6 +286,41 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="spotify-note">
|
||||||
|
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
|
||||||
|
<p>
|
||||||
|
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
|
||||||
|
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
|
||||||
|
(anche con account Spotify gratuito).<br>
|
||||||
|
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
|
||||||
|
le tue <strong>chiavi API Spotify</strong>, che oggi Spotify rilascia solo
|
||||||
|
a chi ha un <strong>account Premium</strong> attivo (le crei tu dal
|
||||||
|
Developer Dashboard, in 2 minuti, con la guida integrata nell'app).
|
||||||
|
</p>
|
||||||
|
<div class="spotify-note-grid">
|
||||||
|
<div class="sn-col sn-with">
|
||||||
|
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
|
||||||
|
<ul>
|
||||||
|
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
|
||||||
|
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
|
||||||
|
<li>Tutte le altre fonti restano sempre attive</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="sn-col sn-without">
|
||||||
|
<div class="sn-title">Senza Premium (modalita' base)</div>
|
||||||
|
<ul>
|
||||||
|
<li>Da Spotify scarichi <strong>un brano alla volta</strong> incollando il link</li>
|
||||||
|
<li>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
|
||||||
|
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="spotify-note-fine">
|
||||||
|
MusicTools non e' affiliato a Spotify. Le chiavi API restano sul tuo
|
||||||
|
dispositivo, non passano per i nostri server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="use-policy">
|
<div class="use-policy">
|
||||||
<h3>Uso responsabile</h3>
|
<h3>Uso responsabile</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -343,8 +380,30 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
<summary>Devo creare un account Spotify Premium o YouTube Premium?</summary>
|
<summary>Mi serve un abbonamento Spotify 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>
|
<p>
|
||||||
|
Solo se vuoi scaricare <strong>playlist e album interi</strong> da Spotify:
|
||||||
|
in quel caso servono le chiavi API Spotify, che oggi Spotify rilascia
|
||||||
|
solo agli account Premium. Con un account Spotify gratuito (o senza
|
||||||
|
account Spotify) <strong>puoi comunque scaricare</strong> da YouTube,
|
||||||
|
SoundCloud, TikTok, IG, FB e i singoli brani Spotify incollando il link.
|
||||||
|
</p>
|
||||||
|
<p>YouTube non richiede alcun account.</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary>Cosa posso fare senza configurare le chiavi API Spotify?</summary>
|
||||||
|
<p>
|
||||||
|
<strong>Da YouTube, SoundCloud, TikTok, Instagram, Facebook</strong>:
|
||||||
|
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
|
||||||
|
incollando il link al singolo brano (l'app estrae artista e titolo
|
||||||
|
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
|
||||||
|
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
|
||||||
|
agli account Premium.
|
||||||
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
@@ -403,7 +462,7 @@
|
|||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="container footer-row">
|
<div class="container footer-row">
|
||||||
<div class="footer-brand">
|
<div class="footer-brand">
|
||||||
<span class="brand-logo">♫</span>
|
<img class="brand-logo brand-logo-img" src="assets/icon.png" alt="MusicTools" />
|
||||||
<span>MusicTools</span>
|
<span>MusicTools</span>
|
||||||
<span class="footer-by">by LuZa</span>
|
<span class="footer-by">by LuZa</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,46 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Windows: forza il caricamento del runtime pythonnet PRIMA di importare
|
||||||
|
# webview. Con PyInstaller il lazy-loader fallisce con
|
||||||
|
# "Failed to resolve Python.Runtime.Loader.Initialize" perche'
|
||||||
|
# Python.Runtime.dll viene bundlata ma il loader non sa risolverla
|
||||||
|
# senza l'inizializzazione esplicita. Fix conferma:
|
||||||
|
# https://github.com/pythonnet/pythonnet/issues/2178
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# ============================================================
|
||||||
|
# Fix Windows: rimuovi Mark of the Web (MOTW) dalle DLL bundled.
|
||||||
|
# Quando l'utente scarica il .zip dal browser, Windows aggiunge
|
||||||
|
# uno stream NTFS Zone.Identifier a TUTTI i file estratti.
|
||||||
|
# .NET Framework 4.x rifiuta silenziosamente di risolvere i
|
||||||
|
# symbol export degli assembly managed con MOTW -> il loader
|
||||||
|
# netfx di pythonnet fallisce con "Failed to resolve
|
||||||
|
# Python.Runtime.Loader.Initialize".
|
||||||
|
# Soluzione: cancelliamo lo stream :Zone.Identifier da ogni
|
||||||
|
# .dll/.exe del bundle al primo avvio (operazione idempotente
|
||||||
|
# e sicura: l'utente ha gia eseguito l'exe esplicitamente).
|
||||||
|
# Vedi: https://github.com/r0x0r/pywebview/issues/1215
|
||||||
|
# ============================================================
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
_kernel32 = ctypes.windll.kernel32
|
||||||
|
_bundle = Path(sys._MEIPASS) # type: ignore[attr-defined]
|
||||||
|
for _f in _bundle.rglob("*"):
|
||||||
|
if _f.suffix.lower() in (".dll", ".exe", ".pyd"):
|
||||||
|
_kernel32.DeleteFileW(f"{_f}:Zone.Identifier")
|
||||||
|
except Exception as _e:
|
||||||
|
print(f"[bootstrap] MOTW cleanup failed: {_e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
os.environ["PYTHONNET_RUNTIME"] = "netfx"
|
||||||
|
from pythonnet import load as _pythonnet_load
|
||||||
|
_pythonnet_load("netfx")
|
||||||
|
import clr # noqa: F401 - inizializza il loader
|
||||||
|
except Exception as _e:
|
||||||
|
print(f"[bootstrap] preload pythonnet failed: {_e}")
|
||||||
|
|
||||||
import webview
|
import webview
|
||||||
|
|
||||||
from api.bridge import Api
|
from api.bridge import Api
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ mutagen>=1.47.0
|
|||||||
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
pyobjc-core>=10.0 ; sys_platform == "darwin"
|
||||||
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
pyobjc-framework-Cocoa>=10.0 ; sys_platform == "darwin"
|
||||||
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
pyobjc-framework-WebKit>=10.0 ; sys_platform == "darwin"
|
||||||
pythonnet>=3.0 ; sys_platform == "win32"
|
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||||
|
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||||
@@ -73,18 +73,31 @@ async function handleOrderCreated(payload, attrs, t) {
|
|||||||
const planCode = planByVariantId(variantId);
|
const planCode = planByVariantId(variantId);
|
||||||
const plan = planCode ? getPlan(planCode) : null;
|
const plan = planCode ? getPlan(planCode) : null;
|
||||||
|
|
||||||
// Subscriptions: il record vero arriva con subscription_created.
|
// Se l'order e' gia stato processato (lo riconosciamo dall'order_id),
|
||||||
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
|
// non duplichiamo licenza ne' email — ack 200 e basta.
|
||||||
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
|
const existing = await one(
|
||||||
if (plan && plan.is_subscription) {
|
"SELECT id FROM licenses WHERE order_id=? LIMIT 1",
|
||||||
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
|
[orderId],
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
return { status: 200, body: { ok: true, duplicate: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Annual one-time (o piano non mappato -> trattalo come annual di default
|
// Piano non mappato -> fallback annual cosi' non blocchiamo l'utente
|
||||||
// cosi' non lasciamo l'utente senza licenza se ha pagato).
|
// che ha gia pagato (la licenza sara' usabile anche se non sappiamo
|
||||||
|
// i limiti esatti del piano comprato).
|
||||||
const planForRecord = plan?.code || "annual";
|
const planForRecord = plan?.code || "annual";
|
||||||
const limit = plan?.daily_limit ?? null;
|
const planMeta = plan || getPlan("annual");
|
||||||
const expiresAt = t + ANNUAL_TTL_SECONDS;
|
const limit = planMeta?.daily_limit ?? null;
|
||||||
|
|
||||||
|
// Annual one-time: settiamo expires_at = now + 365d.
|
||||||
|
// Subscription mensili: NON settiamo current_period_end qui — lo fara'
|
||||||
|
// subscription_created/subscription_payment_success quando arriva.
|
||||||
|
// Se quell'evento non arrivasse mai (perche' non abilitato in LS
|
||||||
|
// dashboard), la licenza resta utilizzabile (current_period_end NULL
|
||||||
|
// = no scadenza imposta lato server: la sottoscrizione vivra' o
|
||||||
|
// morira' su LS, e i webhook successivi aggiorneranno lo status).
|
||||||
|
const expiresAt = planMeta?.is_subscription ? null : t + ANNUAL_TTL_SECONDS;
|
||||||
const key = generateLicenseKey();
|
const key = generateLicenseKey();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -103,7 +116,10 @@ async function handleOrderCreated(payload, attrs, t) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt });
|
await sendLicenseEmail(email, key, {
|
||||||
|
plan: planForRecord,
|
||||||
|
expiresAt: expiresAt,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[email] send failed:", e?.message || e);
|
console.error("[email] send failed:", e?.message || e);
|
||||||
}
|
}
|
||||||
@@ -141,7 +157,7 @@ async function handleSubscriptionCreated(payload, attrs, t) {
|
|||||||
const key = generateLicenseKey();
|
const key = generateLicenseKey();
|
||||||
|
|
||||||
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
||||||
// riuso la licenza esistente (non ne creo un'altra).
|
// riuso la licenza esistente.
|
||||||
const existing = await one(
|
const existing = await one(
|
||||||
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
||||||
[subId],
|
[subId],
|
||||||
@@ -156,6 +172,29 @@ async function handleSubscriptionCreated(payload, attrs, t) {
|
|||||||
return { status: 200, body: { ok: true, duplicate: true } };
|
return { status: 200, body: { ok: true, duplicate: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caso normale ora: la licenza e' gia stata creata da order_created
|
||||||
|
// (con subscription_id NULL). La aggiorno con subscription_id e
|
||||||
|
// current_period_end senza generare una nuova chiave ne' una nuova email.
|
||||||
|
const byOrder = orderId
|
||||||
|
? await one(
|
||||||
|
`SELECT id FROM licenses
|
||||||
|
WHERE order_id=? AND subscription_id IS NULL LIMIT 1`,
|
||||||
|
[orderId],
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (byOrder) {
|
||||||
|
await exec(
|
||||||
|
`UPDATE licenses
|
||||||
|
SET subscription_id=?, current_period_end=?, plan=?, daily_limit=?,
|
||||||
|
updated_at=?
|
||||||
|
WHERE id=?`,
|
||||||
|
[subId, periodEnd, planCode, plan.daily_limit, t, byOrder.id],
|
||||||
|
);
|
||||||
|
return { status: 200, body: { ok: true, attached_subscription: subId } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: nessuna licenza esistente -> ne creiamo una nuova
|
||||||
|
// (puo' succedere se order_created non e' arrivato per qualche motivo).
|
||||||
try {
|
try {
|
||||||
await exec(
|
await exec(
|
||||||
`INSERT INTO licenses
|
`INSERT INTO licenses
|
||||||
|
|||||||
@@ -89,7 +89,13 @@ export async function download(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const name = path.basename(abs);
|
const name = path.basename(abs);
|
||||||
res.setHeader("Content-Type", "application/zip");
|
const ext = path.extname(name).toLowerCase();
|
||||||
|
const contentType = ext === ".dmg"
|
||||||
|
? "application/x-apple-diskimage"
|
||||||
|
: ext === ".exe"
|
||||||
|
? "application/vnd.microsoft.portable-executable"
|
||||||
|
: "application/zip";
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
res.setHeader("Content-Disposition", `attachment; filename="${name}"`);
|
||||||
fs.createReadStream(abs).pipe(res);
|
fs.createReadStream(abs).pipe(res);
|
||||||
}
|
}
|
||||||
@@ -58,6 +58,38 @@
|
|||||||
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
|
--shadow-lg: 0 16px 40px rgba(0, 0, 0, 0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tema chiaro: override delle custom properties via data-theme="light"
|
||||||
|
sull'elemento <html>. I colori brand (verde, blu, viola, ecc.) restano
|
||||||
|
uguali per non perdere identita'. */
|
||||||
|
html[data-theme="light"] {
|
||||||
|
--bg: #FFFFFF;
|
||||||
|
--bg-elev: #F7F7F8;
|
||||||
|
--bg-card: #FFFFFF;
|
||||||
|
--bg-card-2: #F2F3F5;
|
||||||
|
--bg-input: #EDEEF1;
|
||||||
|
--bg-input-hover: #E2E4E8;
|
||||||
|
|
||||||
|
--border: #E2E4E8;
|
||||||
|
--divider: #ECEDF0;
|
||||||
|
|
||||||
|
--text: #111315;
|
||||||
|
--text-2: #4A4D52;
|
||||||
|
--text-3: #8A8E94;
|
||||||
|
|
||||||
|
--green-dim: #C8F0D5;
|
||||||
|
--blue-dim: #D6E6FB;
|
||||||
|
--purple-dim: #E9D8FD;
|
||||||
|
--pink-dim: #FBCFE8;
|
||||||
|
--amber-dim: #FDE68A;
|
||||||
|
--rec-red-dim: #FECACA;
|
||||||
|
--indigo-dim: #C7D2FE;
|
||||||
|
|
||||||
|
--shadow: 0 8px 24px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-lg: 0 16px 40px rgba(15, 23, 42, 0.1);
|
||||||
|
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -841,11 +873,23 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||||
|
sempre visibile mentre l'utente scrolla la tab Impostazioni. Niente
|
||||||
|
ricerca del pulsante in coda. */
|
||||||
|
/* Bottone "Salva impostazioni" — sticky in fondo al viewport per restare
|
||||||
|
sempre visibile mentre l'utente scrolla la tab Impostazioni. */
|
||||||
.save-row {
|
.save-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin: 20px 0 32px;
|
position: sticky;
|
||||||
|
bottom: -24px;
|
||||||
|
padding: 16px 32px;
|
||||||
|
margin: 24px -32px -32px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
box-shadow: 0 -8px 16px rgba(0, 0, 0, 0.25);
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.save-status {
|
.save-status {
|
||||||
@@ -1494,13 +1538,13 @@ input[type="number"]::-webkit-inner-spin-button {
|
|||||||
|
|
||||||
.rec-error-log {
|
.rec-error-log {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
background: #0d0d0d;
|
background: var(--bg-card-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #c0c0c0;
|
color: var(--text-2);
|
||||||
max-height: 320px;
|
max-height: 320px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|||||||
@@ -440,6 +440,11 @@ function showView(name) {
|
|||||||
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
$$(".nav-item").forEach((b) => b.classList.toggle("active", b.dataset.view === name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const t = theme === "light" ? "light" : "dark";
|
||||||
|
document.documentElement.dataset.theme = t;
|
||||||
|
}
|
||||||
|
|
||||||
$$(".nav-item").forEach((btn) => {
|
$$(".nav-item").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => showView(btn.dataset.view));
|
btn.addEventListener("click", () => showView(btn.dataset.view));
|
||||||
});
|
});
|
||||||
@@ -470,6 +475,7 @@ async function init() {
|
|||||||
$("#cookiesInput").value = state.config.cookies_path || "";
|
$("#cookiesInput").value = state.config.cookies_path || "";
|
||||||
$("#outputInput").value = state.config.output_dir || "";
|
$("#outputInput").value = state.config.output_dir || "";
|
||||||
$("#themeSelect").value = state.config.theme || "dark";
|
$("#themeSelect").value = state.config.theme || "dark";
|
||||||
|
applyTheme(state.config.theme);
|
||||||
|
|
||||||
// Download tab — usa output_dir come default
|
// Download tab — usa output_dir come default
|
||||||
state.dlOutputDir = state.config.output_dir || "";
|
state.dlOutputDir = state.config.output_dir || "";
|
||||||
@@ -1027,6 +1033,10 @@ $("#browseOutputBtn").addEventListener("click", async () => {
|
|||||||
if (path) $("#outputInput").value = path;
|
if (path) $("#outputInput").value = path;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("#themeSelect").addEventListener("change", (e) => {
|
||||||
|
applyTheme(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
$("#saveBtn").addEventListener("click", async () => {
|
$("#saveBtn").addEventListener("click", async () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
client_id: $("#clientIdInput").value,
|
client_id: $("#clientIdInput").value,
|
||||||
@@ -1037,6 +1047,7 @@ $("#saveBtn").addEventListener("click", async () => {
|
|||||||
output_dir: $("#outputInput").value,
|
output_dir: $("#outputInput").value,
|
||||||
theme: $("#themeSelect").value,
|
theme: $("#themeSelect").value,
|
||||||
};
|
};
|
||||||
|
applyTheme(payload.theme);
|
||||||
const res = await window.pywebview.api.save_settings(payload);
|
const res = await window.pywebview.api.save_settings(payload);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
state.config = payload;
|
state.config = payload;
|
||||||
|
|||||||