This commit is contained in:
2025-10-22 15:54:36 +02:00
parent 92795cf9b3
commit 6db850c2ad
13 changed files with 330 additions and 338 deletions

View File

@@ -1,341 +1,316 @@
"""Command-line interface for the Aniworld anime download manager.
"""Command-line interface for the Aniworld anime download manager."""
This module provides an interactive CLI for searching, selecting, and
downloading anime series. It coordinates between the SerieScanner for
finding missing episodes and the provider loaders for downloading content.
"""
import logging
import os
import time
from typing import Any, Callable, Mapping, Optional, Sequence
from typing import Optional, Sequence
from rich.progress import Progress
from ..core.entities import SerieList
from ..core.entities.series import Serie
from ..core.providers.provider_factory import Loaders
from ..core.SerieScanner import SerieScanner
from src.core.entities.series import Serie
from src.core.SeriesApp import SeriesApp as CoreSeriesApp
# Configure logging
log_format = "%(asctime)s - %(levelname)s - %(funcName)s - %(message)s"
logging.basicConfig(level=logging.FATAL, format=log_format)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.ERROR)
console_handler.setFormatter(logging.Formatter(log_format))
for h in logging.root.handlers:
logging.root.removeHandler(h)
LOG_FORMAT = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
logging.getLogger('charset_normalizer').setLevel(logging.ERROR)
logging.getLogger().setLevel(logging.ERROR)
for h in logging.getLogger().handlers:
logging.getLogger().removeHandler(h)
logger = logging.getLogger(__name__)
class NoKeyFoundException(Exception):
"""Exception raised when an anime key cannot be found."""
pass
class SeriesCLI:
"""Thin wrapper around :class:`SeriesApp` providing an interactive CLI."""
class MatchNotFoundError(Exception):
"""Exception raised when an anime key cannot be found."""
pass
class SeriesApp:
"""Interactive CLI controller orchestrating scanning and downloads."""
_initialization_count = 0 # Track initialization calls
def __init__(self, directory_to_search: str) -> None:
SeriesApp._initialization_count += 1
# Only show initialization message for the first instance
if SeriesApp._initialization_count <= 1:
print("Please wait while initializing...")
self.progress: Optional[Progress] = None
print("Please wait while initializing...")
self.directory_to_search = directory_to_search
self.Loaders: Loaders = Loaders()
loader = self.Loaders.GetLoader(key="aniworld.to")
self.SerieScanner = SerieScanner(directory_to_search, loader)
self.series_app = CoreSeriesApp(directory_to_search)
self.List = SerieList(self.directory_to_search)
self.__init_list__()
self._progress: Optional[Progress] = None
self._overall_task_id: Optional[int] = None
self._series_task_id: Optional[int] = None
self._episode_task_id: Optional[int] = None
self._scan_task_id: Optional[int] = None
def __init_list__(self) -> None:
"""Initialize the series list by fetching missing episodes."""
self.series_list: Sequence[Serie] = self.List.GetMissingEpisode()
# ------------------------------------------------------------------
# Utility helpers
# ------------------------------------------------------------------
def _get_series_list(self) -> Sequence[Serie]:
"""Return the currently cached series with missing episodes."""
return self.series_app.get_series_list()
# ------------------------------------------------------------------
# Display & selection
# ------------------------------------------------------------------
def display_series(self) -> None:
"""Print all series with assigned numbers."""
print("\nCurrent result:")
for i, serie in enumerate(self.series_list, 1):
name = serie.name # Access the property on the instance
if name is None or str(name).strip() == "":
print(f"{i}. {serie.folder}")
else:
print(f"{i}. {serie.name}")
def search(self, words: str) -> list[dict[str, Any]]:
"""Search for anime series by name."""
loader = self.Loaders.GetLoader(key="aniworld.to")
return loader.search(words)
def get_user_selection(self) -> Optional[Sequence[Serie]]:
"""Handle user input for selecting series."""
self.display_series()
while True:
prompt = (
"\nSelect series by number (e.g. '1', '1,2' or 'all') "
"or type 'exit' to return: "
)
selection = input(prompt).strip().lower()
if selection == "exit":
return None
selected_series: list[Serie] = []
if selection == "all":
selected_series = list(self.series_list)
else:
try:
indexes = [
int(num) - 1 for num in selection.split(",")
]
selected_series = [
self.series_list[i]
for i in indexes
if 0 <= i < len(self.series_list)
]
except ValueError:
msg = (
"Invalid selection. "
"Going back to the result display."
)
print(msg)
self.display_series()
continue
if selected_series:
return selected_series
else:
msg = (
"No valid series selected. "
"Going back to the result display."
)
print(msg)
return None
def retry(
self,
func: Callable[..., Any],
max_retries: int = 3,
delay: float = 2,
*args: Any,
**kwargs: Any,
) -> bool:
"""Retry a function with exponential backoff.
Args:
func: Function to retry
max_retries: Maximum number of retry attempts
delay: Delay in seconds between retries
*args: Positional arguments for the function
**kwargs: Keyword arguments for the function
Returns:
True if function succeeded, False otherwise
"""
for attempt in range(1, max_retries + 1):
try:
func(*args, **kwargs)
return True
except Exception as e:
print(e)
time.sleep(delay)
return False
def download_series(self, series: Sequence[Serie]) -> None:
"""Simulate the downloading process with a progress bar."""
total_downloaded = 0
total_episodes = sum(
sum(len(ep) for ep in serie.episodeDict.values())
for serie in series
)
self.progress = Progress()
task1 = self.progress.add_task(
"[red]Processing...", total=total_episodes
)
task2 = self.progress.add_task("[green]...", total=0)
# Set total to 100 for percentage display
self.download_progress_task = self.progress.add_task(
"[Gray]...", total=100
)
self.progress.start()
for serie in series:
serie_episodes = sum(
len(ep) for ep in serie.episodeDict.values()
)
self.progress.update(
task2,
description=f"[green]{serie.folder}",
total=serie_episodes,
)
downloaded = 0
for season, episodes in serie.episodeDict.items():
for episode in episodes:
loader = self.Loaders.GetLoader(key="aniworld.to")
if loader.is_language(season, episode, serie.key):
self.retry(
loader.download,
3,
1,
self.directory_to_search,
serie.folder,
season,
episode,
serie.key,
"German Dub",
self.print_download_progress,
)
downloaded += 1
total_downloaded += 1
self.progress.update(task1, advance=1)
self.progress.update(task2, advance=1)
time.sleep(0.02)
self.progress.stop()
self.progress = None
def print_download_progress(self, d: Mapping[str, Any]) -> None:
"""Update download progress in the UI.
Args:
d: Dictionary containing download status information
"""
# Use self.progress and self.download_progress_task to display progress
if (
self.progress is None
or not hasattr(self, "download_progress_task")
):
series = self._get_series_list()
if not series:
print("\nNo series with missing episodes were found.")
return
if d["status"] == "downloading":
total = (
d.get("total_bytes")
or d.get("total_bytes_estimate")
print("\nCurrent result:")
for index, serie in enumerate(series, start=1):
name = (serie.name or "").strip()
label = name if name else serie.folder
print(f"{index}. {label}")
def get_user_selection(self) -> Optional[Sequence[Serie]]:
"""Prompt the user to select one or more series for download."""
series = list(self._get_series_list())
if not series:
print("No series available for download.")
return None
self.display_series()
prompt = (
"\nSelect series by number (e.g. '1', '1,2' or 'all') "
"or type 'exit' to return: "
)
selection = input(prompt).strip().lower()
if selection in {"exit", ""}:
return None
if selection == "all":
return series
try:
indexes = [
int(value.strip()) - 1
for value in selection.split(",")
]
except ValueError:
print("Invalid selection. Returning to main menu.")
return None
chosen = [
series[i]
for i in indexes
if 0 <= i < len(series)
]
if not chosen:
print("No valid series selected.")
return None
return chosen
# ------------------------------------------------------------------
# Download logic
# ------------------------------------------------------------------
def download_series(self, series: Sequence[Serie]) -> None:
"""Download all missing episodes for the provided series list."""
total_episodes = sum(
len(episodes)
for serie in series
for episodes in serie.episodeDict.values()
)
if total_episodes == 0:
print("Selected series do not contain missing episodes.")
return
self._progress = Progress()
with self._progress:
self._overall_task_id = self._progress.add_task(
"[red]Processing...", total=total_episodes
)
downloaded = d.get("downloaded_bytes", 0)
if total:
percent = downloaded / total * 100
desc = f"[gray]Download: {percent:.1f}%"
self.progress.update(
self.download_progress_task,
completed=percent,
description=desc
)
else:
mb_downloaded = downloaded / 1024 / 1024
desc = f"[gray]{mb_downloaded:.2f}MB geladen"
self.progress.update(
self.download_progress_task, description=desc
)
elif d["status"] == "finished":
desc = "[gray]Download abgeschlossen."
self.progress.update(
self.download_progress_task,
completed=100,
description=desc
self._series_task_id = self._progress.add_task(
"[green]Current series", total=1
)
self._episode_task_id = self._progress.add_task(
"[gray]Download", total=100
)
for serie in series:
serie_total = sum(len(eps) for eps in serie.episodeDict.values())
self._progress.update(
self._series_task_id,
total=max(serie_total, 1),
completed=0,
description=f"[green]{serie.folder}",
)
for season, episodes in serie.episodeDict.items():
for episode in episodes:
if not self.series_app.loader.is_language(
season, episode, serie.key
):
logger.info(
"Skipping %s S%02dE%02d because the desired language is unavailable",
serie.folder,
season,
episode,
)
continue
result = self.series_app.download(
serieFolder=serie.folder,
season=season,
episode=episode,
key=serie.key,
callback=self._update_download_progress,
)
if not result.success:
logger.error("Download failed: %s", result.message)
self._progress.advance(self._overall_task_id)
self._progress.advance(self._series_task_id)
self._progress.update(
self._episode_task_id,
completed=0,
description="[gray]Waiting...",
)
self._progress = None
self.series_app.refresh_series_list()
def _update_download_progress(self, percent: float) -> None:
"""Update the episode progress bar based on download progress."""
if not self._progress or self._episode_task_id is None:
return
description = f"[gray]Download: {percent:.1f}%"
self._progress.update(
self._episode_task_id,
completed=percent,
description=description,
)
# ------------------------------------------------------------------
# Rescan logic
# ------------------------------------------------------------------
def rescan(self) -> None:
"""Trigger a rescan of the anime directory using the core app."""
total_to_scan = self.series_app.SerieScanner.get_total_to_scan()
total_to_scan = max(total_to_scan, 1)
self._progress = Progress()
with self._progress:
self._scan_task_id = self._progress.add_task(
"[red]Scanning folders...",
total=total_to_scan,
)
result = self.series_app.ReScan(
callback=self._wrap_scan_callback(total_to_scan)
)
self._progress = None
self._scan_task_id = None
if result.success:
print(result.message)
else:
print(f"Scan failed: {result.message}")
def _wrap_scan_callback(self, total: int):
"""Create a callback that updates the scan progress bar."""
def _callback(folder: str, current: int) -> None:
if not self._progress or self._scan_task_id is None:
return
self._progress.update(
self._scan_task_id,
completed=min(current, total),
description=f"[green]{folder}",
)
return _callback
# ------------------------------------------------------------------
# Search & add logic
# ------------------------------------------------------------------
def search_mode(self) -> None:
"""Search for a series and allow user to select an option."""
search_string = input("Enter search string: ").strip()
results = self.search(search_string)
"""Search for a series and add it to the local list if chosen."""
query = input("Enter search string: ").strip()
if not query:
return
results = self.series_app.search(query)
if not results:
print("No results found. Returning to start.")
print("No results found. Returning to main menu.")
return
print("\nSearch results:")
for i, result in enumerate(results, 1):
print(f"{i}. {result.get('name')}")
for index, result in enumerate(results, start=1):
print(f"{index}. {result.get('name', 'Unknown')}")
while True:
prompt = (
"\nSelect an option by number or type '<enter>' to return: "
)
selection = input(prompt).strip().lower()
selection = input(
"\nSelect an option by number or press <enter> to cancel: "
).strip()
if selection == "":
return
if selection == "":
return
try:
index = int(selection) - 1
if 0 <= index < len(results):
chosen_name = results[index]
serie = Serie(
chosen_name["link"],
chosen_name["name"],
"aniworld.to",
chosen_name["link"],
{},
)
self.List.add(serie)
return
else:
print("Invalid selection. Try again.")
except ValueError:
print("Invalid input. Try again.")
try:
chosen_index = int(selection) - 1
except ValueError:
print("Invalid input. Returning to main menu.")
return
def updateFromReinit(self, folder: str, counter: int) -> None:
self.progress.update(self.task1, advance=1)
if not (0 <= chosen_index < len(results)):
print("Invalid selection. Returning to main menu.")
return
chosen = results[chosen_index]
serie = Serie(
chosen.get("link", ""),
chosen.get("name", "Unknown"),
"aniworld.to",
chosen.get("link", ""),
{},
)
self.series_app.List.add(serie)
self.series_app.refresh_series_list()
print(f"Added '{serie.name}' to the local catalogue.")
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
def run(self) -> None:
"""Main function to run the app."""
"""Run the interactive CLI loop."""
while True:
prompt = (
"\nChoose action ('s' for search, 'i' for init "
"or 'd' for download): "
)
action = input(prompt).strip().lower()
action = input(
"\nChoose action ('s' for search, 'i' for rescan, 'd' for download, 'q' to quit): "
).strip().lower()
if action == "s":
self.search_mode()
if action == "i":
elif action == "i":
print("\nRescanning series...\n")
self.progress = Progress()
task1 = self.progress.add_task(
"[red]items processed...", total=300
)
self.task1 = task1
self.progress.start()
self.SerieScanner.reinit()
self.SerieScanner.scan(self.updateFromReinit)
self.List = SerieList(self.directory_to_search)
self.__InitList__()
self.progress.stop()
self.progress = None
self.rescan()
elif action == "d":
selected_series = self.get_user_selection()
if selected_series:
self.download_series(selected_series)
elif action in {"q", "quit", "exit"}:
print("Goodbye!")
break
else:
print("Unknown command. Please choose 's', 'i', 'd', or 'q'.")
def configure_logging() -> None:
"""Set up a basic logging configuration for the CLI."""
logging.basicConfig(level=logging.INFO, format=LOG_FORMAT)
logging.getLogger("urllib3.connectionpool").setLevel(logging.ERROR)
logging.getLogger("charset_normalizer").setLevel(logging.ERROR)
def main() -> None:
"""Entry point for the CLI application."""
configure_logging()
default_dir = os.getenv("ANIME_DIRECTORY")
if not default_dir:
print(
"Environment variable ANIME_DIRECTORY is not set. Please configure it to the base anime directory."
)
return
app = SeriesCLI(default_dir)
app.run()
if __name__ == "__main__":
# Read the base directory from an environment variable
default_dir = (
"\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien"
)
directory_to_search = os.getenv("ANIME_DIRECTORY", default_dir)
app = SeriesApp(directory_to_search)
app.run()
main()