Animecli/animecli.py
2025-06-05 11:48:52 +02:00

973 lines
35 KiB
Python

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()