v0.5.0: Enhanced Ohli24 Player UI, added Year Filtering, and optimized Anilife/Ohli24 extraction speed

This commit is contained in:
2026-01-03 18:51:11 +09:00
parent 8ce34951d5
commit fcd7d6a30b
10 changed files with 626 additions and 326 deletions

View File

@@ -14,6 +14,10 @@ description: anime_downloader 플러그인 코딩 규칙
- 로거 메시지는 영어/한국어 혼용 가능 - 로거 메시지는 영어/한국어 혼용 가능
- 에러 메시지는 간결하게 - 에러 메시지는 간결하게
## 커맨드 실행 규칙
- `rm`, `mv`, `cp` 등 파일을 변조하거나 삭제할 수 있는 파괴적인 명령은 반드시 사용자 승인 필요 (`SafeToAutoRun: false`)
- `cat`, `grep`, `sed`, `ps`, `lsof`, `curl` (단순 조회용) 등 부수 효과가 없는 조회성 명령은 자동 실행 허용 (`SafeToAutoRun: true`)
## FlaskFarm 관련 ## FlaskFarm 관련
- flaskfarm 코어 소스 수정 최소화 (외부 프로젝트) - flaskfarm 코어 소스 수정 최소화 (외부 프로젝트)
- 플러그인 내에서 해결 가능한 것은 플러그인에서 처리 - 플러그인 내에서 해결 가능한 것은 플러그인에서 처리

View File

@@ -72,20 +72,21 @@
## 📝 변경 이력 (Changelog) ## 📝 변경 이력 (Changelog)
### v0.5.0 (2026-01-03) ### v0.5.0 (2026-01-03)
- **Zendriver Daemon 최적화 (성능 대폭 향상)**: - **Ohli24 비디오 플레이어 UI 전면 개편**:
- **브라우저 상시 대기 (Daemon)**: 매 요청마다 브라우저를 새로 띄우지 않고 백그라운드 데몬 프로세스 활 - **프리미엄 글래스모피즘 디자인**: 플레이어 모달 및 플레이리스트 컨트롤에 투명 유리 테마 적
- **우회 속도 개선**: 클라우드플레어 우회 속도 최적화 (기존 4~6초 → **2~3초**) - **Video.js 8.10.0 업그레이드**: 최신 엔진으로 안정성 및 재생 성능 최적화
- **안정성**: 브라우저 프리징 시 자동 재시작 및 HTTP API 기반 통신 - **"Scale to Fill" (줌) 기능**: 모바일 전체화면 시 검은 여백을 없애고 화면을 가득 채우는 기능 추가
- **Python 3.14 정식 지원**: - **중앙 재생 버튼 개선**: 모바일에 최적화된 대형 중앙 재생 버튼 및 아이콘 정렬 수정
- Flask 3.1.2, SQLAlchemy 2.0.45, gevent 25.9.1 등 최신 라이브러리 호환성 확보 - **Anilife / Ohli24 검색 엔진 고도화**:
- gevent fork 시 발생하는 `AssertionError` 경고 완전 제거 (stderr 리다이렉션 기법 적용) - **Zendriver Daemon 최적화**: 매 요청마다 브라우저를 띄우지 않고 백그라운드 프로세스 활용 (응답 속도 2~3초로 단축)
- **UI/UX 편의성 강화**: - **완결 카테고리 & 년도별 필터링**: Ohli24 검색에 '완결' 버튼 추가 및 년도별(2020~2025) 상세 필터링 지원
- **Enter 키 검색**: Ohli24, Anilife, Linkkf 분석 페이지에서 검색창 Enter 키 입력 지원 - **모던 로딩 UI**: 시각적으로 세련된 멀티 링 프리로더 및 글래스모피즘 AJAX 스피너 도입
- **모바일 큐 개선**: 모바일 화면에서 진행바 위에 텍스트로 진행률 표시 (가독성 향상) - **Python 3.14 및 최신 스택 지원**:
- **버그 수정 및 안정성**: - Flask 3.1.2, SQLAlchemy 2.0.45 등 최신 라이브러리 호환성 확보 및 `AssertionError` 경고 제거
- **대소문자 구분 없는 파일 체크**: 파일 존재 확인 시 대소문자 차이로 인한 중복 다운로드 해결 - **안정성 및 UX 강화**:
- **타입 힌트 리팩토링**: `mod_ohli24.py` 전체 모듈 타입 힌트 적용으로 안정성 증대 - **Enter 키 검색**: 모든 분석/검색 페이지에서 Enter 키 지원
- **Zendriver 자동 설치**: 환경에 Zendriver가 없을 경우 실행 시 자동 설치 로직 추가 - **Zendriver 자동 설치**: 환경에 패키지가 없을 경우 실행 시 자동 설치
- **타입 힌트 리팩토링**: `mod_ohli24.py`, `mod_anilife.py` 전반에 엄격한 타입 힌트 적용
### v0.4.18 (2026-01-03) ### v0.4.18 (2026-01-03)
- **Ohli24 4단계 폴백 체인 구현**: `curl_cffi``cloudscraper``Zendriver``Camoufox` - **Ohli24 4단계 폴백 체인 구현**: `curl_cffi``cloudscraper``Zendriver``Camoufox`

View File

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

View File

@@ -72,7 +72,7 @@ class Crawler:
referer: str = None, referer: str = None,
engine: str = "chrome", engine: str = "chrome",
stealth: bool = False, stealth: bool = False,
): ) -> str:
try: try:
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
# from playwright.sync_api import sync_playwright # from playwright.sync_api import sync_playwright

View File

