274 lines
7.4 KiB
Python
274 lines
7.4 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
|
|
# ------------------------------------------------------------
|
|
# Gestion des chemins
|
|
# ------------------------------------------------------------
|
|
# En mode développement :
|
|
# APP_DIR = dossier du projet
|
|
# RESOURCE_DIR = dossier du projet
|
|
#
|
|
# En mode .exe PyInstaller :
|
|
# APP_DIR = dossier où se trouve YoutubeDownloader.exe
|
|
# RESOURCE_DIR = dossier temporaire/interne utilisé par PyInstaller
|
|
# ------------------------------------------------------------
|
|
|
|
if getattr(sys, "frozen", False):
|
|
APP_DIR = Path(sys.executable).resolve().parent
|
|
RESOURCE_DIR = Path(getattr(sys, "_MEIPASS", APP_DIR))
|
|
else:
|
|
APP_DIR = Path(__file__).resolve().parent
|
|
RESOURCE_DIR = APP_DIR
|
|
|
|
|
|
BASE_DIR = APP_DIR
|
|
BIN_DIR = RESOURCE_DIR / "bin"
|
|
|
|
# Les téléchargements iront dans le dossier Downloads de l'utilisateur.
|
|
# C'est plus propre pour une app installée.
|
|
DOWNLOADS_DIR = Path.home() / "Downloads" / "Youtube_Downloader"
|
|
|
|
SETTINGS_FILE = RESOURCE_DIR / "settings.json"
|
|
|
|
YTDLP_EXE = BIN_DIR / "yt-dlp.exe"
|
|
FFMPEG_EXE = BIN_DIR / "ffmpeg.exe"
|
|
FFPROBE_EXE = BIN_DIR / "ffprobe.exe"
|
|
|
|
|
|
def load_settings() -> dict:
|
|
"""
|
|
Charge les paramètres depuis settings.json.
|
|
Si le fichier est absent ou cassé, on utilise des valeurs par défaut.
|
|
"""
|
|
default_settings = {
|
|
"default_output_folder": "downloads",
|
|
"default_video_quality": "720",
|
|
"default_audio_quality": "192K",
|
|
}
|
|
|
|
if not SETTINGS_FILE.exists():
|
|
return default_settings
|
|
|
|
try:
|
|
with open(SETTINGS_FILE, "r", encoding="utf-8") as file:
|
|
settings = json.load(file)
|
|
|
|
return {
|
|
"default_output_folder": settings.get(
|
|
"default_output_folder",
|
|
default_settings["default_output_folder"],
|
|
),
|
|
"default_video_quality": settings.get(
|
|
"default_video_quality",
|
|
default_settings["default_video_quality"],
|
|
),
|
|
"default_audio_quality": settings.get(
|
|
"default_audio_quality",
|
|
default_settings["default_audio_quality"],
|
|
),
|
|
}
|
|
|
|
except Exception:
|
|
return default_settings
|
|
|
|
|
|
def validate_tools() -> list[str]:
|
|
"""
|
|
Vérifie que yt-dlp.exe et ffmpeg.exe existent dans le dossier bin.
|
|
"""
|
|
errors = []
|
|
|
|
if not YTDLP_EXE.exists():
|
|
errors.append("yt-dlp.exe est introuvable dans le dossier bin.")
|
|
|
|
if not FFMPEG_EXE.exists():
|
|
errors.append("ffmpeg.exe est introuvable dans le dossier bin.")
|
|
|
|
# ffprobe est utile mais pas toujours obligatoire.
|
|
# On ne bloque pas l'application s'il est absent.
|
|
return errors
|
|
|
|
|
|
def clean_filename(filename: str) -> str:
|
|
"""
|
|
Nettoie le nom du fichier pour éviter les caractères interdits sous Windows.
|
|
"""
|
|
forbidden_chars = '<>:"/\\|?*'
|
|
cleaned = "".join("_" if char in forbidden_chars else char for char in filename)
|
|
cleaned = cleaned.strip()
|
|
|
|
if not cleaned:
|
|
return "telechargement"
|
|
|
|
return cleaned
|
|
|
|
|
|
def resolve_output_folder(output_folder: str) -> Path:
|
|
"""
|
|
Prépare le dossier de sortie.
|
|
Si le chemin est relatif, il est créé dans le dossier de l'application.
|
|
"""
|
|
if not output_folder.strip():
|
|
folder = DOWNLOADS_DIR
|
|
else:
|
|
folder = Path(output_folder)
|
|
|
|
if not folder.is_absolute():
|
|
folder = BASE_DIR / folder
|
|
|
|
folder.mkdir(parents=True, exist_ok=True)
|
|
return folder
|
|
|
|
|
|
def build_command(
|
|
url: str,
|
|
mode: str,
|
|
output_folder: str,
|
|
filename: str,
|
|
start_time: str = "",
|
|
end_time: str = "",
|
|
video_quality: str = "720",
|
|
audio_quality: str = "192K",
|
|
) -> list[str]:
|
|
"""
|
|
Construit la commande yt-dlp selon le mode choisi.
|
|
"""
|
|
|
|
if not url.strip():
|
|
raise ValueError("L'URL YouTube est obligatoire.")
|
|
|
|
filename = clean_filename(filename)
|
|
folder = resolve_output_folder(output_folder)
|
|
|
|
output_template = str(folder / f"{filename}.%(ext)s")
|
|
|
|
cmd = [
|
|
str(YTDLP_EXE),
|
|
url.strip(),
|
|
"--newline",
|
|
"--no-playlist",
|
|
"--ffmpeg-location",
|
|
str(BIN_DIR),
|
|
]
|
|
|
|
# Gestion des extraits
|
|
if mode in ["video_extract", "audio_extract"]:
|
|
if not start_time.strip() or not end_time.strip():
|
|
raise ValueError("Pour un extrait, il faut indiquer le début et la fin.")
|
|
|
|
cmd += [
|
|
"--download-sections",
|
|
f"*{start_time.strip()}-{end_time.strip()}",
|
|
]
|
|
|
|
# Vidéo MP4
|
|
if mode in ["video_full", "video_extract"]:
|
|
if video_quality == "best":
|
|
format_selector = "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/best"
|
|
else:
|
|
format_selector = (
|
|
f"bv*[height<={video_quality}][ext=mp4]+ba[ext=m4a]/"
|
|
f"b[height<={video_quality}][ext=mp4]/best"
|
|
)
|
|
|
|
cmd += [
|
|
"-f",
|
|
format_selector,
|
|
"--merge-output-format",
|
|
"mp4",
|
|
]
|
|
|
|
# Audio MP3
|
|
elif mode in ["audio_full", "audio_extract"]:
|
|
cmd += [
|
|
"-f",
|
|
"ba/bestaudio",
|
|
"-x",
|
|
"--audio-format",
|
|
"mp3",
|
|
"--audio-quality",
|
|
audio_quality,
|
|
]
|
|
|
|
else:
|
|
raise ValueError("Mode de téléchargement inconnu.")
|
|
|
|
cmd += [
|
|
"-o",
|
|
output_template,
|
|
]
|
|
|
|
return cmd
|
|
|
|
|
|
class CommandRunner:
|
|
"""
|
|
Lance yt-dlp dans un thread séparé pour éviter de bloquer l'interface.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.process: subprocess.Popen | None = None
|
|
self.cancelled = False
|
|
|
|
def run(
|
|
self,
|
|
command: list[str],
|
|
on_line: Callable[[str], None],
|
|
on_finish: Callable[[bool, str], None],
|
|
) -> None:
|
|
self.cancelled = False
|
|
|
|
def worker():
|
|
try:
|
|
creation_flags = 0
|
|
|
|
if os.name == "nt":
|
|
creation_flags = subprocess.CREATE_NO_WINDOW
|
|
|
|
self.process = subprocess.Popen(
|
|
command,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
creationflags=creation_flags,
|
|
)
|
|
|
|
if self.process.stdout:
|
|
for line in self.process.stdout:
|
|
on_line(line.rstrip())
|
|
|
|
return_code = self.process.wait()
|
|
|
|
if self.cancelled:
|
|
on_finish(False, "Téléchargement annulé.")
|
|
elif return_code == 0:
|
|
on_finish(True, "Téléchargement terminé avec succès.")
|
|
else:
|
|
on_finish(False, f"Erreur pendant le téléchargement. Code : {return_code}")
|
|
|
|
except FileNotFoundError:
|
|
on_finish(False, "Impossible de lancer yt-dlp ou FFmpeg.")
|
|
except Exception as error:
|
|
on_finish(False, f"Erreur : {error}")
|
|
|
|
thread = threading.Thread(target=worker, daemon=True)
|
|
thread.start()
|
|
|
|
def cancel(self) -> None:
|
|
self.cancelled = True
|
|
|
|
if self.process and self.process.poll() is None:
|
|
try:
|
|
self.process.terminate()
|
|
except Exception:
|
|
pass |