Compare commits

..
6 Commits
Author SHA1 Message Date
luzadevandClaude Opus 4.7 05a2a58ace Tab Scarica: ricerca diretta per titolo o "Artista - Titolo"
L'input URL ora accetta anche query libere: se non sembra un link
(http/https/spotify:/dominio noto), il testo viene parsato come
{artist, name} e scaricato via download_playlist con una traccia.

Riusa lo stesso flusso ytsearch1: gia in uso per le tracklist da file.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:13:10 +02:00
luzadevandClaude Opus 4.7 8610f4d95c Fix Windows: nascondi finestra cmd per ogni subprocess yt-dlp/ffmpeg
Aggiunto helper subprocess_flags() in core/paths.py che ritorna
CREATE_NO_WINDOW su Windows. Applicato a tutte le subprocess.run/Popen
in downloader, upgrader e recorder per evitare il flash di console nera
ad ogni traccia scaricata.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 23:06:59 +02:00
luzadevandClaude Opus 4.7 b47fdd1ab3 VERSION bump v1.4.0 -> v1.5.0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:36:58 +02:00
luzadevandClaude Opus 4.7 e56a975b77 Nuova tab Guida: manuale d'uso completo dentro l'app
Aggiunta una sezione 'Guida' (icona 📖, hero indaco) con manuale
d'uso interno, organizzato in 10 capitoli con sommario sticky a
sinistra:

- Introduzione, Setup iniziale
- Una sezione per ogni tab (Scarica, Video, Upgrade, Metadati,
  Registra, Impostazioni)
- Aggiornamenti
- Troubleshooting con tabella di 8 sintomi/cause/fix
- Risorse esterne (GitHub, yt-dlp FAQ, BlackHole)

UX:
- Layout grid 200px (TOC) + content fluido. Su schermi <900px
  il TOC diventa una row in alto
- TOC sticky con highlight del link attivo via IntersectionObserver
- Link esterni (data-ext) aperti nel browser di sistema via
  open_external_url
- Hint nella tab Registra ora apre la sezione 'Tab Registra' della
  guida invece del README su GitHub
- Box info/success/warn riusabili, stile <code> verde, <pre>
  monospace, tabella troubleshooting

CSS:
- Nuova palette indigo + hero-indigo
- Stili .guide-layout, .guide-toc, .g-section, .g-box, .g-table,
  .kbd, .toc-link.active

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:30:48 +02:00
luzadevandClaude Opus 4.7 52002f5184 Icona dell'app: squircle verde con nota musicale
Creata un'icona coerente con la palette dell'app: squircle Apple-style
(corner radius ~22%), gradient verde Spotify -> verde scuro, nota
musicale (♫) bianca centrata con leggero glow, sottile highlight in
alto e bordo soft per definizione.

- assets/generate_icon.py: script PIL riproducibile, produce
  icon.png (master 1024) -> icon.iconset/ -> iconutil -> icon.icns
  (macOS) e icon.ico multi-size (Windows)
- assets/icon.png / icon.icns / icon.ico committati
- assets/icon.iconset/ in .gitignore (intermedio rigenerabile)
- build_macos.py: --icon assets/icon.icns se presente
- build_windows.py: --icon assets/icon.ico se presente

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:26:06 +02:00
luzadevandClaude Opus 4.7 878e024c13 Rimuovi playlist personali dal repo
Tolgo dal tracking i file di playlist personali presenti nel
working tree fin dai primi commit:

- Beatport Top 100 Melodic House & Techno.txt
- PlayList_2026.txt
- PlayList_da_scaricare.txt
- Vibes_List.txt

Aggiunti i pattern al .gitignore cosi' non rientrano per sbaglio.
I file rimangono sul disco locale (git rm --cached, non -rf).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 11:19:37 +02:00
19 changed files with 873 additions and 152 deletions

No files matched your search

