Initial commit
This commit is contained in:
274
downloader.py
Normal file
274
downloader.py
Normal file
@@ -0,0 +1,274 @@
|
||||
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
|
||||
Reference in New Issue
Block a user