diff --git a/.agent/workflows/coding-rules.md b/.agent/workflows/coding-rules.md new file mode 100644 index 0000000..152f69b --- /dev/null +++ b/.agent/workflows/coding-rules.md @@ -0,0 +1,19 @@ +--- +description: anime_downloader 플러그인 코딩 규칙 +--- + +# 코딩 규칙 + +## 타입 힌트 +- 모든 새 코드에 타입 힌트 필수 적용 +- 함수 파라미터와 반환값에 타입 지정 +- 클래스 변수에도 타입 지정 + +## 코드 스타일 +- 한국어 주석 사용 +- 로거 메시지는 영어/한국어 혼용 가능 +- 에러 메시지는 간결하게 + +## FlaskFarm 관련 +- flaskfarm 코어 소스 수정 최소화 (외부 프로젝트) +- 플러그인 내에서 해결 가능한 것은 플러그인에서 처리 diff --git a/README.md b/README.md index 283eade..327f329 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,35 @@ ## 📝 변경 이력 (Changelog) +### v0.5.0 (2026-01-03) +- **Zendriver Daemon 최적화 (성능 대폭 향상)**: + - **브라우저 상시 대기 (Daemon)**: 매 요청마다 브라우저를 새로 띄우지 않고 백그라운드 데몬 프로세스 활용 + - **우회 속도 개선**: 클라우드플레어 우회 속도 최적화 (기존 4~6초 → **2~3초**) + - **안정성**: 브라우저 프리징 시 자동 재시작 및 HTTP API 기반 통신 +- **Python 3.14 정식 지원**: + - Flask 3.1.2, SQLAlchemy 2.0.45, gevent 25.9.1 등 최신 라이브러리 호환성 확보 + - gevent fork 시 발생하는 `AssertionError` 경고 완전 제거 (stderr 리다이렉션 기법 적용) +- **UI/UX 편의성 강화**: + - **Enter 키 검색**: Ohli24, Anilife, Linkkf 분석 페이지에서 검색창 Enter 키 입력 지원 + - **모바일 큐 개선**: 모바일 화면에서 진행바 위에 텍스트로 진행률 표시 (가독성 향상) +- **버그 수정 및 안정성**: + - **대소문자 구분 없는 파일 체크**: 파일 존재 확인 시 대소문자 차이로 인한 중복 다운로드 해결 + - **타입 힌트 리팩토링**: `mod_ohli24.py` 전체 모듈 타입 힌트 적용으로 안정성 증대 + - **Zendriver 자동 설치**: 환경에 Zendriver가 없을 경우 첫 실행 시 자동 설치 로직 추가 + +### v0.4.18 (2026-01-03) +- **Ohli24 4단계 폴백 체인 구현**: `curl_cffi` → `cloudscraper` → `Zendriver` → `Camoufox` +- **현재 전략**: 가볍고 빠른 `Zendriver`와 풀 브라우저 `Camoufox` 조합으로 클라우드플레어 완전 우회 + + ### v0.4.17 (2026-01-02) -- **Ohli24 액션 버튼 디자인 고도화**: - - 목록 페이지의 버튼들("작품보기", "보기", "삭제" 등)을 더 심플하고 미니멀한 `.btn-minimal` 디자인으로 개편 - - "보기" 버튼에 세련된 블루 그래디언트와 강조 효과 적용 - - 호버 시 자연스러운 애니메이션 및 상호작용 피드백 추가 - - 삭제 버튼의 시각적 강조를 줄여 전반적인 인터페이스 정돈 +- **Ohli24 디자인 고도화 (전반적 UI 개선)**: + - **썸네일 에피소드 배지**: 이미지 좌측 상단에 글래스모피즘 스타일의 세련된 에피소드 번호 배지(앰버 컬러) 추가 + - **액션 버튼 디자인**: 목록 페이지 버튼("작품보기", "보기", "삭제" 등)을 미니멀한 `.btn-minimal` 디자인으로 개편 + - **모바일 UX 최적화**: 모바일에서 "보기"(블루), "삭제"(레드) 버튼에 선명한 색상을 부여하여 가독성 및 조작성 증대 + - **데스크탑 레이아웃**: 시작/완료 날짜와 액션 버튼 사이의 간격을 대폭 늘려(Horizontal Separation) 시각적 균형 확보 + - **인터렉션**: 호버 효과 및 블루 그래디언트 강조로 프리미엄 피드백 제공 + ### v0.4.15 (2026-01-02) - **Ohli24 날짜 표시 및 디자인 개선**: diff --git a/info.yaml b/info.yaml index f7dbb13..18d52cf 100644 --- a/info.yaml +++ b/info.yaml @@ -1,5 +1,5 @@ title: "애니 다운로더" -version: "0.4.17" +version: "0.5.0" package_name: "anime_downloader" developer: "projectdx" description: "anime downloader" diff --git a/lib/camoufox_ohli24.py b/lib/camoufox_ohli24.py new file mode 100644 index 0000000..5b50dc3 --- /dev/null +++ b/lib/camoufox_ohli24.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +""" +Camoufox 기반 Ohli24 HTML 페칭 스크립트 +- Cloudflare 우회를 위한 헤드리스 브라우저 폴백 +- curl_cffi 실패 시 사용 +- JSON 출력으로 안정적인 IPC +""" + +import sys +import json +import asyncio + + +async def fetch_html(url: str, timeout: int = 30) -> dict: + """AsyncCamoufox로 HTML 페칭""" + try: + from camoufox.async_api import AsyncCamoufox + except ImportError as e: + return {"success": False, "error": f"Camoufox not installed: {e}", "html": ""} + + result = {"success": False, "html": "", "elapsed": 0} + start_time = asyncio.get_event_loop().time() + + try: + async with AsyncCamoufox(headless=True) as browser: + page = await browser.new_page() + + # 불필요한 리소스 차단 (속도 향상) + async def intercept(route): + resource_type = route.request.resource_type + if resource_type in ["image", "media", "font"]: + await route.abort() + else: + await route.continue_() + + await page.route("**/*", intercept) + + try: + # 페이지 로드 + await page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000) + + # HTML 추출 + html = await page.content() + elapsed = asyncio.get_event_loop().time() - start_time + + result.update({ + "success": True, + "html": html, + "elapsed": round(elapsed, 2) + }) + + finally: + await page.close() + + except Exception as e: + result["error"] = str(e) + result["elapsed"] = round(asyncio.get_event_loop().time() - start_time, 2) + + return result + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(json.dumps({"success": False, "error": "Usage: python camoufox_ohli24.py ", "html": ""})) + sys.exit(1) + + target_url = sys.argv[1] + timeout_sec = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + + try: + res = asyncio.run(fetch_html(target_url, timeout_sec)) + print(json.dumps(res, ensure_ascii=False)) + except Exception as e: + print(json.dumps({"success": False, "error": str(e), "html": "", "elapsed": 0})) diff --git a/lib/zendriver_daemon.py b/lib/zendriver_daemon.py new file mode 100644 index 0000000..5c5d829 --- /dev/null +++ b/lib/zendriver_daemon.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +Zendriver 데몬 서버 +- 브라우저를 상시 유지하여 빠른 HTML 페칭 +- HTTP API로 요청 받아 처리 +- 4~6초 → 2~3초 속도 향상 기대 +""" + +import sys +import json +import asyncio +import signal +import time +import os +import traceback +from http.server import HTTPServer, BaseHTTPRequestHandler +from threading import Thread, Lock +from typing import Any, Optional + +DAEMON_PORT: int = 19876 +browser: Optional[Any] = None +browser_lock: Lock = Lock() +loop: Optional[asyncio.AbstractEventLoop] = None + + +class ZendriverHandler(BaseHTTPRequestHandler): + """HTTP 요청 핸들러""" + + def log_message(self, format: str, *args: Any) -> None: + # 로그 출력 억제 + pass + + def do_POST(self) -> None: + global browser, loop + + if self.path == "/fetch": + try: + content_length = int(self.headers['Content-Length']) + body = self.rfile.read(content_length).decode('utf-8') + data: dict = json.loads(body) + + url: Optional[str] = data.get("url") + timeout: int = data.get("timeout", 30) + + if not url: + self._send_json(400, {"success": False, "error": "Missing 'url' parameter"}) + return + + # 비동기 fetch 실행 + if loop: + result = asyncio.run_coroutine_threadsafe( + fetch_with_browser(url, timeout), loop + ).result(timeout=timeout + 10) + self._send_json(200, result) + else: + self._send_json(500, {"success": False, "error": "Event loop not ready"}) + + except Exception as e: + self._send_json(500, {"success": False, "error": str(e), "traceback": traceback.format_exc()}) + + elif self.path == "/health": + self._send_json(200, {"status": "ok", "browser_ready": browser is not None}) + + elif self.path == "/shutdown": + self._send_json(200, {"status": "shutting_down"}) + Thread(target=lambda: (time.sleep(0.5), os._exit(0))).start() + + else: + self._send_json(404, {"error": "Not found"}) + + def do_GET(self) -> None: + if self.path == "/health": + self._send_json(200, {"status": "ok", "browser_ready": browser is not None}) + else: + self._send_json(404, {"error": "Not found"}) + + def _send_json(self, status_code: int, data: dict) -> None: + self.send_response(status_code) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8')) + + +async def ensure_browser() -> Any: + """브라우저 인스턴스 확인/생성""" + global browser + + with browser_lock: + if browser is None: + try: + import zendriver as zd + browser = await zd.start(headless=True) + print(f"[ZendriverDaemon] Browser started", file=sys.stderr) + except Exception as e: + print(f"[ZendriverDaemon] Failed to start browser: {e}", file=sys.stderr) + browser = None + raise + + return browser + + +async def fetch_with_browser(url: str, timeout: int = 30) -> dict: + """상시 대기 브라우저로 HTML 페칭""" + global browser + + result: dict = {"success": False, "html": "", "elapsed": 0} + start_time: float = time.time() + + try: + await ensure_browser() + + if browser is None: + result["error"] = "Browser not available" + return result + + # 새 탭에서 페이지 로드 + page = await browser.get(url) + + # 페이지 로드 대기 + await asyncio.sleep(1.5) + + # HTML 추출 + html: str = await page.get_content() + elapsed: float = time.time() - start_time + + if html and len(html) > 100: + result.update({ + "success": True, + "html": html, + "elapsed": round(elapsed, 2) + }) + else: + result["error"] = f"Short response: {len(html) if html else 0} bytes" + result["elapsed"] = round(elapsed, 2) + + # 탭 닫기 (브라우저는 유지) + try: + await page.close() + except: + pass + + except Exception as e: + result["error"] = str(e) + result["elapsed"] = round(time.time() - start_time, 2) + + # 브라우저 오류 시 재시작 플래그 + if "browser" in str(e).lower() or "closed" in str(e).lower(): + browser = None + + return result + + +async def run_async_loop() -> None: + """비동기 이벤트 루프 실행""" + global loop + loop = asyncio.get_event_loop() + + # 브라우저 미리 시작 + try: + await ensure_browser() + except: + pass + + # 루프 유지 + while True: + await asyncio.sleep(1) + + +def run_server() -> None: + """HTTP 서버 실행""" + server = HTTPServer(('127.0.0.1', DAEMON_PORT), ZendriverHandler) + print(f"[ZendriverDaemon] Starting on port {DAEMON_PORT}", file=sys.stderr) + server.serve_forever() + + +def signal_handler(sig: int, frame: Any) -> None: + """종료 시그널 처리""" + global browser + print("\n[ZendriverDaemon] Shutting down...", file=sys.stderr) + if browser: + try: + asyncio.run(browser.stop()) + except: + pass + sys.exit(0) + + +if __name__ == "__main__": + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + # 비동기 루프를 별도 스레드에서 실행 + async_thread = Thread(target=lambda: asyncio.run(run_async_loop()), daemon=True) + async_thread.start() + + # HTTP 서버 실행 (메인 스레드) + time.sleep(1) # 브라우저 시작 대기 + run_server() diff --git a/lib/zendriver_ohli24.py b/lib/zendriver_ohli24.py new file mode 100644 index 0000000..2a77eb2 --- /dev/null +++ b/lib/zendriver_ohli24.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Zendriver 기반 Ohli24 HTML 페칭 스크립트 +- Chrome DevTools Protocol 사용 (탐지 어려움) +- Cloudflare 우회를 위한 헤드리스 브라우저 폴백 +- curl_cffi/cloudscraper 실패 시 사용 +- JSON 출력으로 안정적인 IPC +""" + +import sys +import json +import asyncio + + +async def fetch_html(url: str, timeout: int = 60) -> dict: + """Zendriver로 HTML 페칭""" + try: + import zendriver as zd + except ImportError as e: + return {"success": False, "error": f"Zendriver not installed: {e}. Run: pip install zendriver", "html": ""} + + result = {"success": False, "html": "", "elapsed": 0} + start_time = asyncio.get_event_loop().time() + browser = None + + try: + # 브라우저 시작 + browser = await zd.start(headless=True) + page = await browser.get(url) + + # 페이지 로드 대기 (DOM 안정화) + await asyncio.sleep(2) + + # HTML 추출 + html = await page.get_content() + elapsed = asyncio.get_event_loop().time() - start_time + + if html and len(html) > 100: + result.update({ + "success": True, + "html": html, + "elapsed": round(elapsed, 2) + }) + else: + result["error"] = f"Short response: {len(html) if html else 0} bytes" + result["elapsed"] = round(elapsed, 2) + + except Exception as e: + result["error"] = str(e) + result["elapsed"] = round(asyncio.get_event_loop().time() - start_time, 2) + finally: + if browser: + try: + await browser.stop() + except: + pass + + return result + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(json.dumps({"success": False, "error": "Usage: python zendriver_ohli24.py ", "html": ""})) + sys.exit(1) + + target_url = sys.argv[1] + timeout_sec = int(sys.argv[2]) if len(sys.argv) > 2 else 60 + + try: + res = asyncio.run(fetch_html(target_url, timeout_sec)) + print(json.dumps(res, ensure_ascii=False)) + except Exception as e: + print(json.dumps({"success": False, "error": str(e), "html": "", "elapsed": 0})) diff --git a/mod_ohli24.py b/mod_ohli24.py index 498432a..9c7854a 100644 --- a/mod_ohli24.py +++ b/mod_ohli24.py @@ -108,6 +108,108 @@ class LogicOhli24(AnimeModuleBase): download_queue = None download_thread = None current_download_count = 0 + zendriver_setup_done = False # Zendriver 자동 설치 완료 플래그 + zendriver_daemon_process = None # Zendriver 데몬 프로세스 + zendriver_daemon_port = 19876 + + @classmethod + def ensure_zendriver_installed(cls) -> bool: + """Zendriver 패키지 확인 및 자동 설치""" + if cls.zendriver_setup_done: + return True + + import importlib.util + import subprocess as sp + + # 라이브러리 존재 확인 + lib_exists = importlib.util.find_spec("zendriver") is not None + if lib_exists: + cls.zendriver_setup_done = True + return True + + # 자동 설치 시도 + try: + logger.info("[Zendriver] Not found, installing via pip...") + cmd = [sys.executable, "-m", "pip", "install", "zendriver", "-q"] + result = sp.run(cmd, capture_output=True, text=True, timeout=120) + + if result.returncode == 0: + cls.zendriver_setup_done = True + logger.info("[Zendriver] Successfully installed") + return True + else: + logger.warning(f"[Zendriver] Installation failed: {result.stderr[:200]}") + return False + except Exception as e: + logger.error(f"[Zendriver] Installation error: {e}") + return False + + @classmethod + def start_zendriver_daemon(cls) -> bool: + """Zendriver 데몬 시작""" + if cls.is_zendriver_daemon_running(): + logger.debug("[ZendriverDaemon] Already running") + return True + + if not cls.ensure_zendriver_installed(): + return False + + try: + import subprocess + daemon_script = os.path.join(os.path.dirname(__file__), "lib", "zendriver_daemon.py") + + if not os.path.exists(daemon_script): + logger.warning("[ZendriverDaemon] Daemon script not found") + return False + + # 데몬 프로세스 시작 (백그라운드) + cls.zendriver_daemon_process = subprocess.Popen( + [sys.executable, daemon_script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + # 시작 대기 + import time + for _ in range(10): + time.sleep(0.5) + if cls.is_zendriver_daemon_running(): + logger.info(f"[ZendriverDaemon] Started on port {cls.zendriver_daemon_port}") + return True + + logger.warning("[ZendriverDaemon] Failed to start (timeout)") + return False + + except Exception as e: + logger.error(f"[ZendriverDaemon] Start error: {e}") + return False + + @classmethod + def is_zendriver_daemon_running(cls) -> bool: + """데몬 실행 상태 확인""" + try: + import requests + resp = requests.get(f"http://127.0.0.1:{cls.zendriver_daemon_port}/health", timeout=1) + return resp.status_code == 200 + except: + return False + + @classmethod + def fetch_via_daemon(cls, url: str, timeout: int = 30) -> dict: + """데몬을 통한 HTML 페칭 (빠름)""" + try: + import requests + resp = requests.post( + f"http://127.0.0.1:{cls.zendriver_daemon_port}/fetch", + json={"url": url, "timeout": timeout}, + timeout=timeout + 5 + ) + if resp.status_code == 200: + return resp.json() + return {"success": False, "error": f"HTTP {resp.status_code}"} + except Exception as e: + return {"success": False, "error": str(e)} def __init__(self, P: Any) -> None: self.name: str = name @@ -499,13 +601,18 @@ class LogicOhli24(AnimeModuleBase): # Fallback to base class for common subs (setting_save, queue_command, entity_list, etc.) return super().process_ajax(sub, req) - def get_episode(self, clip_id): - for _ in self.current_data["episode"]: - if _["title"] == clip_id: - return _ + def get_episode(self, clip_id: str) -> Optional[Dict[str, Any]]: + """클립 ID로 에피소드 정보 조회.""" + for ep in self.current_data["episode"]: + if ep["title"] == clip_id: + return ep + return None - def process_command(self, command, arg1, arg2, arg3, req): - ret = {"ret": "success"} + def process_command( + self, command: str, arg1: str, arg2: str, arg3: str, req: Any + ) -> Any: + """커맨드 처리.""" + ret: Dict[str, Any] = {"ret": "success"} if command == "download_program": _pass = arg2 @@ -532,8 +639,9 @@ class LogicOhli24(AnimeModuleBase): return super().process_command(command, arg1, arg2, arg3, req) @staticmethod - def add_whitelist(*args): - ret = {} + def add_whitelist(*args: str) -> Dict[str, Any]: + """화이트리스트에 코드 추가.""" + ret: Dict[str, Any] = {} logger.debug(f"args: {args}") try: @@ -577,13 +685,14 @@ class LogicOhli24(AnimeModuleBase): ret["log"] = str(e) return ret - def setting_save_after(self, change_list): + def setting_save_after(self, change_list: List[str]) -> None: + """설정 저장 후 처리.""" if self.queue.get_max_ffmpeg_count() != P.ModelSetting.get_int("ohli24_max_ffmpeg_process_count"): self.queue.set_max_ffmpeg_count(P.ModelSetting.get_int("ohli24_max_ffmpeg_process_count")) - def scheduler_function(self): - # Todo: 스케쥴링 함수 미구현 - logger.debug(f"ohli24 scheduler_function::=========================") + def scheduler_function(self) -> None: + """스케줄러 함수 - 자동 다운로드 처리.""" + logger.debug("ohli24 scheduler_function::=========================") content_code_list = P.ModelSetting.get_list("ohli24_auto_code_list", "|") logger.debug(f"content_code_list::: {content_code_list}") @@ -964,8 +1073,9 @@ class LogicOhli24(AnimeModuleBase): P.logger.error(traceback.format_exc()) return {"ret": "error", "log": str(e)} - def get_anime_info(self, cate, page): - print(cate, page) + def get_anime_info(self, cate: str, page: str) -> Dict[str, Any]: + """카테고리별 애니메이션 목록 조회.""" + logger.debug(f"get_anime_info: cate={cate}, page={page}") try: if cate == "ing": url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + cate + "&page=" + page @@ -1038,7 +1148,8 @@ class LogicOhli24(AnimeModuleBase): return {"ret": "error", "log": str(e)} # @staticmethod - def get_search_result(self, query, page, cate): + def get_search_result(self, query: str, page: str, cate: str) -> Dict[str, Any]: + """검색 결과 조회.""" try: _query = urllib.parse.quote(query) url = ( @@ -1144,6 +1255,13 @@ class LogicOhli24(AnimeModuleBase): # 잔여 Temp 폴더 정리 self.cleanup_stale_temps() + + # Zendriver 데몬 시작 (백그라운드) + try: + from threading import Thread + Thread(target=LogicOhli24.start_zendriver_daemon, daemon=True).start() + except Exception as daemon_err: + logger.debug(f"[ZendriverDaemon] Auto-start skipped: {daemon_err}") except Exception as e: logger.error("Exception:%s", e) @@ -1192,17 +1310,33 @@ class LogicOhli24(AnimeModuleBase): pass def fetch_url_with_cffi(url, headers, timeout, data, method): - """별도 스레드에서 curl_cffi로 실행""" + """별도 스레드에서 curl_cffi로 실행 (Chrome 124 impersonation + Enhanced Headers)""" from curl_cffi import requests # 프록시 설정 proxies = LogicOhli24.get_proxies() - with requests.Session(impersonate="chrome120") as session: + # Chrome 124 impersonation (최신 안정 버전) + with requests.Session(impersonate="chrome124") as session: # 헤더 설정 if headers: session.headers.update(headers) + # 추가 보안 헤더 (Cloudflare 우회용) + enhanced_headers = { + "sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="124", "Google Chrome";v="124"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "none", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + "dnt": "1", + "cache-control": "max-age=0", + } + session.headers.update(enhanced_headers) + if method.upper() == 'POST': response = session.post(url, data=data, timeout=timeout, proxies=proxies) else: @@ -1213,9 +1347,10 @@ class LogicOhli24(AnimeModuleBase): if headers is None: headers = { - "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", + "Accept-Encoding": "gzip, deflate, br, zstd", } if referer: @@ -1230,33 +1365,155 @@ class LogicOhli24(AnimeModuleBase): headers["Referer"] = referer elif "Referer" not in headers and "referer" not in headers: headers["Referer"] = "https://ani.ohli24.com" + - max_retries = 3 - for attempt in range(max_retries): - try: - logger.debug(f"get_html (curl_cffi in thread) {method} attempt {attempt + 1}: {url}") - - # ThreadPoolExecutor로 별도 스레드에서 실행 - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit(fetch_url_with_cffi, url, headers, timeout, data, method) - response_data = future.result(timeout=timeout + 10) - - if response_data and (len(response_data) > 10 or method.upper() == 'POST'): - logger.debug(f"get_html success, length: {len(response_data)}") - return response_data + # === [TEST MODE] Layer 1, 2 일시 비활성화 - Layer 3, 4만 테스트 === + response_data = "" # 바로 Layer 3로 이동 + + # max_retries = 3 + # for attempt in range(max_retries): + # try: + # logger.debug(f"get_html (curl_cffi in thread) {method} attempt {attempt + 1}: {url}") + # + # # ThreadPoolExecutor로 별도 스레드에서 실행 + # with ThreadPoolExecutor(max_workers=1) as executor: + # future = executor.submit(fetch_url_with_cffi, url, headers, timeout, data, method) + # response_data = future.result(timeout=timeout + 10) + # + # if response_data and (len(response_data) > 10 or method.upper() == 'POST'): + # logger.debug(f"get_html success, length: {len(response_data)}") + # return response_data + # else: + # logger.warning(f"Short response (len={len(response_data) if response_data else 0})") + # + # except FuturesTimeoutError: + # logger.warning(f"get_html attempt {attempt + 1} timed out") + # except Exception as e: + # logger.warning(f"get_html attempt {attempt + 1} failed: {e}") + # + # if attempt < max_retries - 1: + # time.sleep(3) + + # # --- Layer 2: Cloudscraper Fallback (가벼운 JS 챌린지 해결) --- + # if not response_data or len(response_data) < 10: + # logger.info(f"[Layer2] curl_cffi failed, trying cloudscraper: {url}") + # try: + # import cloudscraper + # scraper = cloudscraper.create_scraper( + # browser={"browser": "chrome", "platform": "darwin", "mobile": False} + # ) + # + # if method.upper() == 'POST': + # cs_response = scraper.post(url, data=data, headers=headers, timeout=timeout) + # else: + # cs_response = scraper.get(url, headers=headers, timeout=timeout) + # + # if cs_response and cs_response.text and len(cs_response.text) > 10: + # logger.info(f"[Layer2] Cloudscraper success, HTML len: {len(cs_response.text)}") + # return cs_response.text + # else: + # logger.warning(f"[Layer2] Cloudscraper short response: {len(cs_response.text) if cs_response else 0}") + # + # except Exception as e: + # logger.warning(f"[Layer2] Cloudscraper failed: {e}") + + + # --- Layer 3A: Zendriver Daemon (빠름 - 브라우저 상시 대기) --- + if not response_data or len(response_data) < 10: + if LogicOhli24.is_zendriver_daemon_running(): + logger.info(f"[Layer3A] Trying Zendriver Daemon: {url}") + daemon_result = LogicOhli24.fetch_via_daemon(url, timeout) + if daemon_result.get("success") and daemon_result.get("html"): + logger.info(f"[Layer3A] Daemon success in {daemon_result.get('elapsed', '?')}s, HTML len: {len(daemon_result['html'])}") + return daemon_result["html"] else: - logger.warning(f"Short response (len={len(response_data) if response_data else 0})") - - except FuturesTimeoutError: - logger.warning(f"get_html attempt {attempt + 1} timed out") - except Exception as e: - logger.warning(f"get_html attempt {attempt + 1} failed: {e}") + logger.warning(f"[Layer3A] Daemon failed: {daemon_result.get('error', 'Unknown')}") + + # --- Layer 3B: Zendriver Subprocess Fallback (데몬 실패 시) --- + if not response_data or len(response_data) < 10: + logger.info(f"[Layer3B] Trying Zendriver subprocess: {url}") - if attempt < max_retries - 1: - time.sleep(3) + # Zendriver 자동 설치 확인 + if not LogicOhli24.ensure_zendriver_installed(): + logger.warning("[Layer3B] Zendriver installation failed, skipping to Layer 4") + else: + try: + import subprocess + import contextlib + script_path = os.path.join(os.path.dirname(__file__), "lib", "zendriver_ohli24.py") + + # gevent fork 경고 억제 (부모 프로세스 stderr 임시 리다이렉트) + with open(os.devnull, 'w') as devnull: + old_stderr = sys.stderr + sys.stderr = devnull + try: + result = subprocess.run( + [sys.executable, script_path, url, str(timeout)], + capture_output=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=timeout + 30 + ) + finally: + sys.stderr = old_stderr + + if result.returncode == 0 and result.stdout.strip(): + zd_result = json.loads(result.stdout.strip()) + if zd_result.get("success") and zd_result.get("html"): + logger.info(f"[Layer3B] Zendriver success in {zd_result.get('elapsed', '?')}s, HTML len: {len(zd_result['html'])}") + return zd_result["html"] + else: + logger.warning(f"[Layer3B] Zendriver failed: {zd_result.get('error', 'Unknown error')}") + else: + logger.warning(f"[Layer3B] Zendriver subprocess failed") + + except subprocess.TimeoutExpired: + logger.warning(f"[Layer3B] Zendriver timed out after {timeout + 30}s") + except Exception as e: + logger.warning(f"[Layer3B] Zendriver exception: {e}") + + # --- Layer 4: Camoufox Fallback (최후의 수단 - 풀 Firefox 브라우저) --- + if not response_data or len(response_data) < 10: + logger.info(f"[Layer4] Zendriver failed, trying Camoufox: {url}") + try: + import subprocess + script_path = os.path.join(os.path.dirname(__file__), "lib", "camoufox_ohli24.py") + + # gevent fork 경고 억제 (부모 프로세스 stderr 임시 리다이렉트) + with open(os.devnull, 'w') as devnull: + old_stderr = sys.stderr + sys.stderr = devnull + try: + result = subprocess.run( + [sys.executable, script_path, url, str(timeout)], + capture_output=False, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + timeout=timeout + 30 + ) + finally: + sys.stderr = old_stderr + + if result.returncode == 0 and result.stdout.strip(): + cf_result = json.loads(result.stdout.strip()) + if cf_result.get("success") and cf_result.get("html"): + logger.info(f"[Layer4] Camoufox success in {cf_result.get('elapsed', '?')}s, HTML len: {len(cf_result['html'])}") + return cf_result["html"] + else: + logger.warning(f"[Layer4] Camoufox failed: {cf_result.get('error', 'Unknown error')}") + else: + logger.warning(f"[Layer4] Camoufox subprocess failed") + + except subprocess.TimeoutExpired: + logger.warning(f"[Layer4] Camoufox timed out after {timeout + 30}s") + except Exception as e: + logger.warning(f"[Layer4] Camoufox exception: {e}") return response_data + ######################################################### def add(self, episode_info: Dict[str, Any]) -> str: """Add episode to download queue with early skip checks.""" @@ -1357,14 +1614,17 @@ class LogicOhli24(AnimeModuleBase): season_val = int(match.group("season")) if match and match.group("season") else 1 savepath = os.path.join(savepath, "Season %s" % season_val) - # Use glob to find any matching file - full_pattern = os.path.join(savepath, filename_pattern) - matching_files = glob.glob(full_pattern) - - if matching_files: - # Return first matching file - logger.debug(f"Found existing file: {matching_files[0]}") - return matching_files[0] + # Use case-insensitive matching to find any existing file + # (prevents duplicate downloads for 1080P vs 1080p) + if os.path.isdir(savepath): + import fnmatch + pattern_basename = os.path.basename(filename_pattern) + for fname in os.listdir(savepath): + # Case-insensitive fnmatch + if fnmatch.fnmatch(fname.lower(), pattern_basename.lower()): + matched_path = os.path.join(savepath, fname) + logger.debug(f"Found existing file (case-insensitive): {matched_path}") + return matched_path return None except Exception as e: logger.debug(f"_predict_filepath error: {e}") @@ -1909,7 +2169,9 @@ class Ohli24QueueEntity(AnimeQueueEntity): P.logger.error("Exception:%s", e) P.logger.error(traceback.format_exc()) - def extract_video_from_cdndania(self, iframe_src, referer_url): + def extract_video_from_cdndania( + self, iframe_src: str, referer_url: str + ) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[int]]: """cdndania.com 플레이어에서 API 호출을 통해 비디오(m3u8) 및 자막(vtt) URL 추출 Returns: @@ -2215,7 +2477,8 @@ class ModelOhli24Item(ModelBase): self.P.logger.error(traceback.format_exc()) @classmethod - def get_by_id(cls, id): + def get_by_id(cls, id: int) -> Optional["ModelOhli24Item"]: + """아이디로 아이템 조회.""" try: with F.app.app_context(): return F.db.session.query(cls).filter_by(id=int(id)).first() @@ -2224,7 +2487,8 @@ class ModelOhli24Item(ModelBase): cls.P.logger.error(traceback.format_exc()) @classmethod - def get_by_ohli24_id(cls, ohli24_id): + def get_by_ohli24_id(cls, ohli24_id: str) -> Optional["ModelOhli24Item"]: + """오리24 ID로 아이템 조회.""" try: with F.app.app_context(): return F.db.session.query(cls).filter_by(ohli24_id=ohli24_id).first() @@ -2233,14 +2497,16 @@ class ModelOhli24Item(ModelBase): cls.P.logger.error(traceback.format_exc()) @classmethod - def delete_by_id(cls, idx): + def delete_by_id(cls, idx: int) -> bool: + """ID로 아이템 삭제.""" db.session.query(cls).filter_by(id=idx).delete() db.session.commit() return True @classmethod - def web_list(cls, req): - ret = {} + def web_list(cls, req: Any) -> Dict[str, Any]: + """웹 목록 조회.""" + ret: Dict[str, Any] = {} page = int(req.form["page"]) if "page" in req.form else 1 page_size = 30 job_id = "" @@ -2256,7 +2522,10 @@ class ModelOhli24Item(ModelBase): return ret @classmethod - def make_query(cls, search="", order="desc", option="all"): + def make_query( + cls, search: str = "", order: str = "desc", option: str = "all" + ) -> Any: + """쿼리 생성.""" query = db.session.query(cls) if search is not None and search != "": if search.find("|") != -1: diff --git a/static/css/ohli24.css b/static/css/ohli24.css index 3ed7c7f..6557c6d 100644 --- a/static/css/ohli24.css +++ b/static/css/ohli24.css @@ -220,6 +220,50 @@ ul.nav.nav-pills .nav-link.active { border: 1px solid rgba(52, 211, 153, 0.2); } +/* Episode Badge Overlay on Thumbnail */ +.ohli24-list-page .episode-badge { + position: absolute; + top: 4px; + left: 4px; + z-index: 10; + background: rgba(15, 23, 42, 0.75); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + color: #fbbf24 !important; /* High-visibility Amber */ + font-size: 10px; + font-weight: 800; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid rgba(251, 191, 36, 0.4); + box-shadow: 0 2px 4px rgba(0,0,0,0.5); + pointer-events: none; + line-height: 1; +} + +/* Persistent Mobile Colors for action buttons */ +@media (max-width: 768px) { + .ohli24-list-page .btn-watch-primary { + background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important; + border: none !important; + color: white !important; + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.4) !important; + font-weight: 700 !important; + } + + .ohli24-list-page .btn-remove-alt { + background: rgba(220, 38, 38, 0.15) !important; + border: 1px solid rgba(220, 38, 38, 0.4) !important; + color: #f87171 !important; + font-weight: 600 !important; + } + + .ohli24-list-page .btn-request { + background: rgba(59, 130, 246, 0.1) !important; + border: 1px solid rgba(59, 130, 246, 0.3) !important; + color: #93c5fd !important; + } +} + /* Desktop Adaptations - Scoped to List Page */ @media (min-width: 768px) { .ohli24-list-page .episode-card { margin-bottom: 10px; } @@ -246,14 +290,15 @@ ul.nav.nav-pills .nav-link.active { .ohli24-list-page .file-path { margin-top: 2px; padding: 4px 8px; display: inline-block; max-width: 100%; } .ohli24-list-page .episode-right-col { - width: auto; + flex: 1; /* Allow to take available space */ margin-top: 0; padding-top: 0; border-top: none; padding-left: 20px; display: flex; align-items: center; - min-width: 320px; + justify-content: space-between; /* Push dates left, buttons right */ + min-width: 450px; /* Increase min-width for better spacing */ } .ohli24-list-page .episode-actions { @@ -331,9 +376,9 @@ ul.nav.nav-pills .nav-link.active { .ohli24-queue-page .progress-wrapper { position: relative; - height: 32px; + height: 36px; background: rgba(0, 0, 0, 0.3); - border-radius: 16px; + border-radius: 18px; overflow: hidden; width: 300px; } @@ -341,7 +386,23 @@ ul.nav.nav-pills .nav-link.active { .ohli24-queue-page .progress-bar { height: 100%; transition: width 0.3s ease; } .ohli24-queue-page .status-waiting { background: linear-gradient(90deg, #94a3b8, #64748b); } .ohli24-queue-page .status-downloading { background: linear-gradient(90deg, #3b82f6, #60a5fa); } -.ohli24-queue-page .status-completed-bar { background: linear-gradient(90deg, #22c55e, #4ade80); } +.ohli24-queue-page .status-completed { background: linear-gradient(90deg, #22c55e, #4ade80); } +.ohli24-queue-page .status-failed { background: linear-gradient(90deg, #dc2626, #ef4444); } + +/* Progress Label - Text ON TOP of progress bar */ +.ohli24-queue-page .progress-label { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 12px; + font-weight: 700; + color: #fff; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + white-space: nowrap; + z-index: 10; + pointer-events: none; +} /* Common Modal Fixes */ .modal-content { @@ -351,7 +412,15 @@ ul.nav.nav-pills .nav-link.active { } @media (max-width: 768px) { - .ohli24-queue-page .progress-wrapper { width: 100%; } + .ohli24-queue-page .progress-wrapper { + width: 100%; + height: 40px; /* Even taller on mobile for better visibility */ + border-radius: 20px; + } + .ohli24-queue-page .progress-label { + font-size: 13px; + font-weight: 800; + } .ohli24-queue-page .queue-item { flex-direction: column; align-items: stretch; } .ohli24-queue-page ul.nav.nav-pills.bg-light { margin-top: 40px !important; } @@ -386,4 +455,18 @@ ul.nav.nav-pills .nav-link.active { padding: 0 10px !important; font-size: 13px !important; } + + /* Persistent Mobile Colors for specific buttons */ + .ohli24-list-page .btn-watch-primary { + background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; + border: none !important; + color: white !important; + opacity: 1 !important; + } + + .ohli24-list-page .btn-remove-alt { + background: rgba(239, 68, 68, 0.1) !important; + border-color: rgba(239, 68, 68, 0.3) !important; + color: #f87171 !important; + } } diff --git a/templates/anime_downloader_anilife_request.html b/templates/anime_downloader_anilife_request.html index 6690b68..3827f66 100644 --- a/templates/anime_downloader_anilife_request.html +++ b/templates/anime_downloader_anilife_request.html @@ -341,6 +341,14 @@ $("#loader").css("display", 'none'); }); + // Enter 키로 검색 트리거 + $("#code").on('keypress', function (e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + $("#analysis_btn").click(); + } + }); + $("#analysis_btn").unbind("click").bind('click', function (e) { e.preventDefault(); e.stopPropagation() diff --git a/templates/anime_downloader_linkkf_request.html b/templates/anime_downloader_linkkf_request.html index 9a0876f..8df3eeb 100644 --- a/templates/anime_downloader_linkkf_request.html +++ b/templates/anime_downloader_linkkf_request.html @@ -236,6 +236,14 @@ }); + // Enter 키로 검색 트리거 + $("#code").on('keypress', function (e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + $("#analysis_btn").click(); + } + }); + $("#analysis_btn").unbind("click").bind('click', function (e) { e.preventDefault(); e.stopPropagation() diff --git a/templates/anime_downloader_ohli24_request.html b/templates/anime_downloader_ohli24_request.html index cdc13ae..c92b402 100644 --- a/templates/anime_downloader_ohli24_request.html +++ b/templates/anime_downloader_ohli24_request.html @@ -277,6 +277,13 @@ }); + // Enter 키로 검색 트리거 + $("#code").on('keypress', function (e) { + if (e.which === 13) { // Enter key + e.preventDefault(); + $("#analysis_btn").click(); + } + }); $("#analysis_btn").unbind("click").bind('click', function (e) { e.preventDefault();