@@ -15,7 +15,21 @@ import os
import traceback import traceback
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from threading import Thread, Lock from threading import Thread, Lock
from typing import Any, Optional from typing import Any, Optional, Dict, List, Type, cast
# 터미널 및 파일로 로그 출력 설정
LOG_FILE: str = "/tmp/zendriver_daemon.log"
def log_debug(msg: str) -> None:
"""타임스탬프와 함께 로그 출력 및 파일 저장"""
timestamp: str = time.strftime("%Y-%m-%d %H:%M:%S")
formatted_msg: str = f"[{timestamp}] {msg}"
print(formatted_msg, file=sys.stderr)
try:
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(formatted_msg + "\n")
except Exception:
pass
DAEMON_PORT: int = 19876 DAEMON_PORT: int = 19876
browser: Optional[Any] = None browser: Optional[Any] = None
@@ -27,20 +41,26 @@ class ZendriverHandler(BaseHTTPRequestHandler):
"""HTTP 요청 핸들러""" """HTTP 요청 핸들러"""
def log_message(self, format: str, *args: Any) -> None: def log_message(self, format: str, *args: Any) -> None:
# 로그 출력 억제 """로그 출력 억제"""
pass pass
def do_POST(self) -> None: def do_POST(self) -> None:
"""POST 요청 처리 (/fetch, /health, /shutdown)"""
global browser, loop global browser, loop
if self.path == "/fetch": if self.path == "/fetch":
try: try:
content_length = int(self.headers['Content-Length']) content_length: int = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8') if content_length == 0:
data: dict = json.loads(body) self._send_json(400, {"success": False, "error": "Empty body"})
return
body_bytes: bytes = self.rfile.read(content_length)
body: str = body_bytes.decode('utf-8')
data: Dict[str, Any] = json.loads(body)
url: Optional[str] = data.get("url") url: Optional[str] = data.get("url")
timeout: int = data.get("timeout", 30) timeout: int = cast(int, data.get("timeout", 30))
if not url: if not url:
self._send_json(400, {"success": False, "error": "Missing 'url' parameter"}) self._send_json(400, {"success": False, "error": "Missing 'url' parameter"})
@@ -48,15 +68,21 @@ class ZendriverHandler(BaseHTTPRequestHandler):
# 비동기 fetch 실행 # 비동기 fetch 실행
if loop: if loop:
result = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
fetch_with_browser(url, timeout), loop fetch_with_browser(url, timeout), loop
).result(timeout=timeout + 10) )
result: Dict[str, Any] = future.result(timeout=timeout + 15)
self._send_json(200, result) self._send_json(200, result)
else: else:
self._send_json(500, {"success": False, "error": "Event loop not ready"}) self._send_json(500, {"success": False, "error": "Event loop not ready"})
except Exception as e: except Exception as e:
self._send_json(500, {"success": False, "error": str(e), "traceback": traceback.format_exc()}) log_debug(f"[Handler] Error: {e}\n{traceback.format_exc()}")
self._send_json(500, {
"success": False,
"error": str(e) or e.__class__.__name__,
"traceback": traceback.format_exc()
})
elif self.path == "/health": elif self.path == "/health":
self._send_json(200, {"status": "ok", "browser_ready": browser is not None}) self._send_json(200, {"status": "ok", "browser_ready": browser is not None})
@@ -69,16 +95,21 @@ class ZendriverHandler(BaseHTTPRequestHandler):
self._send_json(404, {"error": "Not found"}) self._send_json(404, {"error": "Not found"})
def do_GET(self) -> None: def do_GET(self) -> None:
"""GET 요청 처리 (/health)"""
if self.path == "/health": if self.path == "/health":
self._send_json(200, {"status": "ok", "browser_ready": browser is not None}) self._send_json(200, {"status": "ok", "browser_ready": browser is not None})
else: else:
self._send_json(404, {"error": "Not found"}) self._send_json(404, {"error": "Not found"})
def _send_json(self, status_code: int, data: dict) -> None: def _send_json(self, status_code: int, data: Dict[str, Any]) -> None:
self.send_response(status_code) """JSON 응답 전송"""
self.send_header('Content-Type', 'application/json') try:
self.end_headers() self.send_response(status_code)
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8')) self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
except Exception as e:
log_debug(f"[Handler] Failed to send response: {e}")
async def ensure_browser() -> Any: async def ensure_browser() -> Any:
@@ -89,21 +120,23 @@ async def ensure_browser() -> Any:
if browser is None: if browser is None:
try: try:
import zendriver as zd import zendriver as zd
log_debug("[ZendriverDaemon] Starting new browser instance...")
# zendriver.start()는 브라우저를 시작하고 첫 번째 페이지를 반환할 수 있음
browser = await zd.start(headless=True) browser = await zd.start(headless=True)
print(f"[ZendriverDaemon] Browser started", file=sys.stderr) log_debug("[ZendriverDaemon] Browser started successfully")
except Exception as e: except Exception as e:
print(f"[ZendriverDaemon] Failed to start browser: {e}", file=sys.stderr) log_debug(f"[ZendriverDaemon] Failed to start browser: {e}")
browser = None browser = None
raise raise
return browser return browser
async def fetch_with_browser(url: str, timeout: int = 30) -> dict: async def fetch_with_browser(url: str, timeout: int = 30) -> Dict[str, Any]:
"""상시 대기 브라우저로 HTML 페칭""" """상시 대기 브라우저로 HTML 페칭 (탭 유지 방식)"""
global browser global browser
result: dict = {"success": False, "html": "", "elapsed": 0} result: Dict[str, Any] = {"success": False, "html": "", "elapsed": 0.0}
start_time: float = time.time() start_time: float = time.time()
try: try:
@@ -113,38 +146,54 @@ async def fetch_with_browser(url: str, timeout: int = 30) -> dict:
result["error"] = "Browser not available" result["error"] = "Browser not available"
return result return result
# 새 탭에서 페이지 로드 # zendriver의 browser.get(url)은 이미 열린 탭이 있으면 거기서 열려고 시도함.
page = await browser.get(url) # 하지만 모든 탭이 닫히면 StopIteration이 발생할 수 있음.
log_debug(f"[ZendriverDaemon] Fetching URL: {url}")
# 페이지 로드 대기 # StopIteration 방지를 위해 페이지 이동 시도
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: try:
await page.close() # browser.get(url)은 새 탭을 열거나 기존 탭을 사용함
except: page: Any = await browser.get(url)
pass
except Exception as e: # 페이지 로드 대기 (충분히 대기)
result["error"] = str(e) await asyncio.sleep(2.0)
# HTML 추출
html_content: str = await page.get_content()
elapsed: float = time.time() - start_time
if html_content and len(html_content) > 100:
result.update({
"success": True,
"html": html_content,
"elapsed": round(elapsed, 2)
})
log_debug(f"[ZendriverDaemon] Fetch success in {elapsed:.2f}s (Length: {len(html_content)})")
else:
result["error"] = f"Short response: {len(html_content) if html_content else 0} bytes"
result["elapsed"] = round(elapsed, 2)
log_debug(f"[ZendriverDaemon] Fetch failure: Short response ({len(html_content) if html_content else 0} bytes)")
# 여기서 page.close()를 하지 않음! (탭을 하나라도 남겨두어야 StopIteration 방지 가능)
# 대신 나중에 탭이 너무 많아지면 정리하는 로직 필요할 수 있음
except StopIteration:
log_debug("[ZendriverDaemon] StopIteration caught during browser.get, resetting browser")
browser = None
raise
except BaseException as e:
# StopIteration 등 모든 예외 캐치
err_msg: str = str(e) or e.__class__.__name__
result["error"] = err_msg
result["elapsed"] = round(time.time() - start_time, 2) result["elapsed"] = round(time.time() - start_time, 2)
log_debug(f"[ZendriverDaemon] Exception during fetch: {err_msg}")
if not isinstance(e, asyncio.CancelledError):
log_debug(traceback.format_exc())
# 브라우저 오류 시 재시작 플래그 # 브라우저 오류 시 재시작 플래그
if "browser" in str(e).lower() or "closed" in str(e).lower(): if "browser" in err_msg.lower() or "closed" in err_msg.lower() or "stopiteration" in err_msg.lower():
log_debug("[ZendriverDaemon] Resetting browser due to critical error")
browser = None browser = None
return result return result
@@ -155,11 +204,13 @@ async def run_async_loop() -> None:
global loop global loop
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
log_debug("[ZendriverDaemon] Async loop started")
# 브라우저 미리 시작 # 브라우저 미리 시작
try: try:
await ensure_browser() await ensure_browser()
except: except Exception as e:
pass log_debug(f"[ZendriverDaemon] Initial browser start failed: {e}")
# 루프 유지 # 루프 유지
while True: while True:
@@ -168,31 +219,37 @@ async def run_async_loop() -> None:
def run_server() -> None: def run_server() -> None:
"""HTTP 서버 실행""" """HTTP 서버 실행"""
server = HTTPServer(('127.0.0.1', DAEMON_PORT), ZendriverHandler) try:
print(f"[ZendriverDaemon] Starting on port {DAEMON_PORT}", file=sys.stderr) server: HTTPServer = HTTPServer(('127.0.0.1', DAEMON_PORT), ZendriverHandler)
server.serve_forever() log_debug(f"[ZendriverDaemon] HTTP server starting on port {DAEMON_PORT}")
server.serve_forever()
except Exception as e:
log_debug(f"[ZendriverDaemon] HTTP server error: {e}")
def signal_handler(sig: int, frame: Any) -> None: def signal_handler(sig: int, frame: Any) -> None:
"""종료 시그널 처리""" """종료 시그널 처리"""
global browser global browser
print("\n[ZendriverDaemon] Shutting down...", file=sys.stderr) log_debug("\n[ZendriverDaemon] Shutdown signal received")
if browser: if browser:
try: try:
asyncio.run(browser.stop()) if loop and loop.is_running():
except: future = asyncio.run_coroutine_threadsafe(browser.stop(), loop)
pass future.result(timeout=5)
except Exception as e:
log_debug(f"[ZendriverDaemon] Error during browser stop: {e}")
sys.exit(0) sys.exit(0)
if __name__ == "__main__": if __name__ == "__main__":
# 시그널 핸들러 등록
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler) signal.signal(signal.SIGTERM, signal_handler)
# 비동기 루프를 별도 스레드에서 실행 # 비동기 루프를 별도 스레드에서 실행
async_thread = Thread(target=lambda: asyncio.run(run_async_loop()), daemon=True) async_thread: Thread = Thread(target=lambda: asyncio.run(run_async_loop()), daemon=True)
async_thread.start() async_thread.start()
# HTTP 서버 실행 (메인 스레드) # HTTP 서버 실행 (메인 스레드)
time.sleep(1) # 브라우저 시작 대기 time.sleep(2) # 초기화 대기
run_server() run_server()

