Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc6ae1fbe1 | ||
|
|
0e14d161d0 | ||
|
|
7dd793253b | ||
|
|
5771d631fd | ||
|
|
b74a0a53ec | ||
|
|
415175ec4d | ||
|
|
d8349fb79f | ||
|
|
1414c5199c | ||
|
|
6e96e8c6b7 | ||
|
|
c2599a72a1 | ||
|
|
2cc6c5e9c6 |
No files matched your search
@@ -87,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: |
|
||||||
|
|||||||
+16
-3
@@ -42,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)
|
||||||
@@ -55,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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
@@ -221,7 +229,12 @@ class Api:
|
|||||||
),
|
),
|
||||||
"plan": plan,
|
"plan": plan,
|
||||||
}
|
}
|
||||||
# 2) Quota gate server-side
|
# 2) Quota gate server-side: skippato per i piani senza limite
|
||||||
|
# giornaliero (Annual) — evita una chiamata HTTP inutile e fa
|
||||||
|
# funzionare l'app anche offline per quei piani.
|
||||||
|
plan = license_mod.get_plan()
|
||||||
|
if plan.get("daily_limit") is None:
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
res = license_mod.consume_quota(feature)
|
res = license_mod.consume_quota(feature)
|
||||||
except license_mod.LicenseNetworkError as e:
|
except license_mod.LicenseNetworkError as e:
|
||||||
|
|||||||
+238
@@ -238,6 +238,14 @@ def run_pyinstaller():
|
|||||||
app_path = DIST_DIR / "MusicTools.app"
|
app_path = DIST_DIR / "MusicTools.app"
|
||||||
if app_path.exists():
|
if app_path.exists():
|
||||||
log(f"Build completata: {app_path}")
|
log(f"Build completata: {app_path}")
|
||||||
|
patch_info_plist(app_path)
|
||||||
|
# NOTA: dal v1.7.14 il microfono viene letto direttamente da
|
||||||
|
# PyObjC/AVCaptureSession nel processo Python (che ha il TCC del
|
||||||
|
# bundle), non da ffmpeg subprocess. ffmpeg fa solo la conversione
|
||||||
|
# CAF -> MP3 (no microfono = no TCC). Per questo non serve piu'
|
||||||
|
# incapsularlo in un sub-bundle .app con NSMicrophoneUsageDescription.
|
||||||
|
# wrap_subprocess_in_bundle(app_path)
|
||||||
|
adhoc_codesign(app_path)
|
||||||
# Mostra dimensione
|
# Mostra dimensione
|
||||||
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
size = sum(f.stat().st_size for f in app_path.rglob("*") if f.is_file())
|
||||||
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
log(f"Dimensione: {size / 1024 / 1024:.0f} MB")
|
||||||
@@ -249,6 +257,236 @@ def run_pyinstaller():
|
|||||||
log(f"Trovata directory: {alt}")
|
log(f"Trovata directory: {alt}")
|
||||||
|
|
||||||
|
|
||||||
|
def patch_info_plist(app_path):
|
||||||
|
"""Aggiunge le chiavi TCC nell'Info.plist del bundle .app.
|
||||||
|
|
||||||
|
NSMicrophoneUsageDescription e' obbligatoria: senza, macOS uccide
|
||||||
|
l'app con SIGABRT quando AVFoundation tenta di aprire il microfono.
|
||||||
|
|
||||||
|
Usiamo plistlib invece di PlistBuddy perche' i valori con apostrofi
|
||||||
|
(es. "l'audio") rompono il parser shell-based di PlistBuddy.
|
||||||
|
"""
|
||||||
|
import plistlib
|
||||||
|
|
||||||
|
info_plist = app_path / "Contents" / "Info.plist"
|
||||||
|
if not info_plist.exists():
|
||||||
|
log("ATTENZIONE: Info.plist non trovato, skip patch TCC")
|
||||||
|
return
|
||||||
|
|
||||||
|
log("Patching Info.plist con le chiavi TCC...")
|
||||||
|
with open(info_plist, "rb") as f:
|
||||||
|
data = plistlib.load(f)
|
||||||
|
|
||||||
|
data["NSMicrophoneUsageDescription"] = (
|
||||||
|
"MusicTools usa il microfono per registrare l'audio del sistema "
|
||||||
|
"(ad esempio tramite BlackHole o un altro dispositivo loopback)."
|
||||||
|
)
|
||||||
|
# Bundle identifier stabile aiuta TCC a riconoscere l'app tra le sessioni
|
||||||
|
data["CFBundleIdentifier"] = "com.djluza.musictools"
|
||||||
|
data["LSApplicationCategoryType"] = "public.app-category.music"
|
||||||
|
|
||||||
|
with open(info_plist, "wb") as f:
|
||||||
|
plistlib.dump(data, f)
|
||||||
|
|
||||||
|
log(f"Info.plist aggiornato. Chiavi: {sorted(data.keys())[-5:]}")
|
||||||
|
# Verifica
|
||||||
|
with open(info_plist, "rb") as f:
|
||||||
|
verify = plistlib.load(f)
|
||||||
|
if "NSMicrophoneUsageDescription" not in verify:
|
||||||
|
log("ERRORE CRITICO: NSMicrophoneUsageDescription NON e' stata scritta!")
|
||||||
|
else:
|
||||||
|
log("Verifica OK: NSMicrophoneUsageDescription presente.")
|
||||||
|
|
||||||
|
|
||||||
|
_BUNDLE_ID = "com.djluza.musictools"
|
||||||
|
|
||||||
|
|
||||||
|
def _subprocess_info_plist(executable, bundle_id):
|
||||||
|
"""Info.plist per un mini-bundle che incapsula un binario subprocess.
|
||||||
|
|
||||||
|
NSMicrophoneUsageDescription qui dentro e' la chiave: senza di essa,
|
||||||
|
macOS uccide il subprocess con SIGABRT appena tenta di aprire un
|
||||||
|
device audio, anche se il main MusicTools ha il proprio TCC concesso.
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" '
|
||||||
|
'"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
||||||
|
'<plist version="1.0">\n'
|
||||||
|
'<dict>\n'
|
||||||
|
f' <key>CFBundleExecutable</key><string>{executable}</string>\n'
|
||||||
|
f' <key>CFBundleIdentifier</key><string>{bundle_id}</string>\n'
|
||||||
|
f' <key>CFBundleName</key><string>{executable}</string>\n'
|
||||||
|
' <key>CFBundlePackageType</key><string>APPL</string>\n'
|
||||||
|
' <key>CFBundleShortVersionString</key><string>1.0</string>\n'
|
||||||
|
' <key>CFBundleVersion</key><string>1</string>\n'
|
||||||
|
' <key>LSBackgroundOnly</key><true/>\n'
|
||||||
|
' <key>LSUIElement</key><true/>\n'
|
||||||
|
' <key>NSMicrophoneUsageDescription</key>\n'
|
||||||
|
' <string>MusicTools usa il microfono per registrare l\'audio del '
|
||||||
|
'sistema (BlackHole o altri dispositivi loopback).</string>\n'
|
||||||
|
'</dict>\n'
|
||||||
|
'</plist>\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_subprocess_in_bundle(app_path):
|
||||||
|
"""Sposta ffmpeg e ffprobe in mini-bundle .app dentro Frameworks/.
|
||||||
|
|
||||||
|
macOS 14+ ENFORCE il fatto che ogni binario che apre device
|
||||||
|
privacy-sensitive (microfono, camera, ...) debba avere un proprio
|
||||||
|
Info.plist accessibile con NSMicrophoneUsageDescription. Quando
|
||||||
|
ffmpeg viene lanciato come binario standalone (anche se figlio di
|
||||||
|
MusicTools), non c'e' Info.plist associato -> SIGABRT.
|
||||||
|
|
||||||
|
Soluzione: incapsulare ffmpeg in MusicTools.app/Contents/Frameworks/
|
||||||
|
ffmpeg.app/Contents/MacOS/ffmpeg, con Info.plist nel suo bundle.
|
||||||
|
Stesso per ffprobe.
|
||||||
|
"""
|
||||||
|
log("Wrapping ffmpeg e ffprobe in sub-bundle .app...")
|
||||||
|
fw = app_path / "Contents" / "Frameworks"
|
||||||
|
if not fw.exists():
|
||||||
|
log("ATTENZIONE: Contents/Frameworks non trovato, skip wrapping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
for name in ("ffmpeg", "ffprobe"):
|
||||||
|
src = fw / name
|
||||||
|
if not src.exists() or not src.is_file():
|
||||||
|
log(f" skip {name}: non trovato in Frameworks/")
|
||||||
|
continue
|
||||||
|
wrap = fw / f"{name}.app"
|
||||||
|
if wrap.exists():
|
||||||
|
shutil.rmtree(wrap)
|
||||||
|
macos = wrap / "Contents" / "MacOS"
|
||||||
|
macos.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest = macos / name
|
||||||
|
shutil.move(str(src), str(dest))
|
||||||
|
dest.chmod(0o755)
|
||||||
|
|
||||||
|
# Aggiungi un nuovo rpath che punta a Contents/Frameworks/ (dove
|
||||||
|
# stanno le dylib del bundle MusicTools).
|
||||||
|
# @executable_path = ffmpeg.app/Contents/MacOS/
|
||||||
|
# ../../../ = MusicTools.app/Contents/Frameworks/
|
||||||
|
subprocess.run(
|
||||||
|
["install_name_tool", "-add_rpath",
|
||||||
|
"@executable_path/../../../", str(dest)],
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Crea Info.plist con NSMicrophoneUsageDescription.
|
||||||
|
info = wrap / "Contents" / "Info.plist"
|
||||||
|
bundle_id = f"com.djluza.musictools.{name}"
|
||||||
|
info.write_text(_subprocess_info_plist(name, bundle_id))
|
||||||
|
log(f" wrap: {name} -> {dest.relative_to(app_path)}")
|
||||||
|
|
||||||
|
|
||||||
|
def adhoc_codesign(app_path):
|
||||||
|
"""Ad-hoc codesign dell'intero bundle .app con identifier coerente.
|
||||||
|
|
||||||
|
macOS tratta ogni binario Mach-O firmato come una "app" distinta per
|
||||||
|
le decisioni TCC (microfono, camera, ecc.). Con `codesign --deep`
|
||||||
|
standard, i nested binary (ffmpeg, ffprobe, yt-dlp) ricevono ognuno
|
||||||
|
un identifier auto-generato del tipo `<nome>-<hash>`, diverso da
|
||||||
|
quello del bundle principale -> TCC NEGA al subprocess.
|
||||||
|
|
||||||
|
Soluzione: firmiamo PRIMA ogni binario interno con `--identifier
|
||||||
|
com.djluza.musictools`, poi il bundle .app intero. Cosi' TCC vede
|
||||||
|
tutti i Mach-O come parte dello stesso "team" e propaga il permesso
|
||||||
|
del main ai subprocess.
|
||||||
|
"""
|
||||||
|
log("Ad-hoc codesign del bundle (identifier coerente per TCC)...")
|
||||||
|
try:
|
||||||
|
# 1) Pulisci attributi estesi (rimuove quarantine residui dal build)
|
||||||
|
subprocess.run(["xattr", "-cr", str(app_path)], capture_output=True)
|
||||||
|
|
||||||
|
# 2) Trova tutti i Mach-O nested (binari ed eventuali .dylib).
|
||||||
|
# Li firmiamo uno a uno con identifier coerente, dal piu' profondo
|
||||||
|
# al piu' esterno (richiesta da codesign).
|
||||||
|
nested_machos = []
|
||||||
|
for p in sorted(app_path.rglob("*"), key=lambda x: -len(x.parts)):
|
||||||
|
if not p.is_file():
|
||||||
|
continue
|
||||||
|
# Skip risorse non eseguibili
|
||||||
|
if p.suffix in (".plist", ".nib", ".png", ".icns", ".svg",
|
||||||
|
".html", ".css", ".js", ".json", ".md", ".txt"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
with open(p, "rb") as f:
|
||||||
|
magic = f.read(4)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
# Mach-O magic numbers (32/64-bit, big/little endian, fat)
|
||||||
|
if magic in (b"\xcf\xfa\xed\xfe", b"\xce\xfa\xed\xfe",
|
||||||
|
b"\xfe\xed\xfa\xce", b"\xfe\xed\xfa\xcf",
|
||||||
|
b"\xca\xfe\xba\xbe", b"\xbe\xba\xfe\xca"):
|
||||||
|
nested_machos.append(p)
|
||||||
|
|
||||||
|
log(f"Trovati {len(nested_machos)} binari Mach-O da firmare.")
|
||||||
|
|
||||||
|
for p in nested_machos:
|
||||||
|
# Per ffmpeg/ffprobe dentro al sub-bundle .app, usa l'identifier
|
||||||
|
# del SUB-bundle (TCC li tratta come app separate con Info.plist
|
||||||
|
# proprio). Per gli altri binari nested (dylib, framework), usa
|
||||||
|
# l'identifier del bundle principale.
|
||||||
|
ident = _BUNDLE_ID
|
||||||
|
parts = p.relative_to(app_path).parts
|
||||||
|
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||||
|
if sub in parts:
|
||||||
|
ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||||
|
break
|
||||||
|
r = subprocess.run(
|
||||||
|
["codesign", "--force", "--sign", "-",
|
||||||
|
"--identifier", ident,
|
||||||
|
"--timestamp=none",
|
||||||
|
str(p)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
log(f" warn: firma {p.name} fallita: {r.stderr.strip()}")
|
||||||
|
|
||||||
|
# Firma anche i sub-bundle .app come bundle (con il loro Info.plist).
|
||||||
|
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||||
|
sub_path = app_path / "Contents" / "Frameworks" / sub
|
||||||
|
if not sub_path.exists():
|
||||||
|
continue
|
||||||
|
sub_ident = f"{_BUNDLE_ID}.{sub.replace('.app', '')}"
|
||||||
|
r = subprocess.run(
|
||||||
|
["codesign", "--force", "--sign", "-",
|
||||||
|
"--identifier", sub_ident,
|
||||||
|
"--timestamp=none",
|
||||||
|
str(sub_path)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
log(f" warn: firma {sub} fallita: {r.stderr.strip()}")
|
||||||
|
|
||||||
|
# 3) Firma del bundle .app principale (l'identifier corretto viene
|
||||||
|
# letto dall'Info.plist - CFBundleIdentifier che gia' settiamo).
|
||||||
|
r = subprocess.run(
|
||||||
|
["codesign", "--force", "--sign", "-",
|
||||||
|
"--identifier", _BUNDLE_ID,
|
||||||
|
"--timestamp=none",
|
||||||
|
str(app_path)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
log(f"ATTENZIONE: firma bundle fallita: {r.stderr.strip()}")
|
||||||
|
else:
|
||||||
|
log("Codesign ad-hoc completato.")
|
||||||
|
|
||||||
|
# 4) Verifica finale
|
||||||
|
verify = subprocess.run(
|
||||||
|
["codesign", "--verify", "--deep", "--strict", str(app_path)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
if verify.returncode == 0:
|
||||||
|
log("Verifica firma OK.")
|
||||||
|
else:
|
||||||
|
log(f"NOTA verifica firma: {verify.stderr.strip()}")
|
||||||
|
except FileNotFoundError:
|
||||||
|
log("ATTENZIONE: 'codesign' non trovato nel PATH, skip ad-hoc signing")
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Main
|
# Main
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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)])
|
||||||
@@ -118,6 +121,9 @@ def run_pyinstaller():
|
|||||||
"--hidden-import", "webview.platforms.winforms",
|
"--hidden-import", "webview.platforms.winforms",
|
||||||
"--hidden-import", "clr",
|
"--hidden-import", "clr",
|
||||||
"--hidden-import", "clr_loader",
|
"--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: webview, pythonnet, clr_loader (porta
|
# Collegamenti completi: webview, pythonnet, clr_loader (porta
|
||||||
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
|
# con se Python.Runtime.dll + nethost.dll + runtimeconfig.json
|
||||||
@@ -127,6 +133,11 @@ def run_pyinstaller():
|
|||||||
"--collect-all", "pythonnet",
|
"--collect-all", "pythonnet",
|
||||||
"--collect-all", "clr_loader",
|
"--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", "pythonnet",
|
||||||
"--copy-metadata", "clr_loader",
|
"--copy-metadata", "clr_loader",
|
||||||
# Binari bundled
|
# Binari bundled
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
VERSION = "v1.7.5"
|
VERSION = "v1.7.15"
|
||||||
|
|
||||||
|
|
||||||
APP_NAME = "MusicTools"
|
APP_NAME = "MusicTools"
|
||||||
|
|||||||
+31
-34
@@ -33,11 +33,18 @@ def _bundle_dirs() -> list[Path]:
|
|||||||
# Directory dell'eseguibile
|
# Directory dell'eseguibile
|
||||||
exe_dir = Path(sys.executable).parent
|
exe_dir = Path(sys.executable).parent
|
||||||
dirs.append(exe_dir)
|
dirs.append(exe_dir)
|
||||||
# macOS: Contents/Frameworks/ (PyInstaller onedir .app)
|
# macOS: Contents/Frameworks/ (PyInstaller onedir .app) + sub-bundle
|
||||||
|
# ffmpeg.app / ffprobe.app dove sono incapsulati per TCC.
|
||||||
if not _IS_WINDOWS:
|
if not _IS_WINDOWS:
|
||||||
frameworks = exe_dir.parent / "Frameworks"
|
frameworks = exe_dir.parent / "Frameworks"
|
||||||
if frameworks.exists():
|
if frameworks.exists():
|
||||||
dirs.append(frameworks)
|
dirs.append(frameworks)
|
||||||
|
# Mini-bundle .app per i subprocess (vedi build_macos.py
|
||||||
|
# wrap_subprocess_in_bundle).
|
||||||
|
for sub in ("ffmpeg.app", "ffprobe.app"):
|
||||||
|
sub_macos = frameworks / sub / "Contents" / "MacOS"
|
||||||
|
if sub_macos.exists():
|
||||||
|
dirs.append(sub_macos)
|
||||||
return dirs
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
@@ -96,55 +103,45 @@ def find_ytdlp() -> str:
|
|||||||
raise FileNotFoundError(msg)
|
raise FileNotFoundError(msg)
|
||||||
|
|
||||||
|
|
||||||
def find_ffmpeg_dir() -> Optional[str]:
|
def _find_binary(name: str) -> Optional[str]:
|
||||||
"""Trova la directory contenente ffmpeg e ffprobe.
|
"""Trova un binario (ffmpeg o ffprobe) nelle dir del bundle o nel sistema."""
|
||||||
|
exe_name = _exe(name)
|
||||||
Ordine di ricerca:
|
# 1. Bundle (incluso eventuali sub-bundle .app su macOS)
|
||||||
1. Bundle PyInstaller
|
|
||||||
2. Percorsi noti per OS
|
|
||||||
3. Qualsiasi posizione nel PATH
|
|
||||||
"""
|
|
||||||
ffmpeg_name = _exe("ffmpeg")
|
|
||||||
|
|
||||||
# 1. Bundle
|
|
||||||
for d in _bundle_dirs():
|
for d in _bundle_dirs():
|
||||||
if (d / ffmpeg_name).exists():
|
candidate = d / exe_name
|
||||||
return str(d)
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
# 2. Percorsi noti
|
# 2. Percorsi noti per OS
|
||||||
if _IS_WINDOWS:
|
if _IS_WINDOWS:
|
||||||
# Posizioni comuni su Windows
|
|
||||||
for search in (
|
for search in (
|
||||||
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
|
Path(os.environ.get("LOCALAPPDATA", "")) / "Programs" / "ffmpeg" / "bin",
|
||||||
Path("C:/ffmpeg/bin"),
|
Path("C:/ffmpeg/bin"),
|
||||||
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
|
Path(os.environ.get("ProgramFiles", "")) / "ffmpeg" / "bin",
|
||||||
):
|
):
|
||||||
if (search / "ffmpeg.exe").exists():
|
cand = search / exe_name
|
||||||
return str(search)
|
if cand.exists():
|
||||||
|
return str(cand)
|
||||||
else:
|
else:
|
||||||
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
for search_path in ("/opt/homebrew/bin", "/usr/local/bin"):
|
||||||
if (Path(search_path) / "ffmpeg").exists():
|
cand = Path(search_path) / exe_name
|
||||||
return search_path
|
if cand.exists():
|
||||||
|
return str(cand)
|
||||||
# 3. PATH generico
|
# 3. PATH generico
|
||||||
ffmpeg = shutil.which("ffmpeg")
|
found = shutil.which(name)
|
||||||
if ffmpeg:
|
return found if found else None
|
||||||
return str(Path(ffmpeg).parent)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
def find_ffmpeg_dir() -> Optional[str]:
|
||||||
|
"""Ritorna la directory contenente ffmpeg (utile per PATH env)."""
|
||||||
|
ff = _find_binary("ffmpeg")
|
||||||
|
return str(Path(ff).parent) if ff else None
|
||||||
|
|
||||||
|
|
||||||
def find_ffmpeg() -> Optional[str]:
|
def find_ffmpeg() -> Optional[str]:
|
||||||
"""Ritorna il path completo di ffmpeg."""
|
"""Ritorna il path completo di ffmpeg."""
|
||||||
d = find_ffmpeg_dir()
|
return _find_binary("ffmpeg")
|
||||||
if d:
|
|
||||||
return str(Path(d) / _exe("ffmpeg"))
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def find_ffprobe() -> Optional[str]:
|
def find_ffprobe() -> Optional[str]:
|
||||||
"""Ritorna il path completo di ffprobe."""
|
"""Ritorna il path completo di ffprobe."""
|
||||||
d = find_ffmpeg_dir()
|
return _find_binary("ffprobe")
|
||||||
if d:
|
|
||||||
return str(Path(d) / _exe("ffprobe"))
|
|
||||||
return None
|
|
||||||
+347
-211
@@ -1,7 +1,10 @@
|
|||||||
"""Registrazione audio da un dispositivo di input via ffmpeg.
|
"""Registrazione audio.
|
||||||
|
|
||||||
macOS: usa AVFoundation (ingresso fisico + driver virtuali tipo BlackHole)
|
macOS: AVCaptureSession via PyObjC dentro al processo Python principale.
|
||||||
Windows: usa DirectShow
|
Il bundle MusicTools.app ha NSMicrophoneUsageDescription e il TCC viene
|
||||||
|
concesso al main, quindi AVFoundation registra senza problemi. ffmpeg
|
||||||
|
viene usato SOLO per convertire il file CAF a MP3 (no microfono = no TCC).
|
||||||
|
Windows: ffmpeg DirectShow (qui il problema TCC non esiste).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -25,38 +28,33 @@ _IS_WIN = sys.platform == "win32"
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Stato globale (un solo recording alla volta)
|
# Stato globale (un solo recording alla volta)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
_current_proc: Optional[subprocess.Popen] = None
|
_recording_lock = threading.Lock()
|
||||||
_proc_lock = threading.Lock()
|
_recording_state: dict = {}
|
||||||
_stop_event = threading.Event()
|
|
||||||
_recording_thread: Optional[threading.Thread] = None
|
|
||||||
_stderr_buf: list = []
|
_stderr_buf: list = []
|
||||||
_stderr_lock = threading.Lock()
|
_stderr_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def is_recording() -> bool:
|
def is_recording() -> bool:
|
||||||
with _proc_lock:
|
with _recording_lock:
|
||||||
return _current_proc is not None and _current_proc.poll() is None
|
return bool(_recording_state.get("active"))
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
||||||
# Lista dispositivi
|
"""Espone le ultime righe di log diagnostico ai fini diagnostici.
|
||||||
# ============================================================
|
|
||||||
def list_input_devices() -> list[dict]:
|
|
||||||
"""Ritorna la lista dei dispositivi audio di input.
|
|
||||||
Ogni voce: {"id": "0", "name": "...", "is_virtual": bool}.
|
|
||||||
|
|
||||||
is_virtual = True per driver di loopback noti (BlackHole, Soundflower,
|
Su macOS contiene messaggi del backend AVCaptureSession + stderr di
|
||||||
Loopback, Audio Hijack, VB-Cable, ...)
|
ffmpeg durante la conversione CAF->MP3. Su Windows contiene lo
|
||||||
|
stderr di ffmpeg per la registrazione DirectShow.
|
||||||
"""
|
"""
|
||||||
ffmpeg = find_ffmpeg()
|
with _stderr_lock:
|
||||||
if not ffmpeg:
|
return list(_stderr_buf[-max_lines:])
|
||||||
return []
|
|
||||||
|
|
||||||
if _IS_MAC:
|
|
||||||
return _list_macos(ffmpeg)
|
def _log_diag(line: str) -> None:
|
||||||
if _IS_WIN:
|
with _stderr_lock:
|
||||||
return _list_windows(ffmpeg)
|
_stderr_buf.append(line)
|
||||||
return []
|
if len(_stderr_buf) > 200:
|
||||||
|
del _stderr_buf[0]
|
||||||
|
|
||||||
|
|
||||||
_VIRTUAL_KEYWORDS = (
|
_VIRTUAL_KEYWORDS = (
|
||||||
@@ -71,42 +69,44 @@ def _is_virtual(name: str) -> bool:
|
|||||||
return any(k in n for k in _VIRTUAL_KEYWORDS)
|
return any(k in n for k in _VIRTUAL_KEYWORDS)
|
||||||
|
|
||||||
|
|
||||||
def _list_macos(ffmpeg: str) -> list[dict]:
|
# ============================================================
|
||||||
"""Parsa l'output di `ffmpeg -f avfoundation -list_devices true -i ""`.
|
# Lista dispositivi
|
||||||
L'output va su stderr."""
|
# ============================================================
|
||||||
|
def list_input_devices() -> list[dict]:
|
||||||
|
"""Ritorna la lista dei dispositivi audio di input.
|
||||||
|
|
||||||
|
Ogni voce: {"id": "...", "name": "...", "is_virtual": bool}.
|
||||||
|
Su macOS l'`id` e' l'uniqueID di AVCaptureDevice (stabile).
|
||||||
|
Su Windows e' il nome del device (richiesto da DirectShow).
|
||||||
|
"""
|
||||||
|
if _IS_MAC:
|
||||||
|
return _list_macos_avf()
|
||||||
|
if _IS_WIN:
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
return _list_windows(ffmpeg) if ffmpeg else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _list_macos_avf() -> list[dict]:
|
||||||
|
"""Enumera input audio via AVFoundation (no ffmpeg subprocess)."""
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||||
[ffmpeg, "-hide_banner", "-f", "avfoundation",
|
except ImportError as e:
|
||||||
"-list_devices", "true", "-i", ""],
|
_log_diag(f"[avf] AVFoundation non disponibile: {e}")
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
**subprocess_flags(),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
devices: list[dict] = []
|
devices: list[dict] = []
|
||||||
in_audio = False
|
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||||
audio_section_re = re.compile(r"AVFoundation\s+audio\s+devices", re.IGNORECASE)
|
try:
|
||||||
other_section_re = re.compile(r"AVFoundation\s+(?:video)\s+devices", re.IGNORECASE)
|
name = str(d.localizedName())
|
||||||
line_re = re.compile(r"\[(\d+)\]\s+(.+?)\s*$")
|
uid = str(d.uniqueID())
|
||||||
|
except Exception:
|
||||||
for raw in proc.stderr.splitlines():
|
|
||||||
line = raw.strip()
|
|
||||||
if audio_section_re.search(line):
|
|
||||||
in_audio = True
|
|
||||||
continue
|
continue
|
||||||
if other_section_re.search(line):
|
devices.append({
|
||||||
in_audio = False
|
"id": uid,
|
||||||
continue
|
"name": name,
|
||||||
if not in_audio:
|
"is_virtual": _is_virtual(name),
|
||||||
continue
|
})
|
||||||
clean = re.sub(r"^\[AVFoundation[^\]]*\]\s*", "", line)
|
|
||||||
m = line_re.match(clean)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
idx, name = m.group(1), m.group(2).strip()
|
|
||||||
devices.append({"id": idx, "name": name, "is_virtual": _is_virtual(name)})
|
|
||||||
|
|
||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
@@ -150,7 +150,6 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
|||||||
name = mn.group(1)
|
name = mn.group(1)
|
||||||
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
devices.append({"id": name, "name": name, "is_virtual": _is_virtual(name)})
|
||||||
|
|
||||||
# Dedup preservando l'ordine
|
|
||||||
seen = set()
|
seen = set()
|
||||||
out = []
|
out = []
|
||||||
for d in devices:
|
for d in devices:
|
||||||
@@ -162,88 +161,250 @@ def _list_windows(ffmpeg: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Registrazione
|
# Permesso microfono (macOS)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
def _build_record_cmd(ffmpeg: str, device_id: str, output_path: str,
|
def _ensure_macos_mic_permission() -> tuple[bool, str]:
|
||||||
bitrate: str) -> list[str]:
|
"""Verifica/richiede il permesso microfono (TCC) per il processo corrente.
|
||||||
"""Comando ffmpeg per registrare su file MP3."""
|
|
||||||
if _IS_MAC:
|
|
||||||
return [
|
|
||||||
ffmpeg, "-hide_banner", "-y",
|
|
||||||
"-f", "avfoundation",
|
|
||||||
"-i", f":{device_id}",
|
|
||||||
"-acodec", "libmp3lame",
|
|
||||||
"-b:a", bitrate,
|
|
||||||
"-vn",
|
|
||||||
output_path,
|
|
||||||
]
|
|
||||||
if _IS_WIN:
|
|
||||||
return [
|
|
||||||
ffmpeg, "-hide_banner", "-y",
|
|
||||||
"-f", "dshow",
|
|
||||||
"-i", f"audio={device_id}",
|
|
||||||
"-acodec", "libmp3lame",
|
|
||||||
"-b:a", bitrate,
|
|
||||||
"-vn",
|
|
||||||
output_path,
|
|
||||||
]
|
|
||||||
raise RuntimeError("Sistema operativo non supportato per la registrazione")
|
|
||||||
|
|
||||||
|
Ritorna (granted, reason).
|
||||||
def start_recording(
|
|
||||||
device_id: str,
|
|
||||||
output_path: str,
|
|
||||||
bitrate: str = "320k",
|
|
||||||
progress_callback: Optional[Callable] = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Avvia una registrazione in un thread.
|
|
||||||
Ritorna {ok, error?, output_path?}.
|
|
||||||
|
|
||||||
progress_callback(status, payload) viene chiamato con:
|
|
||||||
("started", {"output_path": ...})
|
|
||||||
("tick", {"seconds": int})
|
|
||||||
("stopped", {"output_path": ..., "seconds": int})
|
|
||||||
("error", {"error": "..."})
|
|
||||||
"""
|
"""
|
||||||
global _current_proc, _recording_thread
|
try:
|
||||||
|
from AVFoundation import AVCaptureDevice, AVMediaTypeAudio
|
||||||
|
except ImportError as e:
|
||||||
|
return False, f"AVFoundation non disponibile: {e}"
|
||||||
|
|
||||||
if is_recording():
|
# 0=notDetermined, 1=restricted, 2=denied, 3=authorized
|
||||||
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
status = AVCaptureDevice.authorizationStatusForMediaType_(AVMediaTypeAudio)
|
||||||
|
if status == 3:
|
||||||
|
return True, "authorized"
|
||||||
|
if status in (1, 2):
|
||||||
|
return False, (
|
||||||
|
"Permesso microfono negato.\n"
|
||||||
|
"Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
||||||
|
"Attiva MusicTools e riavvia l'app."
|
||||||
|
)
|
||||||
|
# notDetermined -> chiedi il prompt in modo sincrono.
|
||||||
|
ev = threading.Event()
|
||||||
|
granted = [False]
|
||||||
|
|
||||||
|
def cb(ok):
|
||||||
|
granted[0] = bool(ok)
|
||||||
|
ev.set()
|
||||||
|
|
||||||
|
AVCaptureDevice.requestAccessForMediaType_completionHandler_(AVMediaTypeAudio, cb)
|
||||||
|
# PyObjC pumpa il run loop automaticamente in attesa del callback.
|
||||||
|
ev.wait(60)
|
||||||
|
if not granted[0]:
|
||||||
|
return False, "Permesso microfono negato dall'utente."
|
||||||
|
return True, "granted"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Registrazione macOS via AVCaptureSession
|
||||||
|
# ============================================================
|
||||||
|
def _start_recording_macos(device_id: str, output_path: str, bitrate: str,
|
||||||
|
progress_callback: Optional[Callable]) -> dict:
|
||||||
|
from AVFoundation import (
|
||||||
|
AVCaptureSession, AVCaptureDevice, AVCaptureDeviceInput,
|
||||||
|
AVCaptureAudioFileOutput, AVMediaTypeAudio,
|
||||||
|
)
|
||||||
|
from Foundation import NSURL, NSObject
|
||||||
|
|
||||||
|
ok, reason = _ensure_macos_mic_permission()
|
||||||
|
if not ok:
|
||||||
|
return {"ok": False, "error": reason}
|
||||||
|
|
||||||
|
# Trova il device: prima per uniqueID, poi per nome (fallback)
|
||||||
|
dev = AVCaptureDevice.deviceWithUniqueID_(device_id)
|
||||||
|
if dev is None:
|
||||||
|
for d in AVCaptureDevice.devicesWithMediaType_(AVMediaTypeAudio):
|
||||||
|
if str(d.localizedName()) == device_id:
|
||||||
|
dev = d
|
||||||
|
break
|
||||||
|
if dev is None:
|
||||||
|
return {"ok": False, "error": "Dispositivo audio non trovato."}
|
||||||
|
|
||||||
|
session = AVCaptureSession.alloc().init()
|
||||||
|
inp, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(dev, None)
|
||||||
|
if err is not None:
|
||||||
|
return {"ok": False, "error": f"Errore input: {err.localizedDescription()}"}
|
||||||
|
if not session.canAddInput_(inp):
|
||||||
|
return {"ok": False, "error": "Impossibile aggiungere il device alla session."}
|
||||||
|
session.addInput_(inp)
|
||||||
|
|
||||||
|
out = AVCaptureAudioFileOutput.alloc().init()
|
||||||
|
if not session.canAddOutput_(out):
|
||||||
|
return {"ok": False, "error": "Impossibile aggiungere l'output alla session."}
|
||||||
|
session.addOutput_(out)
|
||||||
|
|
||||||
|
out_path = Path(output_path)
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
caf_path = out_path.with_suffix(".caf")
|
||||||
|
|
||||||
|
# Delegate: aspetta che lo stop produca il file finale
|
||||||
|
class _Delegate(NSObject):
|
||||||
|
def captureOutput_didStartRecordingToOutputFileAtURL_fromConnections_(
|
||||||
|
self, output, url, conns):
|
||||||
|
self.started = True
|
||||||
|
|
||||||
|
def captureOutput_didFinishRecordingToOutputFileAtURL_fromConnections_error_(
|
||||||
|
self, output, url, conns, err):
|
||||||
|
self.done = True
|
||||||
|
self.err = err.localizedDescription() if err is not None else None
|
||||||
|
|
||||||
|
delegate = _Delegate.alloc().init()
|
||||||
|
delegate.started = False
|
||||||
|
delegate.done = False
|
||||||
|
delegate.err = None
|
||||||
|
|
||||||
|
session.startRunning()
|
||||||
|
out.startRecordingToOutputFileURL_outputFileType_recordingDelegate_(
|
||||||
|
NSURL.fileURLWithPath_(str(caf_path)),
|
||||||
|
"com.apple.coreaudio-format",
|
||||||
|
delegate,
|
||||||
|
)
|
||||||
|
|
||||||
|
with _recording_lock:
|
||||||
|
_recording_state.update({
|
||||||
|
"active": True,
|
||||||
|
"session": session,
|
||||||
|
"output": out,
|
||||||
|
"delegate": delegate,
|
||||||
|
"caf_path": str(caf_path),
|
||||||
|
"mp3_path": str(out_path),
|
||||||
|
"bitrate": bitrate,
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
"progress_cb": progress_callback,
|
||||||
|
})
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback("started", {"output_path": str(out_path)})
|
||||||
|
|
||||||
|
# Tick thread per emettere "seconds" elapsed (la UI lo mostra)
|
||||||
|
threading.Thread(target=_tick_worker, daemon=True).start()
|
||||||
|
|
||||||
|
_log_diag(f"[avf] Recording started -> {caf_path}")
|
||||||
|
return {"ok": True, "output_path": str(out_path)}
|
||||||
|
|
||||||
|
|
||||||
|
def _tick_worker():
|
||||||
|
while is_recording():
|
||||||
|
with _recording_lock:
|
||||||
|
cb = _recording_state.get("progress_cb")
|
||||||
|
started_at = _recording_state.get("started_at", time.monotonic())
|
||||||
|
elapsed = int(time.monotonic() - started_at)
|
||||||
|
if cb:
|
||||||
|
cb("tick", {"seconds": elapsed})
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_recording_macos() -> dict:
|
||||||
|
with _recording_lock:
|
||||||
|
if not _recording_state.get("active"):
|
||||||
|
return {"ok": True}
|
||||||
|
state = dict(_recording_state)
|
||||||
|
_recording_state["active"] = False
|
||||||
|
|
||||||
|
session = state["session"]
|
||||||
|
out = state["output"]
|
||||||
|
delegate = state["delegate"]
|
||||||
|
caf_path = Path(state["caf_path"])
|
||||||
|
mp3_path = Path(state["mp3_path"])
|
||||||
|
bitrate = state["bitrate"]
|
||||||
|
cb = state["progress_cb"]
|
||||||
|
started_at = state["started_at"]
|
||||||
|
|
||||||
|
# Stop recording (chiama il delegate didFinish dopo aver finalizzato il file)
|
||||||
|
out.stopRecording()
|
||||||
|
# Aspetta il completamento del file
|
||||||
|
deadline = time.time() + 8
|
||||||
|
while not getattr(delegate, "done", False) and time.time() < deadline:
|
||||||
|
time.sleep(0.1)
|
||||||
|
session.stopRunning()
|
||||||
|
|
||||||
|
if getattr(delegate, "err", None):
|
||||||
|
_log_diag(f"[avf] delegate error: {delegate.err}")
|
||||||
|
|
||||||
|
seconds = int(time.monotonic() - started_at)
|
||||||
|
|
||||||
|
# Conversione CAF -> MP3 via ffmpeg (NO microfono: niente TCC)
|
||||||
|
if caf_path.exists():
|
||||||
|
ffmpeg = find_ffmpeg()
|
||||||
|
if ffmpeg:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[ffmpeg, "-hide_banner", "-y", "-i", str(caf_path),
|
||||||
|
"-acodec", "libmp3lame", "-b:a", bitrate,
|
||||||
|
"-vn", str(mp3_path)],
|
||||||
|
capture_output=True, text=True, timeout=120,
|
||||||
|
**subprocess_flags(),
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
_log_diag(f"[ffmpeg-convert] rc={proc.returncode}")
|
||||||
|
for ln in (proc.stderr or "").splitlines()[-10:]:
|
||||||
|
_log_diag(ln)
|
||||||
|
# Cleanup CAF temporaneo
|
||||||
|
try:
|
||||||
|
caf_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
_log_diag(f"[ffmpeg-convert] exception: {e}")
|
||||||
|
else:
|
||||||
|
_log_diag("[ffmpeg-convert] ffmpeg non trovato, lascio il file CAF.")
|
||||||
|
# Se ffmpeg non c'e', rinomina il CAF in MP3 (estensione errata
|
||||||
|
# ma l'utente ha qualcosa)
|
||||||
|
try:
|
||||||
|
caf_path.rename(mp3_path.with_suffix(".caf"))
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if cb:
|
||||||
|
if mp3_path.exists():
|
||||||
|
cb("stopped", {"output_path": str(mp3_path), "seconds": seconds})
|
||||||
|
else:
|
||||||
|
cb("error", {"error": "File registrato non trovato dopo lo stop."})
|
||||||
|
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Registrazione Windows via ffmpeg DirectShow (invariato)
|
||||||
|
# ============================================================
|
||||||
|
def _start_recording_windows(device_id: str, output_path: str, bitrate: str,
|
||||||
|
progress_callback: Optional[Callable]) -> dict:
|
||||||
ffmpeg = find_ffmpeg()
|
ffmpeg = find_ffmpeg()
|
||||||
if not ffmpeg:
|
if not ffmpeg:
|
||||||
return {"ok": False, "error": "ffmpeg non trovato"}
|
return {"ok": False, "error": "ffmpeg non trovato"}
|
||||||
|
|
||||||
out = Path(output_path)
|
out = Path(output_path)
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
ffmpeg, "-hide_banner", "-y",
|
||||||
|
"-f", "dshow",
|
||||||
|
"-i", f"audio={device_id}",
|
||||||
|
"-acodec", "libmp3lame",
|
||||||
|
"-b:a", bitrate,
|
||||||
|
"-vn",
|
||||||
|
str(out),
|
||||||
|
]
|
||||||
|
|
||||||
cmd = _build_record_cmd(ffmpeg, device_id, str(out), bitrate)
|
|
||||||
|
|
||||||
_stop_event.clear()
|
|
||||||
with _stderr_lock:
|
with _stderr_lock:
|
||||||
_stderr_buf.clear()
|
_stderr_buf.clear()
|
||||||
|
|
||||||
def _drain_stderr(proc):
|
def _drain(proc):
|
||||||
"""Drena lo stderr di ffmpeg in modo continuo per evitare blocchi
|
|
||||||
del pipe e per salvare le ultime righe ai fini diagnostici."""
|
|
||||||
try:
|
try:
|
||||||
for raw in iter(proc.stderr.readline, b""):
|
for raw in iter(proc.stderr.readline, b""):
|
||||||
if not raw:
|
if not raw:
|
||||||
break
|
break
|
||||||
line = raw.decode("utf-8", errors="ignore").rstrip()
|
line = raw.decode("utf-8", errors="ignore").rstrip()
|
||||||
with _stderr_lock:
|
_log_diag(line)
|
||||||
_stderr_buf.append(line)
|
|
||||||
# Mantieni solo le ultime 50 righe per non sforare la RAM
|
|
||||||
if len(_stderr_buf) > 50:
|
|
||||||
del _stderr_buf[0]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _worker():
|
def _worker():
|
||||||
global _current_proc
|
|
||||||
try:
|
try:
|
||||||
with _proc_lock:
|
proc = subprocess.Popen(
|
||||||
_current_proc = subprocess.Popen(
|
|
||||||
cmd,
|
cmd,
|
||||||
stdin=subprocess.PIPE,
|
stdin=subprocess.PIPE,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
@@ -251,41 +412,44 @@ def start_recording(
|
|||||||
text=False,
|
text=False,
|
||||||
**subprocess_flags(),
|
**subprocess_flags(),
|
||||||
)
|
)
|
||||||
|
with _recording_lock:
|
||||||
|
_recording_state.update({
|
||||||
|
"active": True,
|
||||||
|
"proc": proc,
|
||||||
|
"output_path": str(out),
|
||||||
|
"started_at": time.monotonic(),
|
||||||
|
"progress_cb": progress_callback,
|
||||||
|
})
|
||||||
|
threading.Thread(target=_drain, args=(proc,), daemon=True).start()
|
||||||
|
|
||||||
drain_t = threading.Thread(target=_drain_stderr,
|
|
||||||
args=(_current_proc,), daemon=True)
|
|
||||||
drain_t.start()
|
|
||||||
|
|
||||||
# Aspetta brevemente che ffmpeg apra il device. Se fallisce nei
|
|
||||||
# primi ~1.5s, segnala errore prima di emettere 'started'.
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
opened = False
|
opened = False
|
||||||
for _ in range(15):
|
for _ in range(15):
|
||||||
if _current_proc.poll() is not None:
|
if proc.poll() is not None:
|
||||||
break
|
break
|
||||||
# ffmpeg stampa "Press [q] to stop" quando il device e' aperto
|
|
||||||
with _stderr_lock:
|
with _stderr_lock:
|
||||||
if any("Press [q]" in l for l in _stderr_buf):
|
if any("Press [q]" in l for l in _stderr_buf):
|
||||||
opened = True
|
opened = True
|
||||||
break
|
break
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
|
|
||||||
rc_early = _current_proc.poll()
|
if not opened and proc.poll() is not None:
|
||||||
if not opened and rc_early is not None:
|
with _recording_lock:
|
||||||
# ffmpeg e' uscito prima di iniziare a registrare
|
_recording_state["active"] = False
|
||||||
with _proc_lock:
|
|
||||||
_current_proc = None
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("error", {"error": _extract_friendly_error(rc_early)})
|
progress_callback("error", {
|
||||||
|
"error": _extract_friendly_error_windows(proc.returncode),
|
||||||
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("started", {"output_path": str(out)})
|
progress_callback("started", {"output_path": str(out)})
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
if _stop_event.is_set():
|
with _recording_lock:
|
||||||
|
if not _recording_state.get("active"):
|
||||||
break
|
break
|
||||||
rc = _current_proc.poll()
|
rc = proc.poll()
|
||||||
if rc is not None:
|
if rc is not None:
|
||||||
break
|
break
|
||||||
elapsed = int(time.monotonic() - start)
|
elapsed = int(time.monotonic() - start)
|
||||||
@@ -293,123 +457,95 @@ def start_recording(
|
|||||||
progress_callback("tick", {"seconds": elapsed})
|
progress_callback("tick", {"seconds": elapsed})
|
||||||
time.sleep(1.0)
|
time.sleep(1.0)
|
||||||
|
|
||||||
# Stop gracefully
|
if proc.poll() is None:
|
||||||
if _current_proc.poll() is None:
|
|
||||||
try:
|
try:
|
||||||
_current_proc.stdin.write(b"q\n")
|
proc.stdin.write(b"q\n")
|
||||||
_current_proc.stdin.flush()
|
proc.stdin.flush()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
_current_proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
_current_proc.terminate()
|
proc.terminate()
|
||||||
try:
|
try:
|
||||||
_current_proc.wait(timeout=3)
|
proc.wait(timeout=3)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
_current_proc.kill()
|
proc.kill()
|
||||||
|
|
||||||
rc = _current_proc.returncode
|
|
||||||
seconds = int(time.monotonic() - start)
|
seconds = int(time.monotonic() - start)
|
||||||
|
with _recording_lock:
|
||||||
with _proc_lock:
|
_recording_state["active"] = False
|
||||||
_current_proc = None
|
|
||||||
|
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
if rc == 0 or out.exists():
|
if proc.returncode == 0 or out.exists():
|
||||||
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
progress_callback("stopped", {"output_path": str(out), "seconds": seconds})
|
||||||
else:
|
else:
|
||||||
progress_callback("error", {"error": _extract_friendly_error(rc)})
|
progress_callback("error", {
|
||||||
|
"error": _extract_friendly_error_windows(proc.returncode),
|
||||||
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
with _proc_lock:
|
with _recording_lock:
|
||||||
_current_proc = None
|
_recording_state["active"] = False
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
progress_callback("error", {"error": str(e)})
|
progress_callback("error", {"error": f"{type(e).__name__}: {e}"})
|
||||||
|
|
||||||
_recording_thread = threading.Thread(target=_worker, daemon=True)
|
|
||||||
_recording_thread.start()
|
|
||||||
|
|
||||||
|
threading.Thread(target=_worker, daemon=True).start()
|
||||||
return {"ok": True, "output_path": str(out)}
|
return {"ok": True, "output_path": str(out)}
|
||||||
|
|
||||||
|
|
||||||
def _extract_friendly_error(rc: int) -> str:
|
def _extract_friendly_error_windows(rc: int) -> str:
|
||||||
"""Trasforma rc + stderr di ffmpeg in un messaggio leggibile.
|
|
||||||
Riconosce i casi tipici: permessi microfono, device occupato,
|
|
||||||
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 "no such device" in text or "could not find" in text:
|
||||||
|
return "Dispositivo non trovato. Premi 'Aggiorna' e riseleziona."
|
||||||
# ---- Errori specifici (priorita' alta) ----
|
if "permission" in text or "denied" in text:
|
||||||
|
return "Permesso microfono mancante. Impostazioni Windows -> Privacy -> Microfono."
|
||||||
if "input/output error" in text or "errno 22" in text:
|
|
||||||
return (
|
|
||||||
"Dispositivo audio non pronto (BlackHole o scheda virtuale). "
|
|
||||||
"Spesso si risolve cosi':\n"
|
|
||||||
" 1) Smetti la registrazione e riprova fra 5 secondi\n"
|
|
||||||
" 2) Se persiste, da Terminale: 'sudo killall coreaudiod'\n"
|
|
||||||
" 3) Verifica che nessun'altra app stia gia' registrando il device"
|
|
||||||
)
|
|
||||||
|
|
||||||
if "device not configured" in text or "device not available" in text:
|
|
||||||
return "Dispositivo non configurato. Premi 'Aggiorna' e riseleziona."
|
|
||||||
|
|
||||||
if "permission" in text or "not authorized" in text or "not permitted" in text \
|
|
||||||
or "tcc" in text or "denied" in text:
|
|
||||||
return _permission_message()
|
|
||||||
|
|
||||||
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 dalla lista."
|
|
||||||
|
|
||||||
# ---- 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()
|
low = l.lower()
|
||||||
# Riga AVFoundation/dshow specifica (es. "[avfoundation @ 0x...] Could not...")
|
if "error" in low or "fail" in low or "denied" in low:
|
||||||
if l.startswith("[avfoundation") or l.startswith("[dshow") or "@ 0x" in l:
|
|
||||||
return l
|
return l
|
||||||
if "error" in low or "fail" in low or "denied" in low or "cannot" in low:
|
|
||||||
return l
|
|
||||||
|
|
||||||
# Ultima spiaggia: rc + ultime 3 righe di stderr per debug
|
|
||||||
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
tail = " | ".join(line for line in lines[-3:] if line.strip()) or "(nessuno)"
|
||||||
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
return f"ffmpeg exit {rc}. Ultimo stderr: {tail}"
|
||||||
|
|
||||||
|
|
||||||
def _permission_message() -> str:
|
def _stop_recording_windows() -> dict:
|
||||||
"""Messaggio standardizzato per il problema permesso Microfono macOS."""
|
with _recording_lock:
|
||||||
return (
|
if not _recording_state.get("active"):
|
||||||
"Permesso Microfono mancante. Su macOS:\n"
|
return {"ok": True}
|
||||||
" Impostazioni di Sistema -> Privacy e sicurezza -> Microfono\n"
|
# Il worker thread vede 'active'=False e termina ffmpeg con 'q'.
|
||||||
" Abilita 'Terminal' (se lanci l'app da terminale) o 'MusicTools'\n"
|
_recording_state["active"] = False
|
||||||
" (se usi l'app installata). Poi riavvia l'app."
|
return {"ok": True}
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_last_stderr(max_lines: int = 50) -> list[str]:
|
# ============================================================
|
||||||
"""Espone le ultime righe di stderr ai fini diagnostici.
|
# API pubblica
|
||||||
Chiamato dal bridge per la UI 'Mostra log tecnici'."""
|
# ============================================================
|
||||||
|
def start_recording(
|
||||||
|
device_id: str,
|
||||||
|
output_path: str,
|
||||||
|
bitrate: str = "320k",
|
||||||
|
progress_callback: Optional[Callable] = None,
|
||||||
|
) -> dict:
|
||||||
|
if is_recording():
|
||||||
|
return {"ok": False, "error": "Una registrazione e gia in corso"}
|
||||||
|
|
||||||
with _stderr_lock:
|
with _stderr_lock:
|
||||||
return list(_stderr_buf[-max_lines:])
|
_stderr_buf.clear()
|
||||||
|
|
||||||
|
if _IS_MAC:
|
||||||
|
return _start_recording_macos(device_id, output_path, bitrate, progress_callback)
|
||||||
|
if _IS_WIN:
|
||||||
|
return _start_recording_windows(device_id, output_path, bitrate, progress_callback)
|
||||||
|
return {"ok": False, "error": "Sistema operativo non supportato per la registrazione"}
|
||||||
|
|
||||||
|
|
||||||
def stop_recording() -> dict:
|
def stop_recording() -> dict:
|
||||||
"""Ferma la registrazione corrente (se attiva)."""
|
if _IS_MAC:
|
||||||
_stop_event.set()
|
return _stop_recording_macos()
|
||||||
|
if _IS_WIN:
|
||||||
|
return _stop_recording_windows()
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
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;
|
||||||
|
|||||||
+35
-2
@@ -91,6 +91,30 @@
|
|||||||
.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 {
|
.dl-status {
|
||||||
max-width: 460px;
|
max-width: 460px;
|
||||||
margin: -16px auto 24px;
|
margin: -16px auto 24px;
|
||||||
@@ -177,8 +201,17 @@
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
<li>Apri il file <code>.dmg</code> scaricato — si monta come volume "MusicTools"</li>
|
||||||
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
<li>Trascina <strong>MusicTools.app</strong> sull'alias <strong>Applicazioni</strong> dentro il DMG</li>
|
||||||
<li>Doppio click su <strong>Fix Gatekeeper.command</strong> (dentro il DMG): autorizza l'app in 1 click. Si chiude da sola appena finito.</li>
|
<li>
|
||||||
<li>Apri MusicTools dalle Applicazioni, 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>
|
||||||
|
|
||||||
|
|||||||
+30
-20
@@ -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">
|
||||||
@@ -285,17 +287,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="spotify-note">
|
<div class="spotify-note">
|
||||||
<h3>Per sfruttare al massimo MusicTools serve un account Spotify</h3>
|
<h3>Playlist e album Spotify: serve un account Spotify Premium</h3>
|
||||||
<p>
|
<p>
|
||||||
L'app raggiunge il <strong>100% delle sue funzioni</strong> quando colleghi
|
MusicTools funziona <strong>al 100% subito</strong> con YouTube,
|
||||||
le tue <strong>chiavi API Spotify</strong>. Sono <strong>gratuite</strong>:
|
SoundCloud, TikTok, Instagram, Facebook e i singoli brani Spotify
|
||||||
basta un account Spotify (anche <strong>quello gratis</strong>, niente
|
(anche con account Spotify gratuito).<br>
|
||||||
Premium richiesto) e bastano <strong>2 minuti</strong> per generarle dalla
|
Per scaricare <strong>playlist e album interi da Spotify</strong> servono
|
||||||
Developer Dashboard. La guida passo-passo e' integrata nell'app.
|
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>
|
</p>
|
||||||
<div class="spotify-note-grid">
|
<div class="spotify-note-grid">
|
||||||
<div class="sn-col sn-with">
|
<div class="sn-col sn-with">
|
||||||
<div class="sn-title">✓ Con chiavi API Spotify (consigliato)</div>
|
<div class="sn-title">✓ Con Spotify Premium + chiavi API</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
|
<li>Scarichi <strong>playlist intere</strong> e <strong>album</strong> da Spotify</li>
|
||||||
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
|
<li>Brani con <strong>metadati e copertine</strong> ufficiali</li>
|
||||||
@@ -303,9 +307,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="sn-col sn-without">
|
<div class="sn-col sn-without">
|
||||||
<div class="sn-title">Senza chiavi (modalita' base)</div>
|
<div class="sn-title">Senza Premium (modalita' base)</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Da Spotify scarichi <strong>un brano alla volta</strong></li>
|
<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>YouTube, SoundCloud, TikTok, IG, FB: <strong>nessuna limitazione</strong></li>
|
||||||
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
<li>Ricerca per "Artista - Titolo" sempre attiva</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -376,8 +380,15 @@
|
|||||||
</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>
|
||||||
|
|
||||||
<details class="faq-item">
|
<details class="faq-item">
|
||||||
@@ -387,12 +398,11 @@
|
|||||||
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
scarichi senza limiti, link diretti e ricerca per titolo. Senza setup.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<strong>Da Spotify</strong>: senza chiavi puoi scaricare <strong>un
|
<strong>Da Spotify</strong>: puoi scaricare <strong>un brano alla volta</strong>
|
||||||
brano alla volta</strong> incollando il link al singolo brano (l'app
|
incollando il link al singolo brano (l'app estrae artista e titolo
|
||||||
estrae artista e titolo dai metadati pubblici di Spotify e cerca su
|
dai metadati pubblici di Spotify e cerca su YouTube). Per <strong>album
|
||||||
YouTube). Per <strong>album e playlist</strong> servono le chiavi API
|
e playlist</strong> servono le chiavi API, che Spotify rilascia solo
|
||||||
— sono gratis (anche con account Spotify free) e la procedura guidata
|
agli account Premium.
|
||||||
nell'app ci mette 2 minuti.
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -452,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>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
<main class="legal-doc">
|
<main class="legal-doc">
|
||||||
<h1>Informativa Privacy</h1>
|
<h1>Informativa Privacy</h1>
|
||||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||||
|
|
||||||
<h2>1. Titolare del trattamento</h2>
|
<h2>1. Titolare del trattamento</h2>
|
||||||
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
<p>Luciano Zappacosta — email <a href="mailto:info@djluza.com">info@djluza.com</a>.</p>
|
||||||
@@ -30,24 +30,26 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><strong>Email</strong>: associata all'acquisto. Usata per inviare la chiave di licenza, comunicazioni di servizio e supporto.</li>
|
<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>Chiave di licenza, device_id, nome dispositivo, versione app</strong>: salvati nel database licenze per gestire le attivazioni (max 3 dispositivi) e impedire abusi.</li>
|
||||||
|
<li><strong>Piano sottoscritto, id ordine e id sottoscrizione Lemon Squeezy, data scadenza/rinnovo</strong>: necessari per applicare correttamente le funzioni del piano (Basic/Pro/Premium/Annual) e gestire rinnovi, disdette, rimborsi.</li>
|
||||||
|
<li><strong>Contatore download giornaliero</strong>: numero aggregato di download effettuati ogni giorno (azzerato a mezzanotte ora di Roma). Serve esclusivamente ad applicare i limiti del piano (10/30/150 al giorno). Non vengono registrati URL ne' titoli dei brani scaricati.</li>
|
||||||
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
|
<li><strong>Dati di pagamento</strong>: gestiti esclusivamente da Lemon Squeezy Inc. L'Editore non vede ne' conserva i dati della carta.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>3. Finalita' e base giuridica</h2>
|
<h2>3. Finalita' e base giuridica</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Esecuzione del contratto (consegna della licenza, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
<li>Esecuzione del contratto (consegna della licenza, gestione del piano sottoscritto, rinnovi, supporto, aggiornamenti) — art. 6(1)(b) GDPR.</li>
|
||||||
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
<li>Adempimenti fiscali e contabili — art. 6(1)(c) GDPR.</li>
|
||||||
<li>Sicurezza del software (verifica anti-abuso delle attivazioni) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
<li>Sicurezza del software e applicazione dei limiti del piano (verifica anti-abuso delle attivazioni, contatore giornaliero) — interesse legittimo, art. 6(1)(f) GDPR.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2>4. Conservazione</h2>
|
<h2>4. Conservazione</h2>
|
||||||
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni.</p>
|
<p>I dati di licenza vengono conservati per tutta la durata del prodotto e fino a 5 anni dopo la disattivazione, per adempimenti fiscali. I log tecnici (revalidate) sono conservati per 30 giorni. Il contatore download giornaliero viene mantenuto per 90 giorni e poi cancellato.</p>
|
||||||
|
|
||||||
<h2>5. Terze parti</h2>
|
<h2>5. Terze parti</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento.</li>
|
<li><strong>Lemon Squeezy Inc.</strong> — Merchant of Record, processore di pagamento, gestione abbonamenti.</li>
|
||||||
<li><strong>Cloudflare Inc.</strong> — hosting infrastruttura backend (Workers, D1, R2, Pages).</li>
|
<li><strong>Resend Inc.</strong> — invio email transazionali (consegna chiave di licenza, comunicazioni di servizio).</li>
|
||||||
<li><strong>Resend Inc.</strong> — invio email transazionali.</li>
|
<li><strong>Provider di hosting self-managed</strong> — i dati di licenza e il backend API sono ospitati su server gestiti dall'Editore. I dati non vengono condivisi con altri fornitori cloud.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
<p>Tutte queste aziende rispettano standard internazionali di protezione dati e operano con clausole contrattuali standard ove richiesto.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -21,20 +21,31 @@
|
|||||||
|
|
||||||
<main class="legal-doc">
|
<main class="legal-doc">
|
||||||
<h1>Politica rimborsi</h1>
|
<h1>Politica rimborsi</h1>
|
||||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||||
|
|
||||||
<h2>Garanzia 14 giorni</h2>
|
<h2>Garanzia 14 giorni sul primo acquisto</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>
|
<p>Hai <strong>14 giorni</strong> di tempo dal <strong>primo acquisto</strong> per chiedere il rimborso completo, senza fornire motivazioni. Vale per tutti i piani: Basic, Pro, Premium e Annual.</p>
|
||||||
|
<p>La richiesta si effettua scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto.</p>
|
||||||
|
|
||||||
<h2>Cosa succede dopo</h2>
|
<h2>Differenze per piano</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Basic / Pro / Premium</strong> (mensili): puoi disdire l'abbonamento <strong>in qualsiasi momento</strong> dalla dashboard di Lemon Squeezy o scrivendoci. La disdetta interrompe il rinnovo successivo; la licenza resta attiva fino alla scadenza del periodo gia pagato. Il rimborso integrale e' previsto solo entro 14 giorni dal primo addebito. <strong>I rinnovi successivi al primo non sono rimborsabili</strong> ma sono interrompibili con disdetta.</li>
|
||||||
|
<li><strong>Annual</strong> (una tantum, 49,90 € per 12 mesi): rimborso integrale entro 14 giorni dall'acquisto. Oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Cosa succede dopo la richiesta</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Riceverai una risposta entro 48 ore lavorative.</li>
|
<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>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>
|
<li>La tua chiave di licenza viene revocata. L'app smette di funzionare al successivo controllo online.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
|
<h2>Come disdire un abbonamento mensile</h2>
|
||||||
|
<p>Se sei oltre i 14 giorni e vuoi solo non rinnovare, accedi alla tua dashboard Lemon Squeezy (link nella ricevuta) e clicca "Cancel subscription". In alternativa scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a> e la disattiviamo per te.</p>
|
||||||
|
|
||||||
<h2>Limitazioni</h2>
|
<h2>Limitazioni</h2>
|
||||||
<p>Trascorsi 14 giorni dall'acquisto non e' piu' possibile chiedere il rimborso, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
<p>Trascorsi 14 giorni dal primo acquisto non e' piu' possibile chiedere il rimborso integrale, salvo nei casi di malfunzionamento grave del software non risolvibile entro 30 giorni dalla segnalazione.</p>
|
||||||
|
<p>Spotify potrebbe modificare in qualsiasi momento le proprie API o le condizioni di accesso (es. richiedere un account Premium per generare le credenziali). Tali modifiche di terze parti non costituiscono titolo per il rimborso del piano MusicTools acquistato. La sezione "Esclusione di garanzie" dei <a href="/legal/terms.html">Termini di servizio</a> si applica integralmente.</p>
|
||||||
|
|
||||||
<h2>Domande?</h2>
|
<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>
|
<p>Scrivici a <a href="mailto:info@djluza.com">info@djluza.com</a>: siamo persone vere, rispondiamo entro un giorno lavorativo.</p>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
|
|
||||||
<main class="legal-doc">
|
<main class="legal-doc">
|
||||||
<h1>Termini di servizio e Licenza d'uso</h1>
|
<h1>Termini di servizio e Licenza d'uso</h1>
|
||||||
<p class="updated">Ultimo aggiornamento: 8 giugno 2026</p>
|
<p class="updated">Ultimo aggiornamento: 9 giugno 2026</p>
|
||||||
|
|
||||||
<div class="warn-box">
|
<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>
|
<p><strong>Leggi attentamente.</strong> Acquistando, installando o usando MusicTools accetti i presenti Termini. Se non li accetti, non installare ne' usare il software.</p>
|
||||||
@@ -163,6 +163,7 @@
|
|||||||
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
|
<li>Compatibilita' con qualsiasi versione corrente o futura di sistemi operativi, piattaforme online o formati di file.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
|
<p>Le piattaforme di origine (Spotify, YouTube, ecc.) possono modificare in qualsiasi momento le proprie API, formati o politiche, rendendo il Software temporaneamente o permanentemente inutilizzabile per certe funzioni. L'Editore non garantisce alcuna continuita' funzionale.</p>
|
||||||
|
<p><strong>Credenziali Spotify Web API:</strong> per scaricare playlist e album interi da Spotify il Software richiede che l'Utente generi proprie chiavi API (Client ID e Client Secret) dalla Spotify Developer Dashboard e le configuri all'interno del Software. Spotify, in seguito ad aggiornamenti della propria policy, consente la creazione di tali credenziali esclusivamente agli utenti con <strong>account Spotify Premium</strong> attivo. L'Editore non e' in alcun modo affiliato a Spotify, non fornisce, vende o rivende account Spotify, non puo' garantire l'accessibilita' o il funzionamento delle API Spotify per gli account Free, e non e' responsabile per limitazioni, sospensioni o ban applicati da Spotify alle credenziali dell'Utente. I download di singoli brani da Spotify, basati su metadati pubblici, non richiedono credenziali API ne' un abbonamento Premium.</p>
|
||||||
|
|
||||||
<h2 id="s10">10. Limitazione di responsabilita</h2>
|
<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>
|
<p>Nei limiti massimi consentiti dalla legge applicabile, in nessun caso l'Editore sara' responsabile verso l'Utente o verso terzi per:</p>
|
||||||
@@ -185,14 +186,25 @@
|
|||||||
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
<li>Qualsiasi attivita' illecita compiuta dall'Utente tramite il Software.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<h2 id="s12">12. Pagamenti e fatturazione</h2>
|
<h2 id="s12">12. Piani, prezzi e fatturazione</h2>
|
||||||
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
|
<p>I pagamenti sono gestiti da <strong>Lemon Squeezy Inc.</strong>, che opera come Merchant of Record (MoR). Lemon Squeezy emette la ricevuta/fattura fiscale, gestisce l'IVA secondo la giurisdizione del cliente (regime MOSS UE) e fornisce assistenza per problematiche di pagamento.</p>
|
||||||
<p>Prezzo pubblicato: <strong>39,90 € una tantum</strong> (IVA inclusa secondo paese di residenza dell'Utente).</p>
|
<p>Il Software e' commercializzato in quattro piani, ciascuno con prezzi indicati IVA inclusa secondo paese di residenza dell'Utente:</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Basic</strong> — 2,99 € / mese (abbonamento). Include il download di brani audio fino a 10 al giorno; non include video, registrazione, editor metadati, upgrade libreria.</li>
|
||||||
|
<li><strong>Pro</strong> — 5,99 € / mese (abbonamento). Include audio + video + registrazione + editor metadati + upgrade libreria, fino a 30 download al giorno.</li>
|
||||||
|
<li><strong>Premium</strong> — 9,99 € / mese (abbonamento). Stesse funzioni del Pro con limite di 150 download al giorno.</li>
|
||||||
|
<li><strong>Annual</strong> — 49,90 € una tantum. Tutte le funzioni sbloccate, nessun limite giornaliero, validita' 12 (dodici) mesi dalla data di acquisto. Non si rinnova automaticamente.</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Limiti giornalieri:</strong> il contatore si azzera ogni giorno a mezzanotte ora di Roma (Europe/Rome). I download non utilizzati non si cumulano.</p>
|
||||||
|
<p><strong>Abbonamenti mensili (Basic, Pro, Premium):</strong> si rinnovano automaticamente alla data di addebito mensile fino a disdetta dell'Utente, che puo' essere effettuata in qualsiasi momento dalla dashboard Lemon Squeezy o scrivendo a <a href="mailto:info@djluza.com">info@djluza.com</a>. La disdetta interrompe il rinnovo successivo; la Licenza resta attiva fino alla scadenza del periodo gia pagato.</p>
|
||||||
|
<p><strong>Piano Annual (una tantum):</strong> il pagamento e' singolo e copre 12 mesi consecutivi. Allo scadere, la Licenza diventa inattiva e per continuare a utilizzare il Software l'Utente puo' acquistare nuovamente un piano a sua scelta.</p>
|
||||||
|
<p>L'Utente puo' passare a un piano superiore (upgrade) o inferiore (downgrade) in qualsiasi momento. Il prorata viene gestito secondo le regole di Lemon Squeezy.</p>
|
||||||
|
|
||||||
<h2 id="s13">13. Diritto di recesso e rimborsi</h2>
|
<h2 id="s13">13. Diritto di recesso e rimborsi</h2>
|
||||||
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dall'acquisto, senza fornire motivazioni.</p>
|
<p>Ai sensi del Codice del Consumo (D.lgs. 206/2005) e del Regolamento UE sui consumatori, l'Utente ha diritto di recedere dal contratto entro <strong>14 (quattordici) giorni</strong> dal primo acquisto, senza fornire motivazioni. Il diritto di recesso si applica al primo addebito; non si applica ai rinnovi successivi di un abbonamento mensile, per i quali resta possibile la disdetta come previsto al §12.</p>
|
||||||
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
|
<p>La richiesta va inviata a <a href="mailto:info@djluza.com">info@djluza.com</a> dall'email usata per l'acquisto. Il rimborso viene processato da Lemon Squeezy sullo stesso metodo di pagamento; tempi tipici 5-10 giorni lavorativi.</p>
|
||||||
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
|
<p>A seguito del rimborso, la Licenza viene revocata e il Software smettera' di funzionare al successivo controllo online.</p>
|
||||||
|
<p>L'Utente non puo' richiedere il rimborso dell'importo gia pagato per i mesi di abbonamento gia consumati. Il piano Annual, in caso di recesso entro 14 giorni, viene rimborsato interamente; oltre tale termine non e' previsto rimborso parziale per il tempo residuo.</p>
|
||||||
|
|
||||||
<h2 id="s14">14. Aggiornamenti e supporto</h2>
|
<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>L'Editore si impegna a mantenere il Software funzionante e a rilasciare aggiornamenti gratuiti per tutta la durata commerciale del prodotto. Non e' garantito un calendario specifico di rilascio.</p>
|
||||||
|
|||||||
@@ -4,6 +4,46 @@ import os
|
|||||||
import sys
|
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
|
||||||
|
|||||||
+3
-1
@@ -5,4 +5,6 @@ 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.3 ; sys_platform == "win32"
|
pyobjc-framework-AVFoundation>=10.0 ; sys_platform == "darwin"
|
||||||
|
pythonnet==3.0.5 ; sys_platform == "win32"
|
||||||
|
clr-loader==0.2.7.post0 ; sys_platform == "win32"
|
||||||
+50
-11
@@ -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
|
||||||
|
|||||||
+13
-1
@@ -873,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 {
|
||||||
|
|||||||
Reference in new issue
Block a user