Files
Youtube_Downloader/downloader.py
2026-05-30 14:11:33 +01:00

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