+9
View File
@@ -34,3 +34,12 @@ MUSICA/
# Claude # Claude
.claude/ .claude/
Apri con claude-social.command Apri con claude-social.command
# Playlist personali (non vanno nel repo)
Vibes_List.txt
Beatport Top 100*.txt
PlayList_2026.txt
PlayList_da_scaricare.txt
# Asset intermedi rigenerabili (vedi assets/generate_icon.py)
assets/icon.iconset/
-100
View File
@@ -1,100 +0,0 @@
1. Kapuchon, Miss Monique & GLZ – Hot Sauce (Extended) (5:36)
2. Brunello, Vargas – Ghost Dance (Original Mix) (6:12)
3. Hardt Antoine – Raw (Original Mix) (5:25)
4. JUNO (DE) & Samm (BE) – Heart Spin (Original Mix) (5:56)
5. AIKON – To You (Original Mix) (6:12)
6. Adam Port, Keinemusik & SG Lewis – Be The One (Extended) (4:45)
7. HotLap – Recall (Extended Mix) (5:48)
8. JUNO (DE) & Samm (BE) – Muralli (Original Mix) (4:59)
9. Brunello – Science Fiction (Original Mix) (6:36)
10. Emanuel Satie, Tim Engelhardt, Maga, Sean Doron, Unfazed, Scenarios – A Gira (Extended Remix) (5:28)
11. DJ Koze – Spiralen (Original Mix) (7:17)
12. Joris Voorn, Taylr Renee & Roddy Lima – Machine (Original Mix) (5:02)
13. KREAM & Korolova – Annihilation (Extended Mix) (4:50)
14. Bedouin, Karl Williams, LUCH – The Only One (Original Mix) (6:49)
15. Massano & Silver Panda – Pa Ca (Original Mix) (4:47)
16. Moeaike, Arodes – Nobody Else (Extended Mix) (5:58)
17. RÜFÜS DU SOL – In the Moment (Adriatique Extended Remix) (5:12)
18. &ME, Black Coffee – The Rapture Pt.III (Original Mix) (8:02)
19. Liva K – Didn’t Miss You (Original Mix) (5:46)
20. Carlo Whale & TH;EN – Echoes of You (Original Mix) (5:22)
21. Magnus, JEWELS, YUMA., SOMMA, LE YORA – EVERYTHING IN ITS RIGHT PLACE (Original Mix) (7:14)
22. Andrea Oliva – Neon Hearts (Original Mix) (6:06)
23. Jackie Hollander – High On You (Extended Mix) (4:57)
24. KREAM, SCRIPT – Turn Up The Dose (Extended Mix) (4:20)
25. JOSEPH (CH) – B4U (Original Mix) (8:00)
26. Ajna (BE), Misha – Body Moving (Original Mix) (6:16)
27. Green Velvet & Harvard Bass – Lazer Beams (Adam Beyer & Massano Remix) (5:02)
28. Argy, Omiki & Lell Nahar – MANTRA (Extended Mix) (5:03)
29. Sasha & Franky Wah – Get On My Level (Original Mix) (8:00)
30. LUCH – UH HUH (Original Mix) (6:45)
31. Fahlberg – Feeling Good (Extended Mix) (6:49)
32. Ivory (IT) – Back Again (Original Mix) (5:42)
33. Massano, Y Do I – Lost My Mind (Original Mix) (5:23)
34. KOWSKI, Eric Reyes – Break Down! (Original Mix) (5:30)
35. Andrea Oliva – Spotlight (Original Mix) (6:21)
36. Tiga vs Meduza – You Gonna Want Me (I Know) (Extended Mix) (5:21)
37. Marsh – Beside (Extended Mix) (6:18)
38. Moderat, &ME, Rampa & Keinemusik – More Love (Rampa &ME Remix) (4:55)
39. Adriatique & Emmit Fenn – Closer (Extended Mix) (4:53)
40. Jimi Jules, Black Coffee – Trippy Yeah (Extended Mix) (6:40)
41. Liva K & OVEOUS – Blessings (Original Mix) (6:35)
42. Township Rebellion, Kasablanca – It’s Chemical (Original Mix) (6:28)
43. MAXI MERAKI, Sharing-DB.club – First Time (Original Mix) (5:19)
44. Pole Position – Touch Me (Hotlap Remix) (5:40)
45. Da Hool, Cassian & YOTTO – Love Parade (Extended Mix) (4:09)
46. Jody Wisternoff, Jamie McDermott – Citadel (Extended Mix) (5:17)
47. Super Flu, Dennis Louvra – Can’t Escape (Original Mix) (5:56)
48. Armin van Buuren, Argy, Marlo Rex – Like A Child (Extended Mix) (4:08)
49. Robert Babicz – The Wave (Original Mix) (5:08)
50. The Temper Trap – Sweet Disposition (John Summit & Silver Panda Extended Remix) (5:44)
51. Yotto, Tallac – Chemtrail Surfers (Extended Mix) (6:26)
52. HotLap – Waitin’ (Extended Mix) (6:35)
53. Miss Monique, Nicolas Taboada & FRANCO BA – Ghosts (Extended Mix) (5:28)
54. Picca & Mars & Moeaike – Move in Time (Original Mix) (5:39)
55. Hardt Antoine – Sing It Back (Extended Mix) (6:57)
56. Shakedown, Layton Giordani & Anyma (ofc) – At Night (Anyma x Layton Giordani Extended Remix) (4:35)
57. Acrobatik & whatlewisdoes – Lamazi (Extended Mix) (4:46)
58. Massano – Abraza (Original Mix) (5:13)
59. Alcatraz – Giv Me Luv (Jerome Isma-Ae Remix) (7:18)
60. Gespona – Komplex (Original Mix) (5:56)
61. Misha & Martim Rola – On Business (Extended Mix) (6:30)
62. Mind Against, Anakim & HUMAN404 – Late at Night (Extended Mix) (4:39)
63. Anyma (ofc), Argy & Son of Son – Voices In My Head (Extended Mix) (4:16)
64. Fred again.., Swedish House Mafia, Future – Turn On The Lights again.. (feat. Future) (Original Mix) (4:27)
65. Sezer Uysal & Axelax – Calling For More (Original Mix) (5:12)
66. LUCH, Marten Lou – Hideaway (Extended Version) (5:57)
67. Giuseppe Ottaviani, ARTBAT & Conor Ross – Break The Loop (Original Mix) (5:22)
68. Rhodes, CamelPhat, ARTBAT – For a Feeling (Adam Sellouk & Y do I Remix – Extended) (4:49)
69. Monolink, Artbat – Return to Oz (ARTBAT Remix) (8:00)
70. Lisa & Anyma (ofc) – Bad Angel (with Lisa) (Extended Mix) (4:08)
71. Florist (BE) – Unite (Original Mix) (6:25)
72. Moderat – Eating Hooks (Deep Dish Extended Remix) (7:13)
73. CamelPhat & Arodes – Cycles (Extended Mix) (6:52)
74. Interplanetary Criminal, Kettama – Yosemite (Extended) (6:02)
75. Leblanc (ofc) – Strings Anthem (Original Mix) (5:22)
76. Mind Against & Jos & Eli – Overture (Original Mix) (5:21)
77. Tripolism & Kingstarlight – Hurricane (Extended Mix) (4:49)
78. Luke Alexander – Last Night (feat. Y4MI) (Original Mix) (3:30)
79. Braxton – There’s Still Time (Extended Mix) (6:31)
80. Township Rebellion – Birds Fly First Class (Original Mix) (6:19)
81. Agents Of Time & Miss Monique – Rajada (Original Mix) (5:00)
82. Fancy Inc – Hypnotic (Extended Mix) (5:09)
83. Boys Noize, &ME, Rampa, Adam Port, Keinemusik, Vinson – Crazy For It (feat. Vinson) (Original Mix) (6:01)
84. Space Motion & Luca Belluco – Baila Conmigo (Original Mix) (4:38)
85. Tom Westy & Dansyn – Feel Your Soul (Extended) (4:33)
86. CamelPhat & Josh Gigante – The One (Extended Mix) (5:32)
87. Argy & Omnya – Aria (Extended Mix) (5:14)
88. Air & Tur, blou., Airsand, TuraniQa – Teach Me (Extended Mix) (4:35)
89. Dilby, Martin Ka – Moving Trough (Extened Mix) (6:16)
90. Pablo Say – Repique (Original Mix) (4:48)
91. Orsay & Kotiēr – 1886 (Original Mix) (5:46)
92. Adriatique & Elderbrook – Lost In The Woods (Extended Mix) (5:01)
93. Ajna (BE) & Sentin – Bringing The Noise (Original Mix) (5:45)
94. Eric Prydz – Pjanoo (Club Mix) (7:30)
95. Eelke Kleijn – Feel It, See It (Extended Mix) (6:07)
96. Aera – Litoral (Original Mix) (6:10)
97. Julian Koerndl – Horses (Original Mix) (6:09)
98. Supermode – Tell Me Why (MEDUZA Extended Remix) (8:28)
99. Laherte – Pump Up The Jam (Extended Mix) (3:58)
100. M.O.S. – Changes (Extended Mix) (6:43)
-7
View File
@@ -1,7 +0,0 @@
https://open.spotify.com/playlist/0Q4JvzeT0q3FLsj5fqffMV?si=d17c4002f6f349c4
https://open.spotify.com/playlist/4FHKFYziOZF75AkT3aR7QP?si=b013d1cc79ff47a3
https://open.spotify.com/playlist/0mpX2EakDLmFjifuh1fQKU?si=bab58f6a7e984560
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/6NW6XlmE10Xk5PLUw9cOaS?si=705e098d59e049fe
https://open.spotify.com/playlist/4MmZgwwHgrSs2astI4NVxI?si=021eef1a758a42fc
https://open.spotify.com/playlist/4OAx11L58sozuyNVTBi6r4?si=27d4be41e53c4e8a
-18
View File
@@ -1,18 +0,0 @@
https://open.spotify.com/playlist/5HVNSaqs3HgzIsaCVeVGrI?si=dd86ade90d2347ae
https://open.spotify.com/playlist/7ptoSkENfnO1yEQYrly725?si=4d694c3390a54ce6
https://open.spotify.com/playlist/6VjXyFH9Z5HlGPAjRBKR32?si=e8fc98ee593e4e69
https://open.spotify.com/playlist/4rdb0iJ7hZhqgBFpXHGVU6?si=96a6838af2a3487f
https://open.spotify.com/playlist/51ireDdqSrt6juqBxkMsrm?si=6c06fc88e7354b50
https://open.spotify.com/playlist/50SNkybvZnubt5uK4F2kgk?si=b7a146e4d8a54d35
https://open.spotify.com/playlist/0erLbMzXD0U9xJUPlOeKrX?si=54c87940c7be4c65
https://open.spotify.com/playlist/3FGGBSXjLGU5kfPCxL9K6f?si=77e1eee04a0745aa
https://open.spotify.com/playlist/2OxJrcgA6AQGk1sSQ8ZZ2a?si=98ede53357124322
https://open.spotify.com/playlist/345eEKyGNyluygzMsS1n1H?si=9d4be71b28e94339
https://open.spotify.com/playlist/0Umelkk6D75eZHZuxkTtLF?si=7c2f1ff3d2354abb
https://open.spotify.com/playlist/2k9QmVpMHJfyjVVvJdnRLb?si=765f62a3caf843ef
https://open.spotify.com/playlist/7xVYvU65ezCBiOu49BSV4I?si=608741585f91483d
https://open.spotify.com/playlist/1AOMaSEZf0aChEOK6L5uI3?si=e44863bf2782419b
https://open.spotify.com/playlist/3Zn4AOuMAKFCJD4VLUD0uY?si=2179f9cb5e5b4092
https://open.spotify.com/playlist/1LA5WDfX5Z4I0fQSIx4L8N?si=9918a8eadb5744b9
https://open.spotify.com/playlist/0NHDUXVEsoFMwzkrYaRHG0?si=58e61fe6319e48cb
https://open.spotify.com/playlist/2Y6PQB7k73HCrBFwiBy2Jf?si=8da4990576cd444f
-5
View File
@@ -1,5 +0,0 @@
https://open.spotify.com/intl-it/album/3lgCqbQpmLscSRuWVMT5wS?si=UPiqK8smRgO_1JAAiZTrYg
https://open.spotify.com/intl-it/album/3pGb3m5KA1Y3gCVT6leCL3?si=Cj_OrTE5QAiEnwMD63EunA
https://open.spotify.com/intl-it/album/2ewhdpODXqoB0fXIKfq4u2?si=e2N7jWHiTWGiudHHMJNHHQ
https://open.spotify.com/intl-it/album/2xGfPfmx6faZUr9IaKEiSG?si=2Q-qMwWCTp-Yp7RIfhE2Ig
https://open.spotify.com/intl-it/album/43pKffJweYUVwEYcHPIfox?si=2E8IoUmOQp-cgsCujVBPMQ
+234
View File
@@ -0,0 +1,234 @@
"""Genera l'icona dell'app (PNG/ICNS/ICO) in modo riproducibile.
Stile: squircle (Apple-style rounded rect) con gradient verde Spotify
verso nero, nota musicale bianca al centro con leggero glow, highlight
sottile in alto.
Uso:
python3 assets/generate_icon.py
Produce:
assets/icon.png (1024x1024, master)
assets/icon.icns (macOS, generato via iconutil)
assets/icon.ico (Windows, multi-size)
assets/icon.iconset/... (intermedio macOS)
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Tuple
from PIL import Image, ImageDraw, ImageFilter, ImageFont
HERE = Path(__file__).resolve().parent
ROOT = HERE.parent
SIZE = 1024
RADIUS_RATIO = 0.225 # ~22% del lato, Apple iOS-like squircle
PADDING_RATIO = 0.085 # margine fra bordo immagine e squircle
# Palette (coerente col tema della UI)
GREEN_TOP = (30, 215, 96) # verde Spotify lucido
GREEN_BOTTOM = (15, 59, 34) # verde scuro profondo
WHITE = (255, 255, 255, 255)
HIGHLIGHT = (255, 255, 255, 38) # bianco semitrasparente per il riflesso
def vertical_gradient(size: int, top: Tuple[int, int, int],
bottom: Tuple[int, int, int]) -> Image.Image:
"""Crea un gradient verticale top -> bottom."""
img = Image.new("RGB", (size, size), top)
px = img.load()
for y in range(size):
t = y / (size - 1)
# ease (quad) per ammorbidire la transizione
t = t * t
r = int(top[0] + (bottom[0] - top[0]) * t)
g = int(top[1] + (bottom[1] - top[1]) * t)
b = int(top[2] + (bottom[2] - top[2]) * t)
for x in range(size):
px[x, y] = (r, g, b)
return img
def squircle_mask(size: int, radius: int, padding: int) -> Image.Image:
"""Maschera alpha per un rounded rect centrato con padding."""
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle(
(padding, padding, size - padding, size - padding),
radius=radius, fill=255,
)
return mask
def find_music_font() -> str:
"""Trova un font che contenga il glifo della nota musicale (♫)."""
candidates = [
"/System/Library/Fonts/Apple Symbols.ttf",
"/System/Library/Fonts/Symbol.ttf",
"/System/Library/Fonts/SFNS.ttf",
"/Library/Fonts/Arial Unicode.ttf",
]
for f in candidates:
if Path(f).exists():
return f
raise RuntimeError("Nessun font Unicode con simboli trovato")
def draw_note(img: Image.Image, glyph: str = "♫") -> None:
"""Stampa la nota musicale centrata, in bianco con leggero glow."""
font_path = find_music_font()
target_pt = int(SIZE * 0.62)
font = ImageFont.truetype(font_path, target_pt)
cx, cy = SIZE // 2, SIZE // 2 - int(SIZE * 0.01)
# Glow: stessa nota disegnata sfocata sotto in bianco semitrasparente
glow_layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(glow_layer)
glow_draw.text((cx, cy), glyph, font=font, fill=(255, 255, 255, 110),
anchor="mm")
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=24))
img.alpha_composite(glow_layer)
# Testo nitido sopra
draw = ImageDraw.Draw(img)
draw.text((cx, cy), glyph, font=font, fill=WHITE, anchor="mm")
def make_master() -> Image.Image:
"""Genera l'immagine master 1024x1024 RGBA con tutto."""
padding = int(SIZE * PADDING_RATIO)
radius = int(SIZE * RADIUS_RATIO)
# Background gradient verde
gradient = vertical_gradient(SIZE, GREEN_TOP, GREEN_BOTTOM)
# Squircle: prendiamo il gradient mascherato come forma
mask = squircle_mask(SIZE, radius, padding)
base = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
base.paste(gradient, (0, 0), mask)
# Highlight in alto: banda sottile (tipo "shine" iOS), molto trasparente
hl_layer = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
hl_draw = ImageDraw.Draw(hl_layer)
hl_top = padding + int(SIZE * 0.04)
hl_bot = padding + int(SIZE * 0.16)
hl_left = padding + int(SIZE * 0.10)
hl_right = SIZE - padding - int(SIZE * 0.10)
hl_draw.ellipse(
(hl_left, hl_top, hl_right, hl_bot),
fill=(255, 255, 255, 30),
)
hl_layer = hl_layer.filter(ImageFilter.GaussianBlur(radius=18))
masked = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
masked.paste(hl_layer, (0, 0), mask)
base.alpha_composite(masked)
# Bordo interno sottile per dare definizione (stroke da 0.5%)
ring = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
ring_draw = ImageDraw.Draw(ring)
ring_draw.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius,
outline=(255, 255, 255, 28),
width=max(2, int(SIZE * 0.004)),
)
base.alpha_composite(ring)
# Nota musicale al centro
draw_note(base)
# Lieve ombra interna in basso per profondita (vignette)
shade = Image.new("L", (SIZE, SIZE), 0)
sd = ImageDraw.Draw(shade)
sd.rounded_rectangle(
(padding, padding, SIZE - padding, SIZE - padding),
radius=radius, outline=255, width=int(SIZE * 0.025),
)
shade = shade.filter(ImageFilter.GaussianBlur(radius=int(SIZE * 0.025)))
overlay = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0))
overlay.paste((0, 0, 0, 60), mask=shade)
overlay = Image.new("RGBA", overlay.size, (0, 0, 0, 0)).convert("RGBA")
# Skip vignette complicata — base e' gia' coerente
return base
def export_iconset(master: Image.Image, outdir: Path) -> None:
"""Esporta le dimensioni standard macOS in outdir."""
outdir.mkdir(parents=True, exist_ok=True)
sizes = [
(16, "icon_16x16.png"),
(32, "icon_16x16@2x.png"),
(32, "icon_32x32.png"),
(64, "icon_32x32@2x.png"),
(128, "icon_128x128.png"),
(256, "icon_128x128@2x.png"),
(256, "icon_256x256.png"),
(512, "icon_256x256@2x.png"),
(512, "icon_512x512.png"),
(1024, "icon_512x512@2x.png"),
]
for px, name in sizes:
img = master.resize((px, px), Image.LANCZOS)
img.save(outdir / name, "PNG")
def build_icns(iconset_dir: Path, output_icns: Path) -> bool:
"""Usa iconutil di macOS per generare il .icns. Ritorna True se ok."""
if not shutil.which("iconutil"):
return False
try:
subprocess.run(
["iconutil", "-c", "icns", str(iconset_dir), "-o", str(output_icns)],
check=True, capture_output=True,
)
return True
except subprocess.CalledProcessError:
return False
def build_ico(master: Image.Image, output_ico: Path) -> None:
"""Genera il .ico multi-size per Windows."""
sizes = [(16, 16), (24, 24), (32, 32), (48, 48),
(64, 64), (128, 128), (256, 256)]
master.save(output_ico, format="ICO",
sizes=sizes, append_images=[])
def main():
print(f">>> Genero icona master {SIZE}x{SIZE}...")
master = make_master()
master_path = HERE / "icon.png"
master.save(master_path, "PNG")
print(f" {master_path}")
iconset_dir = HERE / "icon.iconset"
if iconset_dir.exists():
shutil.rmtree(iconset_dir)
print(">>> Esporto iconset macOS...")
export_iconset(master, iconset_dir)
icns_path = HERE / "icon.icns"
if build_icns(iconset_dir, icns_path):
print(f" {icns_path}")
else:
print(" SKIP icns (iconutil non disponibile)")
print(">>> Genero icon.ico per Windows...")
ico_path = HERE / "icon.ico"
build_ico(master, ico_path)
print(f" {ico_path}")
print("\nDone.")
if __name__ == "__main__":
main()
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+6 -1
View File
@@ -208,12 +208,17 @@ def run_pyinstaller():
if f.is_file(): if f.is_file():
add_binaries.extend(["--add-binary", f"{f}:lib"]) add_binaries.extend(["--add-binary", f"{f}:lib"])
icon_path = ROOT / "assets" / "icon.icns"
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicTools", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
]
if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.extend([
# Web UI assets (HTML/CSS/JS) — usati da pywebview # Web UI assets (HTML/CSS/JS) — usati da pywebview
"--add-data", f"{ROOT / 'webui'}:webui", "--add-data", f"{ROOT / 'webui'}:webui",
# Hidden imports per pywebview (WebKit su macOS) # Hidden imports per pywebview (WebKit su macOS)
@@ -225,7 +230,7 @@ def run_pyinstaller():
*add_binaries, *add_binaries,
# Entry point # Entry point
str(ROOT / "main.py"), str(ROOT / "main.py"),
] ])
log(f"Comando: {' '.join(cmd[:10])}...") log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT)) subprocess.run(cmd, check=True, cwd=str(ROOT))
+6 -3
View File
@@ -99,14 +99,17 @@ def run_pyinstaller():
if f.is_file() and f.suffix in (".exe", ""): if f.is_file() and f.suffix in (".exe", ""):
add_binaries.extend(["--add-binary", f"{f};."]) add_binaries.extend(["--add-binary", f"{f};."])
icon_path = ROOT / "assets" / "icon.ico"
cmd = [ cmd = [
sys.executable, "-m", "PyInstaller", sys.executable, "-m", "PyInstaller",
"--name", "MusicTools", "--name", "MusicTools",
"--windowed", "--windowed",
"--onedir", "--onedir",
"--noconfirm", "--noconfirm",
# Icona (se presente) ]
# "--icon", str(ROOT / "icon.ico"), if icon_path.exists():
cmd.extend(["--icon", str(icon_path)])
cmd.extend([
# Web UI assets (HTML/CSS/JS) — usati da pywebview # Web UI assets (HTML/CSS/JS) — usati da pywebview
"--add-data", f"{ROOT / 'webui'};webui", "--add-data", f"{ROOT / 'webui'};webui",
# Hidden imports per pywebview (EdgeChromium su Windows) # Hidden imports per pywebview (EdgeChromium su Windows)
@@ -121,7 +124,7 @@ def run_pyinstaller():
*add_binaries, *add_binaries,
# Entry point # Entry point
str(ROOT / "main.py"), str(ROOT / "main.py"),
] ])
log(f"Comando: {' '.join(cmd[:10])}...") log(f"Comando: {' '.join(cmd[:10])}...")
subprocess.run(cmd, check=True, cwd=str(ROOT)) subprocess.run(cmd, check=True, cwd=str(ROOT))
+1 -1
View File
@@ -5,7 +5,7 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
VERSION = "v1.4.0" VERSION = "v1.5.2"
APP_NAME = "MusicTools" APP_NAME = "MusicTools"
+11 -6
View File
@@ -9,7 +9,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir from core.paths import find_ytdlp, find_ffmpeg_dir, subprocess_flags
# Flag globale per interruzione # Flag globale per interruzione
@@ -102,7 +102,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> tuple[str
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(f"Ricerca fallita: {result.stderr.strip()}") raise ValueError(f"Ricerca fallita: {result.stderr.strip()}")
@@ -146,8 +146,10 @@ def download_playlist(
progress_callback(i, total, "", "stopped", 0) progress_callback(i, total, "", "stopped", 0)
return return
query = f"{track['name']} - {track['artist']}" artist = (track.get('artist') or "").strip()
track_only = track['name'] name = (track.get('name') or "").strip()
query = f"{name} - {artist}" if artist else name
track_only = name
# Salta brani gia scaricati in precedenza (file di tracking) # Salta brani gia scaricati in precedenza (file di tracking)
if query in done_set: if query in done_set:
@@ -204,6 +206,7 @@ def download_playlist(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
@@ -278,7 +281,7 @@ def download_direct_url(
probe_cmd.extend(["--cookies", cookies_path]) probe_cmd.extend(["--cookies", cookies_path])
try: try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(result.stderr.strip()) raise ValueError(result.stderr.strip())
except Exception as e: except Exception as e:
@@ -356,6 +359,7 @@ def download_direct_url(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
@@ -443,7 +447,7 @@ def download_video(
probe_cmd.extend(["--cookies", cookies_path]) probe_cmd.extend(["--cookies", cookies_path])
try: try:
result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60) result = subprocess.run(probe_cmd, capture_output=True, text=True, timeout=60, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError(result.stderr.strip()) raise ValueError(result.stderr.strip())
except Exception as e: except Exception as e:
@@ -522,6 +526,7 @@ def download_video(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
+13
View File
@@ -48,6 +48,19 @@ def _exe(name: str) -> str:
return name return name
def subprocess_flags() -> dict:
"""Kwargs per subprocess.run/Popen che nascondono la console su Windows.
Senza CREATE_NO_WINDOW, ogni subprocess (yt-dlp, ffmpeg, ffprobe) lanciato
da un'app GUI PyInstaller apre una finestra cmd nera per la durata del
processo. Su macOS/Linux il flag non esiste, quindi ritorniamo dict vuoto.
"""
if _IS_WINDOWS:
# 0x08000000 == CREATE_NO_WINDOW
return {"creationflags": 0x08000000}
return {}
def find_ytdlp() -> str: def find_ytdlp() -> str:
"""Trova il path di yt-dlp. """Trova il path di yt-dlp.
+4 -1
View File
@@ -15,7 +15,7 @@ import time
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ffmpeg from core.paths import find_ffmpeg, subprocess_flags
_IS_MAC = sys.platform == "darwin" _IS_MAC = sys.platform == "darwin"
@@ -79,6 +79,7 @@ def _list_macos(ffmpeg: str) -> list[dict]:
[ffmpeg, "-hide_banner", "-f", "avfoundation", [ffmpeg, "-hide_banner", "-f", "avfoundation",
"-list_devices", "true", "-i", ""], "-list_devices", "true", "-i", ""],
capture_output=True, text=True, timeout=10, capture_output=True, text=True, timeout=10,
**subprocess_flags(),
) )
except Exception: except Exception:
return [] return []
@@ -116,6 +117,7 @@ def _list_windows(ffmpeg: str) -> list[dict]:
[ffmpeg, "-hide_banner", "-f", "dshow", [ffmpeg, "-hide_banner", "-f", "dshow",
"-list_devices", "true", "-i", "dummy"], "-list_devices", "true", "-i", "dummy"],
capture_output=True, text=True, timeout=10, capture_output=True, text=True, timeout=10,
**subprocess_flags(),
) )
except Exception: except Exception:
return [] return []
@@ -247,6 +249,7 @@ def start_recording(
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=False, text=False,
**subprocess_flags(),
) )
drain_t = threading.Thread(target=_drain_stderr, drain_t = threading.Thread(target=_drain_stderr,
+6 -3
View File
@@ -9,7 +9,7 @@ import threading
from pathlib import Path from pathlib import Path
from typing import Callable, Optional from typing import Callable, Optional
from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe from core.paths import find_ytdlp, find_ffmpeg_dir, find_ffprobe, subprocess_flags
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"} AUDIO_EXTENSIONS = {".mp3", ".m4a", ".wav", ".flac"}
@@ -61,6 +61,7 @@ def get_bitrate(filepath: str | Path) -> int:
capture_output=True, capture_output=True,
text=True, text=True,
timeout=30, timeout=30,
**subprocess_flags(),
) )
data = json.loads(result.stdout) data = json.loads(result.stdout)
bit_rate = int(data.get("format", {}).get("bit_rate", 0)) bit_rate = int(data.get("format", {}).get("bit_rate", 0))
@@ -94,7 +95,7 @@ def _search_youtube(query: str, cookies_path: Optional[str] = None) -> str:
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **subprocess_flags())
if result.returncode != 0: if result.returncode != 0:
raise ValueError("Ricerca fallita") raise ValueError("Ricerca fallita")
@@ -128,7 +129,7 @@ def update_cover_only(
if cookies_path and Path(cookies_path).exists(): if cookies_path and Path(cookies_path).exists():
cmd.extend(["--cookies", cookies_path]) cmd.extend(["--cookies", cookies_path])
subprocess.run(cmd, capture_output=True, timeout=60) subprocess.run(cmd, capture_output=True, timeout=60, **subprocess_flags())
cover_files = list(temp_dir.glob("cover*.jpg")) cover_files = list(temp_dir.glob("cover*.jpg"))
if not cover_files: if not cover_files:
@@ -152,6 +153,7 @@ def update_cover_only(
], ],
capture_output=True, capture_output=True,
timeout=60, timeout=60,
**subprocess_flags(),
) )
except Exception: except Exception:
_cleanup_temp(temp_dir) _cleanup_temp(temp_dir)
@@ -284,6 +286,7 @@ def upgrade_folder(
with _process_lock: with _process_lock:
_current_process = subprocess.Popen( _current_process = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
**subprocess_flags(),
) )
for line in _current_process.stdout: for line in _current_process.stdout:
+218
View File
@@ -38,6 +38,10 @@
--rec-red-hover: #F87171; --rec-red-hover: #F87171;
--rec-red-dim: #7F1D1D; --rec-red-dim: #7F1D1D;
--indigo: #6366F1;
--indigo-hover: #818CF8;
--indigo-dim: #312E81;
--red: #E22134; --red: #E22134;
--red-hover: #FF4055; --red-hover: #FF4055;
@@ -249,6 +253,10 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%); background: linear-gradient(135deg, #DC2626 0%, #7F1D1D 60%, #3F0A0A 100%);
} }
.hero-indigo {
background: linear-gradient(135deg, #4F46E5 0%, #312E81 60%, #1E1B4B 100%);
}
.hero-content { position: relative; z-index: 2; max-width: 70%; } .hero-content { position: relative; z-index: 2; max-width: 70%; }
.hero-eyebrow { .hero-eyebrow {
@@ -264,6 +272,7 @@ button, input, select, textarea { font-family: inherit; font-size: inherit; }
.hero-eyebrow.pink { color: var(--pink-hover); } .hero-eyebrow.pink { color: var(--pink-hover); }
.hero-eyebrow.amber { color: var(--amber-hover); } .hero-eyebrow.amber { color: var(--amber-hover); }
.hero-eyebrow.red { color: var(--rec-red-hover); } .hero-eyebrow.red { color: var(--rec-red-hover); }
.hero-eyebrow.indigo { color: var(--indigo-hover); }
.hero-title { .hero-title {
font-size: 36px; font-size: 36px;
@@ -1038,3 +1047,212 @@ input[type="number"]::-webkit-inner-spin-button {
font-weight: 700; font-weight: 700;
margin-right: 10px; margin-right: 10px;
} }
/* ===============================
GUIDE
=============================== */
.guide-layout {
display: grid;
grid-template-columns: 200px 1fr;
gap: 32px;
align-items: start;
}
.guide-toc {
position: sticky;
top: 0;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 10px;
background: var(--bg-card);
border-radius: var(--r-md);
max-height: calc(100vh - 80px);
overflow-y: auto;
}
.toc-link {
color: var(--text-2);
text-decoration: none;
padding: 8px 12px;
border-radius: var(--r-sm);
font-size: 13px;
font-weight: 600;
transition: background-color 0.15s, color 0.15s;
}
.toc-link:hover {
background: var(--bg-input);
color: var(--text);
}
.toc-link.active {
background: var(--indigo-dim);
color: var(--indigo-hover);
}
.guide-content {
min-width: 0; /* evita overflow horizontal */
color: var(--text-2);
font-size: 14px;
line-height: 1.65;
}
.g-section {
background: var(--bg-card);
border-radius: var(--r-md);
padding: 24px 28px;
margin-bottom: 18px;
scroll-margin-top: 12px;
}
.g-section h2 {
font-size: 22px;
font-weight: 800;
color: var(--text);
margin-bottom: 10px;
letter-spacing: -0.3px;
}
.g-section h3 {
font-size: 15px;
font-weight: 800;
color: var(--text);
margin-top: 22px;
margin-bottom: 8px;
letter-spacing: 0.2px;
}
.g-section h4 {
font-size: 13px;
font-weight: 700;
color: var(--indigo-hover);
margin-top: 16px;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.6px;
}
.g-section p {
margin-bottom: 10px;
}
.g-section ul,
.g-section ol {
padding-left: 22px;
margin-bottom: 12px;
}
.g-section li {
margin-bottom: 6px;
}
.g-section strong { color: var(--text); }
.g-section code {
background: var(--bg-input);
padding: 2px 7px;
border-radius: 4px;
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 12.5px;
color: var(--green-hover);
}
.g-section pre {
background: var(--bg-card-2);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 12px 16px;
margin: 10px 0 14px;
overflow-x: auto;
}
.g-section pre code {
background: transparent;
padding: 0;
color: var(--text);
font-size: 13px;
}
.g-section a {
color: var(--indigo-hover);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
}
.g-section a:hover {
border-bottom-color: var(--indigo-hover);
}
.kbd {
background: var(--bg-input);
border: 1px solid var(--bg-input-hover);
color: var(--text);
padding: 1px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.g-box {
padding: 14px 18px;
border-radius: var(--r-sm);
margin: 14px 0;
font-size: 13.5px;
line-height: 1.55;
}
.g-box.info {
background: rgba(99, 102, 241, 0.10);
border-left: 3px solid var(--indigo);
}
.g-box.success {
background: rgba(29, 185, 84, 0.10);
border-left: 3px solid var(--green);
}
.g-box.warn {
background: rgba(253, 203, 110, 0.10);
border-left: 3px solid #FDCB6E;
}
.g-table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
font-size: 13px;
}
.g-table th,
.g-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--divider);
vertical-align: top;
}
.g-table th {
color: var(--text);
font-weight: 700;
font-size: 11.5px;
letter-spacing: 0.5px;
text-transform: uppercase;
background: var(--bg-card-2);
}
.g-table tr:last-child td {
border-bottom: none;
}
@media (max-width: 900px) {
.guide-layout { grid-template-columns: 1fr; }
.guide-toc {
position: static;
flex-direction: row;
flex-wrap: wrap;
max-height: none;
}
}
+284 -2
View File
@@ -45,6 +45,10 @@
<span class="nav-icon">⚙</span> <span class="nav-icon">⚙</span>
<span>Impostazioni</span> <span>Impostazioni</span>
</button> </button>
<button class="nav-item" data-view="guide">
<span class="nav-icon">📖</span>
<span>Guida</span>
</button>
</nav> </nav>
<div class="footer"> <div class="footer">
@@ -71,15 +75,22 @@
<div class="card"> <div class="card">
<div class="field"> <div class="field">
<label class="field-label"> <label class="field-label">
URL URL o titolo brano
<span class="badge" id="urlListBadge" hidden></span> <span class="badge" id="urlListBadge" hidden></span>
</label> </label>
<div class="row"> <div class="row">
<input type="text" id="urlInput" class="input pill" placeholder="Incolla qui un link Spotify, YouTube o SoundCloud…" /> <input type="text" id="urlInput" class="input pill" placeholder="Link Spotify/YouTube… oppure Artista - Titolo · Titolo brano" />
<button class="btn btn-ghost pill" id="loadListBtn"> <button class="btn btn-ghost pill" id="loadListBtn">
<span>📂</span> Carica lista <span>📂</span> Carica lista
</button> </button>
</div> </div>
<div class="hint">
<span class="hint-ico">ⓘ</span>
<div>
Puoi incollare un <strong>link</strong> (Spotify, YouTube, SoundCloud…) oppure scrivere direttamente
<strong>Artista - Titolo</strong> o solo il <strong>titolo del brano</strong>: l'app cerca il match migliore su YouTube e scarica in MP3.
</div>
</div>
</div> </div>
<div class="field"> <div class="field">
@@ -528,6 +539,277 @@
</div> </div>
</section> </section>
<!-- ===== VIEW: GUIDA ===== -->
<section class="view" id="view-guide">
<header class="hero hero-indigo">
<div class="hero-content">
<div class="hero-eyebrow indigo">GUIDA</div>
<h1 class="hero-title">Manuale d'uso</h1>
<p class="hero-subtitle">Come usare MusicTools al meglio · setup · troubleshooting</p>
</div>
<div class="hero-deco">📖</div>
</header>
<div class="guide-layout">
<!-- TOC sticky -->
<nav class="guide-toc">
<div class="micro-label" style="margin-bottom: 8px;">Sommario</div>
<a href="#g-intro" class="toc-link">Introduzione</a>
<a href="#g-setup" class="toc-link">Setup iniziale</a>
<a href="#g-download" class="toc-link">Tab Scarica</a>
<a href="#g-video" class="toc-link">Tab Video</a>
<a href="#g-upgrade" class="toc-link">Tab Upgrade</a>
<a href="#g-meta" class="toc-link">Tab Metadati</a>
<a href="#g-record" class="toc-link">Tab Registra</a>
<a href="#g-settings" class="toc-link">Impostazioni</a>
<a href="#g-update" class="toc-link">Aggiornamenti</a>
<a href="#g-troubleshoot" class="toc-link">Troubleshooting</a>
</nav>
<!-- Content -->
<article class="guide-content">
<section id="g-intro" class="g-section">
<h2>Introduzione</h2>
<p><strong>MusicTools</strong> è una app desktop tutto-in-uno per chi lavora con la musica: scarica brani e video da decine di piattaforme, migliora la qualità dei file esistenti, modifica i tag/metadati e registra l'audio da qualsiasi ingresso del computer.</p>
<p>L'interfaccia è organizzata in 6 sezioni accessibili dalla barra laterale: <span class="kbd">⬇ Scarica</span>, <span class="kbd">🎬 Video</span>, <span class="kbd">⚡ Upgrade</span>, <span class="kbd">🏷 Metadati</span>, <span class="kbd">● Registra</span>, <span class="kbd">⚙ Impostazioni</span>.</p>
</section>
<section id="g-setup" class="g-section">
<h2>Setup iniziale</h2>
<p>Prima di usare l'app, conviene fare 3 cose nelle <strong>Impostazioni</strong>:</p>
<ol>
<li><strong>Cartella output predefinita</strong> — dove vengono salvati tutti i download.</li>
<li><strong>Credenziali Spotify</strong> (solo se vuoi scaricare da link Spotify) — vedi il bottone "Come ottenere le credenziali?" nella tab Impostazioni.</li>
<li><strong>cookies.txt</strong> (opzionale) — necessario solo per scaricare contenuti privati Instagram/Facebook o aggirare rate-limit YouTube. Esportalo dal browser con l'estensione "Get cookies.txt LOCALLY".</li>
</ol>
<p>Tutti i campi vengono salvati automaticamente quando premi <strong>Salva Impostazioni</strong>.</p>
</section>
<section id="g-download" class="g-section">
<h2>Tab ⬇ Scarica</h2>
<p>Scarica musica in MP3 al bitrate impostato in Impostazioni (default 320 kbps).</p>
<h3>1 · Da Spotify</h3>
<p>Incolla un link a una <strong>playlist, album o brano</strong> (es. <code>open.spotify.com/playlist/...</code>). L'app risolve i metadati via API Spotify e scarica i brani da YouTube. <strong>Richiede</strong> le credenziali API impostate.</p>
<h3>2 · Da YouTube, SoundCloud e altri</h3>
<p>Incolla qualsiasi URL supportato da yt-dlp (singolo video o playlist). Funziona anche con Bandcamp, Mixcloud, ecc.</p>
<h3>3 · Lista da file .txt</h3>
<p>Clicca <strong>📂 Carica lista</strong>. Il file può contenere:</p>
<ul>
<li><strong>Una URL per riga</strong> — l'app le scarica una dopo l'altra.</li>
<li><strong>Tracklist con titoli</strong> (es. Beatport Top 100): formato <code>1. Artista – Titolo (variant) (durata)</code>. L'app riconosce automaticamente il formato e cerca ogni brano su YouTube.</li>
</ul>
<p>Nel caso tracklist, viene creata automaticamente una <strong>sottocartella</strong> col nome del file (es. <code>Beatport Top 100.txt</code> → <code>Beatport Top 100/</code>).</p>
<h3>4 · Skip duplicati</h3>
<p>Se riesegui la stessa playlist, i brani già scaricati vengono saltati in automatico. Il tracking funziona in due modi:</p>
<ul>
<li>File <code>.downloaded_tracks</code> dentro la cartella output.</li>
<li>Scan dei file presenti, con match dei nomi token-per-token (ignora parole come "Official Video", "Lyrics", "HD"...).</li>
</ul>
</section>
<section id="g-video" class="g-section">
<h2>Tab 🎬 Video</h2>
<p>Scarica video da <strong>YouTube, TikTok, Instagram, Facebook</strong> e oltre 1000 altri siti supportati da yt-dlp.</p>
<h3>Modalità</h3>
<ul>
<li><strong>🎬 Video (MP4)</strong> — output video+audio in MP4, qualità a scelta (best / 1080p / 720p / 480p).</li>
<li><strong>🎵 Solo audio (MP3)</strong> — estrae solo l'audio in MP3 al bitrate impostato in Impostazioni, con thumbnail embedded come copertina.</li>
</ul>
<h3>Contenuti privati</h3>
<p>Per scaricare reel Instagram privati, post Facebook privati o aggirare rate-limit YouTube serve un file <strong><code>cookies.txt</code></strong> valido (formato Netscape). Esportalo dal browser e impostalo in <strong>Impostazioni → Percorsi</strong>.</p>
<div class="g-box info">
<strong>Esempio:</strong> per scaricare la <em>Storia</em> di un utente Instagram seguito, fai login al browser, esporta i cookies, riavvia l'app.
</div>
</section>
<section id="g-upgrade" class="g-section">
<h2>Tab ⚡ Upgrade qualità</h2>
<p>Scansiona una cartella e <strong>riscarica i file sotto la soglia kbps</strong> impostata, sostituendoli con la versione a qualità maggiore.</p>
<ol>
<li>Seleziona la cartella con i file audio.</li>
<li>Imposta la <strong>Soglia HQ (kbps)</strong> — i file con bitrate inferiore verranno riprocessati. Default 310 kbps.</li>
<li>(Opzionale) Spunta <strong>Includi sottocartelle</strong> per la scansione ricorsiva.</li>
<li>Premi <strong>Avvia Upgrade</strong>.</li>
</ol>
<p>Per ogni file l'app cerca la versione migliore su YouTube e la sostituisce mantenendo nome originale. I file già processati vengono saltati grazie al tracking <code>.upgraded_tracks</code>.</p>
</section>
<section id="g-meta" class="g-section">
<h2>Tab 🏷 Metadati</h2>
<p>Editor completo dei tag audio. Apri un file con <strong>📂 Apri file audio</strong>.</p>
<h3>Formati supportati</h3>
<ul>
<li><strong>MP3</strong> — ID3v2.3</li>
<li><strong>M4A / AAC / MP4</strong> — Apple atoms</li>
<li><strong>FLAC</strong> — Vorbis comments</li>
<li><strong>WAV</strong> — ID3 embedded + fallback al chunk RIFF LIST/INFO (per file taggati da ffmpeg/Audacity)</li>
</ul>
<h3>Campi editabili</h3>
<p>Titolo, artista, album, album artist, anno, traccia (formato <code>1/12</code>), genere, BPM, key musicale, commento, copertina (JPG/PNG), e su macOS anche <strong>kMDItemWhereFroms</strong> (l'extended attribute che traccia l'origine del file scaricato).</p>
<h3>Copertina</h3>
<p>Anteprima 180×180 della copertina corrente. <strong>Cambia copertina</strong> ne carica una nuova; <strong>Rimuovi</strong> la marca per la rimozione al prossimo salvataggio.</p>
<h3>Annulla modifiche</h3>
<p>Premi <strong>↺ Ricarica</strong> per rileggere i tag dal file e scartare le modifiche non salvate.</p>
</section>
<section id="g-record" class="g-section">
<h2>Tab ● Registra</h2>
<p>Cattura audio in MP3 da qualsiasi ingresso del computer: <strong>mixer, scheda audio esterna, microfono, driver loopback</strong> (BlackHole/Soundflower/VB-Cable). Bitrate selezionabile 128–320 kbps.</p>
<h3>Setup standard (mixer/microfono)</h3>
<ol>
<li>Dropdown <strong>Dispositivo</strong>: seleziona la sorgente.</li>
<li>Imposta una cartella output e (opzionale) un nome file.</li>
<li>Premi <strong>● REC</strong> → registrazione, timer in tempo reale.</li>
<li><strong>◼ Stop</strong> → file MP3 finalizzato e linkato sotto.</li>
</ol>
<h3>Registrare l'audio di sistema su macOS (Spotify, YouTube...)</h3>
<p>macOS non espone nativamente l'uscita audio. Serve un driver loopback gratuito, il più diffuso è <strong>BlackHole</strong>.</p>
<h4>Installazione</h4>
<p>Apri Terminale e lancia:</p>
<pre><code>brew install blackhole-2ch</code></pre>
<p>Se subito dopo l'install BlackHole non compare nel dropdown, riavvia il servizio audio:</p>
<pre><code>sudo killall coreaudiod</code></pre>
<p>(rilancia da solo in pochi secondi, nessun rischio)</p>
<h4>Permesso microfono</h4>
<p>macOS richiede il permesso "Microfono" per leggere da BlackHole via AVFoundation. Vai su <strong>Preferenze di Sistema → Privacy e sicurezza → Microfono</strong> e abilita Terminal/MusicTools. Se la voce non c'è, comparirà un popup la prima volta che premi REC.</p>
<h4>Setup A — minimo (non senti l'audio durante)</h4>
<ol>
<li>Click icona altoparlante nella barra menu → <strong>Output → BlackHole 2ch</strong></li>
<li>Fai partire Spotify (non sentirai nulla — l'audio va a BlackHole)</li>
<li>Tab Registra → dispositivo <strong>🔄 BlackHole 2ch (loopback)</strong> → REC</li>
</ol>
<h4>Setup B — completo (registri E senti l'audio)</h4>
<ol>
<li>Apri <strong>Configurazione MIDI Audio</strong> (<code>Applicazioni → Utility</code>)</li>
<li>In basso a sinistra: <strong>+ → Crea dispositivo aggregato a uscita multipla</strong></li>
<li>Spunta sia <strong>BlackHole 2ch</strong> sia i tuoi <strong>Altoparlanti integrati</strong></li>
<li>Imposta <strong>BlackHole 2ch come Master Device</strong> (menu in alto)</li>
<li>Abilita <strong>Drift Correction</strong> sulla riga degli altoparlanti</li>
<li>(Opzionale) Rinomina in <code>Speakers + BlackHole</code></li>
<li>Preferenze di Sistema → Audio → Uscita → seleziona <code>Speakers + BlackHole</code></li>
<li>Nella tab Registra: scegli <strong>🔄 BlackHole 2ch (loopback)</strong>, non il dispositivo aggregato</li>
</ol>
<div class="g-box success">
Adesso ogni audio del sistema viene riprodotto sugli speaker (lo senti) e contemporaneamente catturato da BlackHole (viene registrato).
</div>
</section>
<section id="g-settings" class="g-section">
<h2>Impostazioni</h2>
<h3>Spotify API</h3>
<p>Le credenziali API Spotify sono <strong>gratuite</strong> e non richiedono Premium. Vai su <a href="#" data-ext="https://developer.spotify.com/dashboard">Spotify Developer Dashboard</a>, "Create app", compila i campi (Redirect URI <code>http://localhost:8888/callback</code>, seleziona "Web API"), e copia Client ID + Client Secret nei campi corrispondenti.</p>
<h3>Download</h3>
<ul>
<li><strong>Bitrate</strong> — qualità target per gli MP3 (128/192/256/320 kbps).</li>
<li><strong>Soglia HQ</strong> — soglia kbps per la tab Upgrade. I file sotto questa soglia vengono riscaricati.</li>
</ul>
<h3>Percorsi</h3>
<ul>
<li><strong>cookies.txt</strong> — per contenuti privati/protetti.</li>
<li><strong>Cartella output predefinita</strong> — viene proposta in tutte le tab.</li>
</ul>
</section>
<section id="g-update" class="g-section">
<h2>Aggiornamenti</h2>
<p>In <strong>Impostazioni → Aggiornamenti</strong> trovi il bottone <strong>Controlla aggiornamenti</strong>. L'app interroga la pagina release di GitHub e ti mostra se c'è una versione nuova.</p>
<p>Se è disponibile un aggiornamento, comparirà un secondo bottone <strong>Scarica aggiornamento</strong> che apre direttamente il browser sull'asset corretto (macOS o Windows) della release più recente.</p>
</section>
<section id="g-troubleshoot" class="g-section">
<h2>Troubleshooting</h2>
<table class="g-table">
<thead><tr><th>Sintomo</th><th>Causa probabile</th><th>Fix</th></tr></thead>
<tbody>
<tr>
<td>BlackHole non compare nel dropdown della tab Registra</td>
<td>CoreAudio non l'ha ancora caricato dopo l'install</td>
<td><code>sudo killall coreaudiod</code> + premi <strong>↻ Aggiorna</strong></td>
</tr>
<tr>
<td>"Permesso microfono mancante" o errore ffmpeg -6</td>
<td>macOS nega l'accesso al device</td>
<td>Privacy e sicurezza → Microfono → abilita Terminal/MusicTools</td>
</tr>
<tr>
<td>File MP3 della registrazione è muto</td>
<td>L'output di sistema non passa da BlackHole</td>
<td>Imposta output su BlackHole o Multi-Output Device (Setup A/B)</td>
</tr>
<tr>
<td>Spotify dice "Autenticazione fallita"</td>
<td>Client ID/Secret mancanti o errati</td>
<td>Impostazioni → Spotify API → ripeti la procedura</td>
</tr>
<tr>
<td>Instagram/Facebook non scarica</td>
<td>Contenuto privato senza cookies validi</td>
<td>Esporta cookies.txt dal browser dopo login</td>
</tr>
<tr>
<td>Brano scaricato è sbagliato (versione cover invece dell'originale)</td>
<td>Ricerca YouTube ha preso il primo risultato</td>
<td>Modifica la query nel file di tracklist (più dettagli artista/titolo)</td>
</tr>
<tr>
<td>Tag non vengono letti su un WAV</td>
<td>File usa solo chunk INFO non ID3 (raro)</td>
<td>L'app ora gestisce entrambi — controlla di avere v1.2.1+</td>
</tr>
<tr>
<td>Stop button non risponde durante il download</td>
<td>yt-dlp sta finalizzando il file</td>
<td>Attendi qualche secondo, il processo si chiude dopo l'I/O</td>
</tr>
</tbody>
</table>
<h3>Reset configurazione</h3>
<p>Se vuoi azzerare tutte le impostazioni, cancella il file <code>config.json</code>. Posizione:</p>
<ul>
<li><strong>macOS</strong>: <code>~/Library/Application Support/MusicTools/config.json</code></li>
<li><strong>Windows</strong>: <code>%APPDATA%\MusicTools\config.json</code></li>
</ul>
<p>Verrà ricreato con valori di default al prossimo avvio.</p>
</section>
<section class="g-section">
<h2>Risorse</h2>
<ul>
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload">Codice sorgente su GitHub</a></li>
<li><a href="#" data-ext="https://github.com/luzadev/musicdownload/releases">Tutte le release</a></li>
<li><a href="#" data-ext="https://github.com/yt-dlp/yt-dlp/wiki/FAQ">FAQ di yt-dlp</a></li>
<li><a href="#" data-ext="https://existential.audio/blackhole/">BlackHole — driver loopback macOS</a></li>
</ul>
<p style="color: var(--text-3); font-size: 12px; margin-top: 20px;">MusicTools — by <strong style="color: var(--green);">LuZa</strong></p>
</section>
</article>
</div>
</section>
</main> </main>
</div> </div>
+81 -5
View File
@@ -27,6 +27,24 @@ function waitApi() {
}); });
} }
// Riconosce se una stringa e un URL (incluso schema spotify:)
function looksLikeUrl(s) {
if (!s) return false;
if (/^(https?|spotify):/i.test(s)) return true;
if (s.includes("://")) return true;
// Domini comuni anche senza http (es. "youtube.com/...")
return /^(www\.)?(youtube\.com|youtu\.be|spotify\.com|soundcloud\.com|tiktok\.com|instagram\.com|facebook\.com|fb\.watch)\//i.test(s);
}
// Trasforma una query libera in {name, artist}.
// Formati riconosciuti: "Artista - Titolo" (anche con – o —) oppure solo "Titolo".
function parseQueryToTrack(q) {
const s = (q || "").trim();
const m = s.match(/^(.+?)\s+[-–—]\s+(.+)$/);
if (m) return { artist: m[1].trim(), name: m[2].trim() };
return { artist: "", name: s };
}
// ============================================================ // ============================================================
// Bridge — eventi spinti dal backend // Bridge — eventi spinti dal backend
// ============================================================ // ============================================================
@@ -340,11 +358,25 @@ $("#downloadBtn").addEventListener("click", async () => {
if (!urls.length) { if (!urls.length) {
const single = $("#urlInput").value.trim(); const single = $("#urlInput").value.trim();
if (!single) { if (!single) {
toast("Inserisci un URL o carica una lista", "error"); toast("Inserisci un URL, un titolo o carica una lista", "error");
bridgeHandlers["download:done"](); bridgeHandlers["download:done"]();
return; return;
} }
urls = [single]; if (looksLikeUrl(single)) {
urls = [single];
} else {
const track = parseQueryToTrack(single);
res = await window.pywebview.api.start_tracks_download({
tracks: [track],
output_dir: state.dlOutputDir,
subfolder: "",
});
if (!res.ok) {
toast(res.error || "Errore", "error");
bridgeHandlers["download:done"]();
}
return;
}
} }
res = await window.pywebview.api.start_download({ res = await window.pywebview.api.start_download({
urls, urls,
@@ -488,11 +520,55 @@ $("#recRefreshBtn").addEventListener("click", refreshRecDevices);
$("#recOpenGuide").addEventListener("click", (e) => { $("#recOpenGuide").addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
window.pywebview.api.open_external_url( showView("guide");
"https://github.com/luzadev/musicdownload#guida-rapida-registrazione-audio-di-sistema-macos" setTimeout(() => {
); const el = document.getElementById("g-record");
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
}, 80);
}); });
// ============================================================
// GUIDE — link esterni + TOC active highlight
// ============================================================
document.querySelectorAll("#view-guide a[data-ext]").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
window.pywebview.api.open_external_url(a.dataset.ext);
});
});
document.querySelectorAll(".toc-link").forEach((a) => {
a.addEventListener("click", (e) => {
e.preventDefault();
const id = a.getAttribute("href").slice(1);
const el = document.getElementById(id);
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
});
});
// Aggiorna TOC active in base allo scroll della view
function setupGuideObserver() {
const sections = document.querySelectorAll(".g-section[id]");
const tocLinks = document.querySelectorAll(".toc-link");
if (!sections.length || !tocLinks.length) return;
const main = document.querySelector(".main");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
tocLinks.forEach((l) => {
l.classList.toggle("active", l.getAttribute("href") === "#" + id);
});
});
},
{ root: main, rootMargin: "-20% 0px -65% 0px", threshold: 0 }
);
sections.forEach((s) => observer.observe(s));
}
setupGuideObserver();
$("#recBrowseBtn").addEventListener("click", async () => { $("#recBrowseBtn").addEventListener("click", async () => {
const path = await window.pywebview.api.browse_directory(); const path = await window.pywebview.api.browse_directory();
if (path) { if (path) {