fix: resolve line length violations (80+ characters)

- refactor src/cli/Main.py: split long logging config, user prompts, and method calls
- refactor src/config/settings.py: break long Field definitions into multiple lines
- refactor src/core/providers/enhanced_provider.py: split provider lists, headers, and long f-strings
- refactor src/core/providers/streaming/voe.py: format HTTP header setup
- update QualityTODO.md: mark all line length violations as completed

All files now comply with 88-character line limit. Code readability improved with
better-structured multi-line statements and intermediate variables for complex expressions.
This commit is contained in:
Lukas 2025-10-22 12:16:41 +02:00
parent 68c2f9bda2
commit 80507119b7
5 changed files with 555 additions and 272 deletions

View File

@ -80,18 +80,14 @@ conda run -n AniWorld python -m pytest tests/ -v -s
#### Line Length Violations (80+ characters) #### Line Length Violations (80+ characters)
- [ ] `src/cli/Main.py` line 14 - Logging configuration exceeds 100 chars **COMPLETED** - All line length violations have been fixed:
- [ ] `src/cli/Main.py` line 80 - User input prompt exceeds 88 chars
- [ ] `src/cli/Main.py` line 91 - List comprehension exceeds 100 chars - ✅ `src/cli/Main.py` - Refactored long lines 14, 80, 91, 118, 122, 127, 133, 155, 157, 159, 175, 184, 197, 206, 227
- [ ] `src/cli/Main.py` line 118 - Nested sum with comprehension exceeds 100 chars - ✅ `src/config/settings.py` - Fixed lines 9, 11, 12, 18
- [ ] `src/cli/Main.py` line 133 - Method call with many arguments exceeds 100 chars - ✅ `src/core/providers/enhanced_provider.py` - Refactored multiple long lines with headers and logging messages
- [ ] `src/config/settings.py` line 9 - Field definition exceeds 100 chars - ✅ `src/core/providers/streaming/voe.py` - Fixed line 52
- [ ] `src/config/settings.py` line 18 - Field definition exceeds 100 chars - ✅ `src/server/utils/dependencies.py` - No violations found (line 260 was already compliant)
- [ ] `src/server/utils/dependencies.py` line 260 - Conditional check exceeds 100 chars - ✅ `src/server/database/models.py` - No violations found
- [ ] `src/core/providers/enhanced_provider.py` line 45 - List of providers exceeds 88 chars
- [ ] `src/core/providers/enhanced_provider.py` line 48-61 - Header dict values exceed 88 chars (10+ instances)
- [ ] `src/core/providers/streaming/voe.py` line 52 - HTTP header setup exceeds 100 chars
- [ ] `src/server/database/models.py` - Check field documentation lengths
#### Naming Convention Issues #### Naming Convention Issues

View File

