diff --git a/animecli.py b/animecli.py index cfb4134..ec23f89 100644 --- a/animecli.py +++ b/animecli.py @@ -23,6 +23,7 @@ DEFAULT_CONFIG = { } CONFIG = {} + def load_config(): config = configparser.ConfigParser() if CONFIG_PATH.exists(): @@ -37,6 +38,7 @@ def load_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()} @@ -44,24 +46,65 @@ def save_config(): with open(CONFIG_PATH, "w") as f: config.write(f) + def init_db(): - db_path = Path(CONFIG.get("db_path", str(DB_PATH))) - db_path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(db_path) - c = conn.cursor() - c.execute( - """ - CREATE TABLE IF NOT EXISTS animes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - titre TEXT UNIQUE, - url TEXT, - saison INTEGER, - dernier_episode TEXT + 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 + 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() @@ -70,19 +113,22 @@ def add_anime(conn, titre, url, saison): "INSERT INTO animes (titre, url, saison, dernier_episode) VALUES (?, ?, ?, ?)", (titre, url, saison, "") ) conn.commit() - return True, f"Anime '{titre}' ajouté." + return True, f"Anime '{titre}' saison {saison} ajouté." except sqlite3.IntegrityError: - return False, f"L'anime '{titre}' existe déjà." + 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) @@ -95,6 +141,7 @@ def extract_master_m3u8_url(page_url): except Exception: return None + def get_source_info(url): if url.endswith('.html'): m3u8_url = extract_master_m3u8_url(url) @@ -114,61 +161,114 @@ def get_source_info(url): best = max(formats, key=lambda f: f.get('height', 0) or 0) qualite = f"{best.get('height', '?')}p" return { + 'url': url, 'qualite': qualite, - 'url': url + 'ext': best.get('ext', 'mp4') } except Exception: return None + def choisir_source_globale(stdscr, episode_data): - # Regroupe les sources par index (site) + # 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])): # nombre de sources par épisode + 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) - # Utilise le premier URL de chaque site pour la qualité + + # Récupération des infos en mode batch (sans perturber l'interface) preview_urls = [urls[0] for urls in sources_by_site] - infos = [None] * len(preview_urls) - with concurrent.futures.ThreadPoolExecutor() as executor: - future_to_idx = {executor.submit(get_source_info, url): i for i, url in enumerate(preview_urls)} - done = set() - sel = 0 - while True: - stdscr.clear() - stdscr.addstr(0, 0, "Choisissez la source à utiliser pour tous les épisodes :") - for idx, url in enumerate(preview_urls): + 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] - domain = re.sub(r'^https?://(www\.)?', '', url).split('/')[0] - if info: - line = f"{idx+1}. {domain} ({info['qualite']})" - else: - line = f"{idx+1}. {domain} (chargement...)" + 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, 2 + idx, 2, line) + safe_addstr(stdscr, i + 1, 2, line) stdscr.attroff(curses.color_pair(1)) else: - safe_addstr(stdscr, 2 + idx, 2, line) - stdscr.refresh() - try: - for future in concurrent.futures.as_completed(future_to_idx, timeout=0.1): - idx = future_to_idx[future] - if idx not in done: - infos[idx] = future.result() - done.add(idx) - except concurrent.futures.TimeoutError: - pass - 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 in [curses.KEY_ENTER, 10, 13]: - return sel, sources_by_site[sel] + 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) @@ -185,17 +285,18 @@ def extract_episode_sources(url_page): for eps_block in all_eps: urls = re.findall(r"'(https?://[^']+)'", eps_block) if not episode_sources: - for _ in urls: - episode_sources.append([]) + 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" @@ -207,6 +308,7 @@ def format_dernier(dernier_url, saison): 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) @@ -228,6 +330,30 @@ def telecharger_episode(url, saison_folder, filename, qualite): 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: @@ -237,163 +363,555 @@ def handle_multi_download(stdscr, conn): stdscr.getch() return - sel = 0 + # 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 :") - for idx, (_, titre, _, saison, dernier) in enumerate(animes): - line = f"{titre} (Saison {saison}, dernier: {format_dernier(dernier, saison)})" - if idx == sel: - stdscr.attron(curses.color_pair(1)) - safe_addstr(stdscr, 2 + idx, 2, line) - stdscr.attroff(curses.color_pair(1)) + + # 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: - safe_addstr(stdscr, 2 + idx, 2, line) + 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, titre, url_page, saison, dernier = animes[sel] - try: - episode_data = extract_episode_sources(url_page) - except Exception as e: - stdscr.clear() - stdscr.addstr(0, 0, f"Erreur récupération des sources: {e}") - stdscr.getch() - return - if not episode_data or not episode_data[0][0]: - stdscr.clear() - stdscr.addstr(0, 0, "Aucune source trouvée.") - stdscr.getch() - 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() - # Choix de la source globale (site) - sel_src, urls_par_source = choisir_source_globale(stdscr, episode_data) - # Vérification des épisodes déjà présents - base_folder = Path(CONFIG["download_dir"]) / titre - saison_folder = base_folder / f"Saison {int(saison):02d}" - present = set() - if saison_folder.exists(): - for f in saison_folder.glob(f"{titre.replace(' ', '_')} - S{int(saison):02d}E*.mp4"): - m = re.search(r"S(\d{2})E(\d{2})", f.name) - if m: - ep_num = int(m.group(2)) - present.add(ep_num) +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]) - selected = [] - for idx in range(len(episode_data)): - selected.append((idx + 1) not in present) - cursor = 0 - scroll_offset = 0 +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() - max_y, max_x = stdscr.getmaxyx() - visible_lines = max_y - 3 - total_lines = len(episode_data) + 1 + stdscr.addstr(0, 0, "Configuration du téléchargement:") - if cursor < scroll_offset: - scroll_offset = cursor - elif cursor >= scroll_offset + visible_lines: - scroll_offset = cursor - visible_lines + 1 + # Ajustement de l'offset + if sel < offset: + offset = sel + if sel >= offset + visible_lines: + offset = sel - visible_lines + 1 - stdscr.addstr(0, 0, "Sélectionnez les épisodes à télécharger (Espace pour cocher, Entrée pour valider) :") + 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]) - tout_sel = all(selected) and len(selected) > 0 - mark = "[X]" if tout_sel else "[ ]" - y = 2 - if scroll_offset == 0: - if cursor == 0: - stdscr.attron(curses.color_pair(1)) - safe_addstr(stdscr, y, 2, f"{mark} Tout sélectionner") - stdscr.attroff(curses.color_pair(1)) - else: - safe_addstr(stdscr, y, 2, f"{mark} Tout sélectionner") - y += 1 - - for idx in range(len(episode_data)): - line_idx = idx + 1 - if line_idx < scroll_offset: - continue - if y - 2 >= visible_lines: - break - mark = "[X]" if selected[idx] else "[ ]" - extra = " (Déjà téléchargé)" if (idx + 1) in present else "" - line = f"{mark} Épisode {idx+1}{extra}" - if cursor == line_idx: - stdscr.attron(curses.color_pair(1)) - safe_addstr(stdscr, y, 2, line) - stdscr.attroff(curses.color_pair(1)) - else: - safe_addstr(stdscr, y, 2, line) - y += 1 + # 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 cursor > 0: - cursor -= 1 - elif k == curses.KEY_DOWN and cursor < total_lines - 1: - cursor += 1 - elif k == ord(' '): - if cursor == 0: - new_val = not all(selected) - for i in range(len(selected)): - selected[i] = new_val - else: - selected[cursor - 1] = not selected[cursor - 1] + 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 - to_download = [i for i, sel_ in enumerate(selected) if sel_] - if not to_download: - stdscr.clear() - stdscr.addstr(0, 0, "Aucun épisode sélectionné.") - stdscr.getch() - return - qualite = "1080p" - download_queue = [] - for idx in to_download: - if idx >= len(urls_par_source): - continue - chosen_url = urls_par_source[idx] - saison_str = f"S{int(saison):02d}" - ep_str = f"E{idx+1: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, qualite, idx+1)) +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() - 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 in download_queue - ] - for (url, folder, fname, qualite, epnum), future in zip(download_queue, futures): - success, url = future.result() - if success: - print(f"Téléchargement réussi: {url}") - episodes_dl.append(epnum) - else: - print(f"Echec téléchargement: {url}") - - # Mise à jour du dernier épisode téléchargé dans la DB (le plus grand numéro téléchargé) - if episodes_dl: - max_ep = max(episodes_dl) - 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 curses_menu(stdscr, conn): curses.curs_set(0) @@ -438,176 +956,18 @@ def curses_menu(stdscr, conn): elif menu[cursor_y] == "Quitter": break -def get_input(stdscr, prompt): - curses.curs_set(1) - stdscr.clear() - stdscr.addstr(0, 0, prompt) - stdscr.refresh() - win = curses.newwin(1, 60, 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 - while True: - stdscr.clear() - stdscr.addstr(0, 0, "Configuration du téléchargement:") - for idx, option in enumerate(options): - if idx == sel: - stdscr.attron(curses.color_pair(1)) - stdscr.addstr(idx + 2, 2, option) - stdscr.attroff(curses.color_pair(1)) - else: - stdscr.addstr(idx + 2, 2, option) - stdscr.addstr(len(options) + 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 - success, msg = add_anime(conn, titre, url, saison) - stdscr.clear() - stdscr.addstr(0, 0, msg) - stdscr.addstr(2, 0, "Appuyez sur une touche pour revenir au menu.") - stdscr.getch() - -def handle_list(stdscr, conn): - stdscr.clear() - animes = get_all_animes(conn) - if not animes: - stdscr.addstr(0, 0, "Aucun anime dans la liste.") - else: - stdscr.addstr(0, 0, "Animes suivis:") - for idx, (_, titre, _, saison, dernier) in enumerate(animes, start=1): - stdscr.addstr(idx, 2, f"- {titre} ({format_dernier(dernier, saison)})") - stdscr.addstr(len(animes) + 2, 0, "Appuyez sur une touche pour revenir au menu.") - stdscr.getch() - -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 - while True: - stdscr.clear() - stdscr.addstr(0, 0, "Sélectionnez un anime à supprimer :") - for idx, (_, titre, _, saison, dernier) in enumerate(animes): - line = f"{titre} (Saison {saison}, dernier: {format_dernier(dernier, saison)})" - if idx == sel: - stdscr.attron(curses.color_pair(1)) - safe_addstr(stdscr, 2 + idx, 2, line) - stdscr.attroff(curses.color_pair(1)) - else: - safe_addstr(stdscr, 2 + idx, 2, line) - 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 in [curses.KEY_ENTER, 10, 13]: - break - 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 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() \ No newline at end of file