import sqlite3 from pathlib import Path import requests from bs4 import BeautifulSoup import yt_dlp import curses import re from urllib.parse import urljoin import concurrent.futures import configparser import os DB_DIR = Path.home() / ".animecli" DB_PATH = DB_DIR / "animes.db" DOWNLOAD_DIR = Path.home() / "MesAnimes" CONFIG_PATH = DB_DIR / "config.ini" DEFAULT_CONFIG = { "vitesse_max": "0", "telechargements_simultanes": "1", "download_dir": str(DOWNLOAD_DIR), "db_path": str(DB_PATH) } CONFIG = {} def load_config(): config = configparser.ConfigParser() if CONFIG_PATH.exists(): config.read(CONFIG_PATH) if "animecli" in config: for k, v in DEFAULT_CONFIG.items(): CONFIG[k] = config["animecli"].get(k, v) else: CONFIG.update(DEFAULT_CONFIG) else: CONFIG.update(DEFAULT_CONFIG) CONFIG["vitesse_max"] = int(CONFIG["vitesse_max"]) CONFIG["telechargements_simultanes"] = int(CONFIG["telechargements_simultanes"]) def save_config(): config = configparser.ConfigParser() config["animecli"] = {k: str(v) for k, v in CONFIG.items()} DB_DIR.mkdir(parents=True, exist_ok=True) with open(CONFIG_PATH, "w") as f: config.write(f) def init_db(): try: # S'assurer que le chemin est bien un objet Path db_path = Path(CONFIG.get("db_path", str(DB_PATH))) # Créer explicitement le répertoire parent os.makedirs(db_path.parent, exist_ok=True) # Tester si on peut accéder au répertoire en écriture if not os.access(db_path.parent, os.W_OK): alternative_path = Path.home() / '.animecli' / 'animes.db' os.makedirs(alternative_path.parent, exist_ok=True) print(f"Répertoire inaccessible. Utilisation du chemin alternatif: {alternative_path}") db_path = alternative_path CONFIG["db_path"] = str(alternative_path) save_config() # Connexion à la base de données conn = sqlite3.connect(db_path) c = conn.cursor() c.execute( """ CREATE TABLE IF NOT EXISTS animes ( id INTEGER PRIMARY KEY AUTOINCREMENT, titre TEXT, url TEXT, saison INTEGER, dernier_episode TEXT, UNIQUE(titre, saison) ) """ ) conn.commit() return conn except Exception as e: print(f"Erreur base de données: {e}") # Chemin de secours dans le répertoire courant fallback_path = Path("animes.db") print(f"Tentative avec: {fallback_path}") conn = sqlite3.connect(fallback_path) c = conn.cursor() c.execute( """ CREATE TABLE IF NOT EXISTS animes ( id INTEGER PRIMARY KEY AUTOINCREMENT, titre TEXT, url TEXT, saison INTEGER, dernier_episode TEXT, UNIQUE(titre, saison) ) """ ) conn.commit() CONFIG["db_path"] = str(fallback_path) save_config() return conn def add_anime(conn, titre, url, saison): c = conn.cursor() try: c.execute( "INSERT INTO animes (titre, url, saison, dernier_episode) VALUES (?, ?, ?, ?)", (titre, url, saison, "") ) conn.commit() return True, f"Anime '{titre}' saison {saison} ajouté." except sqlite3.IntegrityError: return False, f"L'anime '{titre}' saison {saison} existe déjà." def delete_anime(conn, anime_id): c = conn.cursor() c.execute("DELETE FROM animes WHERE id = ?", (anime_id,)) conn.commit() def get_all_animes(conn): c = conn.cursor() return c.execute("SELECT id, titre, url, saison, dernier_episode FROM animes").fetchall() def extract_master_m3u8_url(page_url): try: resp = requests.get(page_url) resp.raise_for_status() urls = re.findall(r'https?://[^\s\'"]+\.m3u8[^\s\'"]*', resp.text) for url in urls: if "master.m3u8" in url: return url return urls[0] if urls else None except Exception: return None def get_source_info(url): if url.endswith('.html'): m3u8_url = extract_master_m3u8_url(url) if m3u8_url: url = m3u8_url ydl_opts = { 'quiet': True, 'skip_download': True, 'downloader_args': {'hls': ['--hls-use-mpegts']} } with yt_dlp.YoutubeDL(ydl_opts) as ydl: try: info = ydl.extract_info(url, download=False) formats = info.get('formats', []) if not formats: return None best = max(formats, key=lambda f: f.get('height', 0) or 0) qualite = f"{best.get('height', '?')}p" return { 'url': url, 'qualite': qualite, 'ext': best.get('ext', 'mp4') } except Exception: return None def choisir_source_globale(stdscr, episode_data): # Restaurer l'écran terminal standard pour l'extraction d'infos curses.endwin() print("Analyse des sources disponibles, veuillez patienter...") # Préparation des sources par site sources_by_site = [] for i in range(len(episode_data[0][0])): urls = [] for ep in episode_data: if len(ep[0]) > i: urls.append(ep[0][i]) if urls: sources_by_site.append(urls) # Récupération des infos en mode batch (sans perturber l'interface) preview_urls = [urls[0] for urls in sources_by_site] infos = [] # Redirection temporaire de stderr pour supprimer les warnings yt-dlp old_stderr = os.dup(2) os.close(2) os.open(os.devnull, os.O_WRONLY) try: for url in preview_urls: info = get_source_info(url) infos.append(info) finally: # Restaurer stderr os.close(2) os.dup2(old_stderr, 2) os.close(old_stderr) # Réinitialisation de l'interface curses stdscr = curses.initscr() curses.noecho() curses.cbreak() stdscr.keypad(True) curses.start_color() curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) sel = 0 # Index sélectionné offset = 0 # Premier élément affiché max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 4 while True: stdscr.clear() stdscr.addstr(0, 0, "Choisissez la source à utiliser pour tous les épisodes :") # Ajustement de l'offset if sel < offset: offset = sel if sel >= offset + visible_lines: offset = sel - visible_lines + 1 # Afficher les éléments visibles for i in range(visible_lines): idx = offset + i if idx < len(preview_urls): url = preview_urls[idx] info = infos[idx] quality = info.get('qualite', '?') if info else "Non disponible" line = f"{url} ({quality})" if len(line) > max_x - 5: line = line[:max_x - 8] + "..." if idx == sel: stdscr.attron(curses.color_pair(1)) safe_addstr(stdscr, i + 1, 2, line) stdscr.attroff(curses.color_pair(1)) else: safe_addstr(stdscr, i + 1, 2, line) # Indicateurs de défilement if offset > 0: stdscr.addstr(0, max_x - 8, "↑ plus") if offset + visible_lines < len(preview_urls): stdscr.addstr(visible_lines + 1, max_x - 8, "↓ plus") stdscr.addstr(visible_lines + 2, 0, "Entrée: choisir, q: annuler") stdscr.refresh() # Traiter les entrées clavier k = stdscr.getch() if k == curses.KEY_UP and sel > 0: sel -= 1 elif k == curses.KEY_DOWN and sel < len(preview_urls) - 1: sel += 1 elif k == curses.KEY_PPAGE: # Page Up sel = max(0, sel - visible_lines) elif k == curses.KEY_NPAGE: # Page Down sel = min(len(preview_urls) - 1, sel + visible_lines) elif k in [curses.KEY_ENTER, 10, 13]: return sel, sources_by_site[sel] elif k in [ord('q'), ord('Q')]: return None, None def extract_episode_sources(url_page): resp = requests.get(url_page) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") episode_sources = [] script_tag = soup.find('script', src=re.compile(r"episodes\.js")) if script_tag and "anime-sama.fr" in url_page: js_src = script_tag.get('src') js_url = urljoin(url_page, js_src) js_resp = requests.get(js_url) js_resp.raise_for_status() all_eps = re.findall(r"var\s+eps\d+\s*=\s*\[([^\]]+)\]", js_resp.text) for eps_block in all_eps: urls = re.findall(r"'(https?://[^']+)'", eps_block) if not episode_sources: episode_sources = [[url] for url in urls] for idx, url in enumerate(urls): if idx < len(episode_sources): episode_sources[idx].append(url) return [(sources, None) for sources in episode_sources] else: links = [a.get("href") for a in soup.select("a.episode-link") if a.get("href")] for url in links: episode_sources.append([url]) return [(sources, None) for sources in episode_sources] def format_dernier(dernier_url, saison): if not dernier_url or dernier_url == "?": return f"Saison {saison}, épisode ?, aucun" m = re.search(r'[Ee](\d{1,3})', dernier_url) if m: ep = m.group(1) else: m2 = re.search(r'(\d{1,3})(?!.*\d)', dernier_url) ep = m2.group(1) if m2 else "?" return f"Saison {saison}, épisode {ep}, {dernier_url}" def telecharger_episode(url, saison_folder, filename, qualite): if url.endswith('.html'): m3u8_url = extract_master_m3u8_url(url) if m3u8_url: url = m3u8_url ydl_opts = { "outtmpl": str(saison_folder / filename), "format": f"bestvideo[height<={qualite.rstrip('p')}]+bestaudio/best", "downloader": "ffmpeg", "downloader_args": {"hls": ["--hls-use-mpegts"]} } if CONFIG["vitesse_max"] > 0: ydl_opts["ratelimit"] = CONFIG["vitesse_max"] * 1024 try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.download([url]) return True, url except Exception as e: print(f"Erreur download {url}: {e}") return False, url def detect_and_add_all_seasons(conn, titre, url, saison): m = re.match(r"(https://anime-sama\.fr/catalogue/[^/]+/saison)\d+(/vostfr/)", url) if not m: add_anime(conn, titre, url, saison) return prefix, suffix = m.group(1), m.group(2) saison_num = 1 total_saisons = 0 while True: saison_url = f"{prefix}{saison_num}{suffix}" resp = requests.get(saison_url) if resp.status_code != 200: break c = conn.cursor() c.execute("SELECT 1 FROM animes WHERE titre=? AND saison=?", (titre, saison_num)) if not c.fetchone(): add_anime(conn, titre, saison_url, saison_num) saison_num += 1 total_saisons += 1 if total_saisons == 0: add_anime(conn, titre, url, saison) def handle_multi_download(stdscr, conn): animes = get_all_animes(conn) if not animes: stdscr.clear() stdscr.addstr(0, 0, "Aucun anime disponible.") stdscr.addstr(2, 0, "Appuyez sur une touche pour revenir au menu.") stdscr.getch() return # Sélection de l'anime titres = sorted(set(a[1] for a in animes)) sel_titre = 0 offset = 0 max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 4 while True: stdscr.clear() stdscr.addstr(0, 0, "Sélectionnez un anime :") # Ajustement de l'offset if sel_titre < offset: offset = sel_titre if sel_titre >= offset + visible_lines: offset = sel_titre - visible_lines + 1 # Affichage des titres visibles for i in range(visible_lines): idx = offset + i if idx < len(titres): if idx == sel_titre: stdscr.attron(curses.color_pair(1)) safe_addstr(stdscr, i + 2, 2, titres[idx]) stdscr.attroff(curses.color_pair(1)) else: safe_addstr(stdscr, i + 2, 2, titres[idx]) # Indicateurs de défilement if offset > 0: stdscr.addstr(1, max_x - 8, "↑ plus") if offset + visible_lines < len(titres): stdscr.addstr(visible_lines + 2, max_x - 8, "↓ plus") stdscr.refresh() k = stdscr.getch() if k == curses.KEY_UP and sel_titre > 0: sel_titre -= 1 elif k == curses.KEY_DOWN and sel_titre < len(titres) - 1: sel_titre += 1 elif k == curses.KEY_PPAGE: # Page Up sel_titre = max(0, sel_titre - visible_lines) elif k == curses.KEY_NPAGE: # Page Down sel_titre = min(len(titres) - 1, sel_titre + visible_lines) elif k in [curses.KEY_ENTER, 10, 13]: break elif k in [ord('q'), ord('Q')]: return titre = titres[sel_titre] saisons = sorted([a for a in animes if a[1] == titre], key=lambda x: x[3]) saison_episodes = [] for anime in saisons: saison_num = anime[3] url_page = anime[2] try: episode_data = extract_episode_sources(url_page) saison_episodes.append((saison_num, episode_data, anime[0])) except Exception: saison_episodes.append((saison_num, [], anime[0])) selection = {"all": False, "saisons": []} for saison_num, episode_data, anime_id in saison_episodes: saison_sel = {"num": saison_num, "selected": False, "episodes": [False] * len(episode_data), "anime_id": anime_id, "episode_data": episode_data} selection["saisons"].append(saison_sel) cursor = [0, 0] # Préparation de la liste des options à naviguer flat_options = [] flat_options.append(([-1, -2], "all", "Tout sélectionner")) for sidx, saison in enumerate(selection["saisons"]): flat_options.append(([sidx, -1], "saison", f"Saison {saison['num']}")) for eidx in range(len(saison["episodes"])): flat_options.append(([sidx, eidx], "ep", f"Episode {eidx + 1}")) sel_idx = 0 offset = 0 max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 3 while True: stdscr.clear() stdscr.addstr(0, 0, "Sélectionnez les épisodes à télécharger (Espace pour cocher, Entrée pour valider) :") # Ajustement de l'offset if sel_idx < offset: offset = sel_idx if sel_idx >= offset + visible_lines: offset = sel_idx - visible_lines + 1 y = 2 for i in range(visible_lines): idx = offset + i if idx < len(flat_options): pos, item_type, label = flat_options[idx] indent = 2 if item_type == "saison": indent = 4 elif item_type == "ep": indent = 6 # Détermine l'état de la case à cocher mark = "[ ]" if item_type == "all": mark = "[X]" if all(s["selected"] or all(s["episodes"]) for s in selection["saisons"]) else "[ ]" elif item_type == "saison": saison = selection["saisons"][pos[0]] mark = "[X]" if saison["selected"] or all(saison["episodes"]) else "[ ]" elif item_type == "ep": mark = "[X]" if selection["saisons"][pos[0]]["episodes"][pos[1]] else "[ ]" line = f"{mark} {label}" if idx == sel_idx: stdscr.attron(curses.color_pair(1)) safe_addstr(stdscr, y, indent, line) stdscr.attroff(curses.color_pair(1)) else: safe_addstr(stdscr, y, indent, line) y += 1 # Indicateurs de défilement if offset > 0: stdscr.addstr(1, max_x - 8, "↑ plus") if offset + visible_lines < len(flat_options): stdscr.addstr(visible_lines + 2, max_x - 8, "↓ plus") stdscr.refresh() k = stdscr.getch() if k == curses.KEY_UP and sel_idx > 0: sel_idx -= 1 elif k == curses.KEY_DOWN and sel_idx < len(flat_options) - 1: sel_idx += 1 elif k == curses.KEY_PPAGE: # Page Up sel_idx = max(0, sel_idx - visible_lines) elif k == curses.KEY_NPAGE: # Page Down sel_idx = min(len(flat_options) - 1, sel_idx + visible_lines) elif k == ord(' '): # Espace pour toggle pos, item_type, _ = flat_options[sel_idx] if item_type == "all": new_val = not all(s["selected"] or all(s["episodes"]) for s in selection["saisons"]) for saison in selection["saisons"]: saison["selected"] = new_val saison["episodes"] = [new_val] * len(saison["episodes"]) elif item_type == "saison": saison = selection["saisons"][pos[0]] new_val = not (saison["selected"] or all(saison["episodes"])) saison["selected"] = new_val saison["episodes"] = [new_val] * len(saison["episodes"]) elif item_type == "ep": saison = selection["saisons"][pos[0]] saison["episodes"][pos[1]] = not saison["episodes"][pos[1]] elif k in [curses.KEY_ENTER, 10, 13]: cursor = flat_options[sel_idx][0] break elif k in [ord('q'), ord('Q')]: return download_queue = [] for saison in selection["saisons"]: if not saison["episode_data"]: continue if not any(saison["episodes"]) and not saison["selected"]: continue sel_src, urls_par_source = choisir_source_globale(stdscr, saison["episode_data"]) if sel_src is None or urls_par_source is None: continue for eidx, sel in enumerate(saison["episodes"]): if (sel or saison["selected"]) and eidx < len(urls_par_source): chosen_url = urls_par_source[eidx] saison_str = f"S{int(saison['num']):02d}" ep_str = f"E{eidx + 1:02d}" base_folder = Path(CONFIG["download_dir"]) / titre saison_folder = base_folder / f"Saison {int(saison['num']):02d}" saison_folder.mkdir(parents=True, exist_ok=True) filename = f"{titre.replace(' ', '_')} - {saison_str}{ep_str}.%(ext)s" download_queue.append( (chosen_url, saison_folder, filename, "1080p", eidx + 1, saison["anime_id"], titre, saison['num'])) if not download_queue: stdscr.clear() stdscr.addstr(0, 0, "Aucun épisode sélectionné.") stdscr.getch() return curses.endwin() episodes_dl = {} with concurrent.futures.ThreadPoolExecutor(max_workers=CONFIG.get("telechargements_simultanes", 2)) as executor: futures = [ executor.submit(telecharger_episode, url, folder, fname, qualite) for url, folder, fname, qualite, epnum, anime_id, titre, saison in download_queue ] for (url, folder, fname, qualite, epnum, anime_id, titre, saison), future in zip(download_queue, futures): success, url = future.result() if success: print(f"Téléchargement réussi: {url}") episodes_dl.setdefault((anime_id, titre, saison), []).append(epnum) else: print(f"Echec téléchargement: {url}") for (anime_id, titre, saison), eps in episodes_dl.items(): if eps: max_ep = max(eps) saison_str = f"S{int(saison):02d}" ep_str = f"E{max_ep:02d}" last_file = f"{titre.replace(' ', '_')} - {saison_str}{ep_str}.mp4" c = conn.cursor() c.execute("UPDATE animes SET dernier_episode = ? WHERE id = ?", (last_file, anime_id)) conn.commit() def handle_list(stdscr, conn): stdscr.clear() animes = get_all_animes(conn) if not animes: stdscr.addstr(0, 0, "Aucun anime dans la liste.") stdscr.getch() return titres = sorted(set(a[1] for a in animes)) sel = 0 # Index sélectionné offset = 0 # Premier élément affiché max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 4 # Lignes visibles (hors titre et instructions) while True: stdscr.clear() stdscr.addstr(0, 0, "Animes suivis :") # Ajustement de l'offset pour garder la sélection visible if sel < offset: offset = sel # Si sélection au-dessus de la vue if sel >= offset + visible_lines: offset = sel - visible_lines + 1 # Si sélection en-dessous de la vue # N'affiche que les éléments visibles selon l'offset for i in range(visible_lines): idx = offset + i if idx < len(titres): titre = titres[idx] saisons = [a for a in animes if a[1] == titre] nb_saisons = len(saisons) m = re.match(r"(https://anime-sama\.fr/catalogue/[^/]+/saison)\d+(/vostfr/)", saisons[0][2]) total_saisons = nb_saisons if m: prefix, suffix = m.group(1), m.group(2) saison_num = nb_saisons + 1 while True: saison_url = f"{prefix}{saison_num}{suffix}" resp = requests.get(saison_url) if resp.status_code != 200: break total_saisons += 1 saison_num += 1 nb_episodes = 0 for s in saisons: dernier = s[4] m = re.search(r'[Ee](\d{1,3})', dernier or "") if m: nb_episodes += int(m.group(1)) line = f"- {titre} ({nb_saisons}/{total_saisons} saisons, {nb_episodes} épisodes téléchargés)" if idx == sel: stdscr.attron(curses.color_pair(1)) safe_addstr(stdscr, i + 1, 2, line) stdscr.attroff(curses.color_pair(1)) else: safe_addstr(stdscr, i + 1, 2, line) # Indicateurs de défilement if offset > 0: stdscr.addstr(0, max_x - 8, "↑ plus") if offset + visible_lines < len(titres): stdscr.addstr(visible_lines + 1, max_x - 8, "↓ plus") stdscr.addstr(visible_lines + 2, 0, "Entrée: détail, q: retour") stdscr.refresh() k = stdscr.getch() if k in [curses.KEY_ENTER, 10, 13]: show_anime_detail(stdscr, animes, titres[sel]) elif k == curses.KEY_UP and sel > 0: sel -= 1 elif k == curses.KEY_DOWN and sel < len(titres) - 1: sel += 1 elif k == curses.KEY_PPAGE: # Page Up sel = max(0, sel - visible_lines) elif k == curses.KEY_NPAGE: # Page Down sel = min(len(titres) - 1, sel + visible_lines) elif k in [ord('q'), ord('Q')]: break def show_anime_detail(stdscr, animes, titre): saisons = sorted([a for a in animes if a[1] == titre], key=lambda x: x[3]) lines = [f"Contenu de {titre} :"] for saison in saisons: lines.append(f" Saison {saison[3]} :") try: episode_data = extract_episode_sources(saison[2]) for idx, _ in enumerate(episode_data): lines.append(f" Épisode {idx + 1}") except Exception: lines.append(" (Erreur récupération épisodes)") lines.append("") lines.append("Appuyez sur q pour revenir. Flèches pour défiler.") max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 2 offset = 0 # Position du premier élément affiché while True: stdscr.clear() # Affiche les lignes visibles selon l'offset for i in range(visible_lines): if offset + i < len(lines): safe_addstr(stdscr, i, 0, lines[offset + i]) # Indicateurs de scroll if offset > 0: stdscr.addstr(0, max_x - 8, "↑ plus") if offset + visible_lines < len(lines): stdscr.addstr(visible_lines - 1, max_x - 8, "↓ plus") stdscr.refresh() k = stdscr.getch() if k in [ord('q'), ord('Q')]: break elif k == curses.KEY_UP and offset > 0: offset -= 1 elif k == curses.KEY_DOWN and offset < len(lines) - visible_lines: offset += 1 # Page Up/Down pour défilement rapide elif k == curses.KEY_PPAGE: offset = max(0, offset - visible_lines) elif k == curses.KEY_NPAGE: offset = min(len(lines) - visible_lines, offset + visible_lines) def handle_delete(stdscr, conn): animes = get_all_animes(conn) if not animes: stdscr.clear() stdscr.addstr(0, 0, "Aucun anime à supprimer.") stdscr.addstr(2, 0, "Appuyez sur une touche pour revenir au menu.") stdscr.getch() return sel = 0 # Index sélectionné offset = 0 # Premier élément affiché max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 4 # Lignes visibles (hors titre et instructions) while True: stdscr.clear() stdscr.addstr(0, 0, "Sélectionnez un anime à supprimer :") # Ajustement de l'offset pour garder la sélection visible if sel < offset: offset = sel if sel >= offset + visible_lines: offset = sel - visible_lines + 1 # Affichage des animes visibles for i in range(visible_lines): idx = offset + i if idx < len(animes): _, titre, _, saison, dernier = animes[idx] line = f"{titre} (Saison {saison}, dernier: {format_dernier(dernier, saison)})" if idx == sel: stdscr.attron(curses.color_pair(1)) safe_addstr(stdscr, i + 2, 2, line) stdscr.attroff(curses.color_pair(1)) else: safe_addstr(stdscr, i + 2, 2, line) # Indicateurs de défilement if offset > 0: stdscr.addstr(1, max_x - 8, "↑ plus") if offset + visible_lines < len(animes): stdscr.addstr(visible_lines + 2, max_x - 8, "↓ plus") stdscr.refresh() k = stdscr.getch() if k == curses.KEY_UP and sel > 0: sel -= 1 elif k == curses.KEY_DOWN and sel < len(animes) - 1: sel += 1 elif k == curses.KEY_PPAGE: # Page Up sel = max(0, sel - visible_lines) elif k == curses.KEY_NPAGE: # Page Down sel = min(len(animes) - 1, sel + visible_lines) elif k in [curses.KEY_ENTER, 10, 13]: break elif k in [ord('q'), ord('Q')]: return anime_id = animes[sel][0] delete_anime(conn, anime_id) stdscr.clear() stdscr.addstr(0, 0, "Anime supprimé.") stdscr.addstr(2, 0, "Appuyez sur une touche pour revenir au menu.") stdscr.getch() def safe_addstr(stdscr, y, x, text): max_y, max_x = stdscr.getmaxyx() if 0 <= y < max_y: stdscr.addstr(y, x, text[:max_x - x - 1]) def get_input(stdscr, prompt): curses.curs_set(1) stdscr.clear() stdscr.addstr(0, 0, prompt) stdscr.refresh() win = curses.newwin(1, 200, 2, 0) curses.echo() value = win.getstr().decode('utf-8') curses.noecho() curses.curs_set(0) return value.strip() def popup_menu(stdscr, title, options, default_sel=0): popup_height = len(options) + 4 popup_width = max(len(title), max(len(opt) for opt in options) + 4, 20) + 4 popup = curses.newwin(popup_height, popup_width, (curses.LINES - popup_height) // 2, (curses.COLS - popup_width) // 2) popup.keypad(1) popup.box() sel = default_sel while True: popup.clear() popup.box() popup.addstr(1, 2, title) for idx, option in enumerate(options): if idx == sel: popup.attron(curses.color_pair(1)) popup.addstr(idx + 3, 2, option) popup.attroff(curses.color_pair(1)) else: popup.addstr(idx + 3, 2, option) popup.refresh() k = popup.getch() if k == curses.KEY_UP and sel > 0: sel -= 1 elif k == curses.KEY_DOWN and sel < len(options) - 1: sel += 1 elif k in [curses.KEY_ENTER, 10, 13]: return sel elif k in [27, ord('q')]: return None def handle_config(stdscr): options = [ f"Vitesse max: {CONFIG['vitesse_max']} KB/s (0 = illimité)", f"Téléchargements simultanés: {CONFIG['telechargements_simultanes']}", f"Dossier de téléchargement: {CONFIG['download_dir']}", f"Emplacement de la base de données: {CONFIG['db_path']}" ] sel = 0 max_y, max_x = stdscr.getmaxyx() visible_lines = max_y - 5 offset = 0 while True: stdscr.clear() stdscr.addstr(0, 0, "Configuration du téléchargement:") # Ajustement de l'offset if sel < offset: offset = sel if sel >= offset + visible_lines: offset = sel - visible_lines + 1 for i in range(min(visible_lines, len(options))): idx = offset + i if idx < len(options): if idx == sel: stdscr.attron(curses.color_pair(1)) stdscr.addstr(i + 2, 2, options[idx]) stdscr.attroff(curses.color_pair(1)) else: stdscr.addstr(i + 2, 2, options[idx]) # Indicateurs de défilement if offset > 0: stdscr.addstr(1, max_x - 8, "↑ plus") if offset + visible_lines < len(options): stdscr.addstr(visible_lines + 2, max_x - 8, "↓ plus") stdscr.addstr(visible_lines + 3, 0, "Entrée pour modifier, 'q' pour quitter") stdscr.refresh() k = stdscr.getch() if k == curses.KEY_UP and sel > 0: sel -= 1 elif k == curses.KEY_DOWN and sel < len(options) - 1: sel += 1 elif k in [curses.KEY_ENTER, 10, 13]: if sel == 0: speed_options = ["0 (Illimité)", "512", "1024", "2048", "4096"] speed_idx = popup_menu(stdscr, "Limite de vitesse (KB/s):", speed_options) if speed_idx is not None: CONFIG["vitesse_max"] = int(speed_options[speed_idx].split()[0]) options[0] = f"Vitesse max: {CONFIG['vitesse_max']} KB/s (0 = illimité)" elif sel == 1: sim_options = ["1", "2", "3", "4"] sim_idx = popup_menu(stdscr, "Téléchargements simultanés :", sim_options) if sim_idx is not None: CONFIG["telechargements_simultanes"] = int(sim_options[sim_idx]) options[1] = f"Téléchargements simultanés: {CONFIG['telechargements_simultanes']}" elif sel == 2: new_dir = get_input(stdscr, "Nouveau dossier de téléchargement : ") if new_dir: CONFIG["download_dir"] = new_dir options[2] = f"Dossier de téléchargement: {CONFIG['download_dir']}" elif sel == 3: new_db = get_input(stdscr, "Nouvel emplacement de la base de données : ") if new_db: CONFIG["db_path"] = new_db options[3] = f"Emplacement de la base de données: {CONFIG['db_path']}" save_config() elif k in [ord('q'), ord('Q')]: break def handle_add(stdscr, conn): titre = get_input(stdscr, "Titre de l'anime: ") url = get_input(stdscr, "URL de la page d'épisodes: ") saison = get_input(stdscr, "Numéro de saison (ex: 1): ") try: saison = int(saison) except Exception: saison = 1 detect_and_add_all_seasons(conn, titre, url, saison) stdscr.clear() stdscr.addstr(0, 0, f"Ajout de toutes les saisons détectées pour {titre}.") stdscr.addstr(2, 0, "Appuyez sur une touche pour revenir au menu.") stdscr.getch() def curses_menu(stdscr, conn): curses.curs_set(0) menu = [ "Ajouter un anime", "Lister les animes", "Télécharger plusieurs épisodes", "Supprimer un anime", "Configuration", "Quitter" ] cursor_y = 0 while True: stdscr.clear() stdscr.addstr(0, 2, "animecli - Gestion d'animes", curses.A_BOLD) for idx, item in enumerate(menu): x = 4 y = 2 + idx if y - 2 == cursor_y: stdscr.attron(curses.color_pair(1)) stdscr.addstr(y, x, item) stdscr.attroff(curses.color_pair(1)) else: stdscr.addstr(y, x, item) stdscr.refresh() k = stdscr.getch() if k == curses.KEY_UP and cursor_y > 0: cursor_y -= 1 elif k == curses.KEY_DOWN and cursor_y < len(menu) - 1: cursor_y += 1 elif k in [curses.KEY_ENTER, 10, 13]: if menu[cursor_y] == "Ajouter un anime": handle_add(stdscr, conn) elif menu[cursor_y] == "Lister les animes": handle_list(stdscr, conn) elif menu[cursor_y] == "Télécharger plusieurs épisodes": handle_multi_download(stdscr, conn) elif menu[cursor_y] == "Supprimer un anime": handle_delete(stdscr, conn) elif menu[cursor_y] == "Configuration": handle_config(stdscr) elif menu[cursor_y] == "Quitter": break def main(): load_config() conn = init_db() curses.wrapper(setup_and_run, conn) def setup_and_run(stdscr, conn): curses.start_color() curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN) curses_menu(stdscr, conn) if __name__ == "__main__": main()