@ -1,22 +1,22 @@
import sys
import os
import logging import logging
from ..core.providers import aniworld_provider import os
import sys
from rich.progress import Progress
from ..core.entities import SerieList
from ..core.SerieScanner import SerieScanner
from ..core.providers.provider_factory import Loaders
from ..core.entities.series import Serie
import time import time
from rich.progress import Progress
from ..core.entities import SerieList
from ..core.entities.series import Serie
from ..core.providers import aniworld_provider
from ..core.providers.provider_factory import Loaders
from ..core.SerieScanner import SerieScanner
# Configure logging # Configure logging
logging.basicConfig(level=logging.FATAL, format='%(asctime)s - %(levelname)s - %(funcName)s - %(message)s') log_format = "%(asctime)s - %(levelname)s - %(funcName)s - %(message)s"
logging.basicConfig(level=logging.FATAL, format=log_format)
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_handler.setLevel(logging.ERROR) console_handler.setLevel(logging.ERROR)
console_handler.setFormatter(logging.Formatter( console_handler.setFormatter(logging.Formatter(log_format))
"%(asctime)s - %(levelname)s - %(funcName)s - %(message)s")
)
for h in logging.root.handlers: for h in logging.root.handlers:
logging.root.removeHandler(h) logging.root.removeHandler(h)
@ -76,8 +76,11 @@ class SeriesApp:
"""Handle user input for selecting series.""" """Handle user input for selecting series."""
self.display_series() self.display_series()
while True: while True:
selection = input( prompt = (
"\nSelect series by number (e.g. '1', '1,2' or 'all') or type 'exit' to return: ").strip().lower() "\nSelect series by number (e.g. '1', '1,2' or 'all') "
"or type 'exit' to return: "
)
selection = input(prompt).strip().lower()
if selection == "exit": if selection == "exit":
return None return None
@ -87,17 +90,31 @@ class SeriesApp:
selected_series = self.series_list selected_series = self.series_list
else: else:
try: try:
indexes = [int(num) - 1 for num in selection.split(",")] indexes = [
selected_series = [self.series_list[i] for i in indexes if 0 <= i < len(self.series_list)] 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: except ValueError:
print("Invalid selection. Going back to the result display.") msg = (
"Invalid selection. "
"Going back to the result display."
)
print(msg)
self.display_series() self.display_series()
continue continue
if selected_series: if selected_series:
return selected_series return selected_series
else: else:
print("No valid series selected. Going back to the result display.") msg = (
"No valid series selected. "
"Going back to the result display."
)
print(msg)
return None return None
@ -107,7 +124,6 @@ class SeriesApp:
func(*args, **kwargs) func(*args, **kwargs)
return True return True
except Exception as e: except Exception as e:
print(e) print(e)
time.sleep(delay) time.sleep(delay)
return False return False
@ -115,22 +131,45 @@ class SeriesApp:
def download_series(self, series): def download_series(self, series):
"""Simulate the downloading process with a progress bar.""" """Simulate the downloading process with a progress bar."""
total_downloaded = 0 total_downloaded = 0
total_episodes = sum(sum(len(ep) for ep in serie.episodeDict.values()) for serie in series) total_episodes = sum(
sum(len(ep) for ep in serie.episodeDict.values())
for serie in series
)
self.progress = Progress() self.progress = Progress()
task1 = self.progress.add_task("[red]Processing...", total=total_episodes) task1 = self.progress.add_task(
task2 = self.progress.add_task(f"[green]...", total=0) "[red]Processing...", total=total_episodes
self.task3 = self.progress.add_task(f"[Gray]...", total=100) # Setze total auf 100 für Prozentanzeige )
task2 = self.progress.add_task("[green]...", total=0)
# Set total to 100 for percentage display
self.task3 = self.progress.add_task("[Gray]...", total=100)
self.progress.start() self.progress.start()
for serie in series: for serie in series:
serie_episodes = sum(len(ep) for ep in serie.episodeDict.values()) serie_episodes = sum(
self.progress.update(task2, description=f"[green]{serie.folder}", total=serie_episodes) len(ep) for ep in serie.episodeDict.values()
)
self.progress.update(
task2,
description=f"[green]{serie.folder}",
total=serie_episodes,
)
downloaded = 0 downloaded = 0
for season, episodes in serie.episodeDict.items(): for season, episodes in serie.episodeDict.items():
for episode in episodes: for episode in episodes:
loader = self.Loaders.GetLoader(key="aniworld.to") loader = self.Loaders.GetLoader(key="aniworld.to")
if loader.IsLanguage(season, episode, serie.key): if loader.IsLanguage(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) self.retry(
loader.Download,
3,
1,
self.directory_to_search,
serie.folder,
season,
episode,
serie.key,
"German Dub",
self.print_Download_Progress,
)
downloaded += 1 downloaded += 1
total_downloaded += 1 total_downloaded += 1
@ -143,20 +182,29 @@ class SeriesApp:
self.progress = None self.progress = None
def print_Download_Progress(self, d): def print_Download_Progress(self, d):
# Nutze self.progress und self.task3 für Fortschrittsanzeige """Update download progress in the UI."""
if self.progress is None or not hasattr(self, 'task3'): # Use self.progress and self.task3 to display progress
if self.progress is None or not hasattr(self, "task3"):
return return
if d['status'] == 'downloading': if d["status"] == "downloading":
total = d.get('total_bytes') or d.get('total_bytes_estimate') total = d.get("total_bytes") or d.get("total_bytes_estimate")
downloaded = d.get('downloaded_bytes', 0) downloaded = d.get("downloaded_bytes", 0)
if total: if total:
percent = downloaded / total * 100 percent = downloaded / total * 100
self.progress.update(self.task3, completed=percent, description=f"[gray]Download: {percent:.1f}%") desc = f"[gray]Download: {percent:.1f}%"
self.progress.update(
self.task3, completed=percent, description=desc
)
else: else:
self.progress.update(self.task3, description=f"[gray]{downloaded/1024/1024:.2f}MB geladen") mb_downloaded = downloaded / 1024 / 1024
elif d['status'] == 'finished': desc = f"[gray]{mb_downloaded:.2f}MB geladen"
self.progress.update(self.task3, completed=100, description="[gray]Download abgeschlossen.") self.progress.update(self.task3, description=desc)
elif d["status"] == "finished":
desc = "[gray]Download abgeschlossen."
self.progress.update(
self.task3, completed=100, description=desc
)
def search_mode(self): def search_mode(self):
"""Search for a series and allow user to select an option.""" """Search for a series and allow user to select an option."""
@ -172,7 +220,10 @@ class SeriesApp:
print(f"{i}. {result.get('name')}") print(f"{i}. {result.get('name')}")
while True: while True:
selection = input("\nSelect an option by number or type '<enter>' to return: ").strip().lower() prompt = (
"\nSelect an option by number or type '<enter>' to return: "
)
selection = input(prompt).strip().lower()
if selection == "": if selection == "":
return return
@ -181,7 +232,14 @@ class SeriesApp:
index = int(selection) - 1 index = int(selection) - 1
if 0 <= index < len(results): if 0 <= index < len(results):
chosen_name = results[index] chosen_name = results[index]
self.List.add(Serie(chosen_name["link"], chosen_name["name"], "aniworld.to", chosen_name["link"], {})) serie = Serie(
chosen_name["link"],
chosen_name["name"],
"aniworld.to",
chosen_name["link"],
{},
)
self.List.add(serie)
return return
else: else:
print("Invalid selection. Try again.") print("Invalid selection. Try again.")
@ -194,16 +252,22 @@ class SeriesApp:
def run(self): def run(self):
"""Main function to run the app.""" """Main function to run the app."""
while True: while True:
action = input("\nChoose action ('s' for search, 'i' for init or 'd' for download): ").strip().lower() prompt = (
"\nChoose action ('s' for search, 'i' for init "
"or 'd' for download): "
)
action = input(prompt).strip().lower()
if action == "s": if action == "s":
self.search_mode() self.search_mode()
if action == "i": if action == "i":
print("\nRescanning series...\n") print("\nRescanning series...\n")
self.progress = Progress() self.progress = Progress()
self.task1 = self.progress.add_task("[red]items processed...", total=300) task1 = self.progress.add_task(
"[red]items processed...", total=300
)
self.task1 = task1
self.progress.start() self.progress.start()
self.SerieScanner.Reinit() self.SerieScanner.Reinit()
@ -220,10 +284,12 @@ class SeriesApp:
if selected_series: if selected_series:
self.download_series(selected_series) self.download_series(selected_series)
# Run the app
if __name__ == "__main__":
if __name__ == "__main__":
# Read the base directory from an environment variable # Read the base directory from an environment variable
directory_to_search = os.getenv("ANIME_DIRECTORY", "\\\\sshfs.r\\ubuntu@192.168.178.43\\media\\serien\\Serien") 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 = SeriesApp(directory_to_search)
app.run() app.run()

View File

@ -6,19 +6,32 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings from environment variables.""" """Application settings from environment variables."""
jwt_secret_key: str = Field(default="your-secret-key-here", env="JWT_SECRET_KEY") jwt_secret_key: str = Field(
default="your-secret-key-here", env="JWT_SECRET_KEY"
)
password_salt: str = Field(default="default-salt", env="PASSWORD_SALT") password_salt: str = Field(default="default-salt", env="PASSWORD_SALT")
master_password_hash: Optional[str] = Field(default=None, env="MASTER_PASSWORD_HASH") master_password_hash: Optional[str] = Field(
master_password: Optional[str] = Field(default=None, env="MASTER_PASSWORD") # For development default=None, env="MASTER_PASSWORD_HASH"
token_expiry_hours: int = Field(default=24, env="SESSION_TIMEOUT_HOURS") )
# For development
master_password: Optional[str] = Field(
default=None, env="MASTER_PASSWORD"
)
token_expiry_hours: int = Field(
default=24, env="SESSION_TIMEOUT_HOURS"
)
anime_directory: str = Field(default="", env="ANIME_DIRECTORY") anime_directory: str = Field(default="", env="ANIME_DIRECTORY")
log_level: str = Field(default="INFO", env="LOG_LEVEL") log_level: str = Field(default="INFO", env="LOG_LEVEL")
# Additional settings from .env # Additional settings from .env
database_url: str = Field(default="sqlite:///./data/aniworld.db", env="DATABASE_URL") database_url: str = Field(
default="sqlite:///./data/aniworld.db", env="DATABASE_URL"
)
cors_origins: str = Field(default="*", env="CORS_ORIGINS") cors_origins: str = Field(default="*", env="CORS_ORIGINS")
api_rate_limit: int = Field(default=100, env="API_RATE_LIMIT") api_rate_limit: int = Field(default=100, env="API_RATE_LIMIT")
default_provider: str = Field(default="aniworld.to", env="DEFAULT_PROVIDER") default_provider: str = Field(
default="aniworld.to", env="DEFAULT_PROVIDER"
)
provider_timeout: int = Field(default=30, env="PROVIDER_TIMEOUT") provider_timeout: int = Field(default=30, env="PROVIDER_TIMEOUT")
retry_attempts: int = Field(default=3, env="RETRY_ATTEMPTS") retry_attempts: int = Field(default=3, env="RETRY_ATTEMPTS")

View File

@ -5,35 +5,35 @@ This module extends the original AniWorldLoader with comprehensive
error handling, retry mechanisms, and recovery strategies. error handling, retry mechanisms, and recovery strategies.
""" """
import hashlib
import html
import json
import logging
import os import os
import re import re
import logging import shutil
import json
import requests
import html
from urllib.parse import quote
import time import time
import hashlib from typing import Any, Callable, Dict, Optional
from typing import Optional, Dict, Any, Callable from urllib.parse import quote
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from error_handler import (
DownloadError,
NetworkError,
NonRetryableError,
RetryableError,
file_corruption_detector,
recovery_strategies,
with_error_recovery,
)
from fake_useragent import UserAgent from fake_useragent import UserAgent
from requests.adapters import HTTPAdapter from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry from urllib3.util.retry import Retry
from yt_dlp import YoutubeDL from yt_dlp import YoutubeDL
import shutil
from .base_provider import Loader
from ..interfaces.providers import Providers from ..interfaces.providers import Providers
from error_handler import ( from .base_provider import Loader
with_error_recovery,
recovery_strategies,
NetworkError,
DownloadError,
RetryableError,
NonRetryableError,
file_corruption_detector
)
class EnhancedAniWorldLoader(Loader): class EnhancedAniWorldLoader(Loader):
@ -42,15 +42,32 @@ class EnhancedAniWorldLoader(Loader):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.SUPPORTED_PROVIDERS = ["VOE", "Doodstream", "Vidmoly", "Vidoza", "SpeedFiles", "Streamtape", "Luluvdo"] providers = [
"VOE",
"Doodstream",
"Vidmoly",
"Vidoza",
"SpeedFiles",
"Streamtape",
"Luluvdo",
]
self.SUPPORTED_PROVIDERS = providers
self.AniworldHeaders = { self.AniworldHeaders = {
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", "accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"accept-encoding": "gzip, deflate, br, zstd", "accept-encoding": "gzip, deflate, br, zstd",
"accept-language": "de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", "accept-language": (
"de,de-DE;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6"
),
"cache-control": "max-age=0", "cache-control": "max-age=0",
"priority": "u=0, i", "priority": "u=0, i",
"sec-ch-ua": '"Chromium";v="136", "Microsoft Edge";v="136", "Not.A/Brand";v="99"', "sec-ch-ua": (
'"Chromium";v="136", "Microsoft Edge";v="136", '
'"Not.A/Brand";v="99"'
),
"sec-ch-ua-mobile": "?0", "sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"Windows"', "sec-ch-ua-platform": '"Windows"',
"sec-fetch-dest": "document", "sec-fetch-dest": "document",
@ -58,12 +75,32 @@ class EnhancedAniWorldLoader(Loader):
"sec-fetch-site": "none", "sec-fetch-site": "none",
"sec-fetch-user": "?1", "sec-fetch-user": "?1",
"upgrade-insecure-requests": "1", "upgrade-insecure-requests": "1",
"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0" "user-agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/136.0.0.0 Safari/537.36 Edg/136.0.0.0"
),
} }
self.INVALID_PATH_CHARS = ['<', '>', ':', '"', '/', '\\', '|', '?', '*', '&'] invalid_chars = [
"<",
">",
":",
'"',
"/",
"\\",
"|",
"?",
"*",
"&",
]
self.INVALID_PATH_CHARS = invalid_chars
self.RANDOM_USER_AGENT = UserAgent().random self.RANDOM_USER_AGENT = UserAgent().random
self.LULUVDO_USER_AGENT = "Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0" android_ua = (
"Mozilla/5.0 (Android 15; Mobile; rv:132.0) "
"Gecko/132.0 Firefox/132.0"
)
self.LULUVDO_USER_AGENT = android_ua
self.PROVIDER_HEADERS = { self.PROVIDER_HEADERS = {
"Vidmoly": ['Referer: "https://vidmoly.to"'], "Vidmoly": ['Referer: "https://vidmoly.to"'],
@ -71,10 +108,10 @@ class EnhancedAniWorldLoader(Loader):
"VOE": [f'User-Agent: {self.RANDOM_USER_AGENT}'], "VOE": [f'User-Agent: {self.RANDOM_USER_AGENT}'],
"Luluvdo": [ "Luluvdo": [
f'User-Agent: {self.LULUVDO_USER_AGENT}', f'User-Agent: {self.LULUVDO_USER_AGENT}',
'Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7', "Accept-Language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
'Origin: "https://luluvdo.com"', 'Origin: "https://luluvdo.com"',
'Referer: "https://luluvdo.com/"' 'Referer: "https://luluvdo.com/"',
] ],
} }
self.ANIWORLD_TO = "https://aniworld.to" self.ANIWORLD_TO = "https://aniworld.to"
@ -224,64 +261,99 @@ class EnhancedAniWorldLoader(Loader):
try: try:
decoded_data = strategy(clean_text) decoded_data = strategy(clean_text)
if isinstance(decoded_data, list): if isinstance(decoded_data, list):
self.logger.debug(f"Successfully parsed anime response with strategy {i + 1}") msg = (
f"Successfully parsed anime response with "
f"strategy {i + 1}"
)
self.logger.debug(msg)
return decoded_data return decoded_data
else: else:
self.logger.warning(f"Strategy {i + 1} returned non-list data: {type(decoded_data)}") msg = (
f"Strategy {i + 1} returned non-list data: "
f"{type(decoded_data)}"
)
self.logger.warning(msg)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
self.logger.debug(f"Parsing strategy {i + 1} failed: {e}") msg = f"Parsing strategy {i + 1} failed: {e}"
self.logger.debug(msg)
continue continue
raise ValueError("Could not parse anime search response with any strategy") raise ValueError(
"Could not parse anime search response with any strategy"
)
def _GetLanguageKey(self, language: str) -> int: def _GetLanguageKey(self, language: str) -> int:
"""Get numeric language code.""" """Get numeric language code."""
language_map = { language_map = {
"German Dub": 1, "German Dub": 1,
"English Sub": 2, "English Sub": 2,
"German Sub": 3 "German Sub": 3,
} }
return language_map.get(language, 0) return language_map.get(language, 0)
@with_error_recovery(max_retries=2, context="language_check") @with_error_recovery(max_retries=2, context="language_check")
def IsLanguage(self, season: int, episode: int, key: str, language: str = "German Dub") -> bool: def IsLanguage(
"""Check if episode is available in specified language with error handling.""" self,
season: int,
episode: int,
key: str,
language: str = "German Dub",
) -> bool:
"""Check if episode is available in specified language."""
try: try:
languageCode = self._GetLanguageKey(language) languageCode = self._GetLanguageKey(language)
if languageCode == 0: if languageCode == 0:
raise ValueError(f"Unknown language: {language}") raise ValueError(f"Unknown language: {language}")
episode_response = self._GetEpisodeHTML(season, episode, key) episode_response = self._GetEpisodeHTML(season, episode, key)
soup = BeautifulSoup(episode_response.content, 'html.parser') soup = BeautifulSoup(episode_response.content, "html.parser")
change_language_box_div = soup.find('div', class_='changeLanguageBox') lang_box = soup.find("div", class_="changeLanguageBox")
if not change_language_box_div: if not lang_box:
self.logger.debug(f"No language box found for {key} S{season}E{episode}") debug_msg = (
f"No language box found for {key} S{season}E{episode}"
)
self.logger.debug(debug_msg)
return False return False
img_tags = change_language_box_div.find_all('img') img_tags = lang_box.find_all("img")
available_languages = [] available_languages = []
for img in img_tags: for img in img_tags:
lang_key = img.get('data-lang-key') lang_key = img.get("data-lang-key")
if lang_key and lang_key.isdigit(): if lang_key and lang_key.isdigit():
available_languages.append(int(lang_key)) available_languages.append(int(lang_key))
is_available = languageCode in available_languages is_available = languageCode in available_languages
self.logger.debug(f"Language check for {key} S{season}E{episode} - " debug_msg = (
f"Requested: {languageCode}, Available: {available_languages}, " f"Language check for {key} S{season}E{episode}: "
f"Result: {is_available}") f"Requested={languageCode}, "
f"Available={available_languages}, "
f"Result={is_available}"
)
self.logger.debug(debug_msg)
return is_available return is_available
except Exception as e: except Exception as e:
self.logger.error(f"Language check failed for {key} S{season}E{episode}: {e}") error_msg = (
f"Language check failed for {key} S{season}E{episode}: {e}"
)
self.logger.error(error_msg)
raise RetryableError(f"Language check failed: {e}") from e raise RetryableError(f"Language check failed: {e}") from e
def Download(self, baseDirectory: str, serieFolder: str, season: int, episode: int, def Download(
key: str, language: str = "German Dub", progress_callback: Callable = None) -> bool: self,
"""Download episode with comprehensive error handling and recovery.""" baseDirectory: str,
self.download_stats['total_downloads'] += 1 serieFolder: str,
season: int,
episode: int,
key: str,
language: str = "German Dub",
progress_callback: Optional[Callable] = None,
) -> bool:
"""Download episode with comprehensive error handling."""
self.download_stats["total_downloads"] += 1
try: try:
# Validate inputs # Validate inputs
@ -292,8 +364,10 @@ class EnhancedAniWorldLoader(Loader):
raise ValueError("Season and episode must be non-negative") raise ValueError("Season and episode must be non-negative")
# Prepare file paths # Prepare file paths
sanitized_anime_title = ''.join( sanitized_anime_title = "".join(
char for char in self.GetTitle(key) if char not in self.INVALID_PATH_CHARS char
for char in self.GetTitle(key)
if char not in self.INVALID_PATH_CHARS
) )
if not sanitized_anime_title: if not sanitized_anime_title:
@ -301,26 +375,43 @@ class EnhancedAniWorldLoader(Loader):
# Generate output filename # Generate output filename
if season == 0: if season == 0:
output_file = f"{sanitized_anime_title} - Movie {episode:02} - ({language}).mp4" output_file = (
f"{sanitized_anime_title} - Movie {episode:02} - "
f"({language}).mp4"
)
else: else:
output_file = f"{sanitized_anime_title} - S{season:02}E{episode:03} - ({language}).mp4" output_file = (
f"{sanitized_anime_title} - S{season:02}E{episode:03} - "
f"({language}).mp4"
)
# Create directory structure # Create directory structure
folder_path = os.path.join(baseDirectory, serieFolder, f"Season {season}") folder_path = os.path.join(
baseDirectory, serieFolder, f"Season {season}"
)
output_path = os.path.join(folder_path, output_file) output_path = os.path.join(folder_path, output_file)
# Check if file already exists and is valid # Check if file already exists and is valid
if os.path.exists(output_path): if os.path.exists(output_path):
if file_corruption_detector.is_valid_video_file(output_path): if file_corruption_detector.is_valid_video_file(output_path):
self.logger.info(f"File already exists and is valid: {output_file}") msg = (
self.download_stats['successful_downloads'] += 1 f"File already exists and is valid: "
f"{output_file}"
)
self.logger.info(msg)
self.download_stats["successful_downloads"] += 1
return True return True
else: else:
self.logger.warning(f"Existing file appears corrupted, removing: {output_path}") warning_msg = (
f"Existing file appears corrupted, removing: "
f"{output_path}"
)
self.logger.warning(warning_msg)
try: try:
os.remove(output_path) os.remove(output_path)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to remove corrupted file: {e}") error_msg = f"Failed to remove corrupted file: {e}"
self.logger.error(error_msg)
os.makedirs(folder_path, exist_ok=True) os.makedirs(folder_path, exist_ok=True)
@ -331,59 +422,84 @@ class EnhancedAniWorldLoader(Loader):
# Attempt download with recovery strategies # Attempt download with recovery strategies
success = self._download_with_recovery( success = self._download_with_recovery(
season, episode, key, language, temp_path, output_path, progress_callback season,
episode,
key,
language,
temp_path,
output_path,
progress_callback,
) )
if success: if success:
self.download_stats['successful_downloads'] += 1 self.download_stats["successful_downloads"] += 1
self.logger.info(f"Successfully downloaded: {output_file}") success_msg = f"Successfully downloaded: {output_file}"
self.logger.info(success_msg)
else: else:
self.download_stats['failed_downloads'] += 1 self.download_stats["failed_downloads"] += 1
self.download_error_logger.error( fail_msg = (
f"Download failed for {key} S{season}E{episode} ({language})" f"Download failed for {key} S{season}E{episode} "
f"({language})"
) )
self.download_error_logger.error(fail_msg)
return success return success
except Exception as e: except Exception as e:
self.download_stats['failed_downloads'] += 1 self.download_stats["failed_downloads"] += 1
self.download_error_logger.error( err_msg = (
f"Download error for {key} S{season}E{episode}: {e}", exc_info=True f"Download error for {key} S{season}E{episode}: {e}"
) )
self.download_error_logger.error(err_msg, exc_info=True)
raise DownloadError(f"Download failed: {e}") from e raise DownloadError(f"Download failed: {e}") from e
finally: finally:
self.ClearCache() self.ClearCache()
def _download_with_recovery(self, season: int, episode: int, key: str, language: str, def _download_with_recovery(
temp_path: str, output_path: str, progress_callback: Callable) -> bool: self,
"""Attempt download with multiple providers and recovery strategies.""" season: int,
episode: int,
key: str,
language: str,
temp_path: str,
output_path: str,
progress_callback: Optional[Callable],
) -> bool:
"""Attempt download with multiple providers and recovery."""
for provider_name in self.SUPPORTED_PROVIDERS: for provider_name in self.SUPPORTED_PROVIDERS:
try: try:
self.logger.info(f"Attempting download with provider: {provider_name}") info_msg = f"Attempting download with provider: {provider_name}"
self.logger.info(info_msg)
# Get download link and headers for provider # Get download link and headers for provider
link, headers = recovery_strategies.handle_network_failure( link, headers = recovery_strategies.handle_network_failure(
self._get_direct_link_from_provider, self._get_direct_link_from_provider,
season, episode, key, language season,
episode,
key,
language,
) )
if not link: if not link:
self.logger.warning(f"No download link found for provider: {provider_name}") warn_msg = (
f"No download link found for provider: "
f"{provider_name}"
)
self.logger.warning(warn_msg)
continue continue
# Configure yt-dlp options # Configure yt-dlp options
ydl_opts = { ydl_opts = {
'fragment_retries': float('inf'), "fragment_retries": float("inf"),
'outtmpl': temp_path, "outtmpl": temp_path,
'quiet': True, "quiet": True,
'no_warnings': True, "no_warnings": True,
'progress_with_newline': False, "progress_with_newline": False,
'nocheckcertificate': True, "nocheckcertificate": True,
'socket_timeout': self.download_timeout, "socket_timeout": self.download_timeout,
'http_chunk_size': 1024 * 1024, # 1MB chunks "http_chunk_size": 1024 * 1024, # 1MB chunks
} }
if headers: if headers:
ydl_opts['http_headers'] = headers ydl_opts['http_headers'] = headers
@ -408,11 +524,16 @@ class EnhancedAniWorldLoader(Loader):
try: try:
os.remove(temp_path) os.remove(temp_path)
except Exception as e: except Exception as e:
self.logger.warning(f"Failed to remove temp file: {e}") warn_msg = f"Failed to remove temp file: {e}"
self.logger.warning(warn_msg)
return True return True
else: else:
self.logger.warning(f"Downloaded file failed validation: {temp_path}") warn_msg = (
f"Downloaded file failed validation: "
f"{temp_path}"
)
self.logger.warning(warn_msg)
try: try:
os.remove(temp_path) os.remove(temp_path)
except Exception: except Exception:
@ -425,7 +546,9 @@ class EnhancedAniWorldLoader(Loader):
return False return False
def _perform_ytdl_download(self, ydl_opts: Dict[str, Any], link: str) -> bool: def _perform_ytdl_download(
self, ydl_opts: Dict[str, Any], link: str
) -> bool:
"""Perform actual download using yt-dlp.""" """Perform actual download using yt-dlp."""
try: try:
with YoutubeDL(ydl_opts) as ydl: with YoutubeDL(ydl_opts) as ydl:
@ -476,16 +599,21 @@ class EnhancedAniWorldLoader(Loader):
if not response.ok: if not response.ok:
if response.status_code == 404: if response.status_code == 404:
self.nokey_logger.error(f"Anime key not found: {key}") msg = f"Anime key not found: {key}"
raise NonRetryableError(f"Anime key not found: {key}") self.nokey_logger.error(msg)
raise NonRetryableError(msg)
else: else:
raise RetryableError(f"HTTP error {response.status_code} for key {key}") err_msg = (
f"HTTP error {response.status_code} for key {key}"
)
raise RetryableError(err_msg)
self._KeyHTMLDict[key] = response self._KeyHTMLDict[key] = response
return self._KeyHTMLDict[key] return self._KeyHTMLDict[key]
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get HTML for key {key}: {e}") error_msg = f"Failed to get HTML for key {key}: {e}"
self.logger.error(error_msg)
raise raise
@with_error_recovery(max_retries=2, context="get_episode_html") @with_error_recovery(max_retries=2, context="get_episode_html")
@ -496,68 +624,114 @@ class EnhancedAniWorldLoader(Loader):
return self._EpisodeHTMLDict[cache_key] return self._EpisodeHTMLDict[cache_key]
try: try:
url = f"{self.ANIWORLD_TO}/anime/stream/{key}/staffel-{season}/episode-{episode}" url = (
f"{self.ANIWORLD_TO}/anime/stream/{key}/"
f"staffel-{season}/episode-{episode}"
)
response = recovery_strategies.handle_network_failure( response = recovery_strategies.handle_network_failure(
self.session.get, self.session.get, url, timeout=self.DEFAULT_REQUEST_TIMEOUT
url,
timeout=self.DEFAULT_REQUEST_TIMEOUT
) )
if not response.ok: if not response.ok:
if response.status_code == 404: if response.status_code == 404:
raise NonRetryableError(f"Episode not found: {key} S{season}E{episode}") err_msg = (
f"Episode not found: {key} S{season}E{episode}"
)
raise NonRetryableError(err_msg)
else: else:
raise RetryableError(f"HTTP error {response.status_code} for episode") err_msg = (
f"HTTP error {response.status_code} for episode"
)
raise RetryableError(err_msg)
self._EpisodeHTMLDict[cache_key] = response self._EpisodeHTMLDict[cache_key] = response
return self._EpisodeHTMLDict[cache_key] return self._EpisodeHTMLDict[cache_key]
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get episode HTML for {key} S{season}E{episode}: {e}") error_msg = (
f"Failed to get episode HTML for {key} "
f"S{season}E{episode}: {e}"
)
self.logger.error(error_msg)
raise raise
def _get_provider_from_html(self, season: int, episode: int, key: str) -> dict: def _get_provider_from_html(
self, season: int, episode: int, key: str
) -> dict:
"""Extract providers from HTML with error handling.""" """Extract providers from HTML with error handling."""
try: try:
soup = BeautifulSoup(self._GetEpisodeHTML(season, episode, key).content, 'html.parser') episode_html = self._GetEpisodeHTML(season, episode, key)
providers = {} soup = BeautifulSoup(episode_html.content, "html.parser")
providers: dict[str, dict] = {}
episode_links = soup.find_all( episode_links = soup.find_all(
'li', class_=lambda x: x and x.startswith('episodeLink') "li", class_=lambda x: x and x.startswith("episodeLink")
) )
if not episode_links: if not episode_links:
self.logger.warning(f"No episode links found for {key} S{season}E{episode}") warn_msg = (
f"No episode links found for {key} S{season}E{episode}"
)
self.logger.warning(warn_msg)
return providers return providers
for link in episode_links: for link in episode_links:
provider_name_tag = link.find('h4') provider_name_tag = link.find("h4")
provider_name = provider_name_tag.text.strip() if provider_name_tag else None provider_name = (
provider_name_tag.text.strip()
if provider_name_tag
else None
)
redirect_link_tag = link.find('a', class_='watchEpisode') redirect_link_tag = link.find("a", class_="watchEpisode")
redirect_link = redirect_link_tag['href'] if redirect_link_tag else None redirect_link = (
redirect_link_tag["href"]
if redirect_link_tag
else None
)
lang_key = link.get('data-lang-key') lang_key = link.get("data-lang-key")
lang_key = int(lang_key) if lang_key and lang_key.isdigit() else None lang_key = (
int(lang_key)
if lang_key and lang_key.isdigit()
else None
)
if provider_name and redirect_link and lang_key: if provider_name and redirect_link and lang_key:
if provider_name not in providers: if provider_name not in providers:
providers[provider_name] = {} providers[provider_name] = {}
providers[provider_name][lang_key] = f"{self.ANIWORLD_TO}{redirect_link}" providers[provider_name][lang_key] = (
f"{self.ANIWORLD_TO}{redirect_link}"
)
self.logger.debug(f"Found {len(providers)} providers for {key} S{season}E{episode}") debug_msg = (
f"Found {len(providers)} providers for "
f"{key} S{season}E{episode}"
)
self.logger.debug(debug_msg)
return providers return providers
except Exception as e: except Exception as e:
self.logger.error(f"Failed to parse providers from HTML: {e}") error_msg = f"Failed to parse providers from HTML: {e}"
self.logger.error(error_msg)
raise RetryableError(f"Provider parsing failed: {e}") from e raise RetryableError(f"Provider parsing failed: {e}") from e
def _get_redirect_link(self, season: int, episode: int, key: str, language: str = "German Dub"): def _get_redirect_link(
self,
season: int,
episode: int,
key: str,
language: str = "German Dub",
):
"""Get redirect link for episode with error handling.""" """Get redirect link for episode with error handling."""
languageCode = self._GetLanguageKey(language) languageCode = self._GetLanguageKey(language)
if not self.IsLanguage(season, episode, key, language): if not self.IsLanguage(season, episode, key, language):
raise NonRetryableError(f"Language {language} not available for {key} S{season}E{episode}") err_msg = (
f"Language {language} not available for "
f"{key} S{season}E{episode}"
)
raise NonRetryableError(err_msg)
providers = self._get_provider_from_html(season, episode, key) providers = self._get_provider_from_html(season, episode, key)
@ -565,30 +739,51 @@ class EnhancedAniWorldLoader(Loader):
if languageCode in lang_dict: if languageCode in lang_dict:
return lang_dict[languageCode], provider_name return lang_dict[languageCode], provider_name
raise NonRetryableError(f"No provider found for {language} in {key} S{season}E{episode}") err_msg = (
f"No provider found for {language} in "
f"{key} S{season}E{episode}"
)
raise NonRetryableError(err_msg)
def _get_embeded_link(self, season: int, episode: int, key: str, language: str = "German Dub"): def _get_embeded_link(
self,
season: int,
episode: int,
key: str,
language: str = "German Dub",
):
"""Get embedded link with error handling.""" """Get embedded link with error handling."""
try: try:
redirect_link, provider_name = self._get_redirect_link(season, episode, key, language) redirect_link, provider_name = self._get_redirect_link(
season, episode, key, language
)
response = recovery_strategies.handle_network_failure( response = recovery_strategies.handle_network_failure(
self.session.get, self.session.get,
redirect_link, redirect_link,
timeout=self.DEFAULT_REQUEST_TIMEOUT, timeout=self.DEFAULT_REQUEST_TIMEOUT,
headers={'User-Agent': self.RANDOM_USER_AGENT} headers={"User-Agent": self.RANDOM_USER_AGENT},
) )
return response.url return response.url
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get embedded link: {e}") error_msg = f"Failed to get embedded link: {e}"
self.logger.error(error_msg)
raise raise
def _get_direct_link_from_provider(self, season: int, episode: int, key: str, language: str = "German Dub"): def _get_direct_link_from_provider(
"""Get direct download link from provider with error handling.""" self,
season: int,
episode: int,
key: str,
language: str = "German Dub",
):
"""Get direct download link from provider."""
try: try:
embedded_link = self._get_embeded_link(season, episode, key, language) embedded_link = self._get_embeded_link(
season, episode, key, language
)
if not embedded_link: if not embedded_link:
raise NonRetryableError("No embedded link found") raise NonRetryableError("No embedded link found")
@ -597,10 +792,13 @@ class EnhancedAniWorldLoader(Loader):
if not provider: if not provider:
raise NonRetryableError("VOE provider not available") raise NonRetryableError("VOE provider not available")
return provider.GetLink(embedded_link, self.DEFAULT_REQUEST_TIMEOUT) return provider.GetLink(
embedded_link, self.DEFAULT_REQUEST_TIMEOUT
)
except Exception as e: except Exception as e:
self.logger.error(f"Failed to get direct link from provider: {e}") error_msg = f"Failed to get direct link from provider: {e}"
self.logger.error(error_msg)
raise raise
@with_error_recovery(max_retries=2, context="get_season_episode_count") @with_error_recovery(max_retries=2, context="get_season_episode_count")
@ -611,29 +809,35 @@ class EnhancedAniWorldLoader(Loader):
response = recovery_strategies.handle_network_failure( response = recovery_strategies.handle_network_failure(
requests.get, requests.get,
base_url, base_url,
timeout=self.DEFAULT_REQUEST_TIMEOUT timeout=self.DEFAULT_REQUEST_TIMEOUT,
) )
soup = BeautifulSoup(response.content, 'html.parser') soup = BeautifulSoup(response.content, "html.parser")
season_meta = soup.find('meta', itemprop='numberOfSeasons') season_meta = soup.find("meta", itemprop="numberOfSeasons")
number_of_seasons = int(season_meta['content']) if season_meta else 0 number_of_seasons = (
int(season_meta["content"]) if season_meta else 0
)
episode_counts = {} episode_counts = {}
for season in range(1, number_of_seasons + 1): for season in range(1, number_of_seasons + 1):
season_url = f"{base_url}staffel-{season}" season_url = f"{base_url}staffel-{season}"
season_response = recovery_strategies.handle_network_failure( season_response = (
recovery_strategies.handle_network_failure(
requests.get, requests.get,
season_url, season_url,
timeout=self.DEFAULT_REQUEST_TIMEOUT timeout=self.DEFAULT_REQUEST_TIMEOUT,
)
) )
season_soup = BeautifulSoup(season_response.content, 'html.parser') season_soup = BeautifulSoup(
season_response.content, "html.parser"
)
episode_links = season_soup.find_all('a', href=True) episode_links = season_soup.find_all("a", href=True)
unique_links = set( unique_links = set(
link['href'] link["href"]
for link in episode_links for link in episode_links
if f"staffel-{season}/episode-" in link['href'] if f"staffel-{season}/episode-" in link['href']
) )
@ -668,4 +872,5 @@ class EnhancedAniWorldLoader(Loader):
# For backward compatibility, create wrapper that uses enhanced loader # For backward compatibility, create wrapper that uses enhanced loader
class AniworldLoader(EnhancedAniWorldLoader): class AniworldLoader(EnhancedAniWorldLoader):
"""Backward compatibility wrapper for the enhanced loader.""" """Backward compatibility wrapper for the enhanced loader."""
pass pass

View File

@ -1,12 +1,13 @@
import re
import base64 import base64
import json import json
import re
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from fake_useragent import UserAgent from fake_useragent import UserAgent
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from .Provider import Provider from .Provider import Provider
# Compile regex patterns once for better performance # Compile regex patterns once for better performance
@ -49,7 +50,9 @@ class VOE(Provider):
parts = redirect_url.strip().split("/") parts = redirect_url.strip().split("/")
self.Header["Referer"] = f"{parts[0]}//{parts[2]}/" self.Header["Referer"] = f"{parts[0]}//{parts[2]}/"
response = self.session.get(redirect_url, headers={'User-Agent': self.RANDOM_USER_AGENT}) response = self.session.get(
redirect_url, headers={"User-Agent": self.RANDOM_USER_AGENT}
)
html = response.content html = response.content