View File

@@ -210,25 +210,34 @@ class LogicAniLife(AnimeModuleBase):
is_stealth: bool = False, is_stealth: bool = False,
timeout: int = 5, timeout: int = 5,
headless: bool = False, headless: bool = False,
): ) -> str:
import time
start_time = time.time()
data = "" data = ""
try: try:
print("cloudflare protection bypass ==================") # --- Zendriver Daemon 최적 최적화 (v0.5.0) ---
# print(self) from .mod_ohli24 import LogicOhli24
# return LogicAniLife.get_html_cloudflare(url) if LogicOhli24.is_zendriver_daemon_running():
# return self.get_html_selenium(url=url, referer=referer, is_stealth=is_stealth) logger.info(f"[Anilife] Trying Zendriver Daemon: {url}")
# url: str, daemon_res = LogicOhli24.fetch_via_daemon(url, timeout=30)
# headless: bool = False, elapsed = time.time() - start_time
# referer: str = None, if daemon_res.get("success") and daemon_res.get("html"):
# engine: str = "chrome", logger.info(f"[Anilife] Daemon success in {elapsed:.2f}s, HTML len: {len(daemon_res['html'])}")
# stealth: bool = False, return daemon_res["html"]
# return asyncio.run(LogicAniLife.get_html_playwright(url, engine="chrome", headless=True)) else:
return asyncio.run( logger.warning(f"[Anilife] Daemon failed in {elapsed:.2f}s: {daemon_res.get('error', 'Unknown')}")
LogicAniLife.get_html_playwright(
# --- Fallback: Playwright ---
logger.info("[Anilife] Falling back to Playwright...")
from .lib.crawler import Crawler
res = asyncio.run(
Crawler().get_html_playwright(
url, engine="chromium", headless=headless url, engine="chromium", headless=headless
) )
) )
# return LogicAniLife.get_html_playwright_sync(url, engine="chrome", headless=True) elapsed = time.time() - start_time
logger.info(f"[Anilife] Playwright finished in {elapsed:.2f}s")
return res
except Exception as e: except Exception as e:
logger.error("Exception:%s", e) logger.error("Exception:%s", e)
@@ -543,7 +552,7 @@ class LogicAniLife(AnimeModuleBase):
page = request.form["page"] page = request.form["page"]
try: try:
data = self.get_anime_info(cate, page) data = self.get_anime_info(cate, page)
logger.debug(data) # logger.debug(data)
if data is not None: if data is not None:
return jsonify( return jsonify(
{"ret": "success", "cate": cate, "page": page, "data": data} {"ret": "success", "cate": cate, "page": page, "data": data}
@@ -829,7 +838,7 @@ class LogicAniLife(AnimeModuleBase):
return True return True
# 시리즈 정보를 가져오는 함수 (cloudscraper 버전) # 시리즈 정보를 가져오는 함수 (cloudscraper 버전)
def get_series_info(self, code): def get_series_info(self, code: str) -> Dict[str, Any]:
try: try:
if code.isdigit(): if code.isdigit():
url = P.ModelSetting.get("anilife_url") + "/detail/id/" + code url = P.ModelSetting.get("anilife_url") + "/detail/id/" + code
@@ -838,26 +847,14 @@ class LogicAniLife(AnimeModuleBase):
logger.debug("get_series_info()::url > %s", url) logger.debug("get_series_info()::url > %s", url)
# cloudscraper를 사용하여 Cloudflare 우회 # self.get_html을 사용하여 Zendriver Daemon 우선 시도
scraper = cloudscraper.create_scraper( html_content = self.get_html(url)
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
# 리다이렉트 자동 처리 (숫자 ID → UUID 페이지로 리다이렉트됨) if not html_content:
response = scraper.get(url, timeout=15, allow_redirects=True) logger.error(f"Failed to fetch series info: Empty content")
return {"ret": "error", "log": "Empty content"}
if response.status_code != 200:
logger.error(f"Failed to fetch series info: HTTP {response.status_code}")
return {"ret": "error", "log": f"HTTP {response.status_code}"}
# 최종 URL 로깅 (리다이렉트된 경우)
logger.debug(f"Final URL after redirect: {response.url}")
tree = html.fromstring(response.text) tree = html.fromstring(html_content)
# tree = html.fromstring(response_data) # tree = html.fromstring(response_data)
# logger.debug(response_data) # logger.debug(response_data)
@@ -992,7 +989,7 @@ class LogicAniLife(AnimeModuleBase):
return {"ret": "exception", "log": str(e)} return {"ret": "exception", "log": str(e)}
@staticmethod @staticmethod
def get_real_link(url): def get_real_link(url: str) -> str:
response = requests.get(url) response = requests.get(url)
if response.history: if response.history:
print("Request was redirected") print("Request was redirected")
@@ -1004,8 +1001,7 @@ class LogicAniLife(AnimeModuleBase):
else: else:
print("Request was not redirected") print("Request was not redirected")
@staticmethod def get_anime_info(self, cate: str, page: str) -> Dict[str, Any]:
def get_anime_info(cate, page):
logger.debug(f"get_anime_info() routine") logger.debug(f"get_anime_info() routine")
logger.debug(f"cate:: {cate}") logger.debug(f"cate:: {cate}")
wrapper_xpath = '//div[@class="bsx"]' wrapper_xpath = '//div[@class="bsx"]'
@@ -1028,35 +1024,26 @@ class LogicAniLife(AnimeModuleBase):
+ "/vodtype/categorize/Movie/" + "/vodtype/categorize/Movie/"
+ page + page
) )
# cate == "complete":
logger.info("url:::> %s", url) logger.info("url:::> %s", url)
data = {} data: Dict[str, Any] = {}
# cloudscraper를 사용하여 Cloudflare 우회 url = url.split("?")[0]
scraper = cloudscraper.create_scraper( html_content: str = self.get_html(url)
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
response = scraper.get(url, timeout=15) if not html_content:
logger.error("Failed to fetch anime info: Empty content")
if response.status_code != 200: return {"ret": "error", "log": "Empty content"}
logger.error(f"Failed to fetch anime info: HTTP {response.status_code}")
return {"ret": "error", "log": f"HTTP {response.status_code}"}
LogicAniLife.episode_url = response.url LogicAniLife.episode_url = url
logger.info(response.url) # logger.info(response.url)
logger.debug(LogicAniLife.episode_url) # logger.debug(LogicAniLife.episode_url)
soup_text = BeautifulSoup(response.text, "lxml") soup_text = BeautifulSoup(html_content, "lxml")
tree = html.fromstring(response.text) tree = html.fromstring(html_content)
tmp_items = tree.xpath(wrapper_xpath) tmp_items = tree.xpath(wrapper_xpath)
logger.debug(tmp_items) # logger.debug(tmp_items)
data["anime_count"] = len(tmp_items) data["anime_count"] = len(tmp_items)
data["anime_list"] = [] data["anime_list"] = []
@@ -1115,7 +1102,7 @@ class LogicAniLife(AnimeModuleBase):
# cloudscraper 버전 직접 사용 (외부 playwright API 서버 불필요) # cloudscraper 버전 직접 사용 (외부 playwright API 서버 불필요)
return self.get_search_result_v2(query, page, cate) return self.get_search_result_v2(query, page, cate)
def get_search_result_v2(self, query, page, cate): def get_search_result_v2(self, query: str, page: int, cate: str) -> Dict[str, Any]:
""" """
anilife.live 검색 결과를 가져오는 함수 (cloudscraper 버전) anilife.live 검색 결과를 가져오는 함수 (cloudscraper 버전)
외부 playwright API 서버 없이 직접 cloudscraper를 사용 외부 playwright API 서버 없이 직접 cloudscraper를 사용
@@ -1135,22 +1122,14 @@ class LogicAniLife(AnimeModuleBase):
logger.info("get_search_result_v2()::url> %s", url) logger.info("get_search_result_v2()::url> %s", url)
data = {} data = {}
# cloudscraper를 사용하여 Cloudflare 우회 # self.get_html을 사용하여 Zendriver Daemon 우선 시도
scraper = cloudscraper.create_scraper( html_content = self.get_html(url)
browser={
"browser": "chrome",
"platform": "windows",
"desktop": True
}
)
response = scraper.get(url, timeout=15) if not html_content:
logger.error(f"Failed to fetch search results: Empty content")
if response.status_code != 200: return {"ret": "error", "log": "Empty content"}
logger.error(f"Failed to fetch search results: HTTP {response.status_code}")
return {"ret": "error", "log": f"HTTP {response.status_code}"}
tree = html.fromstring(response.text) tree = html.fromstring(html_content)
# 검색 결과 항목들 (div.bsx) # 검색 결과 항목들 (div.bsx)
tmp_items = tree.xpath('//div[@class="bsx"]') tmp_items = tree.xpath('//div[@class="bsx"]')

View File

@@ -297,17 +297,19 @@ class LogicOhli24(AnimeModuleBase):
self.current_data = data self.current_data = data
return jsonify({"ret": "success", "data": data, "code": code}) return jsonify({"ret": "success", "data": data, "code": code})
elif sub == "anime_list": elif sub == "anime_list":
data = self.get_anime_info(cate, page) sca = request.form.get("sca", None)
data = self.get_anime_info(cate, page, sca=sca)
if isinstance(data, dict) and data.get("ret") == "error": if isinstance(data, dict) and data.get("ret") == "error":
return jsonify(data) return jsonify(data)
return jsonify({"ret": "success", "cate": cate, "page": page, "data": data}) return jsonify({"ret": "success", "cate": cate, "page": page, "data": data, "sca": sca})
elif sub == "complete_list": elif sub == "complete_list":
logger.debug("cate:: %s", cate) logger.debug("cate:: %s", cate)
page = request.form["page"] page = request.form["page"]
data = self.get_anime_info(cate, page) sca = request.form.get("sca", None)
data = self.get_anime_info(cate, page, sca=sca)
if isinstance(data, dict) and data.get("ret") == "error": if isinstance(data, dict) and data.get("ret") == "error":
return jsonify(data) return jsonify(data)
return jsonify({"ret": "success", "cate": cate, "page": page, "data": data}) return jsonify({"ret": "success", "cate": cate, "page": page, "data": data, "sca": sca})
elif sub == "search": elif sub == "search":
query = request.form["query"] query = request.form["query"]
page = request.form["page"] page = request.form["page"]
@@ -1073,17 +1075,13 @@ class LogicOhli24(AnimeModuleBase):
P.logger.error(traceback.format_exc()) P.logger.error(traceback.format_exc())
return {"ret": "error", "log": str(e)} return {"ret": "error", "log": str(e)}
def get_anime_info(self, cate: str, page: str) -> Dict[str, Any]: def get_anime_info(self, cate: str, page: str, sca: Optional[str] = None) -> Dict[str, Any]:
"""카테고리별 애니메이션 목록 조회.""" """카테고리별 애니메이션 목록 조회."""
logger.debug(f"get_anime_info: cate={cate}, page={page}") logger.debug(f"get_anime_info: cate={cate}, page={page}, sca={sca}")
try: try:
if cate == "ing": url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + cate + "&page=" + page
url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + cate + "&page=" + page if sca:
elif cate == "movie": url += "&sca=" + sca
url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + cate + "&page=" + page
else:
url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + cate + "&page=" + page
# cate == "complete":
logger.info("url:::> %s", url) logger.info("url:::> %s", url)
data = {} data = {}
response_data = LogicOhli24.get_html(url, timeout=10) response_data = LogicOhli24.get_html(url, timeout=10)

View File

@@ -70,31 +70,39 @@
</button> </button>
</div> </div>
<div class="modal-body" style="padding: 0;"> <div class="modal-body" style="padding: 0;">
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" style="width: 100%; height: auto; max-height: 75vh;"> <div class="video-container" style="position: relative; overflow: hidden; background: #000;">
<p class="vjs-no-js">JavaScript가 필요합니다.</p> <video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" playsinline webkit-playsinline style="width: 100%; height: auto; max-height: 80vh;">
</video> <p class="vjs-no-js">JavaScript가 필요합니다.</p>
</video>
<!-- 화면 꽉 채우기 토글 버튼 (모바일용) -->
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
<i class="fa fa-expand"></i>
</button>
</div>
<!-- 플레이리스트 컨트롤 UI --> <!-- 플레이리스트 컨트롤 UI -->
<div class="playlist-controls" style="padding: 12px 16px; background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%); border-top: 1px solid rgba(255,255,255,0.1);"> <div class="playlist-controls">
<!-- 현재 재생 정보 + 버튼 --> <!-- 현재 재생 정보 + 버튼 -->
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;"> <div class="playlist-header">
<button id="btn-prev-ep" class="playlist-nav-btn" style="display: none;" title="이전 에피소드"> <button id="btn-prev-ep" class="nav-btn" style="display: none;" title="이전 에피소드">
<i class="fa fa-step-backward"></i> <i class="fa fa-chevron-left"></i>
</button> </button>
<div style="flex: 1; min-width: 200px;"> <div class="playing-info">
<div id="current-video-title" style="color: #fbbf24; font-weight: 600; font-size: 14px;"></div> <div id="current-video-title" class="video-title"></div>
<div id="playlist-progress" style="color: #64748b; font-size: 12px; margin-top: 2px;"></div> <div id="playlist-progress" class="progress-text"></div>
</div> </div>
<button id="btn-next-ep" class="playlist-nav-btn" style="display: none;" title="다음 에피소드"> <button id="btn-next-ep" class="nav-btn" style="display: none;" title="다음 에피소드">
<i class="fa fa-step-forward"></i> <i class="fa fa-chevron-right"></i>
</button>
<button id="btn-toggle-playlist" class="playlist-toggle-btn" title="에피소드 목록">
<i class="fa fa-list"></i>
</button> </button>
<div class="control-group">
<button id="btn-toggle-playlist" class="action-btn" title="목록 토글">
<i class="fa fa-list-ul"></i>
</button>
</div>
</div> </div>
<!-- 에피소드 목록 (접히는) --> <!-- 에피소드 목록 -->
<div id="playlist-list-container" style="display: none; margin-top: 12px; max-height: 200px; overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 8px; padding: 8px;"> <div id="playlist-list-container" class="playlist-drawer">
<div id="playlist-list"></div> <div id="playlist-list" class="playlist-grid"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -369,6 +377,28 @@
playVideoAtIndex(index); playVideoAtIndex(index);
} }
}); });
// 비디오 줌/확장 처리 (모바일 Fullscreen 꽉 차게)
var isVideoZoomed = false;
$('#btn-video-zoom').click(function() {
isVideoZoomed = !isVideoZoomed;
const $video = $('#video-player_html5_api'); // Video.js 내부 태그
if (isVideoZoomed) {
$video.css({
'object-fit': 'cover',
'height': '100%'
});
$(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress');
$.notify('<strong>화면을 꽉 채웠습니다 (일부 잘릴 수 있음)</strong>', {type: 'info', delay: 1000});
} else {
$video.css({
'object-fit': 'contain',
'height': 'auto'
});
$(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand');
$.notify('<strong>원래 비율로 복원되었습니다</strong>', {type: 'info', delay: 1000});
}
});
// 모달 닫을 때 비디오 정지 + 포커스 해제 (aria-hidden 경고 방지) // 모달 닫을 때 비디오 정지 + 포커스 해제 (aria-hidden 경고 방지)
var playlistRefreshInterval = null; var playlistRefreshInterval = null;
@@ -517,7 +547,7 @@
</script> </script>
<style> <style>
/* Pagination & Modal Fixes (Moved to template for specific overrides) */ /* Pagination & Modal Fixes */
.btn-toolbar { justify-content: center; margin: 20px 0; } .btn-toolbar { justify-content: center; margin: 20px 0; }
#page1 .btn-group .btn, #page2 .btn-group .btn { #page1 .btn-group .btn, #page2 .btn-group .btn {
background: rgba(30, 41, 59, 0.6); background: rgba(30, 41, 59, 0.6);
@@ -531,6 +561,107 @@
.modal-dialog { max-width: 95% !important; width: 900px !important; margin: 10px auto; } .modal-dialog { max-width: 95% !important; width: 900px !important; margin: 10px auto; }
.modal-body pre { background: #000 !important; color: #4ade80 !important; padding: 15px !important; border-radius: 8px !important; max-height: 70vh !important; font-size: 12px !important; } .modal-body pre { background: #000 !important; color: #4ade80 !important; padding: 15px !important; border-radius: 8px !important; max-height: 70vh !important; font-size: 12px !important; }
/* Video Player Enhancements */
.playlist-controls {
padding: 16px;
background: linear-gradient(to bottom, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 1));
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.playlist-header {
display: flex;
align-items: center;
gap: 15px;
}
.playing-info { flex: 1; min-width: 0; }
.video-title {
color: #fbbf24; font-weight: 700; font-size: 15px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.progress-text { color: #94a3b8; font-size: 12px; margin-top: 2px; }
.nav-btn, .action-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
cursor: pointer;
}
.nav-btn:hover, .action-btn:hover { background: rgba(59, 130, 246, 0.2); border-color: #3b82f6; }
.action-btn.active { background: #3b82f6; box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); }
.playlist-drawer {
display: none;
margin-top: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
padding: 10px;
max-height: 250px;
overflow-y: auto;
}
.playlist-grid { display: flex; flex-direction: column; gap: 4px; }
.playlist-item {
padding: 10px 15px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
display: flex; align-items: center; gap: 12px;
cursor: pointer; transition: all 0.2s;
font-size: 13px; color: #cbd5e1;
}
.playlist-item:hover { background: rgba(255, 255, 255, 0.08); transform: translateX(5px); }
.playlist-item.active { background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); color: #60a5fa; }
.playlist-item .ep-num { font-weight: 800; color: #fbbf24; min-width: 30px; }
/* Zoom Button overlay */
.video-zoom-btn {
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.15);
color: white;
width: 38px; height: 38px;
border-radius: 10px;
opacity: 0; transition: opacity 0.3s;
display: flex; align-items: center; justify-content: center;
}
.video-container:hover .video-zoom-btn { opacity: 1; }
.video-zoom-btn.active { background: #3b82f6; border-color: transparent; }
/* Video.js skin overrides */
.video-js.vjs-theme-fantasy .vjs-big-play-button {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%) !important;
border: none !important;
width: 90px !important; height: 90px !important;
line-height: 90px !important;
border-radius: 50% !important;
box-shadow: 0 0 30px rgba(37, 99, 235, 0.6) !important;
transition: all 0.3s ease !important;
}
.video-js.vjs-theme-fantasy .vjs-big-play-button .vjs-icon-placeholder:before {
font-size: 60px !important;
line-height: 90px !important;
}
.video-js .vjs-control-bar { background: rgba(15, 23, 42, 0.8) !important; backdrop-filter: blur(10px) !important; }
@media (max-width: 768px) {
#videoModal .modal-dialog { width: 100% !important; margin: 0 !important; }
#videoModal .modal-content { border-radius: 0 !important; height: 100vh; display: flex; flex-direction: column; }
#video-player { max-height: 100vh !important; height: 100% !important; }
.video-container { flex: 1; display: flex; align-items: center; }
.playlist-controls { padding-bottom: 25px; } /* Mobile safe area */
.video-zoom-btn { opacity: 0.8; top: 10px; right: 10px; }
}
@media (max-width: 768px) { @media (max-width: 768px) {
#videoModal .modal-dialog { width: 100% !important; margin: 5px !important; } #videoModal .modal-dialog { width: 100% !important; margin: 5px !important; }
.episode-actions { grid-template-columns: repeat(2, 1fr); gap: 6px; } .episode-actions { grid-template-columns: repeat(2, 1fr); gap: 6px; }

View File

@@ -1318,40 +1318,51 @@ $(document).ready(function(){
}, 100); }, 100);
}); });
</script> </script>
<!-- Video Player Modal (Copied from List page) --> <!-- Video Player Modal -->
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-hidden="true"> <div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-lg" style="max-width: 90%; margin: 1.75rem auto;"> <div class="modal-dialog modal-xl" role="document">
<div class="modal-content" style="background: #0f172a; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 16px;"> <div class="modal-content" style="background: #0f172a; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1);">
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.05); padding: 15px 20px;"> <div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.05); padding: 15px 20px;">
<h5 class="modal-title" style="color: #ecfdf5; font-weight: 700; display: flex; align-items: center; gap: 10px;"> <h5 class="modal-title" style="color: #f1f5f9; font-weight: 700; display: flex; align-items: center; gap: 10px;">
<i class="fa fa-youtube-play" style="color: #10b981;"></i> <i class="fa fa-play-circle" style="color: #3b82f6;"></i>
<span id="current-video-title">Video Player</span> <span id="current-video-title">Video Player</span>
</h5> </h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #94a3b8; text-shadow: none; opacity: 1;"> <button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9; opacity: 1;">
<span aria-hidden="true">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body p-0" style="display: flex; flex-direction: column; height: 80vh;"> <div class="modal-body p-0">
<div style="flex-grow: 1; background: black; position: relative;"> <div class="video-container" style="position: relative; overflow: hidden; background: #000;">
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-forest" controls preload="auto" style="width: 100%; height: 100%;"></video> <video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" playsinline webkit-playsinline style="width: 100%; height: auto; max-height: 80vh;">
</div> <p class="vjs-no-js">JavaScript가 필요합니다.</p>
</video>
<!-- Playlist Control Bar --> <!-- 화면 꽉 채우기 토글 버튼 -->
<div style="height: 60px; background: rgba(6, 78, 59, 0.3); border-top: 1px solid rgba(16, 185, 129, 0.1); display: flex; align-items: center; justify-content: space-between; padding: 0 20px;"> <button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
<div style="display: flex; align-items: center; gap: 15px;"> <i class="fa fa-expand"></i>
<button id="btn-prev-ep" class="playlist-nav-btn" title="이전 에피소드"><i class="fa fa-step-backward"></i></button>
<button id="btn-next-ep" class="playlist-nav-btn" title="다음 에피소드"><i class="fa fa-step-forward"></i></button>
<span id="playlist-progress" style="color: #d1fae5; font-weight: 600; font-size: 14px;"></span>
</div>
<button id="btn-toggle-playlist" class="playlist-toggle-btn active">
<i class="fa fa-list"></i> 플레이리스트
</button> </button>
</div> </div>
<!-- Playlist Drawer --> <!-- 플레이리스트 컨트롤 UI -->
<div id="playlist-list-container" style="height: 0px; background: rgba(2, 44, 34, 0.95); transition: height 0.3s ease; overflow-y: auto; border-top: 1px solid rgba(16, 185, 129, 0.1); display: none;"> <div class="playlist-controls">
<div id="playlist-list" style="padding: 10px;"> <div class="playlist-header">
<!-- JS generated items --> <button id="btn-prev-ep" class="nav-btn" title="이전 에피소드">
<i class="fa fa-chevron-left"></i>
</button>
<div class="playing-info">
<div id="playlist-progress" class="progress-text"></div>
</div>
<button id="btn-next-ep" class="nav-btn" title="다음 에피소드">
<i class="fa fa-chevron-right"></i>
</button>
<div class="control-group">
<button id="btn-toggle-playlist" class="action-btn active" title="목록 토글">
<i class="fa fa-list-ul"></i>
</button>
</div>
</div>
<div id="playlist-list-container" class="playlist-drawer" style="display: block;">
<div id="playlist-list" class="playlist-grid"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1359,87 +1370,97 @@ $(document).ready(function(){
</div> </div>
</div> </div>
<link href="https://vjs.zencdn.net/7.20.3/video-js.css" rel="stylesheet" /> <link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/7.20.3/video.min.js"></script> <script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<style> <style>
/* VideoJS Customization */ /* Video Player Enhancements */
.vjs-theme-forest { --vjs-theme-forest--emerald: #10b981; } .playlist-controls {
.video-js.vjs-theme-forest .vjs-big-play-button { background-color: #10b981; border-color: #34d399; } padding: 16px;
background: linear-gradient(to bottom, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 1));
border-top: 1px solid rgba(255, 255, 255, 0.08);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
.playlist-header { display: flex; align-items: center; gap: 15px; }
.playing-info { flex: 1; min-width: 0; }
.progress-text { color: #94a3b8; font-size: 13px; font-weight: 600; }
.nav-btn, .action-btn {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
width: 36px; height: 36px;
border-radius: 10px;
display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
cursor: pointer;
}
.nav-btn:hover, .action-btn:hover { background: rgba(59, 130, 246, 0.2); border-color: #3b82f6; }
.action-btn.active { background: #3b82f6; box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); }
.playlist-nav-btn { .playlist-drawer {
width: 40px; height: 40px; background: linear-gradient(135deg, #10b981, #059669); margin-top: 15px;
border: none; border-radius: 50%; color: white; font-size: 14px; background: rgba(0, 0, 0, 0.3);
cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; border-radius: 10px;
} padding: 10px;
.playlist-nav-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); } max-height: 250px;
.playlist-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } overflow-y: auto;
}
.playlist-grid { display: flex; flex-direction: column; gap: 4px; }
.playlist-item {
padding: 10px 15px;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
display: flex; align-items: center; gap: 12px;
cursor: pointer; transition: all 0.2s;
font-size: 13px; color: #cbd5e1;
}
.playlist-item:hover { background: rgba(255, 255, 255, 0.08); transform: translateX(5px); }
.playlist-item.active { background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); color: #60a5fa; font-weight: 700; }
.playlist-toggle-btn { /* Zoom Button overlay */
padding: 8px 14px; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); .video-zoom-btn {
border-radius: 8px; color: #6ee7b7; font-size: 13px; cursor: pointer; transition: all 0.2s ease; position: absolute;
} top: 15px;
.playlist-toggle-btn:hover { background: rgba(16, 185, 129, 0.2); color: #ecfdf5; } right: 15px;
.playlist-toggle-btn.active { background: rgba(16, 185, 129, 0.3); border-color: #10b981; color: #10b981; } z-index: 10;
background: rgba(15, 23, 42, 0.6);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.15);
color: white;
width: 38px; height: 38px;
border-radius: 10px;
opacity: 0; transition: opacity 0.3s;
display: flex; align-items: center; justify-content: center;
}
.video-container:hover .video-zoom-btn { opacity: 1; }
.video-zoom-btn.active { background: #3b82f6; border-color: transparent; }
.playlist-item { /* Video.js skin overrides */
padding: 10px 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s; .video-js.vjs-theme-fantasy .vjs-big-play-button {
color: #d1fae5; font-size: 13px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid rgba(16, 185, 129, 0.05); background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%) !important;
} border: none !important;
.playlist-item:hover { background: rgba(16, 185, 129, 0.1); color: #fff; } width: 90px !important; height: 90px !important;
.playlist-item.active { background: rgba(16, 185, 129, 0.2); color: #10b981; font-weight: 600; border-left: 3px solid #10b981; } line-height: 90px !important;
border-radius: 50% !important;
box-shadow: 0 0 30px rgba(37, 99, 235, 0.6) !important;
transition: all 0.3s ease !important;
}
.video-js.vjs-theme-fantasy .vjs-big-play-button .vjs-icon-placeholder:before {
font-size: 60px !important;
line-height: 90px !important;
}
/* ========== Mobile Video Modal Fix ========== */ @media (max-width: 768px) {
@media (max-width: 768px) { #videoModal .modal-dialog { width: 100% !important; margin: 0 !important; max-width: 100% !important; }
/* 모달 크기 조정 */ #videoModal .modal-content { border-radius: 0 !important; height: 100vh; display: flex; flex-direction: column; }
#videoModal .modal-dialog { .video-container { flex: 1; display: flex; align-items: center; }
margin: 10px auto !important; .playlist-controls { padding-bottom: 25px; }
max-width: calc(100vw - 20px) !important; .video-zoom-btn { opacity: 0.8; top: 10px; right: 10px; }
} }
/* 모달 바디 높이 조정 */
#videoModal .modal-body {
height: auto !important;
max-height: 75vh !important;
}
/* 비디오 높이 제한 */
#video-player, .video-js {
max-height: 40vh !important;
}
/* 플레이리스트 높이 제한 */
#playlist-list-container {
max-height: 20vh !important;
}
/* 플레이리스트 컨트롤 간소화 */
.playlist-nav-btn {
width: 32px !important;
height: 32px !important;
font-size: 12px !important;
}
.playlist-toggle-btn {
padding: 6px 10px !important;
font-size: 11px !important;
}
#playlist-progress {
font-size: 12px !important;
}
}
/* 초소형 모바일 (400px 미만) */
@media (max-width: 400px) {
#video-player, .video-js {
max-height: 35vh !important;
}
#playlist-list-container {
max-height: 15vh !important;
}
}
</style> </style>
<script> <script>
@@ -1474,6 +1495,21 @@ function play_video(path, filename) {
playVideoAtIndex(currentPlaylistIndex + 1); playVideoAtIndex(currentPlaylistIndex + 1);
} }
}); });
// 비디오 줌/확장 처리
$('#btn-video-zoom').click(function() {
const $video = $('#video-player_html5_api');
$(this).toggleClass('active');
if ($(this).hasClass('active')) {
$video.css({'object-fit': 'cover', 'height': '100%'});
$(this).find('i').removeClass('fa-expand').addClass('fa-compress');
$.notify('화면을 꽉 채웠습니다.', {type: 'info', delay: 1000});
} else {
$video.css({'object-fit': 'contain', 'height': 'auto'});
$(this).find('i').removeClass('fa-compress').addClass('fa-expand');
$.notify('원래 비율로 복원되었습니다.', {type: 'info', delay: 1000});
}
});
} }
playVideoAtIndex(currentPlaylistIndex); playVideoAtIndex(currentPlaylistIndex);
@@ -1569,13 +1605,7 @@ $('#btn-next-ep').click(function() { playVideoAtIndex(currentPlaylistIndex + 1);
$('#btn-toggle-playlist').click(function() { $('#btn-toggle-playlist').click(function() {
$(this).toggleClass('active'); $(this).toggleClass('active');
var container = $('#playlist-list-container'); var container = $('#playlist-list-container');
if (container.is(':visible')) { container.slideToggle(300);
container.css('height', '0');
setTimeout(function() { container.hide(); }, 300);
} else {
container.show();
setTimeout(function() { container.css('height', '200px'); }, 10);
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -3,25 +3,20 @@
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/> <link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
<div id="preloader" class="loader"> <!-- Modern UI Loader -->
<div class="loader-inner"> <div id="preloader" class="loader-bg">
<div class="loader-line-wrap"> <div class="modern-loader">
<div class="loader-line"></div> <div class="spinner-ring"></div>
</div> <div class="spinner-ring"></div>
<div class="loader-line-wrap"> <div class="spinner-ring"></div>
<div class="loader-line"></div> <div class="loader-text">Loading Ohli24</div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
</div> </div>
</div> </div>
<!-- AJAX Loading Spinner (Global) -->
<div id="ajax_loader" class="ajax-loader-container" style="display: none;">
<div class="ajax-spinner"></div>
</div>
<div id="yommi_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%;"> <div id="yommi_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%;">
<!-- Search Section --> <!-- Search Section -->
<div class="card p-4 mb-4 border-0" style="background: rgba(30,30,40,0.6); backdrop-filter: blur(10px); border-radius: 16px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);"> <div class="card p-4 mb-4 border-0" style="background: rgba(30,30,40,0.6); backdrop-filter: blur(10px); border-radius: 16px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
@@ -48,9 +43,22 @@
<!-- Category Buttons --> <!-- Category Buttons -->
<div class="row mt-4 justify-content-center"> <div class="row mt-4 justify-content-center">
<div id="anime_category" class="d-flex flex-wrap justify-content-center gap-2" role="group"> <div id="anime_category" class="d-flex flex-wrap justify-content-center gap-2" role="group">
<button id="ing" type="button" class="btn btn-outline-success btn-pill px-4 mx-1 active-glow">방영중</button> <button id="ing" type="button" class="btn btn-success btn-pill px-4 mx-1 active active-glow">방영중</button>
<button id="fin" type="button" class="btn btn-outline-light btn-pill px-4 mx-1 active-glow">완결</button>
<button id="theater" type="button" class="btn btn-outline-primary btn-pill px-4 mx-1 active-glow">극장판</button> <button id="theater" type="button" class="btn btn-outline-primary btn-pill px-4 mx-1 active-glow">극장판</button>
<button id="complete_anilist" type="button" class="btn btn-outline-light btn-pill px-4 mx-1 active-glow">완결</button> </div>
</div>
<!-- Year Selection (Sub-category) -->
<div id="year_filter_row" class="row mt-3 justify-content-center animate__animated animate__fadeIn" style="display: none;">
<div id="year_category" class="d-flex flex-wrap justify-content-center gap-2" role="group">
<button data-sca="" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 active">전체</button>
<button data-sca="2025" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3">2025</button>
<button data-sca="2024" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3">2024</button>
<button data-sca="2023" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3">2023</button>
<button data-sca="2022" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3">2022</button>
<button data-sca="2021" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3">2021</button>
<button data-sca="2020" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3">2020</button>
</div> </div>
</div> </div>
</div> </div>
@@ -85,7 +93,8 @@
// let current_data = null; // let current_data = null;
let page = 1; let page = 1;
let next_page = Number let next_page = Number
let current_cate = '' let current_cate = 'ing'
let current_sca = ''
let current_query = '' let current_query = ''
const loader = document.getElementById("preloader"); const loader = document.getElementById("preloader");
@@ -111,10 +120,12 @@
observer.observe(); observer.observe();
const get_anime_list = (type, page) => { const get_anime_list = (type, page, sca = '') => {
// console.log(`type: ${type}, page: ${page}`) // console.log(`type: ${type}, page: ${page}, sca: ${sca}`)
let url = '' let url = ''
let data = {"page": page, "type": type} let data = {"page": page, "type": type}
if (sca) data.sca = sca;
current_sca = sca;
switch (type) { switch (type) {
case 'ing': case 'ing':
@@ -137,12 +148,17 @@
break; break;
} }
$('#ajax_loader').show();
$.ajax({ $.ajax({
url: url, url: url,
type: "POST", type: "POST",
data: data, data: data,
cache: false, cache: false,
global: false,
dataType: "json", dataType: "json",
complete: () => {
$('#ajax_loader').hide();
},
success: (ret) => { success: (ret) => {
let current_screen_movie_data = ret let current_screen_movie_data = ret
// console.log('ret::>', ret) // console.log('ret::>', ret)
@@ -297,6 +313,7 @@
str += '<div class="d-flex justify-content-between align-items-center mb-3">'; str += '<div class="d-flex justify-content-between align-items-center mb-3">';
let title = current_cate === 'movie' ? '극장판' : (current_cate === 'theater' ? '극장판(구)' : '완결 애니메이션'); let title = current_cate === 'movie' ? '극장판' : (current_cate === 'theater' ? '극장판(구)' : '완결 애니메이션');
if (current_sca) title += ' (' + current_sca + ')';
str += '<h5 class="text-white font-weight-bold border-left pl-3" style="border-width: 4px !important; border-color: #ffd700 !important;">' + title + '</h5>'; str += '<h5 class="text-white font-weight-bold border-left pl-3" style="border-width: 4px !important; border-color: #ffd700 !important;">' + title + '</h5>';
str += '<button type="button" class="btn btn-sm btn-dark rounded-pill px-3">Page <span class="badge badge-warning ml-1">' + page + '</span></button>'; str += '<button type="button" class="btn btn-sm btn-dark rounded-pill px-3">Page <span class="badge badge-warning ml-1">' + page + '</span></button>';
str += '</div>'; str += '</div>';
@@ -367,13 +384,18 @@
return false; return false;
} }
$('#ajax_loader').show();
$.ajax({ $.ajax({
url: "/" + package_name + "/ajax/" + sub + "/search", url: "/" + package_name + "/ajax/" + sub + "/search",
type: "POST", type: "POST",
cache: false, cache: false,
global: false,
data: {query: query, type: current_cate, page: page}, data: {query: query, type: current_cate, page: page},
// dataType: "json", // dataType: "json",
contentType: "application/x-www-form-urlencoded; charset=UTF-8", contentType: "application/x-www-form-urlencoded; charset=UTF-8",
complete: () => {
$('#ajax_loader').hide();
},
success: function (ret) { success: function (ret) {
if (ret.ret) { if (ret.ret) {
// console.log('ret:::', ret) // console.log('ret:::', ret)
@@ -388,24 +410,44 @@
}); });
}); });
// Category button active state handling
function updateActiveButton(id) {
$('#anime_category button').removeClass('active btn-success btn-light btn-primary text-dark');
$('#anime_category button#ing').addClass('btn-outline-success');
$('#anime_category button#fin').addClass('btn-outline-light');
$('#anime_category button#theater').addClass('btn-outline-primary');
const btn = $('#' + id);
btn.addClass('active');
if (id === 'ing') btn.removeClass('btn-outline-success').addClass('btn-success');
else if (id === 'fin') btn.removeClass('btn-outline-light').addClass('btn-light text-dark');
else if (id === 'theater') btn.removeClass('btn-outline-primary').addClass('btn-primary');
}
$('#anime_category #ing').on("click", function () { $('#anime_category #ing').on("click", function () {
// {#// console.log(this.id)#} updateActiveButton('ing');
// let spinner = document.getElementById('spinner');
// spinner.style.visibility = 'visible';
get_anime_list("ing", 1) get_anime_list("ing", 1)
}) })
$('#anime_category #complete_anilist').on("click", function () { $('#anime_category #fin').on("click", function () {
// {#// console.log(this.id)#} updateActiveButton('fin');
// let spinner = document.getElementById('spinner'); $('#year_filter_row').fadeIn();
// spinner.style.visibility = 'visible'; get_anime_list("fin", 1, current_sca)
get_anime_list("fin", 1)
}) })
$('#year_category button').on("click", function() {
$('#year_category button').removeClass('active btn-secondary').addClass('btn-outline-secondary');
$(this).removeClass('btn-outline-secondary').addClass('active btn-secondary');
current_sca = $(this).data('sca');
get_anime_list("fin", 1, current_sca);
});
$('#anime_category #ing, #anime_category #theater').on("click", function() {
$('#year_filter_row').hide();
});
$('#anime_category #theater').on("click", function () { $('#anime_category #theater').on("click", function () {
// {#// console.log(this.id)#} updateActiveButton('theater');
// let spinner = document.getElementById('spinner');
// spinner.style.visibility = 'visible';
get_anime_list("theater", 1) get_anime_list("theater", 1)
}) })
@@ -414,12 +456,17 @@
e.preventDefault(); e.preventDefault();
const code = document.getElementById("code").value const code = document.getElementById("code").value
// console.log(code) // console.log(code)
$('#ajax_loader').show();
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/analysis', url: '/' + package_name + '/ajax/' + sub + '/analysis',
type: "POST", type: "POST",
cache: false, cache: false,
global: false,
data: {code: code}, data: {code: code},
dataType: "json", dataType: "json",
complete: () => {
$('#ajax_loader').hide();
},
success: function (ret) { success: function (ret) {
if (ret.ret === 'success' && ret.data != null) { if (ret.ret === 'success' && ret.data != null) {
// // console.log(ret.code) // // console.log(ret.code)
@@ -504,6 +551,7 @@
const loadNextAnimes = (cate, page) => { const loadNextAnimes = (cate, page) => {
// spinner.style.display = "block"; // spinner.style.display = "block";
let data = {type: cate, page: String(page)}; let data = {type: cate, page: String(page)};
if (current_sca) data.sca = current_sca;
let url = '' let url = ''
switch (cate) { switch (cate) {
case 'ing': case 'ing':
@@ -531,6 +579,7 @@
break; break;
} }
$('#ajax_loader').show();
fetch(url, { fetch(url, {
method: "POST", method: "POST",
cache: "no-cache", cache: "no-cache",
@@ -554,7 +603,10 @@
page++; page++;
next_page++; next_page++;
}) })
.catch((error) => console.error("Error:", error)); .catch((error) => console.error("Error:", error))
.finally(() => {
$('#ajax_loader').hide();
});
}; };
@@ -719,19 +771,67 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
/* Preloader (Original but styled) */ /* Modern Premium Preloader */
#preloader { .loader-bg {
background-color: #0f172a;
position: fixed; position: fixed;
top: 0; left: 0; right: 0; bottom: 0; top: 0; left: 0; width: 100%; height: 100%;
z-index: 9999; background: #0f172a;
display: flex; display: flex; align-items: center; justify-content: center;
align-items: center; z-index: 10000;
justify-content: center;
} }
.loader-inner { .modern-loader {
position: relative; position: relative;
width: 120px; height: 120px;
display: flex; align-items: center; justify-content: center;
}
.spinner-ring {
position: absolute;
width: 100%; height: 100%;
border: 4px solid transparent;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.spinner-ring:nth-child(2) { border-top-color: #00d4ff; animation-delay: -0.3s; width: 80%; height: 80%; }
.spinner-ring:nth-child(3) { border-top-color: #fbbf24; animation-delay: -0.6s; width: 60%; height: 60%; }
.loader-text {
position: absolute;
bottom: -40px;
color: #94a3b8;
font-size: 14px;
font-weight: 700;
letter-spacing: 2px;
text-transform: uppercase;
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
/* AJAX Global Spinner */
.ajax-loader-container {
position: fixed;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(8px);
padding: 25px;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.ajax-spinner {
width: 50px; height: 50px;
border: 4px solid rgba(59, 130, 246, 0.1);
border-left-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
/* Scrollbar */ /* Scrollbar */