v0.5.0: Zendriver Daemon optimization, Python 3.14 support, and UI/UX improvements

This commit is contained in:
2026-01-03 15:33:13 +09:00
parent 1e10c43fef
commit 8ce34951d5
11 changed files with 829 additions and 67 deletions

View File

@@ -0,0 +1,19 @@
---
description: anime_downloader 플러그인 코딩 규칙
---
# 코딩 규칙
## 타입 힌트
- 모든 새 코드에 타입 힌트 필수 적용
- 함수 파라미터와 반환값에 타입 지정
- 클래스 변수에도 타입 지정
## 코드 스타일
- 한국어 주석 사용
- 로거 메시지는 영어/한국어 혼용 가능
- 에러 메시지는 간결하게
## FlaskFarm 관련
- flaskfarm 코어 소스 수정 최소화 (외부 프로젝트)
- 플러그인 내에서 해결 가능한 것은 플러그인에서 처리

View File

@@ -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 날짜 표시 및 디자인 개선**:

View File

@@ -1,5 +1,5 @@
title: "애니 다운로더"
version: "0.4.17"
version: "0.5.0"
package_name: "anime_downloader"
developer: "projectdx"
description: "anime downloader"

74
lib/camoufox_ohli24.py Normal file
View File

@@ -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 <url>", "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}))

198
lib/zendriver_daemon.py Normal file
View File

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

73
lib/zendriver_ohli24.py Normal file
View File

@@ -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 <url>", "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}))

View File

@@ -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:

View File

@@ -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;
}
}

View File

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

View File

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

View File

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