973 lines
35 KiB
Python
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() |