Compare commits

..

53 Commits

Author SHA1 Message Date
583ba8dbcf Bump version to 0.7.17: Fix Ohli24 naming, queue controls, and analysis badge mismatch 2026-02-17 10:26:28 +09:00
25688db376 Linkkf Fixes: resolve unknown sub add_queue and JS errors, refactor file handling, bump version to 0.7.16 2026-01-27 16:01:52 +09:00
31aaaaf8e9 Fix indentation and ensure unique chrome profiles for Synology Docker stability 2026-01-19 21:56:31 +09:00
677baf662e Enhance zendriver safety and lengthen handler timeouts for Synology 2026-01-19 21:53:49 +09:00
4e0401c95f Reinforce profile cleanup with rm -rf for Linux/Docker/Root 2026-01-19 21:50:18 +09:00
fcee5a5919 Force clean profile dir to fix SingletonLock error in Synology Docker 2026-01-19 21:46:53 +09:00
cf4f7ab7b4 Reinforce zendriver startup for Synology Docker (extended timeouts and logging) 2026-01-19 21:42:03 +09:00
7abaaa8d38 Bump version to 0.7.10 2026-01-19 21:34:43 +09:00
74655b31df Correct zendriver sandbox parameter for root/docker 2026-01-19 21:34:25 +09:00
b22e544f0b Add auto-reset for browser crash (0-byte response) in Synology Docker 2026-01-19 21:22:37 +09:00
4200f48ec0 Fix zendriver daemon robustness and switch to FF_3.10 environment 2026-01-19 21:14:47 +09:00
f01f41499c Bump version to 0.7.7: Fix Linkkf headers and Anilife speed optimization 2026-01-19 14:58:27 +09:00
ecc16520be Add horizontal scroll hint (fade) to mobile menus (v0.7.6) 2026-01-12 21:32:24 +09:00
59b7715a93 Final mobile layout stabilization (v0.7.5): Fix horizontal shift and background rendering 2026-01-12 21:07:57 +09:00
50982f19b0 Fix mobile layout shift and menu overflow (comprehensive CSS normalization) 2026-01-12 21:04:11 +09:00
41c7fa456e Bump version to v0.7.3: Anilife Mobile UI Overhaul & Linkkf Fixes 2026-01-12 20:58:02 +09:00
69a0fe0078 Fix Linkkf path pollution and status reflection: apply os.path.normpath and improve GDM callback logging 2026-01-11 18:47:32 +09:00
535a0ca3f9 Fix Linkkf GDM progress display: implement Smart Sync to prevent flickering 2026-01-11 18:28:33 +09:00
9b3f4f72bd v0.7.2: Linkkf subtitle download, list-request integration, and hot reload stability improvements 2026-01-11 16:56:35 +09:00
8e3594386d Hotfix: v0.7.1 - Fix 'undefined' fields in queue and improve real-time socket updates 2026-01-11 14:18:08 +09:00
02d26a104d Bump version to v0.7.0: Enhanced GDM integration, status sync, and notification system 2026-01-11 14:00:27 +09:00
1175acd16e v0.6.25: Add self-update feature with hot reload 2026-01-09 22:18:48 +09:00
783b44b6b6 docs: Update README with v0.6.24 changelog 2026-01-08 21:11:15 +09:00
6c31b96be6 feat: Add tag chips UI to all 3 setting pages (ohli24, anilife, linkkf) - Modern UI replacing textarea with draggable chips - Tab renamed from 홈화면 자동 to 자동등록 2026-01-08 16:38:24 +09:00
0a811fdfc1 fix: Handle Zendriver HTML-wrapped JSON responses in get_anime_info
- Extract JSON from <pre> tag when Zendriver returns HTML-wrapped response
- Add guard to prevent TypeError when items_xpath is None after JSON parsing fails
- Improves error logging for debugging failed API responses
2026-01-08 01:34:28 +09:00
0696a40901 v0.6.24: Fix Linkkf search page items_xpath NoneType error
- Add missing items_xpath=None in else block for JSON API responses
- Prevents lxml TypeError when parsing JSON as HTML
2026-01-08 01:31:51 +09:00
24217712a6 v0.6.23: Fix Linkkf download - CDP Headers wrapper, yt-dlp --add-header support
- Fix zendriver_daemon CDP Headers bug (wrap dict with zd.cdp.network.Headers())
- Fix HTML entity decoding in iframe URLs (use html.unescape())
- Simplify GDM source_type to always use 'linkkf'
2026-01-08 01:29:36 +09:00
d1866111c7 v0.6.22: Robust Linkkf extraction with zendriver fallback 2026-01-07 23:48:00 +09:00
76be367a9e v0.6.21: Fix Linkkf GDM delegation and extraction logic 2026-01-07 23:31:26 +09:00
c4cba97a7f v0.6.20: GDM integration fixes and app context error resolution 2026-01-07 22:35:46 +09:00
7cfdfde446 v0.6.19: Lower min_acceptable_length to 10000 bytes - Some sites like anilife.live have smaller pages (~40k) - Prevents valid content from being rejected as 'short' 2026-01-07 20:10:42 +09:00
a730e41c41 v0.6.18: Fix StopIteration error on repeated requests - Reset tab to about:blank instead of closing - Zendriver requires at least 1 tab to remain open - Prevents coroutine raised StopIteration error 2026-01-07 17:40:29 +09:00
80de2b0689 v0.6.17: Fix Zendriver content polling and tab cleanup - Add content length polling with stabilization detection - Early exit when list/player markers found AND length > 50k - Close tabs after use to prevent accumulation - Minimum acceptable length set to 50k for ohli24 pages 2026-01-07 17:34:46 +09:00
786e070a50 v0.6.16: Fix Zendriver API compatibility for Docker - Use browser.get() instead of page.goto() - Remove CDP set_blocked_urls (not available in all versions) - Simplified navigation with asyncio.wait_for timeout 2026-01-07 17:28:04 +09:00
9e0ef5f120 v0.6.15: Non-blocking navigation for Zendriver Daemon (Docker 17s fix) 2026-01-07 17:14:25 +09:00
8759d1e1c8 v0.6.14: Ubuntu Docker performance optimization for Ohli24 2026-01-07 17:07:46 +09:00
49aea1bb54 v0.6.13: Fix initialization order for curl_cffi auto-install 2026-01-07 15:46:53 +09:00
e026247cbf v0.6.11: Add curl_cffi auto-install and fix URL check 2026-01-07 15:27:04 +09:00
c532ffaef8 v0.6.10: Fix Ohli24 GDM integration and update README 2026-01-07 15:09:04 +09:00
759f772ca8 Fix: Resolve gevent-Trio conflict on Mac by using Botasaurus subprocess 2026-01-07 13:54:41 +09:00
67b7647f41 Fix: NameError 'cls' in get_html staticmethod 2026-01-07 13:52:22 +09:00
15d6d1a9e7 Feat: Add Botasaurus @request fetching layer and automatic dependency installation 2026-01-07 13:50:21 +09:00
afce36640d Fix: Only mark Ohli24 download as completed when file actually exists 2026-01-07 00:51:43 +09:00
5f7521eb6b Anilife: Implement HTTP caching with cache_ttl setting (default 300s) 2026-01-07 00:39:21 +09:00
2f0523b70d Fix: Escaped quotes syntax error in setting_save_after 2026-01-07 00:34:36 +09:00
e75e34dadd Fix: Null check for self.queue in setting_save_after 2026-01-07 00:32:15 +09:00
62dfb2a8b2 Add Anilife proxy settings (proxy_url, get_proxy, get_proxies) 2026-01-07 00:30:14 +09:00
def2b5b3c5 Linkkf GDM integration: ModuleQueue delegation (already has CachedSession) 2026-01-07 00:01:03 +09:00
72e0882308 Release v0.6.0: Anilife GDM integration, filename fixes, changelog update 2026-01-06 23:57:11 +09:00
0a2bb86504 Anilife GDM integration: CachedSession, ModuleQueue, Go GDM button 2026-01-06 23:55:38 +09:00
f2aa78fa48 Fix: Improve filename sanitization to prevent Windows 8.3 short names on Synology 2026-01-06 23:36:11 +09:00
92276396ce Update: UI improvements and GDM integration fixes 2026-01-06 23:20:03 +09:00
a6affc5b2b Fix: Update GDM package name for import 2026-01-06 21:27:51 +09:00
32 changed files with 5598 additions and 2138 deletions

140
README.md
View File

@@ -8,6 +8,9 @@
## 🚀 주요 기능 (Key Features) ## 🚀 주요 기능 (Key Features)
* **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드. * **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드.
* **자막 최적화**:
* **자막 전용 다운로드 (Linkkf)**: 영상 없이 자막(VTT)만 별도로 추출하여 SRT로 변환 후 다운로드할 수 있습니다.
* **자막 합침 (Muxing)**: 다운로드된 외부 자막(SRT)을 MP4 컨테이너에 자동으로 삽입하여 범용성을 높입니다.
* **강력한 우회 기술 (Anti-Bot Bypass)**: * **강력한 우회 기술 (Anti-Bot Bypass)**:
* **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다. * **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다.
* **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결) * **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결)
@@ -80,6 +83,143 @@
--- ---
## 📝 변경 이력 (Changelog) ## 📝 변경 이력 (Changelog)
### v0.7.7 (2026-01-19)
- **Linkkf 추출 핵심 보강 및 Anilife 고속화**:
- **전용 헤더 처리**: Linkkf 스트리밍 영상(m3u8) 추출 시 Referer 헤더가 유실되던 문제를 CDP 타입 래핑(`zd.cdp.network.Headers()`)으로 완벽 해결.
- **Anilife 퍼포먼스**: 애니라이프 영상 추출 시 불필요한 대기 시간을 제거하고 네비게이션 전략을 최적화하여 추출 속도를 개선했습니다.
- **GDM 연동 안정성**: yt-dlp 호출 시 `--add-header` 옵션을 통해 모든 보안 파라미터를 정확히 전달하도록 보강했습니다.
### v0.7.6 (2026-01-12)
- **모바일 상단 메뉴 스크롤 힌트 도입**:
- 메뉴가 가로로 길어질 때 우측에 은은한 페이드 효과를 추가하여 '더 많은 메뉴'가 있음을 직관적으로 알 수 있도록 개선했습니다.
- 아주 가늘고 투명한 스크롤바 가이드를 추가하여 모던한 감성을 더했습니다.
- CSS 구문 최적화 및 미디어 쿼리 중복을 정리했습니다.
### v0.7.5 (2026-01-12)
- **모바일 레이아웃 가로 핏 완벽 최적화**:
- 화면이 70%만 보이고 오른쪽으로 밀리던 현상을 해결하기 위해 전역적인 너비 정규화(`width: 100%`)와 오버플로우 차단을 적용했습니다.
- 모바일 브라우저 렌더링 오류를 유발하는 `background-attachment: fixed` 속성을 모바일 한정으로 해제했습니다.
- 모든 요소에 `box-sizing: border-box`를 강제하여 패딩으로 인한 너비 확장을 방지했습니다.
- 에피소드 그리드를 모바일 1열 배치로 최적화하여 오버플로우를 원천 차단했습니다.
### v0.7.4 (2026-01-12)
- **모바일 레이아웃 시프트 최종 수정**:
- 부트스트랩 `row`의 음수 마진으로 인해 화면이 오른쪽으로 밀려 보이던 현상을 정규화 작업을 통해 해결했습니다.
- `html`, `body` 레벨에서 가로 오버플로우를 차단하고 전역적인 단위 대응(`100vw`)을 적용하여 안정적인 가로 핏을 구현했습니다.
- 상단 메뉴(브레드크럼)가 부모 너비를 확장시키지 않도록 `display` 속성을 개선했습니다.
### v0.7.3 (2026-01-12)
- **Anilife 모바일 UI 최적화 (프리미엄 개편)**:
- **분석 페이지**: 가로 여백을 최소화(15px -> 2px)하여 모바일 가독성 증대.
- **큐 페이지**: 기존 테이블 레이아웃을 모바일 전용 **카드형 레이아웃**으로 전면 교체. 파일명과 진행률 바를 강조하고 2단 정보 그리드를 도입하여 가시성 확보.
- **오버플로우 수정**: 상단 메뉴(브레드크럼) 가로 스크롤 적용 및 액션 버튼 자동 줄바꿈 배치를 통해 화면 넘침 해결.
- **Linkkf 안정화 및 자막 연동**:
- **자막 자동화**: GDM(v0.2.30+) 연동을 통해 비디오와 자막을 동시에 안전하게 다운로드.
- **경로 정규화**: 저장 경로 내 중복 구분자(`//./`) 발생 문제를 원천 해결.
- **추출 성능**: 비디오 URL 추출 시 타임아웃 및 예외 처리를 강화하여 UI 프리징 방지.
### v0.7.2 (2026-01-11)
- **Linkkf 자막 전용 다운로드 지원**:
- 에피소드 분석 페이지에 **"자막만 다운로드"** 버튼 추가. (VTT 추출 및 SRT 자동 변환)
- 백그라운드 스레드 처리를 통해 대량의 자막을 끊김 없이 다운로드 가능.
- **Linkkf 목록-분석 연동 강화**:
- 목록(`list`) 페이지 카드에 **"작품소개"** 버튼 추가하여 원클릭 분석 지원.
- URL 파라미터(`code`)를 통한 자동 분석 로직 개선으로 연동 편의성 증대.
- **플러그인 핫 리로드 안정화**:
- Linkkf, AniLife, Ohli24 모든 모듈의 DB 모델에 `extend_existing: True` 옵션 적용.
- 플러그인 자가 업데이트 시 DB 모델 제약으로 인한 리로드 실패 문제 해결.
- **UI/UX 보안**:
- 분석 페이지의 AJAX 요청 결과 핸들링을 강화하여 사용자에게 정확한 성공/실패 알림 제공.
### v0.7.1 (2026-01-11)
- **GDM 통합 버그 수정 (Hotfix)**:
- **UI 필드 매핑 수정**: 큐 페이지에서 GDM 작업의 상태, 진행률 등이 'undefined'로 표시되던 필드명 불일치 문제 해결.
- **실시간 업데이트(Socket) 강화**: GDM 전용 소켓 이벤트 리스너를 추가하여, 수동 새로고침 없이도 다운로드 상태가 실시간으로 반영되도록 개선.
- **모든 모듈 적용**: Linkkf뿐만 아니라 AniLife, Ohli24 큐 페이지에도 동일한 수정 사항 반영.
### v0.7.0 (2026-01-11)
- **GDM(Gommi Downloader Manager) 통합 고도화**:
- **통합 큐 페이지**: 링크애니, 애니라이프, 오클리24의 큐 페이지에서 GDM 작업을 실시간으로 확인 및 중지/삭제 가능하도록 통합.
- **상태 자동 동기화**: GDM 다운로드 완료 시 콜백을 통해 로컬 DB 상태를 자동으로 '컴플리트'로 업데이트하여 목록 페이지(`list`)에 즉시 반영.
- **GDM 작업 매핑**: GDM의 다양한 상태 코드 및 진행률을 각 플러그인 UI 형식에 맞게 변환 처리.
- **안정성 강화**:
- **백그라운드 DB 안정화**: 스케줄러 및 비동기 작업 중 데이터베이스 접근 시 `app_context` 오류 방지를 위해 전역적인 컨텍스트 래핑 적용.
- **자동 다운로드 로직 개선**: 링크애니 '전체(all)' 모드 모니터링 및 자동 에피소드 등록 로직 보강.
- **알림 시스템**: 링크애니 새 에피소드 감지 시 Discord/Telegram 알림 기능 및 설정 UI 추가.
### v0.6.25 (2026-01-09)
- **자가 업데이트 기능 추가**: 모든 설정 페이지 (Ohli24, Anilife, Linkkf)에서 "업데이트" 버튼 클릭으로 Git Pull 및 플러그인 핫 리로드 지원
- **버전 체크 API**: GitHub에서 최신 버전 정보를 가져와 업데이트 알림 표시 (1시간 캐싱)
- **공통 베이스 통합**: `AnimeModuleBase``get_update_info`, `reload_plugin` 메서드 추가로 모든 모듈에서 자동 사용 가능
### v0.6.24 (2026-01-08)
- **Ohli24 GDM 연동 버그 수정**:
- **썸네일 누락 해결**: GDM 위임 시 `image` 키를 `thumbnail`로 올바르게 매핑하여 목록에서 이미지가 보이도록 수정.
- **소스 타입 통일**: GDM 측 명칭 변경에 맞춰 `ani24` 대신 `ohli24`를 기본값으로 사용 (하위 호환 유지).
- **검색 결과 썸네일**: 일부 환경에서 썸네일 URL이 누락되던 필드 보강.
### v0.6.23 (2026-01-08)
- **Linkkf 다운로드 완전 복구**:
- **Zendriver Daemon CDP 헤더 버그 수정**: `zd.cdp.network.Headers()` 타입 래핑 누락으로 Referer 헤더가 적용되지 않던 문제 해결.
- **HTML 엔티티 디코딩 개선**: iframe URL의 `&amp;` 등 HTML 엔티티를 `html.unescape()`로 올바르게 디코딩.
- **GDM yt-dlp 헤더 전달**: `--add-header` 옵션으로 Referer/User-Agent를 yt-dlp에 전달하여 CDN 리다이렉트 방지.
- **부수 효과**: Ohli24 등 모든 브라우저 기반 추출에서 동일한 헤더 적용 개선.
### v0.6.22 (2026-01-08)
- **Linkkf 추출 로직 강화**: Cloudflare 보호가 강화된 Linkkf 도메인(flexora.xyz 등)에 대응하기 위해 브라우저 기반(Zendriver/Camoufox) 추출 엔진을 도입했습니다.
- **오추출 방지**: 광고나 서비스 차단 페이지(Google Cloud 등)의 iframe을 비디오 URL로 오인하는 문제를 수정했습니다.
## v0.6.21 (2026-01-07)
- **Linkkf GDM 연동 수정**:
- GDM 위임 전 실제 스트림 URL(m3u8) 추출 로직을 강제 호출하여 "Invalid data" 오류 해결.
- Linkkf 설정의 다운로드 방식 및 쓰레드 수를 GDM에 전달하도록 개선.
- 추출된 Referer 헤더 및 자막 정보를 GDM에 누락 없이 전달.
### v0.6.20 (2026-01-07)
- **GDM 연동 고도화 및 버그 수정**:
- **App Context 오류 해결**: 백그라운드 쓰레드(일괄 추가, Camoufox 설치, 자막 합침)에서 발생하던 `RuntimeError: Working outside of application context` 수정.
- **다운로드 설정 연동**: Ohli24 설정의 다운로드 방식(aria2c/ytdlp) 및 쓰레드 수를 GDM에 그대로 전달하여 멀티쓰레드 다운로드 지원.
- **안정성 개선**:
- GDM 위임 시 파일명/제목/썸네일 등 메타데이터 가공 로직 보강.
### v0.6.15 (2026-01-07)
- **Zendriver Daemon 비동기 네비게이션 최적화**:
- `browser.get(url)` 대기 시간으로 인한 17초 지연 해결
- 네비게이션 전 리소스 블로킹 설정 (about:blank에서 미리 차단)
- `asyncio.create_task()`를 활용한 비동기 네비게이션 + 병렬 DOM 폴링
- 리스트/에피소드 페이지 마커 발견 즉시 조기 반환 → 예상 속도 1~3초
### v0.6.14 (2026-01-07)
- **Ohli24 Docker 성능 고속화**:
- Zendriver Daemon에 리눅스/도커 전용 최적화 플래그 추가 (`--no-zygote`, `--disable-dev-shm-usage`, `--disable-features=IsolateOrigins,site-per-process` 등)
- 정밀 성능 메트릭 도입 (`/tmp/zendriver_daemon.log`에서 Init/Nav/Block/Poll 단계별 시간 측정 가능)
- 목록 페이지 페칭 시 Zendriver Daemon(Layer 3A)을 최우선 순위로 격상 (기존 17초 → 1초 내외 단축 기대)
- `LogicOhli24.get_base_url()` 및 각 모듈에서 URL 끝 슬래시 제거(`rstrip`) 처리를 강화하여 불필요한 리다이렉트 방지
- **Zendriver Daemon 안정성**:
- 리눅스 환경의 `/dev/shm` 여유 공간 체크 로직 추가
- 변수 참조 오류(`NameError`, `elapsed` -> `total_elapsed`) 수정 및 에러 핸들링 보강
### v0.6.13 (2026-01-07)
- **초기화 순서 오류 수정**: `P.logger` 접근 전 `P` 인스턴스 생성이 완료되도록 `curl_cffi` 자동 설치 루틴 위치 조정 (`NameError: name 'P' is not defined` 해결)
### v0.6.11 (2026-01-07)
- **Docker 환경 최적화**:
- `curl_cffi` 라이브러리 부재 시 자동 설치(pip install) 루틴 추가
- URL 추출 실패 시 GDM 위임 중단 및 에러 처리 강화
- **Ohli24 GDM 연동 버그 수정**:
- `LogicOhli24.add` 메서드의 인덴트 오류 및 문법 오류 해결
- 다운로드 완료 시 Ohli24 DB 자동 업데이트 로직 안정화
- `__init__.py` 안정성 강화 (P.logic 지연 로딩 대응)
- **Anilife GDM 연동**:
- `ModuleQueue` 연동으로 Anilife 다운로드가 GDM (Gommi Downloader Manager)으로 통합
- Ohli24와 동일한 패턴으로 `source_type: "anilife"` 메타데이터 포함
- Go FFMPEG 버튼 → **Go GDM** 버튼으로 변경 및 GDM 큐 페이지로 링크
- **파일명 정리 개선**:
- `Util.change_text_for_use_filename()` 함수에서 연속 점(`..`) → 단일 점(`.`) 변환
- 끝에 오는 점/공백 자동 제거로 Synology NAS에서 Windows 8.3 단축 파일명 생성 방지
- **Git 워크플로우 개선**:
- GitHub + Gitea 양방향 동시 푸시 설정 (GitHub 우선)
### v0.5.3 (2026-01-04) ### v0.5.3 (2026-01-04)
- **보안 스트리밍 토큰 시스템 도입**: - **보안 스트리밍 토큰 시스템 도입**:

View File

@@ -4,9 +4,15 @@
# @Site : # @Site :
# @File : __init__ # @File : __init__
# @Software: PyCharm # @Software: PyCharm
# from .plugin import P from .setup import P
# blueprint = P.blueprint blueprint = P.blueprint
# menu = P.menu menu = P.menu
# plugin_load = P.logic.plugin_load plugin_info = P.plugin_info
# plugin_unload = P.logic.plugin_unload
# plugin_info = P.plugin_info def plugin_load():
if P.logic:
P.logic.plugin_load()
def plugin_unload():
if P.logic:
P.logic.plugin_unload()

View File

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

31
inspect_zendriver_test.py Normal file
View File

@@ -0,0 +1,31 @@
import asyncio
import zendriver as zd
import json
import os
async def test():
try:
browser = await zd.start(headless=True)
page = await browser.get("about:blank")
# Test header setting
headers = {"Referer": "https://v2.linkkf.app/"}
try:
await page.send(zd.cdp.network.enable())
headers_obj = zd.cdp.network.Headers(headers)
await page.send(zd.cdp.network.set_extra_http_headers(headers_obj))
print("Successfully set headers")
except Exception as e:
print(f"Failed to set headers: {e}")
import traceback
traceback.print_exc()
methods = [m for m in dir(page) if not m.startswith("_")]
print(json.dumps({"methods": methods}))
await browser.stop()
except Exception as e:
import traceback
print(json.dumps({"error": str(e), "traceback": traceback.format_exc()}))
if __name__ == "__main__":
asyncio.run(test())

122
lib/botasaurus_ohli24.py Normal file
View File

@@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""
Botasaurus 기반 Ohli24 HTML 페칭 스크립트
- gevent monkey-patching과 Trio 간의 충돌을 방지하기 위해 별도 프로세스로 실행
- JSON 출력으로 상위 프로세스(mod_ohli24)와 통신
"""
import sys
import json
import os
import time
import traceback
from typing import Dict, Any, Optional
# 봇사우루스 디버깅 일시정지 방지 및 자동 종료 설정
os.environ["BOTASAURUS_ENV"] = "production"
def fetch_html(url: str, headers: Optional[Dict[str, str]] = None, proxy: Optional[str] = None) -> Dict[str, Any]:
result: Dict[str, Any] = {"success": False, "html": "", "elapsed": 0}
max_retries = 2
try:
from botasaurus.request import request as b_request
# use_stealth=True 추가하여 탐지 회피 강화
@b_request(
proxy=proxy,
raise_exception=True,
close_on_crash=True
)
def fetch_url(request: Any, data: Dict[str, Any]) -> str:
target_url = data.get('url')
headers = data.get('headers') or {}
# 기본적인 헤더 보강 (Ohli24 대응 - Cloudflare/TLS Fingerprinting 대응)
default_headers = {
"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",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
"sec-ch-ua": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
}
for k, v in default_headers.items():
if k not in headers and k.lower() not in [hk.lower() for hk in headers]:
headers[k] = v
return request.get(target_url, headers=headers, timeout=20)
for attempt in range(max_retries + 1):
start_time = time.time()
try:
b_resp: str = fetch_url({'url': url, 'headers': headers})
elapsed = time.time() - start_time
# 리스트 페이지는 보통 수백KB 이상 (최소 500바이트 체크)
if b_resp and len(b_resp) > 500:
result.update({
"success": True,
"html": b_resp,
"elapsed": round(elapsed, 2),
"attempt": attempt + 1
})
return result
else:
reason = f"Short response ({len(b_resp) if b_resp else 0} bytes)"
if attempt < max_retries:
time.sleep(1)
continue
result["error"] = reason
result["elapsed"] = round(time.time() - start_time, 2)
except Exception as inner_e:
if attempt < max_retries:
time.sleep(1)
continue
result["error"] = str(inner_e)
result["elapsed"] = round(time.time() - start_time, 2)
except Exception as e:
result["error"] = f"Botasaurus init/import error: {str(e)}"
result["elapsed"] = 0
return result
if __name__ == "__main__":
# 모든 stdout을 stderr로 리다이렉트 (라이브러리 로그가 stdout을 오염시키는 것 방지)
original_stdout = sys.stdout
sys.stdout = sys.stderr
try:
if len(sys.argv) < 2:
# 에러 메시지는 출력해야 하므로 다시 복구 후 출력
sys.stdout = original_stdout
print(json.dumps({"success": False, "error": "Usage: script.py <url> [headers] [proxy]"}))
sys.exit(1)
target_url: str = sys.argv[1]
headers_arg: Optional[Dict[str, str]] = json.loads(sys.argv[2]) if len(sys.argv) > 2 and sys.argv[2] else None
proxy_arg: Optional[str] = sys.argv[3] if len(sys.argv) > 3 and sys.argv[3] else None
res: Dict[str, Any] = fetch_html(target_url, headers_arg, proxy_arg)
# 최종 결과 출력 전에만 stdout 복구
sys.stdout = original_stdout
print(json.dumps(res, ensure_ascii=False))
except Exception as fatal_e:
# 에러 발생 시에도 JSON 형태로 출력하도록 보장
sys.stdout = original_stdout
print(json.dumps({
"success": False,
"error": f"Fatal execution error: {str(fatal_e)}",
"traceback": traceback.format_exc()
}, ensure_ascii=False))

View File

@@ -32,10 +32,8 @@ def download(url, file_name):
def read_file(filename): def read_file(filename):
try: try:
import codecs import codecs
ifp = codecs.open(filename, 'r', encoding='utf8') with codecs.open(filename, 'r', encoding='utf8') as ifp:
data = ifp.read() return ifp.read()
ifp.close()
return data
except Exception as exception: except Exception as exception:
logger.error('Exception:%s', exception) logger.error('Exception:%s', exception)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -58,20 +56,29 @@ class Util(object):
@staticmethod @staticmethod
def change_text_for_use_filename(text): def change_text_for_use_filename(text):
# text = text.replace('/', '') # 1. Remove/replace Windows-forbidden characters
# 2021-07-31 X:X text = re.sub('[\\/:*?"<>|]', ' ', text)
# text = text.replace(':', ' ')
text = re.sub('[\\/:*?\"<>|]', ' ', text).strip() # 2. Remove consecutive dots (.. → .)
text = re.sub("\s{2,}", ' ', text) text = re.sub(r'\.{2,}', '.', text)
# 3. Remove leading/trailing dots and spaces
text = text.strip('. ')
# 4. Collapse multiple spaces to single space
text = re.sub(r'\s{2,}', ' ', text)
# 5. Remove any remaining trailing dots (after space collapse)
text = text.rstrip('.')
return text return text
@staticmethod @staticmethod
def write_file(data, filename): def write_file(data, filename):
try: try:
import codecs import codecs
ofp = codecs.open(filename, 'w', encoding='utf8') with codecs.open(filename, 'w', encoding='utf8') as ofp:
ofp.write(data) ofp.write(data)
ofp.close()
except Exception as exception: except Exception as exception:
logger.debug('Exception:%s', exception) logger.debug('Exception:%s', exception)
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())

View File

@@ -16,20 +16,29 @@ 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, Dict, List, Type, cast from typing import Any, Optional, Dict, List, Type, cast
import zendriver as zd
import datetime # Added for datetime.now()
import logging # Added for logging setup
# 터미널 및 파일로 로그 출력 설정 # 터미널 및 파일로 로그 출력 설정
LOG_FILE: str = "/tmp/zendriver_daemon.log" LOG_FILE: str = "/tmp/zendriver_daemon.log"
def log_debug(msg: str) -> None: # 로그 설정
"""타임스탬프와 함께 로그 출력 및 파일 저장""" def log_debug(msg):
timestamp: str = time.strftime("%Y-%m-%d %H:%M:%S") timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted_msg: str = f"[{timestamp}] {msg}" log_msg = f"[{timestamp}] {msg}"
print(formatted_msg, file=sys.stderr) print(log_msg)
try: with open(LOG_FILE, "a", encoding="utf-8") as f:
with open(LOG_FILE, "a", encoding="utf-8") as f: f.write(log_msg + "\n")
f.write(formatted_msg + "\n")
except Exception: # Zendriver 내부 로그 연동
pass class ZendriverLogHandler(logging.Handler):
def emit(self, record):
log_debug(f"[ZendriverLib] {record.levelname}: {record.getMessage()}")
zd_logger = logging.getLogger("zendriver")
zd_logger.setLevel(logging.DEBUG)
zd_logger.addHandler(ZendriverLogHandler())
DAEMON_PORT: int = 19876 DAEMON_PORT: int = 19876
browser: Optional[Any] = None browser: Optional[Any] = None
@@ -38,38 +47,51 @@ loop: Optional[asyncio.AbstractEventLoop] = None
manual_browser_path: Optional[str] = None manual_browser_path: Optional[str] = None
def find_browser_executable() -> Optional[str]: def find_browser_executable() -> List[str]:
"""시스템에서 브라우저 실행 파일 찾기 (Docker/Ubuntu 환경 대응)""" """시스템에서 브라우저 실행 파일 찾기 (OS별 대응)"""
import platform
import shutil
# 수동 설정된 경로 최우선 # 수동 설정된 경로 최우선
if manual_browser_path and os.path.exists(manual_browser_path): if manual_browser_path and os.path.exists(manual_browser_path):
return manual_browser_path return [manual_browser_path]
common_paths: List[str] = [ system = platform.system()
"/usr/bin/google-chrome", app_dirs = ["/Applications", "/Volumes/WD/Users/Applications"]
"/usr/bin/google-chrome-stable", common_paths = []
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/lib/chromium-browser/chromium-browser",
"google-chrome", # PATH에서 찾기
"chromium-browser",
"chromium",
]
# 먼저 절대 경로 확인 if system == "Darwin": # Mac
for path in common_paths: for base in app_dirs:
if path.startswith("/") and os.path.exists(path): common_paths.extend([
log_debug(f"[ZendriverDaemon] Found browser at absolute path: {path}") f"{base}/Google Chrome.app/Contents/MacOS/Google Chrome",
return path f"{base}/Chromium.app/Contents/MacOS/Chromium",
f"{base}/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
# shutil.which로 PATH 확인 ])
import shutil elif system == "Windows":
for cmd in ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"]: common_paths = [
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"),
]
else: # Linux/Other
common_paths = [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/lib/chromium-browser/chromium-browser",
]
# 존재하는 모든 후보들 반환
candidates = [p for p in common_paths if os.path.exists(p)]
# PATH에서 찾기 추가
for cmd in ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "chrome", "microsoft-edge"]:
found = shutil.which(cmd) found = shutil.which(cmd)
if found: if found and found not in candidates:
log_debug(f"[ZendriverDaemon] Found browser via shutil.which: {found}") candidates.append(found)
return found
return None return candidates
class ZendriverHandler(BaseHTTPRequestHandler): class ZendriverHandler(BaseHTTPRequestHandler):
@@ -95,6 +117,7 @@ class ZendriverHandler(BaseHTTPRequestHandler):
data: Dict[str, Any] = json.loads(body) data: Dict[str, Any] = json.loads(body)
url: Optional[str] = data.get("url") url: Optional[str] = data.get("url")
headers: Optional[Dict[str, str]] = data.get("headers")
timeout: int = cast(int, data.get("timeout", 30)) timeout: int = cast(int, data.get("timeout", 30))
if not url: if not url:
@@ -104,9 +127,10 @@ class ZendriverHandler(BaseHTTPRequestHandler):
# 비동기 fetch 실행 # 비동기 fetch 실행
if loop: if loop:
future = asyncio.run_coroutine_threadsafe( future = asyncio.run_coroutine_threadsafe(
fetch_with_browser(url, timeout), loop fetch_with_browser(url, timeout, headers), loop
) )
result: Dict[str, Any] = future.result(timeout=timeout + 15) # 시놀로지 등 느린 환경을 위해 타임아웃 마진을 15초 -> 45초로 확장
result: Dict[str, Any] = future.result(timeout=timeout + 45)
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"})
@@ -154,30 +178,110 @@ async def ensure_browser() -> Any:
with browser_lock: with browser_lock:
if browser is None: if browser is None:
try: try:
import zendriver as zd # 존재하는 후보군 가져오기
log_debug("[ZendriverDaemon] Starting new browser instance...") candidates = find_browser_executable()
if not candidates:
log_debug("[ZendriverDaemon] No browser candidates found!")
return None
# 실행 가능한 브라우저 찾기 # 리눅스/도커 성능 분석용 로그
exec_path = find_browser_executable() import platform
log_debug(f"[ZendriverDaemon] Startup params: headless=True, no_sandbox=True, path={exec_path}") if platform.system() == "Linux":
try:
shm_size = os.statvfs('/dev/shm')
free_shm = (shm_size.f_bavail * shm_size.f_frsize) / (1024 * 1024)
log_debug(f"[ZendriverDaemon] Linux detected. /dev/shm free: {free_shm:.1f} MB")
except Exception as shm_e:
log_debug(f"[ZendriverDaemon] Failed to check /dev/shm: {shm_e}")
# 사용자 데이터 디렉토리 설정 (Mac/Root 권한 이슈 대응)
import tempfile
import platform
uid = os.getuid() if hasattr(os, 'getuid') else 'win'
if exec_path: log_debug(f"[ZendriverDaemon] Environment: Python {sys.version.split()[0]} on {platform.system()} (UID: {uid})")
log_debug(f"[ZendriverDaemon] Starting browser at: {exec_path}")
browser = await zd.start( browser_args = [
headless=True, "--no-sandbox",
browser_executable_path=exec_path, "--disable-setuid-sandbox",
no_sandbox=True, "--disable-dev-shm-usage",
browser_args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--no-first-run"] "--disable-gpu",
) "--disable-software-rasterizer",
else: "--remote-allow-origins=*",
log_debug("[ZendriverDaemon] Starting browser with default path") "--disable-background-networking",
browser = await zd.start( "--disable-background-timer-throttling",
headless=True, "--disable-backgrounding-occluded-windows",
no_sandbox=True, "--disable-breakpad",
browser_args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--no-first-run"] "--disable-client-side-phishing-detection",
) "--disable-default-apps",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--no-default-browser-check",
"--safebrowsing-disable-auto-update",
"--remote-allow-origins=*",
"--blink-settings=imagesEnabled=false",
"--disable-blink-features=AutomationControlled",
# 추가적인 도커 최적화 플래그
"--disable-features=IsolateOrigins,site-per-process",
"--no-zygote",
"--disable-extensions",
"--wasm-tier-up=false",
]
# 추가적인 리소스 블로킹 설정
# Note: zendriver supports direct CDP commands
for exec_path in candidates:
# 프로세스별/시간별 고유한 프로필 디렉토리 생성 (SingletonLock 충돌 원천 차단)
unique_id = f"{uid}_{int(time.time())}"
user_data_dir = os.path.join(tempfile.gettempdir(), f"zd_daemon_{unique_id}_{os.path.basename(exec_path).replace(' ', '_')}")
log_debug("[ZendriverDaemon] Browser started successfully") # 기존 락(Lock) 파일이나 깨진 프로필이 있으면 제거 (중요: 시놀로지 도커 SingletonLock 대응)
if os.path.exists(user_data_dir):
try:
import shutil
# 안전장치: 경로가 임시 디렉토리에 있고 zd_daemon_ 접두사를 포함하는지 확인
temp_dir = tempfile.gettempdir()
if user_data_dir.startswith(temp_dir) and "zd_daemon_" in user_data_dir:
shutil.rmtree(user_data_dir, ignore_errors=True)
# 리눅스에서 SingletonLock이 끈질기게 남는 경우 대응
if platform.system() == "Linux":
# 명령어 주입 방지를 위해 경로를 인자로 전달하지 않고 직접 문자열 검증 후 실행
os.system(f"rm -rf '{user_data_dir}'")
log_debug(f"[ZendriverDaemon] Cleaned up existing profile dir: {user_data_dir}")
else:
log_debug(f"[ZendriverDaemon] Skip cleanup: Path safety check failed ({user_data_dir})")
except Exception as rm_e:
log_debug(f"[ZendriverDaemon] Failed to clean profile dir: {rm_e}")
os.makedirs(user_data_dir, exist_ok=True)
try:
log_debug(f"[ZendriverDaemon] Trying browser at: {exec_path}")
start_time_init = time.time()
log_debug(f"[ZendriverDaemon] Launching browser: {exec_path} (Sandbox: False, Timeout: 1.0s, Tries: 30)")
browser = await zd.start(
headless=True,
browser_executable_path=exec_path,
sandbox=False,
user_data_dir=user_data_dir,
browser_args=browser_args,
browser_connection_timeout=1.0,
browser_connection_max_tries=30
)
log_debug(f"[ZendriverDaemon] Browser started successfully in {time.time() - start_time_init:.2f}s using: {exec_path}")
return browser
except Exception as e:
log_debug(f"[ZendriverDaemon] Failed to start {exec_path}: {e}")
browser = None
raise Exception("All browser candidates failed to start")
except Exception as e: except Exception as e:
log_debug(f"[ZendriverDaemon] Failed to start browser: {e}") log_debug(f"[ZendriverDaemon] Failed to start browser: {e}")
browser = None browser = None
@@ -186,61 +290,158 @@ async def ensure_browser() -> Any:
return browser return browser
async def fetch_with_browser(url: str, timeout: int = 30) -> Dict[str, Any]: async def fetch_with_browser(url: str, timeout: int = 30, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""상시 대기 브라우저로 HTML 페칭 (탭 유지 방식)""" """상시 대기 브라우저로 HTML 페칭 (탭 유지 방식, 헤더 지원)"""
global browser global browser
result: Dict[str, Any] = {"success": False, "html": "", "elapsed": 0.0} result: Dict[str, Any] = {"success": False, "html": "", "elapsed": 0.0}
start_time: float = time.time() start_time: float = time.time()
try: try:
init_start = time.time()
await ensure_browser() await ensure_browser()
init_elapsed = time.time() - init_start
if browser is None: if browser is None:
result["error"] = "Browser not available" result["error"] = "Browser not available"
return result return result
# zendriver의 browser.get(url)은 이미 열린 탭이 있으면 거기서 열려고 시도함. log_debug(f"[ZendriverDaemon] Fetching URL: {url} (Init: {init_elapsed:.2f}s)")
# 하지만 모든 탭이 닫히면 StopIteration이 발생할 수 있음.
log_debug(f"[ZendriverDaemon] Fetching URL: {url}")
# StopIteration 방지를 위해 페이지 이동 시도
try: try:
# browser.get(url)은 새 탭을 열거나 기존 탭을 사용함 nav_start = time.time()
page: Any = await browser.get(url) # zendriver는 browser.get(url)로 페이지 로드
# 페이지 로드 대기 - cdndania iframe 로딩될 때까지 폴링 (최대 15초) page: Any = None
max_wait = 15
poll_interval = 1
waited = 0
html_content = "" html_content = ""
nav_elapsed = 0.0
poll_elapsed = 0.0
while waited < max_wait: # 페이지 로드 시도
await asyncio.sleep(poll_interval) try:
waited += poll_interval # zendriver/core/browser.py:304 에서 self.targets가 비어있을 때 StopIteration 발생 가능
html_content = await page.get_content() # 이를 방지하기 위해 tabs가 생길 때까지 잠시 대기하거나 직접 생성 시도
# cdndania iframe이 로드되었는지 # 탭(페이지)
if "cdndania" in html_content or "fireplayer" in html_content: page = None
log_debug(f"[ZendriverDaemon] cdndania/fireplayer found after {waited}s") for attempt in range(5):
break try:
if browser.tabs:
page = browser.tabs[0]
log_debug(f"[ZendriverDaemon] Using existing tab (Attempt {attempt+1})")
break
else:
log_debug(f"[ZendriverDaemon] No tabs found, trying browser.get('about:blank') (Attempt {attempt+1})")
page = await browser.get("about:blank")
break
except (StopIteration, RuntimeError, Exception) as tab_e:
log_debug(f"[ZendriverDaemon] Tab acquisition failed: {tab_e}. Retrying...")
await asyncio.sleep(0.5)
if not page:
result["error"] = "Failed to acquire browser tab"
return result
# 헤더 설정 (CDP 사용)
if headers:
try:
log_debug(f"[ZendriverDaemon] Setting headers: {list(headers.keys())}")
await page.send(zd.cdp.network.enable())
# Wrap dict with Headers type for CDP compatibility
cdp_headers = zd.cdp.network.Headers(headers)
await page.send(zd.cdp.network.set_extra_http_headers(cdp_headers))
except Exception as e:
log_debug(f"[ZendriverDaemon] Failed to set headers: {e}")
# 실제 페이지 로드
await asyncio.wait_for(page.get(url), timeout=20)
nav_elapsed = time.time() - nav_start
except asyncio.TimeoutError:
log_debug(f"[ZendriverDaemon] Navigation timeout after 20s")
nav_elapsed = 20.0
elapsed: float = time.time() - start_time # 컨텐츠 완전 로드 대기 (폴링)
poll_start = time.time()
if page:
max_wait = 10 # 최대 10초 대기
poll_interval = 0.3
waited = 0
last_length = 0
stable_count = 0
while waited < max_wait:
try:
html_content = await page.get_content()
current_length = len(html_content) if html_content else 0
# 충분히 긴 컨텐츠 + 마커 발견시 즉시 탈출
if current_length > 50000:
if "post-list" in html_content or "list-box" in html_content or "post-row" in html_content:
log_debug(f"[ZendriverDaemon] List page ready in {waited:.1f}s (len: {current_length})")
break
if "cdndania" in html_content or "fireplayer" in html_content:
log_debug(f"[ZendriverDaemon] Player ready in {waited:.1f}s (len: {current_length})")
break
# 컨텐츠 길이가 안정화됐는지 체크
if current_length > 1000 and current_length == last_length:
stable_count += 1
if stable_count >= 3: # 연속 3회 동일하면 로드 완료
log_debug(f"[ZendriverDaemon] Content stabilized at {current_length} bytes")
break
else:
stable_count = 0
last_length = current_length
except Exception as e:
log_debug(f"[ZendriverDaemon] get_content error during poll: {e}")
await asyncio.sleep(poll_interval)
waited += poll_interval
# 최종 컨텐츠 가져오기
if not html_content or len(html_content) < 1000:
try:
html_content = await page.get_content()
except Exception as e:
log_debug(f"[ZendriverDaemon] Final get_content failed: {e}")
if html_content and len(html_content) > 100: poll_elapsed = time.time() - poll_start
total_elapsed = time.time() - start_time
# 최소 길이 임계값 (사이트마다 페이지 크기가 다름)
min_acceptable_length = 10000
if html_content and len(html_content) > min_acceptable_length:
result.update({ result.update({
"success": True, "success": True,
"html": html_content, "html": html_content,
"elapsed": round(elapsed, 2) "elapsed": round(total_elapsed, 2),
"metrics": {
"init": round(init_elapsed, 2),
"nav": round(nav_elapsed, 2),
"poll": round(poll_elapsed, 2)
}
}) })
log_debug(f"[ZendriverDaemon] Fetch success in {elapsed:.2f}s (Length: {len(html_content)})") log_debug(f"[ZendriverDaemon] Success in {total_elapsed:.2f}s (Nav: {nav_elapsed:.2f}s, Poll: {poll_elapsed:.2f}s, Length: {len(html_content)})")
else: else:
result["error"] = f"Short response: {len(html_content) if html_content else 0} bytes" length = len(html_content) if html_content else 0
result["elapsed"] = round(elapsed, 2) result["error"] = f"Short response: {length} bytes"
log_debug(f"[ZendriverDaemon] Fetch failure: Short response ({len(html_content) if html_content else 0} bytes)") result["elapsed"] = round(total_elapsed, 2)
log_debug(f"[ZendriverDaemon] Fetch failure: Short response ({length} bytes)")
# 0바이트거나 너무 짧으면 브라우저/렌더러가 죽었을 가능성이 큼 -> 다음 번엔 강제 재시작
if length < 100:
log_debug("[ZendriverDaemon] Response extremely short, forcing browser reset for next request")
browser = None
# 여기서 page.close()를 하지 않음! (탭을 하나라도 남겨두어야 StopIteration 방지 가능) # 탭 정리: 닫지 말고 about:blank로 리셋 (최소 1개 탭 유지 필요)
# 대신 나중에 탭이 너무 많아지면 정리하는 로직 필요할 수 있음 if page:
try:
await page.get("about:blank")
except Exception as e:
log_debug(f"[ZendriverDaemon] Tab reset failed: {e}")
except StopIteration: except StopIteration:
log_debug("[ZendriverDaemon] StopIteration caught during browser.get, resetting browser") log_debug("[ZendriverDaemon] StopIteration caught during browser.get, resetting browser")

View File

@@ -15,31 +15,49 @@ import shutil
def find_browser_executable(manual_path=None): def find_browser_executable(manual_path=None):
"""시스템에서 브라우저 실행 파일 찾기 (Docker/Ubuntu 환경 대응)""" """시스템에서 브라우저 실행 파일 찾기 (OS별 대응)"""
import platform
# 수동 설정 시 우선 # 수동 설정 시 우선
if manual_path and os.path.exists(manual_path): if manual_path and os.path.exists(manual_path):
return manual_path return manual_path
common_paths = [ system = platform.system()
"/usr/bin/google-chrome", app_dirs = ["/Applications", "/Volumes/WD/Users/Applications"]
"/usr/bin/google-chrome-stable", common_paths = []
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/lib/chromium-browser/chromium-browser",
]
# 먼저 절대 경로 확인 if system == "Darwin": # Mac
for path in common_paths: for base in app_dirs:
if os.path.exists(path): common_paths.extend([
return path f"{base}/Google Chrome.app/Contents/MacOS/Google Chrome",
f"{base}/Chromium.app/Contents/MacOS/Chromium",
# shutil.which로 PATH 확인 f"{base}/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
for cmd in ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium"]: ])
elif system == "Windows":
common_paths = [
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%LocalAppData%\Google\Chrome\Application\chrome.exe"),
]
else: # Linux/Other
common_paths = [
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
"/usr/lib/chromium-browser/chromium-browser",
]
# 존재하는 모든 후보들 반환
candidates = [p for p in common_paths if os.path.exists(p)]
# PATH에서 찾기 추가
for cmd in ["google-chrome", "google-chrome-stable", "chromium-browser", "chromium", "chrome", "microsoft-edge"]:
found = shutil.which(cmd) found = shutil.which(cmd)
if found: if found and found not in candidates:
return found candidates.append(found)
return None return candidates
async def fetch_html(url: str, timeout: int = 60, browser_path: str = None) -> dict: async def fetch_html(url: str, timeout: int = 60, browser_path: str = None) -> dict:
@@ -53,63 +71,112 @@ async def fetch_html(url: str, timeout: int = 60, browser_path: str = None) -> d
start_time = asyncio.get_event_loop().time() start_time = asyncio.get_event_loop().time()
browser = None browser = None
try: # 실행 가능한 브라우저 후보들 찾기
# 실행 가능한 브라우저 찾기 candidates = find_browser_executable(browser_path)
exec_path = find_browser_executable(browser_path) if not candidates:
return {"success": False, "error": "No browser executable found", "html": ""}
# 브라우저 시작 # 사용자 데이터 디렉토리 설정 (Mac/Root 권한 이슈 대응)
if exec_path: import tempfile
uid = os.getuid() if hasattr(os, 'getuid') else 'win'
# 공통 브라우저 인자
browser_args = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu",
"--no-first-run",
"--no-service-autorun",
"--password-store=basic",
"--mute-audio",
"--disable-notifications",
"--disable-background-networking",
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-breakpad",
"--disable-client-side-phishing-detection",
"--disable-default-apps",
"--disable-hang-monitor",
"--disable-popup-blocking",
"--disable-prompt-on-repost",
"--disable-sync",
"--disable-translate",
"--metrics-recording-only",
"--no-default-browser-check",
"--safebrowsing-disable-auto-update",
"--remote-allow-origins=*",
"--blink-settings=imagesEnabled=false",
]
last_error = "All candidates failed"
# 여러 브라우저 후보들 시도 (크롬이 이미 실행 중일 때 등의 상황 대비)
for exec_path in candidates:
browser = None
user_data_dir = os.path.join(tempfile.gettempdir(), f"zd_ohli_{uid}_{os.path.basename(exec_path).replace(' ', '_')}")
os.makedirs(user_data_dir, exist_ok=True)
try:
# 브라우저 시작
browser = await zd.start( browser = await zd.start(
headless=True, headless=True,
browser_executable_path=exec_path, browser_executable_path=exec_path,
no_sandbox=True, no_sandbox=True,
browser_args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--no-first-run"] user_data_dir=user_data_dir,
) browser_args=browser_args
else:
browser = await zd.start(
headless=True,
no_sandbox=True,
browser_args=["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu", "--no-first-run"]
) )
page = await browser.get(url) page = await browser.get(url)
# 페이지 로드 대기 - cdndania iframe 로딩될 때까지 폴링 (최대 15초)
max_wait = 15
poll_interval = 1
waited = 0
html = ""
while waited < max_wait:
await asyncio.sleep(poll_interval)
waited += poll_interval
html = await page.get_content()
# cdndania iframe이 로드되었는지 확인 # 페이지 로드 대기 - 지능형 폴링 (최대 10초)
if "cdndania" in html or "fireplayer" in html: # 1. 리스트 페이지는 바로 반환, 2. 에피소드 페이지는 플레이어 로딩 대기
break max_wait = 10
poll_interval = 0.2 # 1.0s -> 0.2s로 단축하여 반응속도 향상
elapsed = asyncio.get_event_loop().time() - start_time waited = 0
html = ""
if html and len(html) > 100:
result.update({ while waited < max_wait:
"success": True, await asyncio.sleep(poll_interval)
"html": html, waited += poll_interval
"elapsed": round(elapsed, 2) html = await page.get_content()
})
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) if "post-list" in html or "list-box" in html or "post-row" in html:
result["elapsed"] = round(asyncio.get_event_loop().time() - start_time, 2) # log_debug(f"[Zendriver] List page detected in {waited:.1f}s")
finally: break
if browser:
try: # cdndania/fireplayer iframe이 로드되었는지 확인 (에피소드 페이지)
if "cdndania" in html or "fireplayer" in html:
# log_debug(f"[Zendriver] Player detected in {waited:.1f}s")
break
elapsed = asyncio.get_event_loop().time() - start_time
if html and len(html) > 100:
result.update({
"success": True,
"html": html,
"elapsed": round(elapsed, 2)
})
# 성공했으므로 루프 종료
await browser.stop() await browser.stop()
except: return result
pass else:
last_error = f"Short response from {exec_path}: {len(html) if html else 0} bytes"
except Exception as e:
last_error = f"Failed with {exec_path}: {str(e)}"
finally:
if browser:
try:
await browser.stop()
except:
pass
result["error"] = last_error
result["elapsed"] = round(asyncio.get_event_loop().time() - start_time, 2)
return result
return result return result

View File

@@ -65,6 +65,13 @@ from typing import Awaitable, TypeVar
T = TypeVar("T") T = TypeVar("T")
from .setup import * from .setup import *
from requests_cache import CachedSession
# GDM Integration
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
except ImportError:
ModuleQueue = None
logger = P.logger logger = P.logger
name = "anilife" name = "anilife"
@@ -74,6 +81,8 @@ class LogicAniLife(AnimeModuleBase):
db_default = { db_default = {
"anilife_db_version": "1", "anilife_db_version": "1",
"anilife_url": "https://anilife.live", "anilife_url": "https://anilife.live",
"anilife_proxy_url": "",
"anilife_cache_ttl": "300", # HTTP cache TTL in seconds (5 minutes)
"anilife_download_path": os.path.join(path_data, P.package_name, "ohli24"), "anilife_download_path": os.path.join(path_data, P.package_name, "ohli24"),
"anilife_auto_make_folder": "True", "anilife_auto_make_folder": "True",
"anilife_auto_make_season_folder": "True", "anilife_auto_make_season_folder": "True",
@@ -91,8 +100,39 @@ class LogicAniLife(AnimeModuleBase):
"anilife_image_url_prefix_series": "https://www.jetcloud.cc/series/", "anilife_image_url_prefix_series": "https://www.jetcloud.cc/series/",
"anilife_image_url_prefix_episode": "https://www.jetcloud-list.cc/thumbnail/", "anilife_image_url_prefix_episode": "https://www.jetcloud-list.cc/thumbnail/",
"anilife_camoufox_installed": "False", "anilife_camoufox_installed": "False",
"anilife_cache_minutes": "5", # HTML 캐시 시간 (분)
"anilife_zendriver_browser_path": "", # Zendriver 브라우저 경로
} }
# Class variables for caching
cache_path = os.path.dirname(__file__)
session = None
@classmethod
def get_proxy(cls) -> str:
return P.ModelSetting.get("anilife_proxy_url")
@classmethod
def get_proxies(cls) -> Optional[Dict[str, str]]:
proxy = cls.get_proxy()
if proxy:
return {"http": proxy, "https": proxy}
return None
@classmethod
def get_session(cls):
"""Get or create a cached session for HTTP requests."""
if cls.session is None:
cache_ttl = P.ModelSetting.get_int("anilife_cache_ttl")
cls.session = CachedSession(
os.path.join(cls.cache_path, "anilife_cache"),
backend="sqlite",
expire_after=cache_ttl,
cache_control=True,
)
logger.info(f"[Anilife] CachedSession initialized with TTL: {cache_ttl}s")
return cls.session
current_headers = None current_headers = None
current_data = None current_data = None
referer = None referer = None
@@ -122,34 +162,36 @@ class LogicAniLife(AnimeModuleBase):
# 3. 실제 설치/패치 과정 진행 # 3. 실제 설치/패치 과정 진행
try: try:
# 시스템 패키지 xvfb 설치 확인 (Linux/Docker 전용) with F.app.app_context():
if platform.system() == 'Linux' and shutil.which('Xvfb') is None: # 시스템 패키지 xvfb 설치 확인 (Linux/Docker 전용)
logger.info("Xvfb not found. Attempting to background install system package...") if platform.system() == 'Linux' and shutil.which('Xvfb') is None:
try: logger.info("Xvfb not found. Attempting to background install system package...")
sp.run(['apt-get', 'update', '-qq'], capture_output=True) try:
sp.run(['apt-get', 'install', '-y', 'xvfb', '-qq'], capture_output=True) sp.run(['apt-get', 'update', '-qq'], capture_output=True)
except Exception as e: sp.run(['apt-get', 'install', '-y', 'xvfb', '-qq'], capture_output=True)
logger.error(f"Failed to install xvfb system package: {e}") except Exception as e:
logger.error(f"Failed to install xvfb system package: {e}")
# Camoufox 패키지 확인 및 설치 # Camoufox 패키지 확인 및 설치
if not lib_exists: if not lib_exists:
logger.info("Camoufox NOT found in DB or system. Installing in background...") logger.info("Camoufox NOT found in DB or system. Installing in background...")
cmd = [sys.executable, "-m", "pip", "install", "camoufox[geoip]", "-q"] cmd = [sys.executable, "-m", "pip", "install", "camoufox[geoip]", "-q"]
sp.run(cmd, capture_output=True, text=True, timeout=120) sp.run(cmd, capture_output=True, text=True, timeout=120)
logger.info("Ensuring Camoufox browser binary is fetched (pre-warming)...") logger.info("Ensuring Camoufox browser binary is fetched (pre-warming)...")
sp.run([sys.executable, "-m", "camoufox", "fetch"], capture_output=True, text=True, timeout=300) sp.run([sys.executable, "-m", "camoufox", "fetch"], capture_output=True, text=True, timeout=300)
# 성공 시 DB에 기록하여 다음 재시작 시에는 아예 이 과정을 건너뜀 # 성공 시 DB에 기록하여 다음 재시작 시에는 아예 이 과정을 건너뜀
LogicAniLife.camoufox_setup_done = True LogicAniLife.camoufox_setup_done = True
P.ModelSetting.set("anilife_camoufox_installed", "True") P.ModelSetting.set("anilife_camoufox_installed", "True")
logger.info("Camoufox setup finished and persisted to DB") logger.info("Camoufox setup finished and persisted to DB")
return True return True
except Exception as install_err: except Exception as install_err:
logger.error(f"Failed during Camoufox setup: {install_err}") logger.error(f"Failed during Camoufox setup: {install_err}")
return lib_exists return lib_exists
session = requests.Session() session = requests.Session()
cached_session = None # Will be initialized on first use
headers = { headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.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.9", "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.9",
@@ -174,26 +216,68 @@ class LogicAniLife(AnimeModuleBase):
def process_command(self, command, arg1, arg2, arg3, req): def process_command(self, command, arg1, arg2, arg3, req):
try: try:
if command == "list": if command == "list":
# 1. 자체 큐 목록 가져오기
ret = self.queue.get_entity_list() if self.queue else [] ret = self.queue.get_entity_list() if self.queue else []
# 2. GDM 태스크 가져오기 (설치된 경우)
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
if ModuleQueue:
gdm_tasks = ModuleQueue.get_all_downloads()
# 이 모듈(anilife)이 추가한 작업만 필터링
anilife_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"]
for task in anilife_tasks:
# 템플릿 호환 형식으로 변환
gdm_item = self._convert_gdm_task_to_queue_item(task)
ret.append(gdm_item)
except Exception as e:
logger.debug(f"GDM tasks fetch error: {e}")
return jsonify(ret) return jsonify(ret)
elif command == "stop":
entity_id = int(arg1) if arg1 else -1 elif command in ["stop", "remove", "cancel"]:
result = self.queue.command("cancel", entity_id) if self.queue else {"ret": "error"} entity_id = arg1
return jsonify(result) if entity_id and str(entity_id).startswith("dl_"):
elif command == "remove": # GDM 작업 처리
entity_id = int(arg1) if arg1 else -1 try:
result = self.queue.command("remove", entity_id) if self.queue else {"ret": "error"} from gommi_downloader_manager.mod_queue import ModuleQueue
return jsonify(result) if ModuleQueue:
elif command in ["reset", "delete_completed"]: if command == "stop" or command == "cancel":
result = self.queue.command(command, 0) if self.queue else {"ret": "error"} task = ModuleQueue.get_download(entity_id)
if task:
task.cancel()
return jsonify({"ret": "success", "log": "GDM 작업을 중지하였습니다."})
elif command == "remove" or command == "delete":
# GDM에서 삭제 처리
class DummyReq:
def __init__(self, id):
self.form = {"id": id}
ModuleQueue.process_ajax("delete", DummyReq(entity_id))
return jsonify({"ret": "success", "log": "GDM 작업을 삭제하였습니다."})
except Exception as e:
logger.error(f"GDM command error: {e}")
return jsonify({"ret": "error", "log": f"GDM 명령 실패: {e}"})
# 자체 큐 처리
entity_id = int(arg1) if arg1 and str(arg1).isdigit() else -1
command_to_call = "cancel" if command == "stop" else command
if self.queue:
result = self.queue.command(command_to_call, entity_id)
else:
result = {"ret": "error", "log": "Queue not initialized"}
return jsonify(result) return jsonify(result)
elif command == "merge_subtitle": elif command == "merge_subtitle":
# AniUtil already imported at module level # AniUtil already imported at module level
db_id = int(arg1) db_id = int(arg1)
db_item = ModelAniLifeItem.get_by_id(db_id) db_item = ModelAniLifeItem.get_by_id(db_id)
if db_item and db_item.status == 'completed': if db_item and db_item.status == 'completed':
import threading import threading
threading.Thread(target=AniUtil.merge_subtitle, args=(self.P, db_item)).start() def merge_with_ctx(P, db_item):
with F.app.app_context():
AniUtil.merge_subtitle(P, db_item)
threading.Thread(target=merge_with_ctx, args=(self.P, db_item)).start()
return jsonify({"ret": "success", "log": "자막 합칩을 시작합니다."}) return jsonify({"ret": "success", "log": "자막 합칩을 시작합니다."})
return jsonify({"ret": "fail", "log": "파일을 찾을 수 없거나 완료된 상태가 아닙니다."}) return jsonify({"ret": "fail", "log": "파일을 찾을 수 없거나 완료된 상태가 아닙니다."})
@@ -203,6 +287,78 @@ class LogicAniLife(AnimeModuleBase):
self.P.logger.error(traceback.format_exc()) self.P.logger.error(traceback.format_exc())
return jsonify({'ret': 'fail', 'log': str(e)}) return jsonify({'ret': 'fail', 'log': str(e)})
def _convert_gdm_task_to_queue_item(self, task):
"""GDM DownloadTask 객체를 FfmpegQueueEntity.as_dict() 호환 형식으로 변환"""
status_kor_map = {
"pending": "대기중",
"extracting": "분석중",
"downloading": "다운로드중",
"paused": "일시정지",
"completed": "완료",
"error": "실패",
"cancelled": "취소됨"
}
status_str_map = {
"pending": "WAITING",
"extracting": "ANALYZING",
"downloading": "DOWNLOADING",
"paused": "PAUSED",
"completed": "COMPLETED",
"error": "FAILED",
"cancelled": "FAILED"
}
t_dict = task.as_dict()
return {
"entity_id": t_dict["id"],
"url": t_dict["url"],
"filename": t_dict["filename"] or t_dict["title"],
"status_kor": status_kor_map.get(t_dict["status"], "알수없음"),
"percent": t_dict["progress"],
"created_time": t_dict["created_time"],
"current_speed": t_dict["speed"] or "0 B/s",
"download_time": t_dict["eta"] or "-",
"status_str": status_str_map.get(t_dict["status"], "WAITING"),
"idx": t_dict["id"],
"callback_id": "anilife",
"start_time": t_dict["start_time"] or t_dict["created_time"],
"save_fullpath": t_dict["filepath"],
"duration_str": "GDM",
"current_pf_count": 0,
"duration": "-",
"current_duration": "-",
"current_bitrate": "-",
"max_pf_count": 0,
"is_gdm": True
}
def plugin_callback(self, data):
"""GDM 모듈로부터 다운로드 상태 업데이트 수신"""
try:
callback_id = data.get('callback_id')
status = data.get('status')
logger.info(f"[AniLife] Received GDM callback: id={callback_id}, status={status}")
if callback_id:
from framework import F
with F.app.app_context():
db_item = ModelAniLifeItem.get_by_anilife_id(callback_id)
if db_item:
if status == "completed":
db_item.status = "completed"
db_item.completed_time = datetime.now()
db_item.filepath = data.get('filepath')
db_item.save()
logger.info(f"[AniLife] Updated DB item {db_item.id} to COMPLETED via GDM callback")
elif status == "error":
pass
except Exception as e:
logger.error(f"[AniLife] Callback processing error: {e}")
logger.error(traceback.format_exc())
# @staticmethod # @staticmethod
def get_html( def get_html(
self, self,
@@ -679,19 +835,20 @@ class LogicAniLife(AnimeModuleBase):
data = json.loads(request.form["data"]) data = json.loads(request.form["data"])
def func(): def func():
count = 0 with F.app.app_context():
for tmp in data: count = 0
add_ret = self.add(tmp) for tmp in data:
if add_ret.startswith("enqueue"): add_ret = self.add(tmp)
self.socketio_callback("list_refresh", "") if add_ret.startswith("enqueue"):
count += 1 self.socketio_callback("list_refresh", "")
notify = { count += 1
"type": "success", notify = {
"msg": "%s 개의 에피소드를 큐에 추가 하였습니다." % count, "type": "success",
} "msg": "%s 개의 에피소드를 큐에 추가 하였습니다." % count,
socketio.emit( }
"notify", notify, namespace="/framework" socketio.emit(
) "notify", notify, namespace="/framework"
)
thread = threading.Thread(target=func, args=()) thread = threading.Thread(target=func, args=())
thread.daemon = True thread.daemon = True
thread.start() thread.start()
@@ -700,10 +857,124 @@ class LogicAniLife(AnimeModuleBase):
image_url = request.args.get("url") or request.args.get("image_url") image_url = request.args.get("url") or request.args.get("image_url")
return self.proxy_image(image_url) return self.proxy_image(image_url)
elif sub == "entity_list": elif sub == "entity_list":
# GDM 연동: ModuleQueue에서 anilife 플러그인 항목만 필터링
if ModuleQueue:
caller_id = f"{P.package_name}_{self.name}"
all_items: List[Dict[str, Any]] = [d.get_status() for d in ModuleQueue._downloads.values()]
plugin_items = [i for i in all_items if i.get('caller_plugin') == caller_id]
# 상태 한글 매핑
status_map: Dict[str, str] = {
'pending': '대기중',
'extracting': '추출중',
'downloading': '다운로드중',
'paused': '일시정지',
'completed': '완료',
'error': '실패',
'cancelled': '취소됨'
}
mapped_items: List[Dict[str, Any]] = []
active_ids: set = set()
for item in plugin_items:
active_ids.add(item.get('callback_id'))
mapped: Dict[str, Any] = {
'idx': item['id'],
'filename': item.get('filename') or item.get('title') or '파일명 없음',
'percent': item.get('progress', 0),
'status_str': str(item.get('status', 'pending')).upper(),
'status_kor': status_map.get(str(item.get('status', 'pending')), '알 수 없음'),
'current_speed': item.get('speed', ''),
'start_time': item.get('start_time', item.get('created_time', '')),
'download_time': item.get('eta', ''),
'callback_id': item.get('caller_plugin', '').split('_')[-1] if item.get('caller_plugin') else 'anilife',
}
mapped_items.append(mapped)
# DB에서 최근 완료 항목 추가 (영속성)
try:
from framework import F
with F.app.app_context():
db_items = F.db.session.query(ModelAniLifeItem).order_by(ModelAniLifeItem.id.desc()).limit(50).all()
for db_item in db_items:
if db_item.anilife_id in active_ids:
continue
if db_item.status == 'completed':
mapped: Dict[str, Any] = {
'idx': f"db_{db_item.id}",
'filename': db_item.filename or '파일명 없음',
'percent': 100,
'status_str': 'COMPLETED',
'status_kor': '완료',
'current_speed': '',
'start_time': str(db_item.created_time) if db_item.created_time else '',
'download_time': '',
'callback_id': 'anilife',
}
mapped_items.append(mapped)
except Exception as db_err:
logger.warning(f"Failed to add DB items: {db_err}")
return jsonify(mapped_items)
# Fallback: 기존 큐 시스템
if self.queue is not None: if self.queue is not None:
return jsonify(self.queue.get_entity_list()) return jsonify(self.queue.get_entity_list())
else: return jsonify([])
return jsonify([])
elif sub == "queue_command":
command = req.form.get("command", "")
entity_id = req.form.get("entity_id", "")
if ModuleQueue:
if command in ["stop", "cancel"]:
# 특정 다운로드 취소
if entity_id and entity_id in ModuleQueue._downloads:
ModuleQueue._downloads[entity_id].cancel()
return jsonify({"ret": "success", "log": "다운로드가 취소되었습니다."})
return jsonify({"ret": "error", "log": "다운로드를 찾을 수 없습니다."})
elif command == "reset":
# Anilife 모듈의 다운로드만 취소 (다른 플러그인 항목은 그대로)
caller_id = f"{P.package_name}_{self.name}"
cancelled_count = 0
for task_id, task in list(ModuleQueue._downloads.items()):
if task.caller_plugin == caller_id:
task.cancel()
del ModuleQueue._downloads[task_id]
cancelled_count += 1
# Anilife DB도 정리
try:
from framework import F
with F.app.app_context():
F.db.session.query(ModelAniLifeItem).delete()
F.db.session.commit()
except Exception as e:
logger.error(f"Failed to clear Anilife DB: {e}")
return jsonify({"ret": "notify", "log": f"{cancelled_count}개 Anilife 항목이 초기화되었습니다."})
elif command == "delete_completed":
# 완료된 항목만 DB에서 삭제
try:
from framework import F
with F.app.app_context():
deleted = F.db.session.query(ModelAniLifeItem).filter(
ModelAniLifeItem.status == 'completed'
).delete()
F.db.session.commit()
return jsonify({"ret": "success", "log": f"{deleted}개 완료 항목이 삭제되었습니다."})
except Exception as e:
logger.error(f"Failed to delete completed: {e}")
return jsonify({"ret": "error", "log": str(e)})
# Fallback: 기존 큐 시스템
if self.queue:
ret = self.queue.command(command, int(entity_id) if entity_id.isdigit() else 0)
return jsonify(ret)
return jsonify({"ret": "error", "log": "Queue not initialized"})
elif sub == "web_list": elif sub == "web_list":
return jsonify(ModelAniLifeItem.web_list(request)) return jsonify(ModelAniLifeItem.web_list(request))
elif sub == "db_remove": elif sub == "db_remove":
@@ -779,6 +1050,66 @@ class LogicAniLife(AnimeModuleBase):
logger.error(f"browse_dir error: {e}") logger.error(f"browse_dir error: {e}")
return jsonify({"ret": "error", "error": str(e)}), 500 return jsonify({"ret": "error", "error": str(e)}), 500
elif sub == "system_check":
# 시스템 체크 (Zendriver 브라우저 설치 상태)
from .mod_ohli24 import LogicOhli24
result: Dict[str, Any] = LogicOhli24.system_check()
return jsonify(result)
elif sub == "install_browser":
# 시스템 브라우저 설치 (Ubuntu/Docker)
from .mod_ohli24 import LogicOhli24
result: Dict[str, Any] = LogicOhli24.install_system_browser()
if result.get("ret") == "success" and result.get("path"):
P.ModelSetting.set("anilife_zendriver_browser_path", result["path"])
return jsonify(result)
elif sub == "immediately_execute":
# 스케줄러 1회 실행
try:
self.scheduler_function()
return jsonify({"ret": "success"})
except Exception as e:
logger.error(f"immediately_execute error: {e}")
return jsonify({"ret": "error", "msg": str(e)})
elif sub == "reset_db":
# DB 초기화
try:
self.reset_db()
return jsonify({"ret": "success"})
except Exception as e:
logger.error(f"reset_db error: {e}")
return jsonify({"ret": "error", "msg": str(e)})
elif sub == "add_schedule":
# 스케쥴 등록 (자동 다운로드 목록에 코드 추가)
try:
code = request.form.get("code", "")
title = request.form.get("title", "")
logger.debug(f"add_schedule: code={code}, title={title}")
if not code:
return jsonify({"ret": "error", "msg": "코드가 없습니다."})
# 기존 whitelist 가져오기
whitelist = P.ModelSetting.get("anilife_auto_code_list") or ""
code_list = [c.strip() for c in whitelist.replace("\n", "|").split("|") if c.strip()]
if code in code_list:
return jsonify({"ret": "exist", "msg": "이미 등록되어 있습니다."})
# 코드 추가
code_list.append(code)
new_whitelist = "|".join(code_list)
P.ModelSetting.set("anilife_auto_code_list", new_whitelist)
logger.info(f"[Anilife] Schedule added: {code} ({title})")
return jsonify({"ret": "success", "msg": f"스케쥴 등록 완료: {title}"})
except Exception as e:
logger.error(f"add_schedule error: {e}")
logger.error(traceback.format_exc())
return jsonify({"ret": "error", "msg": str(e)})
# Fallback to base class for common subs (queue_command, entity_list, browse_dir, command, etc.) # Fallback to base class for common subs (queue_command, entity_list, browse_dir, command, etc.)
return super().process_ajax(sub, req) return super().process_ajax(sub, req)
@@ -896,12 +1227,73 @@ class LogicAniLife(AnimeModuleBase):
return False return False
def scheduler_function(self): def scheduler_function(self):
logger.debug(f"ohli24 scheduler_function::=========================") """스케줄러 함수 - anilife 자동 다운로드 처리"""
logger.info("anilife scheduler_function::=========================")
content_code_list = P.ModelSetting.get_list("anilife_auto_code_list", "|")
url = f'{P.ModelSetting.get("anilife_url")}/dailyani' try:
if "all" in content_code_list: content_code_list = P.ModelSetting.get_list("anilife_auto_code_list", "|")
ret_data = LogicAniLife.get_auto_anime_info(self, url=url) auto_mode_all = P.ModelSetting.get_bool("anilife_auto_mode_all")
logger.info(f"Auto-download codes: {content_code_list}")
logger.info(f"Auto mode all episodes: {auto_mode_all}")
if not content_code_list:
logger.info("[Scheduler] No auto-download codes configured")
return
# 각 작품 코드별 처리
for code in content_code_list:
code = code.strip()
if not code:
continue
if code.lower() == "all":
# TODO: 전체 최신 에피소드 스캔 로직 (추후 구현)
logger.info("[Scheduler] 'all' mode - skipping for now")
continue
logger.info(f"[Scheduler] Processing code: {code}")
try:
# 작품 정보 조회
series_info = self.get_series_info(code)
if not series_info or "episode" not in series_info:
logger.warning(f"[Scheduler] No episode info for: {code}")
continue
episodes = series_info.get("episode", [])
logger.info(f"[Scheduler] Found {len(episodes)} episodes for: {series_info.get('title', code)}")
# 에피소드 순회 및 자동 등록
added_count = 0
for episode_info in episodes:
try:
result = self.add(episode_info)
if result and result.startswith("enqueue"):
added_count += 1
logger.info(f"[Scheduler] Auto-enqueued: {episode_info.get('title', 'Unknown')}")
self.socketio_callback("list_refresh", "")
# auto_mode_all이 False면 최신 1개만 (리스트가 최신순이라고 가정)
if not auto_mode_all and added_count > 0:
logger.info(f"[Scheduler] Auto mode: latest only - stopping after 1 episode")
break
except Exception as ep_err:
logger.error(f"[Scheduler] Episode add error: {ep_err}")
continue
logger.info(f"[Scheduler] Completed {code}: added {added_count} episodes")
except Exception as code_err:
logger.error(f"[Scheduler] Error processing {code}: {code_err}")
logger.error(traceback.format_exc())
continue
except Exception as e:
logger.error(f"[Scheduler] Fatal error: {e}")
logger.error(traceback.format_exc())
def reset_db(self): def reset_db(self):
db.session.query(ModelAniLifeItem).delete() db.session.query(ModelAniLifeItem).delete()
@@ -1318,7 +1710,40 @@ class LogicAniLife(AnimeModuleBase):
db_entity.save() db_entity.save()
return "file_exists" return "file_exists"
# 4. Proceed with queue addition # 4. Try GDM if available (like Ohli24)
if ModuleQueue is not None:
entity = AniLifeQueueEntity(P, self, episode_info)
logger.debug("entity:::> %s", entity.as_dict())
# Save to DB first
if db_entity is None:
ModelAniLifeItem.append(entity.as_dict())
# Prepare GDM options (same pattern as Ohli24)
gdm_options = {
"url": entity.url,
"save_path": entity.savepath,
"filename": entity.filename,
"source_type": "anilife",
"caller_plugin": f"{P.package_name}_{self.name}",
"callback_id": episode_info["_id"],
"title": entity.filename or episode_info.get('title'),
"thumbnail": episode_info.get('image'),
"meta": {
"series": entity.content_title,
"season": entity.season,
"episode": entity.epi_queue,
"source": "anilife"
},
}
task = ModuleQueue.add_download(**gdm_options)
if task:
logger.info(f"Delegated Anilife download to GDM: {entity.filename}")
return "enqueue_gdm_success"
# 5. Fallback to FfmpegQueue if GDM not available
logger.warning("GDM Module not found, falling back to FfmpegQueue")
if db_entity is None: if db_entity is None:
logger.debug(f"episode_info:: {episode_info}") logger.debug(f"episode_info:: {episode_info}")
entity = AniLifeQueueEntity(P, self, episode_info) entity = AniLifeQueueEntity(P, self, episode_info)
@@ -1803,7 +2228,7 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
class ModelAniLifeItem(db.Model): class ModelAniLifeItem(db.Model):
__tablename__ = "{package_name}_anilife_item".format(package_name=P.package_name) __tablename__ = "{package_name}_anilife_item".format(package_name=P.package_name)
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
created_time = db.Column(db.DateTime) created_time = db.Column(db.DateTime)
@@ -1849,118 +2274,137 @@ class ModelAniLifeItem(db.Model):
return ret return ret
def save(self): def save(self):
db.session.add(self) from framework import F
db.session.commit() with F.app.app_context():
db.session.add(self)
db.session.commit()
@classmethod @classmethod
def get_by_id(cls, idx): def get_by_id(cls, idx):
return db.session.query(cls).filter_by(id=idx).first() from framework import F
with F.app.app_context():
return db.session.query(cls).filter_by(id=idx).first()
@classmethod @classmethod
def get_by_anilife_id(cls, anilife_id): def get_by_anilife_id(cls, anilife_id):
return db.session.query(cls).filter_by(anilife_id=anilife_id).first() from framework import F
with F.app.app_context():
return db.session.query(cls).filter_by(anilife_id=anilife_id).first()
@classmethod @classmethod
def delete_by_id(cls, idx): def delete_by_id(cls, idx):
try: from framework import F
logger.debug(f"delete_by_id: {idx} (type: {type(idx)})") with F.app.app_context():
if isinstance(idx, str) and ',' in idx: try:
id_list = [int(x.strip()) for x in idx.split(',') if x.strip()] logger.debug(f"delete_by_id: {idx} (type: {type(idx)})")
logger.debug(f"Batch delete: {id_list}") if isinstance(idx, str) and ',' in idx:
count = db.session.query(cls).filter(cls.id.in_(id_list)).delete(synchronize_session='fetch') id_list = [int(x.strip()) for x in idx.split(',') if x.strip()]
logger.debug(f"Deleted count: {count}") logger.debug(f"Batch delete: {id_list}")
else: count = db.session.query(cls).filter(cls.id.in_(id_list)).delete(synchronize_session='fetch')
db.session.query(cls).filter_by(id=int(idx)).delete() logger.debug(f"Deleted count: {count}")
logger.debug(f"Single delete: {idx}") else:
db.session.commit() db.session.query(cls).filter_by(id=int(idx)).delete()
return True logger.debug(f"Single delete: {idx}")
except Exception as e: db.session.commit()
logger.error(f"Exception: {str(e)}") return True
logger.error(traceback.format_exc()) except Exception as e:
return False logger.error(f"Exception: {str(e)}")
# logger.error(traceback.format_exc())
return False
@classmethod @classmethod
def delete_all(cls): def delete_all(cls):
try: from framework import F
db.session.query(cls).delete() with F.app.app_context():
db.session.commit() try:
return True db.session.query(cls).delete()
except Exception as e: db.session.commit()
logger.error(f"Exception: {str(e)}") return True
logger.error(traceback.format_exc()) except Exception as e:
return False logger.error(f"Exception: {str(e)}")
# logger.error(traceback.format_exc())
return False
@classmethod @classmethod
def web_list(cls, req): def web_list(cls, req):
ret = {} from framework import F
page = int(req.form["page"]) if "page" in req.form else 1 with F.app.app_context():
page_size = 30 ret = {}
job_id = "" page = int(req.form["page"]) if "page" in req.form else 1
search = req.form["search_word"] if "search_word" in req.form else "" page_size = 30
option = req.form["option"] if "option" in req.form else "all" job_id = ""
order = req.form["order"] if "order" in req.form else "desc" search = req.form["search_word"] if "search_word" in req.form else ""
query = cls.make_query(search=search, order=order, option=option) option = req.form["option"] if "option" in req.form else "all"
count = query.count() order = req.form["order"] if "order" in req.form else "desc"
query = query.limit(page_size).offset((page - 1) * page_size) query = cls.make_query(search=search, order=order, option=option)
lists = query.all() count = query.count()
ret["list"] = [item.as_dict() for item in lists] query = query.limit(page_size).offset((page - 1) * page_size)
ret["paging"] = Util.get_paging_info(count, page, page_size) lists = query.all()
return ret ret["list"] = [item.as_dict() for item in lists]
ret["paging"] = Util.get_paging_info(count, page, page_size)
return ret
@classmethod @classmethod
def make_query(cls, search="", order="desc", option="all"): def make_query(cls, search="", order="desc", option="all"):
query = db.session.query(cls) from framework import F
if search is not None and search != "": with F.app.app_context():
if search.find("|") != -1: query = db.session.query(cls)
tmp = search.split("|") if search is not None and search != "":
conditions = [] if search.find("|") != -1:
for tt in tmp: tmp = search.split("|")
if tt != "": conditions = []
conditions.append(cls.filename.like("%" + tt.strip() + "%")) for tt in tmp:
query = query.filter(or_(*conditions)) if tt != "":
elif search.find(",") != -1: conditions.append(cls.filename.like("%" + tt.strip() + "%"))
tmp = search.split(",") query = query.filter(or_(*conditions))
for tt in tmp: elif search.find(",") != -1:
if tt != "": tmp = search.split(",")
query = query.filter(cls.filename.like("%" + tt.strip() + "%")) for tt in tmp:
else: if tt != "":
query = query.filter(cls.filename.like("%" + search + "%")) query = query.filter(cls.filename.like("%" + tt.strip() + "%"))
if option == "completed": else:
query = query.filter(cls.status == "completed") query = query.filter(cls.filename.like("%" + search + "%"))
if option == "completed":
query = query.filter(cls.status == "completed")
query = ( query = (
query.order_by(desc(cls.id)) if order == "desc" else query.order_by(cls.id) query.order_by(desc(cls.id)) if order == "desc" else query.order_by(cls.id)
) )
return query return query
@classmethod @classmethod
def get_list_uncompleted(cls): def get_list_uncompleted(cls):
return db.session.query(cls).filter(cls.status != "completed").all() from framework import F
with F.app.app_context():
return db.session.query(cls).filter(cls.status != "completed").all()
@classmethod @classmethod
def append(cls, q): def append(cls, q):
# 중복 체크 from framework import F
existing = cls.get_by_anilife_id(q["_id"]) with F.app.app_context():
if existing: # 중복 체크
logger.debug(f"Item already exists in DB: {q['_id']}") existing = cls.get_by_anilife_id(q["_id"])
return existing if existing:
logger.debug(f"Item already exists in DB: {q['_id']}")
item = ModelAniLifeItem() return existing
item.content_code = q["content_code"]
item.season = q["season"] item = ModelAniLifeItem()
item.episode_no = q.get("epi_queue") item.content_code = q["content_code"]
item.title = q["content_title"] item.season = q["season"]
item.episode_title = q["title"] item.episode_no = q.get("epi_queue")
item.anilife_va = q.get("va") item.title = q["content_title"]
item.anilife_vi = q.get("_vi") item.episode_title = q["title"]
item.anilife_id = q["_id"] item.anilife_va = q.get("va")
item.quality = q["quality"] item.anilife_vi = q.get("_vi")
item.filepath = q.get("filepath") item.anilife_id = q["_id"]
item.filename = q.get("filename") item.quality = q["quality"]
item.savepath = q.get("savepath") item.filepath = q.get("filepath")
item.video_url = q.get("url") item.filename = q.get("filename")
item.vtt_url = q.get("vtt") item.savepath = q.get("savepath")
item.thumbnail = q.get("thumbnail") item.video_url = q.get("url")
item.status = "wait" item.vtt_url = q.get("vtt")
item.anilife_info = q.get("anilife_info") item.thumbnail = q.get("image", "")
item.save() item.status = "wait"
item.anilife_info = q["anilife_info"]
item.save()
return item

View File

@@ -5,6 +5,10 @@ import os, traceback, time, json
from datetime import datetime from datetime import datetime
class AnimeModuleBase(PluginModuleBase): class AnimeModuleBase(PluginModuleBase):
# 업데이트 체크 캐싱 (클래스 레벨)
_last_update_check = 0
_latest_version = None
def __init__(self, P, setup_default=None, **kwargs): def __init__(self, P, setup_default=None, **kwargs):
super(AnimeModuleBase, self).__init__(P, **kwargs) super(AnimeModuleBase, self).__init__(P, **kwargs)
self.P = P # Ensure P is available via self.P self.P = P # Ensure P is available via self.P
@@ -131,6 +135,59 @@ class AnimeModuleBase(PluginModuleBase):
arg3 = request.form.get('arg3') or request.args.get('arg3') arg3 = request.form.get('arg3') or request.args.get('arg3')
return self.process_command(command, arg1, arg2, arg3, req) return self.process_command(command, arg1, arg2, arg3, req)
elif sub == 'self_update':
# 자가 업데이트 (Git Pull) 및 모듈 리로드
try:
import subprocess
plugin_path = os.path.dirname(__file__)
self.P.logger.info(f"애니 다운로더 자가 업데이트 시작: {plugin_path}")
# 먼저 변경될 파일 목록 확인 (model 파일 변경 감지)
diff_cmd = ['git', '-C', plugin_path, 'diff', '--name-only', 'HEAD', 'origin/main']
subprocess.run(['git', '-C', plugin_path, 'fetch'], capture_output=True) # fetch first
diff_result = subprocess.run(diff_cmd, capture_output=True, text=True)
changed_files = diff_result.stdout.strip().split('\n') if diff_result.stdout.strip() else []
# 모델 파일 변경 여부 확인
model_patterns = ['model', 'db', 'migration']
needs_restart = any(
any(pattern in f.lower() for pattern in model_patterns)
for f in changed_files if f
)
# Git Pull 실행
cmd = ['git', '-C', plugin_path, 'pull']
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
if process.returncode != 0:
raise Exception(f"Git pull 실패: {stderr}")
self.P.logger.info(f"Git pull 결과: {stdout}")
# 모델 변경 없으면 리로드 시도
if not needs_restart:
self.reload_plugin()
msg = f"업데이트 완료! 새로고침하세요.<br><pre>{stdout}</pre>"
else:
self.P.logger.warning("모델 파일 변경 감지 - 서버 재시작 필요")
msg = f"<strong>모델 변경 감지!</strong> 서버 재시작이 필요합니다.<br><pre>{stdout}</pre>"
return jsonify({
'ret': 'success',
'msg': msg,
'data': stdout,
'needs_restart': needs_restart
})
except Exception as e:
self.P.logger.error(f"자가 업데이트 중 오류: {str(e)}")
self.P.logger.error(traceback.format_exc())
return jsonify({'ret': 'danger', 'msg': f"업데이트 실패: {str(e)}"})
elif sub == 'check_update':
force = req.form.get('force') == 'true'
return jsonify({'ret': 'success', 'data': self.get_update_info(force=force)})
return jsonify({'ret': 'fail', 'log': f"Unknown sub: {sub}"}) return jsonify({'ret': 'fail', 'log': f"Unknown sub: {sub}"})
except Exception as e: except Exception as e:
@@ -198,3 +255,101 @@ class AnimeModuleBase(PluginModuleBase):
except Exception as e: except Exception as e:
return {'ret': 'fail', 'msg': str(e)} return {'ret': 'fail', 'msg': str(e)}
def get_update_info(self, force=False):
"""GitHub에서 최신 버전 정보 가져오기 (캐싱 활용)"""
import requests
now = time.time()
# 실제 로컬 파일에서 현재 버전 읽기
current_version = self.P.plugin_info.get('version', '0.0.0')
try:
info_path = os.path.join(os.path.dirname(__file__), 'info.yaml')
if os.path.exists(info_path):
import yaml
with open(info_path, 'r', encoding='utf-8') as f:
local_info = yaml.safe_load(f)
current_version = str(local_info.get('version', current_version))
except: pass
# 1시간마다 체크 (force=True면 즉시)
if not force and AnimeModuleBase._latest_version and (now - AnimeModuleBase._last_update_check < 3600):
return {
'current': current_version,
'latest': AnimeModuleBase._latest_version,
'has_update': self._is_newer(AnimeModuleBase._latest_version, current_version)
}
try:
url = "https://raw.githubusercontent.com/projectdx75/anime_downloader/master/info.yaml"
res = requests.get(url, timeout=5)
if res.status_code == 200:
import yaml
data = yaml.safe_load(res.text)
AnimeModuleBase._latest_version = str(data.get('version', ''))
AnimeModuleBase._last_update_check = now
return {
'current': current_version,
'latest': AnimeModuleBase._latest_version,
'has_update': self._is_newer(AnimeModuleBase._latest_version, current_version)
}
except Exception as e:
self.P.logger.error(f"Update check failed: {e}")
return {
'current': current_version,
'latest': AnimeModuleBase._latest_version or current_version,
'has_update': False
}
def _is_newer(self, latest, current):
"""버전 비교 (0.7.8 vs 0.7.7)"""
if not latest or not current: return False
try:
l_parts = [int(p) for p in latest.split('.')]
c_parts = [int(p) for p in current.split('.')]
return l_parts > c_parts
except:
return latest != current
def reload_plugin(self):
"""플러그인 모듈 핫 리로드"""
import sys
import importlib
try:
package_name = self.P.package_name
self.P.logger.info(f"플러그인 리로드 시작: {package_name}")
# 리로드에서 제외할 패턴 (모델/DB 관련 - SQLAlchemy 충돌 방지)
skip_patterns = ['model', 'db', 'migration', 'setup', 'create_plugin']
# 관련 모듈 찾기 및 리로드
modules_to_reload = []
for module_name in list(sys.modules.keys()):
if module_name.startswith(package_name):
# 모델 관련 모듈은 건너뛰기
should_skip = any(pattern in module_name.lower() for pattern in skip_patterns)
if not should_skip:
modules_to_reload.append(module_name)
# 의존성 역순으로 정렬 (깊은 모듈 먼저)
modules_to_reload.sort(key=lambda x: x.count('.'), reverse=True)
reloaded_count = 0
for module_name in modules_to_reload:
try:
module = sys.modules[module_name]
importlib.reload(module)
self.P.logger.debug(f"Reloaded: {module_name}")
reloaded_count += 1
except Exception as e:
self.P.logger.warning(f"Skip reload {module_name}: {e}")
self.P.logger.info(f"플러그인 [{package_name}] 리로드 완료: {reloaded_count}개 모듈")
self.P.logger.info("템플릿/정적 파일은 새로고침 시 자동 적용됩니다.")
return True
except Exception as e:
self.P.logger.error(f"모듈 리로드 중 실패: {str(e)}")
self.P.logger.error(traceback.format_exc())
return False

File diff suppressed because it is too large Load Diff

View File

@@ -59,7 +59,7 @@ from .mod_base import AnimeModuleBase
from .model_base import AnimeQueueEntity from .model_base import AnimeQueueEntity
try: try:
from gommi_download_manager.mod_queue import ModuleQueue from gommi_downloader_manager.mod_queue import ModuleQueue
except ImportError: except ImportError:
ModuleQueue = None ModuleQueue = None
@@ -155,12 +155,45 @@ class LogicOhli24(AnimeModuleBase):
return token_data.get("path") return token_data.get("path")
return None return None
@classmethod
def ensure_essential_dependencies(cls) -> bool:
"""필수 패키지(jsbeautifier, loguru, botasaurus) 확인 및 자동 설치"""
target_packages = ["jsbeautifier", "loguru", "botasaurus"]
need_install = []
import importlib.util
for pkg in target_packages:
if importlib.util.find_spec(pkg) is None:
need_install.append(pkg)
if not need_install:
return True
import subprocess as sp
try:
logger.info(f"[Dependencies] Missing: {need_install}, installing via pip...")
cmd = [sys.executable, "-m", "pip", "install"] + need_install + ["-q"]
result = sp.run(cmd, capture_output=True, text=True, timeout=180)
if result.returncode == 0:
logger.info(f"[Dependencies] Successfully installed: {need_install}")
return True
else:
logger.warning(f"[Dependencies] Installation failed: {result.stderr[:200]}")
return False
except Exception as e:
logger.error(f"[Dependencies] Installation error: {e}")
return False
@classmethod @classmethod
def ensure_zendriver_installed(cls) -> bool: def ensure_zendriver_installed(cls) -> bool:
"""Zendriver 패키지 확인 및 자동 설치""" """Zendriver 패키지 확인 및 자동 설치"""
if cls.zendriver_setup_done: if cls.zendriver_setup_done:
return True return True
# 필수 패키지 먼저 확인
cls.ensure_essential_dependencies()
import importlib.util import importlib.util
import subprocess as sp import subprocess as sp
@@ -257,13 +290,17 @@ class LogicOhli24(AnimeModuleBase):
return False return False
@classmethod @classmethod
def fetch_via_daemon(cls, url: str, timeout: int = 30) -> dict: def fetch_via_daemon(cls, url: str, timeout: int = 30, headers: dict = None) -> dict:
"""데몬을 통한 HTML 페칭 (빠름)""" """데몬을 통한 HTML 페칭 (빠름, 헤더 지원)"""
try: try:
import requests import requests
payload = {"url": url, "timeout": timeout}
if headers:
payload["headers"] = headers
resp = requests.post( resp = requests.post(
f"http://127.0.0.1:{cls.zendriver_daemon_port}/fetch", f"http://127.0.0.1:{cls.zendriver_daemon_port}/fetch",
json={"url": url, "timeout": timeout}, json=payload,
timeout=timeout + 5 timeout=timeout + 5
) )
if resp.status_code == 200: if resp.status_code == 200:
@@ -378,8 +415,6 @@ class LogicOhli24(AnimeModuleBase):
return {"ret": "error", "msg": f"설치 중 예외가 발생했습니다: {str(e)}"} return {"ret": "error", "msg": f"설치 중 예외가 발생했습니다: {str(e)}"}
def __init__(self, P: Any) -> None: def __init__(self, P: Any) -> None:
self.name: str = name
self.db_default = { self.db_default = {
"ohli24_db_version": "1", "ohli24_db_version": "1",
"ohli24_proxy_url": "", "ohli24_proxy_url": "",
@@ -387,11 +422,11 @@ class LogicOhli24(AnimeModuleBase):
"ohli24_url": "https://ani.ohli24.com", "ohli24_url": "https://ani.ohli24.com",
"ohli24_download_path": os.path.join(path_data, P.package_name, "ohli24"), "ohli24_download_path": os.path.join(path_data, P.package_name, "ohli24"),
"ohli24_auto_make_folder": "True", "ohli24_auto_make_folder": "True",
f"{self.name}_recent_code": "", f"{name}_recent_code": "",
"ohli24_auto_make_season_folder": "True", "ohli24_auto_make_season_folder": "True",
"ohli24_finished_insert": "[완결]", "ohli24_finished_insert": "[완결]",
"ohli24_max_ffmpeg_process_count": "1", "ohli24_max_ffmpeg_process_count": "1",
f"{self.name}_download_method": "cdndania", # cdndania (default), ffmpeg, ytdlp, aria2c f"{name}_download_method": "cdndania", # cdndania (default), ffmpeg, ytdlp, aria2c
"ohli24_download_threads": "2", # 기본값 2 (안정성 권장) "ohli24_download_threads": "2", # 기본값 2 (안정성 권장)
"ohli24_order_desc": "False", "ohli24_order_desc": "False",
"ohli24_auto_start": "False", "ohli24_auto_start": "False",
@@ -412,6 +447,10 @@ class LogicOhli24(AnimeModuleBase):
self.web_list_model = ModelOhli24Item self.web_list_model = ModelOhli24Item
default_route_socketio_module(self, attach="/queue") default_route_socketio_module(self, attach="/queue")
@staticmethod
def get_base_url():
return P.ModelSetting.get("ohli24_url").rstrip('/')
def cleanup_stale_temps(self) -> None: def cleanup_stale_temps(self) -> None:
"""서버 시작 시 잔여 tmp 폴더 정리""" """서버 시작 시 잔여 tmp 폴더 정리"""
try: try:
@@ -593,8 +632,29 @@ class LogicOhli24(AnimeModuleBase):
if ModuleQueue: if ModuleQueue:
if command == "stop" or command == "cancel": if command == "stop" or command == "cancel":
ModuleQueue.process_ajax('cancel', req) # Create a mock request object for GDM cancel as req.form is often immutable
return jsonify({'ret':'success'}) class MockRequest:
def __init__(self, form_data):
self.form = form_data
mock_req = MockRequest({"id": entity_id})
try:
# Try to call process_ajax on what we have
ret = ModuleQueue.process_ajax('cancel', mock_req)
except Exception as e:
logger.error(f"Failed to delegate cancel to ModuleQueue: {e}")
# Fallback: Find the instance via P if available
try:
from gommi_downloader_manager.setup import P as GDM_P
if GDM_P and hasattr(GDM_P, 'module_list'):
for m in GDM_P.module_list:
if m.name == 'queue':
ret = m.process_ajax('cancel', mock_req)
break
except: pass
return jsonify({'ret':'success', 'data': {'idx': entity_id, 'status_str': 'STOP', 'status_kor': '중지'}})
elif command == "reset": elif command == "reset":
# Ohli24 모듈의 다운로드만 취소 (다른 플러그인 항목은 그대로) # Ohli24 모듈의 다운로드만 취소 (다른 플러그인 항목은 그대로)
caller_id = f"{P.package_name}_{self.name}" caller_id = f"{P.package_name}_{self.name}"
@@ -602,7 +662,8 @@ class LogicOhli24(AnimeModuleBase):
for task_id, task in list(ModuleQueue._downloads.items()): for task_id, task in list(ModuleQueue._downloads.items()):
if task.caller_plugin == caller_id: if task.caller_plugin == caller_id:
task.cancel() task.cancel()
del ModuleQueue._downloads[task_id] # GDM 내부 클린업은 cancel()이 담당하므로 여기서 del은 신중해야 함
# 하지만 강제 초기화이므로 제거 시도
cancelled_count += 1 cancelled_count += 1
# Ohli24 DB도 정리 # Ohli24 DB도 정리
@@ -613,7 +674,7 @@ class LogicOhli24(AnimeModuleBase):
F.db.session.commit() F.db.session.commit()
except Exception as e: except Exception as e:
logger.error(f"Failed to clear Ohli24 DB: {e}") logger.error(f"Failed to clear Ohli24 DB: {e}")
return jsonify({'ret':'notify', 'log':f'{cancelled_count}개 Ohli24 항목이 초기화되었습니다.'}) return jsonify({'ret':'success', 'log':f'{cancelled_count}개 Ohli24 항목이 초기화되었습니다.'})
elif command == "delete_completed": elif command == "delete_completed":
# 완료 항목만 삭제 # 완료 항목만 삭제
try: try:
@@ -1147,31 +1208,154 @@ class LogicOhli24(AnimeModuleBase):
self, command: str, arg1: str, arg2: str, arg3: str, req: Any self, command: str, arg1: str, arg2: str, arg3: str, req: Any
) -> Any: ) -> Any:
"""커맨드 처리.""" """커맨드 처리."""
ret: Dict[str, Any] = {"ret": "success"} try:
if command == "list":
# 1. 자체 큐 목록 가져오기
ret = self.queue.get_entity_list() if self.queue else []
# 2. GDM 태스크 가져오기 (설치된 경우)
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
if ModuleQueue:
gdm_tasks = ModuleQueue.get_all_downloads()
# 이 모듈(ohli24)이 추가한 작업만 필터링
ohli24_tasks = [t for t in gdm_tasks if t.caller_plugin == f"{P.package_name}_{self.name}"]
for task in ohli24_tasks:
# 템플릿 호환 형식으로 변환
gdm_item = self._convert_gdm_task_to_queue_item(task)
ret.append(gdm_item)
except Exception as e:
logger.debug(f"GDM tasks fetch error: {e}")
return jsonify(ret)
elif command in ["stop", "remove", "cancel"]:
entity_id = arg1
if entity_id and str(entity_id).startswith("dl_"):
# GDM 작업 처리
try:
from gommi_downloader_manager.mod_queue import ModuleQueue
if ModuleQueue:
if command == "stop" or command == "cancel":
task = ModuleQueue.get_download(entity_id)
if task:
task.cancel()
return jsonify({"ret": "success", "log": "GDM 작업을 중지하였습니다."})
elif command == "remove" or command == "delete":
# GDM에서 삭제 처리
class DummyReq:
def __init__(self, id):
self.form = {"id": id}
ModuleQueue.process_ajax("delete", DummyReq(entity_id))
return jsonify({"ret": "success", "log": "GDM 작업을 삭제하였습니다."})
except Exception as e:
logger.error(f"GDM command error: {e}")
return jsonify({"ret": "error", "log": f"GDM 명령 실패: {e}"})
# 자체 큐 처리
return super().process_command(command, arg1, arg2, arg3, req)
if command == "download_program": if command == "download_program":
_pass = arg2 ret: Dict[str, Any] = {"ret": "success"}
db_item = ModelOhli24Program.get(arg1) _pass = arg2
if _pass == "false" and db_item is not None: db_item = ModelOhli24Program.get(arg1)
ret["ret"] = "warning" if _pass == "false" and db_item is not None:
ret["msg"] = "이미 DB에 있는 항목 입니다." ret["ret"] = "warning"
elif ( ret["msg"] = "이미 DB에 있는 항목 입니다."
_pass == "true" elif (
and db_item is not None _pass == "true"
and ModelOhli24Program.get_by_id_in_queue(db_item.id) is not None and db_item is not None
): and ModelOhli24Program.get_by_id_in_queue(db_item.id) is not None
ret["ret"] = "warning" ):
ret["msg"] = "이미 큐에 있는 항목 입니다." ret["ret"] = "warning"
else: ret["msg"] = "이미 큐에 있는 항목 입니다."
if db_item is None: else:
db_item = ModelOhli24Program(arg1, self.get_episode(arg1)) if db_item is None:
db_item.save() db_item = ModelOhli24Program(arg1, self.get_episode(arg1))
db_item.init_for_queue() db_item.save()
self.download_queue.put(db_item) db_item.init_for_queue()
ret["msg"] = "다운로드를 추가 하였습니다." self.download_queue.put(db_item)
return jsonify(ret) ret["msg"] = "다운로드를 추가 하였습니다."
return jsonify(ret)
return super().process_command(command, arg1, arg2, arg3, req) return super().process_command(command, arg1, arg2, arg3, req)
except Exception as e:
logger.error(f"process_command Error: {e}")
logger.error(traceback.format_exc())
return jsonify({'ret': 'fail', 'log': str(e)})
def _convert_gdm_task_to_queue_item(self, task):
"""GDM DownloadTask 객체를 FfmpegQueueEntity.as_dict() 호환 형식으로 변환"""
status_kor_map = {
"pending": "대기중",
"extracting": "분석중",
"downloading": "다운로드중",
"paused": "일시정지",
"completed": "완료",
"error": "실패",
"cancelled": "취소됨"
}
status_str_map = {
"pending": "WAITING",
"extracting": "ANALYZING",
"downloading": "DOWNLOADING",
"paused": "PAUSED",
"completed": "COMPLETED",
"error": "FAILED",
"cancelled": "FAILED"
}
t_dict = task.as_dict()
return {
"entity_id": t_dict["id"],
"url": t_dict["url"],
"filename": t_dict["filename"] or t_dict["title"],
"status_kor": status_kor_map.get(t_dict["status"], "알수없음"),
"percent": t_dict["progress"],
"created_time": t_dict["created_time"],
"current_speed": t_dict["speed"] or "0 B/s",
"download_time": t_dict["eta"] or "-",
"status_str": status_str_map.get(t_dict["status"], "WAITING"),
"idx": t_dict["id"],
"callback_id": "ohli24",
"start_time": t_dict["start_time"] or t_dict["created_time"],
"save_fullpath": t_dict["filepath"],
"duration_str": "GDM",
"current_pf_count": 0,
"duration": "-",
"current_duration": "-",
"current_bitrate": "-",
"max_pf_count": 0,
"is_gdm": True
}
def plugin_callback(self, data):
"""GDM 모듈로부터 다운로드 상태 업데이트 수신"""
try:
callback_id = data.get('callback_id')
status = data.get('status')
logger.info(f"[Ohli24] Received GDM callback: id={callback_id}, status={status}")
if callback_id:
from framework import F
with F.app.app_context():
db_item = ModelOhli24Item.get_by_ohli24_id(callback_id)
if db_item:
if status == "completed":
db_item.status = "completed"
db_item.completed_time = datetime.now()
db_item.filepath = data.get('filepath')
db_item.save()
logger.info(f"[Ohli24] Updated DB item {db_item.id} to COMPLETED via GDM callback")
elif status == "error":
pass
except Exception as e:
logger.error(f"[Ohli24] Callback processing error: {e}")
logger.error(traceback.format_exc())
@staticmethod @staticmethod
def add_whitelist(*args: str) -> Dict[str, Any]: def add_whitelist(*args: str) -> Dict[str, Any]:
@@ -1222,6 +1406,8 @@ class LogicOhli24(AnimeModuleBase):
def setting_save_after(self, change_list: List[str]) -> None: def setting_save_after(self, change_list: List[str]) -> None:
"""설정 저장 후 처리.""" """설정 저장 후 처리."""
if self.queue is None:
return
if self.queue.get_max_ffmpeg_count() != P.ModelSetting.get_int("ohli24_max_ffmpeg_process_count"): 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")) self.queue.set_max_ffmpeg_count(P.ModelSetting.get_int("ohli24_max_ffmpeg_process_count"))
@@ -1239,7 +1425,7 @@ class LogicOhli24(AnimeModuleBase):
# print() # print()
# print(today.weekday()) # print(today.weekday())
url = f'{P.ModelSetting.get("ohli24_url")}/bbs/board.php?bo_table=ing&sca={week[today.weekday()]}' url = f'{LogicOhli24.get_base_url()}/bbs/board.php?bo_table=ing&sca={week[today.weekday()]}'
# print(url) # print(url)
@@ -1267,7 +1453,7 @@ class LogicOhli24(AnimeModuleBase):
elif len(content_code_list) > 0: elif len(content_code_list) > 0:
for item in content_code_list: for item in content_code_list:
url = P.ModelSetting.get("ohli24_url") + "/c/" + item url = LogicOhli24.get_base_url() + "/c/" + item
logger.debug(f"scheduling url: {url}") logger.debug(f"scheduling url: {url}")
# ret_data = LogicOhli24.get_auto_anime_info(self, url=url) # ret_data = LogicOhli24.get_auto_anime_info(self, url=url)
content_info = self.get_series_info(item, "", "") content_info = self.get_series_info(item, "", "")
@@ -1385,9 +1571,9 @@ class LogicOhli24(AnimeModuleBase):
if image: if image:
if image.startswith(".."): if image.startswith(".."):
image = image.replace("..", P.ModelSetting.get("ohli24_url")) image = image.replace("..", LogicOhli24.get_base_url())
elif not image.startswith("http"): elif not image.startswith("http"):
image = P.ModelSetting.get("ohli24_url") + image image = LogicOhli24.get_base_url() + image
logger.info(f"image:: {image}") logger.info(f"image:: {image}")
@@ -1440,7 +1626,7 @@ class LogicOhli24(AnimeModuleBase):
href = a_elem.get("href", "") href = a_elem.get("href", "")
if not href.startswith("http"): if not href.startswith("http"):
href = P.ModelSetting.get("ohli24_url").rstrip("/") + href href = LogicOhli24.get_base_url() + href
# 부모에서 날짜 찾기 # 부모에서 날짜 찾기
parent = a_elem.getparent() parent = a_elem.getparent()
@@ -1456,6 +1642,15 @@ class LogicOhli24(AnimeModuleBase):
m = hashlib.md5(ep_title.encode("utf-8")) m = hashlib.md5(ep_title.encode("utf-8"))
_vi = m.hexdigest() _vi = m.hexdigest()
# Parse episode number for UI badge
epi_no = None
ep_match = re.search(r"(\d+(?:\.\d+)?)[\s\.\…화회]*$", ep_title)
if ep_match:
try:
epi_val = float(ep_match.group(1))
epi_no = int(epi_val) if epi_val.is_integer() else epi_val
except: pass
episodes.append({ episodes.append({
"title": ep_title, "title": ep_title,
"link": href, "link": href,
@@ -1466,6 +1661,7 @@ class LogicOhli24(AnimeModuleBase):
"va": href, "va": href,
"_vi": _vi, "_vi": _vi,
"content_code": code, "content_code": code,
"epi_no": epi_no,
}) })
except Exception as ep_err: except Exception as ep_err:
logger.warning(f"Episode parse error: {ep_err}") logger.warning(f"Episode parse error: {ep_err}")
@@ -1612,7 +1808,9 @@ class LogicOhli24(AnimeModuleBase):
"""카테고리별 애니메이션 목록 조회.""" """카테고리별 애니메이션 목록 조회."""
logger.debug(f"get_anime_info: cate={cate}, page={page}, sca={sca}") logger.debug(f"get_anime_info: cate={cate}, page={page}, sca={sca}")
try: try:
url = P.ModelSetting.get("ohli24_url") + "/bbs/board.php?bo_table=" + cate + "&page=" + page # URL 끝 슬래시 제거 로직 추가
base_url = P.ModelSetting.get("ohli24_url").rstrip('/')
url = base_url + "/bbs/board.php?bo_table=" + cate + "&page=" + page
if sca: if sca:
url += "&sca=" + sca url += "&sca=" + sca
logger.info("url:::> %s", url) logger.info("url:::> %s", url)
@@ -1636,7 +1834,7 @@ class LogicOhli24(AnimeModuleBase):
if len(item.xpath(".//div[@class='img-item']/img/@src")) > 0: if len(item.xpath(".//div[@class='img-item']/img/@src")) > 0:
entity["image_link"] = item.xpath(".//div[@class='img-item']/img/@src")[0].replace( entity["image_link"] = item.xpath(".//div[@class='img-item']/img/@src")[0].replace(
"..", P.ModelSetting.get("ohli24_url") "..", LogicOhli24.get_base_url()
) )
else: else:
entity["image_link"] = item.xpath(".//div[@class='img-item']/img/@data-ezsrc")[0] entity["image_link"] = item.xpath(".//div[@class='img-item']/img/@data-ezsrc")[0]
@@ -1667,7 +1865,7 @@ class LogicOhli24(AnimeModuleBase):
entity["code"] = entity["link"].split("/")[-1] entity["code"] = entity["link"].split("/")[-1]
entity["title"] = item.xpath(".//div[@class='post-title']/text()")[0].strip() entity["title"] = item.xpath(".//div[@class='post-title']/text()")[0].strip()
entity["image_link"] = item.xpath(".//div[@class='img-item']/img/@src")[0].replace( entity["image_link"] = item.xpath(".//div[@class='img-item']/img/@src")[0].replace(
"..", P.ModelSetting.get("ohli24_url") "..", LogicOhli24.get_base_url()
) )
data["ret"] = "success" data["ret"] = "success"
data["anime_list"].append(entity) data["anime_list"].append(entity)
@@ -1684,7 +1882,7 @@ class LogicOhli24(AnimeModuleBase):
try: try:
_query = urllib.parse.quote(query) _query = urllib.parse.quote(query)
url = ( url = (
P.ModelSetting.get("ohli24_url") LogicOhli24.get_base_url()
+ "/bbs/search.php?srows=24&gr_id=&sfl=wr_subject&stx=" + "/bbs/search.php?srows=24&gr_id=&sfl=wr_subject&stx="
+ _query + _query
+ "&page=" + "&page="
@@ -1714,7 +1912,7 @@ class LogicOhli24(AnimeModuleBase):
for attr in img_attributes: for attr in img_attributes:
matches = item.xpath(attr) matches = item.xpath(attr)
if matches and matches[0].strip(): if matches and matches[0].strip():
original_img = matches[0].replace("..", P.ModelSetting.get("ohli24_url")) original_img = matches[0].replace("..", LogicOhli24.get_base_url())
break break
if not original_img: if not original_img:
@@ -1748,7 +1946,7 @@ class LogicOhli24(AnimeModuleBase):
# Fetch image with referer # Fetch image with referer
headers = { 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", "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",
"Referer": P.ModelSetting.get("ohli24_url") + "/", "Referer": LogicOhli24.get_base_url() + "/",
} }
# Use stream=True to handle binary data efficiently # Use stream=True to handle binary data efficiently
@@ -1768,6 +1966,8 @@ class LogicOhli24(AnimeModuleBase):
# @staticmethod # @staticmethod
def plugin_load(self) -> None: def plugin_load(self) -> None:
try: try:
# 필수 패키지 확인 및 설치
LogicOhli24.ensure_essential_dependencies()
# SupportFfmpeg.initialize(ffmpeg_modelsetting.get('ffmpeg_path'), os.path.join(F.config['path_data'], 'tmp'), # SupportFfmpeg.initialize(ffmpeg_modelsetting.get('ffmpeg_path'), os.path.join(F.config['path_data'], 'tmp'),
# self.callback_function, ffmpeg_modelsetting.get_int('max_pf_count')) # self.callback_function, ffmpeg_modelsetting.get_int('max_pf_count'))
@@ -1841,6 +2041,8 @@ class LogicOhli24(AnimeModuleBase):
import time import time
from urllib import parse from urllib import parse
total_start = time.time()
# URL 인코딩 (한글 주소 대응) # URL 인코딩 (한글 주소 대응)
if '://' in url: if '://' in url:
try: try:
@@ -1910,8 +2112,98 @@ class LogicOhli24(AnimeModuleBase):
headers["Referer"] = "https://ani.ohli24.com" headers["Referer"] = "https://ani.ohli24.com"
# === [TEST MODE] Layer 1, 2 일시 비활성화 - Layer 3, 4만 테스트 === # === [Layer 3A: Zendriver Daemon (Primary - Persistent Browser)] ===
response_data = "" # 바로 Layer 3로 이동 # 리눅스/도커 차단 환경 대응: 가장 확실하고 빠른 젠드라이버 데몬을 최우선으로 시도
if not response_data or len(response_data) < 10:
if LogicOhli24.is_zendriver_daemon_running():
logger.debug(f"[Layer3A] Trying Zendriver Daemon: {url}")
daemon_result = LogicOhli24.fetch_via_daemon(url, 30)
if daemon_result.get("success") and daemon_result.get("html"):
elapsed = time.time() - total_start
logger.info(f"[Layer3A] Success in {elapsed:.2f}s (HTML: {len(daemon_result['html'])})")
LogicOhli24.daemon_fail_count = 0
return daemon_result["html"]
else:
logger.warning(f"[Layer3A] Daemon failed: {daemon_result.get('error', 'Unknown')}")
LogicOhli24.daemon_fail_count += 1
# === [Layer 1: curl-cffi (Fallback 1)] ===
if not response_data or len(response_data) < 10:
try:
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
logger.debug(f"[Layer1] Trying curl_cffi: {url}")
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(fetch_url_with_cffi, url, headers, 15, data, method)
response_data = future.result(timeout=20)
if response_data and len(response_data) > 500:
logger.info(f"[Layer1] curl_cffi success, HTML len: {len(response_data)}")
return response_data
else:
response_data = ""
except Exception as e:
logger.warning(f"[Layer1] curl_cffi failed: {e}")
response_data = ""
# === [Layer 2: Botasaurus @request (Mac Subprocess / Stealth)] ===
if not response_data or len(response_data) < 10:
# 리스트/검색 페이지에서 Botasaurus 활용 (Zendriver보다 빠름)
is_list_page = any(x in url for x in ["bo_table=", "/anime/", "search"])
if is_list_page and LogicOhli24.ensure_essential_dependencies():
import platform
is_mac = platform.system() == "Darwin"
try:
if is_mac:
# Mac에서는 gevent-Trio 충돌로 인해 서브프로세스로 실행
logger.debug(f"[Layer2] Trying Botasaurus subprocess (Mac): {url}")
import subprocess
script_path = os.path.join(os.path.dirname(__file__), "lib", "botasaurus_ohli24.py")
cmd = [sys.executable, script_path, url, json.dumps(headers), LogicOhli24.get_proxy() or ""]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout + 15
)
if result.returncode == 0 and result.stdout.strip():
try:
b_result = json.loads(result.stdout.strip())
if b_result.get("success") and b_result.get("html"):
logger.info(f"[Layer2] Botasaurus(sub) success, HTML len: {len(b_result['html'])} (Attempt: {b_result.get('attempt', 1)})")
return b_result["html"]
else:
logger.warning(f"[Layer2] Botasaurus(sub) logic failed: {b_result.get('error')}")
if b_result.get("traceback"):
logger.debug(f"Botasaurus Traceback: {b_result.get('traceback')}")
except json.JSONDecodeError:
logger.error(f"[Layer2] Botasaurus JSON Decode Error. Output: {result.stdout[:200]}")
logger.debug(f"Botasaurus Stderr: {result.stderr}")
else:
logger.warning(f"[Layer2] Botasaurus subprocess error (RC: {result.returncode}): {result.stderr}")
else:
# Linux 등에서는 직접 실행 시도
logger.debug(f"[Layer2] Trying Botasaurus @request (Direct): {url}")
from botasaurus.request import request as b_request
@b_request(headers=headers, use_stealth=True, proxy=LogicOhli24.get_proxy())
def fetch_url(request, data):
return request.get(data)
b_resp = fetch_url(url)
if b_resp and len(b_resp) > 500:
logger.info(f"[Layer2] Botasaurus success, HTML len: {len(b_resp)}")
return b_resp
else:
logger.warning(f"[Layer2] Botasaurus short response: {len(b_resp) if b_resp else 0}")
except Exception as e:
logger.warning(f"[Layer2] Botasaurus failed: {e}")
response_data = ""
# max_retries = 3 # max_retries = 3
# for attempt in range(max_retries): # for attempt in range(max_retries):
@@ -1961,32 +2253,7 @@ class LogicOhli24(AnimeModuleBase):
# logger.warning(f"[Layer2] Cloudscraper failed: {e}") # logger.warning(f"[Layer2] Cloudscraper failed: {e}")
# --- Layer 3A: Zendriver Daemon (빠름 - 브라우저 상시 대기) --- # (Layer 3A was moved to the top)
if not response_data or len(response_data) < 10:
if LogicOhli24.is_zendriver_daemon_running():
# 30초 타임아웃 적용
logger.debug(f"[Layer3A] Trying Zendriver Daemon: {url} (Timeout: 30s)")
daemon_result = LogicOhli24.fetch_via_daemon(url, 30)
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'])}")
# 성공 시 연속 실패 카운트 초기화
LogicOhli24.daemon_fail_count = 0
return daemon_result["html"]
else:
error_msg = daemon_result.get('error', 'Unknown')
logger.warning(f"[Layer3A] Daemon failed: {error_msg}")
# 실패 카운트 증가 및 10회 누적 시 재시작
LogicOhli24.daemon_fail_count += 1
if LogicOhli24.daemon_fail_count >= 10:
logger.error(f"[Layer3A] Daemon failed {LogicOhli24.daemon_fail_count} times consecutively. Restarting daemon...")
try:
import subprocess
subprocess.run(['pkill', '-f', 'zendriver_daemon'], check=False)
LogicOhli24.daemon_fail_count = 0
except Exception as e:
logger.error(f"Failed to kill daemon: {e}")
# --- Layer 3B: Zendriver Subprocess Fallback (데몬 실패 시) --- # --- Layer 3B: Zendriver Subprocess Fallback (데몬 실패 시) ---
if not response_data or len(response_data) < 10: if not response_data or len(response_data) < 10:
@@ -2025,7 +2292,8 @@ class LogicOhli24(AnimeModuleBase):
if result.returncode == 0 and result.stdout.strip(): if result.returncode == 0 and result.stdout.strip():
zd_result = json.loads(result.stdout.strip()) zd_result = json.loads(result.stdout.strip())
if zd_result.get("success") and zd_result.get("html"): 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'])}") elapsed = time.time() - total_start
logger.info(f"[Ohli24] Fetch success via Layer3B: {url} in {elapsed:.2f}s (HTML: {len(zd_result['html'])})")
return zd_result["html"] return zd_result["html"]
else: else:
logger.warning(f"[Layer3B] Zendriver failed: {zd_result.get('error', 'Unknown error')}") logger.warning(f"[Layer3B] Zendriver failed: {zd_result.get('error', 'Unknown error')}")
@@ -2091,6 +2359,7 @@ class LogicOhli24(AnimeModuleBase):
# 캐시 비활성화 시 바로 fetch # 캐시 비활성화 시 바로 fetch
if cache_minutes <= 0: if cache_minutes <= 0:
logger.debug(f"[Cache SKIP] Cache disabled (minutes: {cache_minutes})")
return LogicOhli24.get_html(url, **kwargs) return LogicOhli24.get_html(url, **kwargs)
# 캐시 디렉토리 생성 # 캐시 디렉토리 생성
@@ -2111,8 +2380,14 @@ class LogicOhli24(AnimeModuleBase):
if cached_html and len(cached_html) > 100: if cached_html and len(cached_html) > 100:
logger.debug(f"[Cache HIT] {url[:60]}... (age: {cache_age:.0f}s)") logger.debug(f"[Cache HIT] {url[:60]}... (age: {cache_age:.0f}s)")
return cached_html return cached_html
else:
logger.debug(f"[Cache MISS] Cached content is empty or too short for {url[:60]}...")
except Exception as e: except Exception as e:
logger.warning(f"[Cache READ ERROR] {e}") logger.warning(f"[Cache READ ERROR] {e}")
else:
logger.debug(f"[Cache EXPIRED] {url[:60]}... (age: {cache_age:.0f}s, expiry: {cache_minutes * 60}s)")
else:
logger.debug(f"[Cache MISS] No cache file found for {url[:60]}")
# 신규 fetch # 신규 fetch
html = LogicOhli24.get_html(url, **kwargs) html = LogicOhli24.get_html(url, **kwargs)
@@ -2160,52 +2435,76 @@ class LogicOhli24(AnimeModuleBase):
# GDM 모듈 사용 시나리오 # GDM 모듈 사용 시나리오
if ModuleQueue: if ModuleQueue:
logger.info(f"Preparing GDM delegation for: {episode_info.get('title')}") logger.info(f"Preparing GDM delegation for: {episode_info.get('title')}")
# Entity 인스턴스를 생성하여 메타데이터 파싱 및 URL 추출 수행 # Entity 인스턴스를 생성하여 메타데이터 파싱 및 URL 추출 수행
entity = Ohli24QueueEntity(P, self, episode_info) entity = Ohli24QueueEntity(P, self, episode_info)
# URL/자막/쿠키 추출 수행 (동기식 - 상위에서 비동기로 호출 권장되나 현재 ajax_process는 동기) # URL/자막/쿠키 추출 수행 (동기식 - 상위에서 비동기로 호출 권장되나 현재 ajax_process는 동기)
# 만약 이게 너무 느려지면 별도 쓰레드로 빼야 하지만, 일단 작동 확인을 위해 동기 처리 try:
try: logger.debug(f"Calling entity.prepare_extra() for {episode_info.get('_id')}")
entity.prepare_extra() entity.prepare_extra()
except Exception as e: logger.debug(f"entity.prepare_extra() done. URL found: {entity.url is not None}")
logger.error(f"Failed to extract video info: {e}") if not entity.url:
# 추출 실패 시 기존 방식(전체 큐)으로 넘기거나 에러 반환 logger.error(f"Failed to extract video URL for {episode_info.get('_id')}")
return "extract_failed" return "extract_failed"
except Exception as e:
logger.error(f"Failed to extract video info: {e}")
# 추출 실패 시 기존 방식(전체 큐)으로 넘기거나 에러 반환
return "extract_failed"
# 추출된 정보를 바탕으로 GDM 옵션 준비 (표준화된 필드명 사용) # 추출된 정보를 바탕으로 GDM 옵션 준비 (표준화된 필드명 사용)
gdm_options = { download_method = P.ModelSetting.get("ohli24_download_method")
"url": entity.url, # 추출된 m3u8 URL download_threads = P.ModelSetting.get_int("ohli24_download_threads")
"save_path": entity.savepath,
"filename": entity.filename, # GDM 소스 타입 결정 (멀티쓰레드/aria2c 사용 여부에 따라)
"source_type": "ani24", # GDM의 'general'은 yt-dlp + aria2c를 사용함
"caller_plugin": f"{P.package_name}_{self.name}", gdm_source_type = "ohli24"
"callback_id": episode_info["_id"], if download_method in ['ytdlp', 'aria2c']:
"title": entity.filename or episode_info.get('title'), gdm_source_type = "general"
"thumbnail": episode_info.get('image'),
"meta": {
"series": entity.content_title,
"season": entity.season,
"episode": entity.epi_queue,
"source": "ohli24"
},
# options 내부가 아닌 상위 레벨로 headers/cookies 전달 (GDM 평탄화 대응)
"headers": entity.headers,
"subtitles": entity.srt_url or entity.vtt,
"cookies_file": entity.cookies_file
}
task = ModuleQueue.add_download(**gdm_options) gdm_options = {
if task: "url": entity.url, # 추출된 m3u8 URL
logger.info(f"Delegated Ohli24 download to GDM: {entity.filename}") "save_path": entity.savepath,
# DB 상태 업데이트 (prepare_extra에서도 이미 수행하지만 명시적 상태 변경) "filename": entity.filename,
if db_entity is None: "source_type": gdm_source_type,
# append는 이미 prepare_extra 상단에서 db_entity를 조회하므로 "caller_plugin": f"{P.package_name}_{self.name}",
# 이미 DB에 entry가 생겼을 가능성 높음 (만약 없다면 여기서 추가) "callback_id": episode_info["_id"],
db_entity = ModelOhli24Item.get_by_ohli24_id(episode_info["_id"]) "title": entity.filename or episode_info.get('title'),
if not db_entity: "thumbnail": episode_info.get('thumbnail') or episode_info.get('image'),
ModelOhli24Item.append(entity.as_dict()) "meta": {
return "enqueue_gdm_success" "series": entity.content_title,
"season": entity.season,
"episode": entity.epi_queue,
"source": "ohli24"
},
"connections": download_threads, # 멀티쓰레드 개수 전달
# options 내부가 아닌 상위 레벨로 headers/cookies 전달 (GDM 평탄화 대응)
"headers": entity.headers,
"subtitles": entity.srt_url or entity.vtt,
"cookies_file": entity.cookies_file
}
try:
logger.debug(f"Calling ModuleQueue.add_download with options: {list(gdm_options.keys())}")
task = ModuleQueue.add_download(**gdm_options)
if task:
logger.info(f"Delegated Ohli24 download to GDM: {entity.filename} (Task ID: {task.id})")
else:
logger.error("ModuleQueue.add_download returned None")
except Exception as e:
logger.error(f"Error calling ModuleQueue.add_download: {e}")
logger.error(traceback.format_exc())
task = None
if task:
# DB 상태 업데이트 (prepare_extra에서도 이미 수행하지만 명시적 상태 변경)
if db_entity is None:
# append는 이미 prepare_extra 상단에서 db_entity를 조회하므로
# 이미 DB에 entry가 생겼을 가능성 높음 (만약 없다면 여기서 추가)
db_entity = ModelOhli24Item.get_by_ohli24_id(episode_info["_id"])
if not db_entity:
ModelOhli24Item.append(entity.as_dict())
return "enqueue_gdm_success"
# GDM 미설치 시 기존 방식 fallback (또는 에러 처리) # GDM 미설치 시 기존 방식 fallback (또는 에러 처리)
logger.warning("GDM Module not found, falling back to FfmpegQueue") logger.warning("GDM Module not found, falling back to FfmpegQueue")
@@ -2229,7 +2528,17 @@ class LogicOhli24(AnimeModuleBase):
if P.ModelSetting.get_bool("ohli24_auto_make_folder"): if P.ModelSetting.get_bool("ohli24_auto_make_folder"):
day = episode_info.get("day", "") day = episode_info.get("day", "")
# Robust extraction logic (Sync with Ohli24QueueEntity.parse_metadata)
content_title_clean = match.group("title").strip() if match else title content_title_clean = match.group("title").strip() if match else title
if not match:
# Fallback for truncated titles (e.g. "Long Title 6…")
fallback_match = re.search(r"(?P<title>.*?)\s*(?P<epi_no>\d+(?:\.\d+)?)(?:\.+|…)?\s*[^\d]*$", title.strip())
if fallback_match:
content_title_clean = fallback_match.group("title").strip().rstrip('-').strip()
else:
content_title_clean = title
if "완결" in day: if "완결" in day:
folder_name = "%s %s" % (P.ModelSetting.get("ohli24_finished_insert"), content_title_clean) folder_name = "%s %s" % (P.ModelSetting.get("ohli24_finished_insert"), content_title_clean)
else: else:
@@ -2602,15 +2911,27 @@ class Ohli24QueueEntity(AnimeQueueEntity):
if not title_full: if not title_full:
return return
match = re.compile(r"(?P<title>.*?)\s*((?P<season>\d+)기)?\s*((?P<epi_no>\d+)화)").search(title_full) # Improved Regex: Handle optional [-(, optional season, and various episode suffixes
regex_main = re.compile(r"(?P<title>.*?)\s*(?:[\-\(\[])?\s*((?P<season>\d+)기)?\s*(?P<epi_no>\d+(?:\.\d+)?)\s*(?:화|회|part|part\s*\d+)?\s*(?:\(完\))?\s*(?:[\)\]])?$")
match = regex_main.search(title_full.strip())
if match: if match:
self.content_title = match.group("title").strip() self.content_title = match.group("title").strip().rstrip('-').strip()
if match.group("season"): if match.group("season"):
self.season = int(match.group("season")) self.season = int(match.group("season"))
self.epi_queue = int(match.group("epi_no")) self.epi_queue = float(match.group("epi_no"))
if self.epi_queue.is_integer():
self.epi_queue = int(self.epi_queue)
else: else:
self.content_title = title_full # Fallback for truncated titles or unusual suffixes (e.g. "Title 6…")
self.epi_queue = 1 fallback_match = re.search(r"(?P<title>.*?)\s*(?P<epi_no>\d+(?:\.\d+)?)(?:\.+|…)?\s*[^\d]*$", title_full.strip())
if fallback_match:
self.content_title = fallback_match.group("title").strip().rstrip('-').strip()
epi_val = float(fallback_match.group("epi_no"))
self.epi_queue = int(epi_val) if epi_val.is_integer() else epi_val
else:
self.content_title = title_full
self.epi_queue = 1
# Predict initial filename/filepath for UI # Predict initial filename/filepath for UI
epi_no = self.epi_queue epi_no = self.epi_queue
@@ -2669,6 +2990,14 @@ class Ohli24QueueEntity(AnimeQueueEntity):
def download_completed(self) -> None: def download_completed(self) -> None:
super().download_completed() super().download_completed()
logger.debug("download completed.......!!") logger.debug("download completed.......!!")
# Verify file actually exists before marking as completed
if not self.filepath or not os.path.exists(self.filepath):
logger.warning(f"[DB_COMPLETE] File does not exist after download_completed: {self.filepath}")
# Call download_failed instead
self.download_failed("File not found after download")
return
logger.debug(f"[DB_COMPLETE] Looking up entity by ohli24_id: {self.info.get('_id')}") logger.debug(f"[DB_COMPLETE] Looking up entity by ohli24_id: {self.info.get('_id')}")
db_entity = ModelOhli24Item.get_by_ohli24_id(self.info["_id"]) db_entity = ModelOhli24Item.get_by_ohli24_id(self.info["_id"])
logger.debug(f"[DB_COMPLETE] Found db_entity: {db_entity}") logger.debug(f"[DB_COMPLETE] Found db_entity: {db_entity}")
@@ -2709,13 +3038,17 @@ class Ohli24QueueEntity(AnimeQueueEntity):
# [Lazy Extraction] prepare_extra() replaces make_episode_info() # [Lazy Extraction] prepare_extra() replaces make_episode_info()
def prepare_extra(self): def prepare_extra(self):
try: try:
base_url = P.ModelSetting.get("ohli24_url") base_url = LogicOhli24.get_base_url()
# 에피소드 페이지 URL (예: https://ani.ohli24.com/e/원펀맨 3기 1화) # 에피소드 페이지 URL (예: https://ani.ohli24.com/e/원펀맨 3기 1화)
url = self.info["va"] url = self.info["va"]
if "//e/" in url: if "//e/" in url:
url = url.replace("//e/", "/e/") url = url.replace("//e/", "/e/")
# URL Sanitization for va
if base_url in url and f"{base_url}//" in url:
url = url.replace(f"{base_url}//", f"{base_url}/")
ourls = parse.urlparse(url) ourls = parse.urlparse(url)
headers = { headers = {
@@ -3168,7 +3501,7 @@ class Ohli24QueueEntity(AnimeQueueEntity):
class ModelOhli24Item(ModelBase): class ModelOhli24Item(ModelBase):
P = P P = P
__tablename__ = "{package_name}_ohli24_item".format(package_name=P.package_name) __tablename__ = "{package_name}_ohli24_item".format(package_name=P.package_name)
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
created_time = db.Column(db.DateTime) created_time = db.Column(db.DateTime)
@@ -3324,7 +3657,7 @@ class ModelOhli24Item(ModelBase):
class ModelOhli24Program(ModelBase): class ModelOhli24Program(ModelBase):
P = P P = P
__tablename__ = f"{P.package_name}_{name}_program" __tablename__ = f"{P.package_name}_{name}_program"
__table_args__ = {"mysql_collate": "utf8_general_ci"} __table_args__ = {"mysql_collate": "utf8_general_ci", "extend_existing": True}
__bind_key__ = P.package_name __bind_key__ = P.package_name
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)

View File

@@ -143,6 +143,20 @@ class LogicGuide(PluginModuleBase):
DEFINE_DEV = True DEFINE_DEV = True
P = create_plugin_instance(setting) P = create_plugin_instance(setting)
# curl_cffi 자동 설치 루틴
try:
import curl_cffi
except ImportError:
try:
import subprocess
import sys
P.logger.info("curl_cffi not found. Attempting to install...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "curl-cffi"])
P.logger.info("curl_cffi installed successfully.")
except Exception as e:
P.logger.error(f"Failed to install curl_cffi: {e}")
try: try:
if DEFINE_DEV: if DEFINE_DEV:
from .mod_ohli24 import LogicOhli24 from .mod_ohli24 import LogicOhli24

View File

@@ -14,6 +14,12 @@ body {
color: #e0e7ff !important; color: #e0e7ff !important;
} }
@media (max-width: 768px) {
body {
background-attachment: scroll !important; /* Fixed background causes shift on some mobile browsers */
}
}
/* Common Layout Wrapper - Responsive */ /* Common Layout Wrapper - Responsive */
.anilife-common-wrapper { .anilife-common-wrapper {
max-width: 100%; max-width: 100%;

View File

@@ -54,82 +54,125 @@
font-weight: 600 !important; font-weight: 600 !important;
} }
/* Common Mobile Responsive Fixes */ /* Common Mobile Responsive Fixes - Comprehensive Normalization */
@media (max-width: 768px) { @media (max-width: 768px) {
body { *, ::before, ::after {
padding-top: 5px !important; box-sizing: border-box !important;
}
html, body {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
overflow-x: hidden !important;
position: relative !important;
-webkit-text-size-adjust: 100%;
touch-action: manipulation;
}
/* Layout Expansion on Mobile - Critical Fix for Horizontal Shift */
.container, .container-fluid, #main_container {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
padding-left: 5px !important;
padding-right: 5px !important;
margin-left: 0 !important;
margin-right: 0 !important;
box-sizing: border-box !important;
overflow-x: hidden !important; overflow-x: hidden !important;
} }
/* Compact Navbar */ .row {
margin-left: 0 !important;
margin-right: 0 !important;
width: 100% !important;
display: flex;
flex-wrap: wrap;
}
[class*="col-"] {
padding-left: 4px !important;
padding-right: 4px !important;
min-width: 0 !important;
}
/* Compact Navbar Fix */
.navbar { .navbar {
width: 100% !important;
max-width: 100% !important;
padding-top: 0.25rem !important; padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important; padding-bottom: 0.25rem !important;
margin: 0 !important;
} }
/* Global Navigation Pills Fix & Premium Styling */ /* Scroll Hint Container Fix */
#menu_module_div, #menu_page_div {
position: relative;
width: 100% !important;
overflow: hidden;
}
/* Gradient Hint to indicate more items (Scroll Hint) */
#menu_module_div::after, #menu_page_div::after {
content: '';
position: absolute;
top: 4px; bottom: 4px; right: 0;
width: 40px;
background: linear-gradient(to right, transparent, rgba(30, 41, 59, 0.9));
pointer-events: none;
z-index: 10;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Navigation Pills Styling */
ul.nav.nav-pills.bg-light { ul.nav.nav-pills.bg-light {
background-color: rgba(30, 41, 59, 0.6) !important; background-color: rgba(30, 41, 59, 0.6) !important;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 50rem !important; /* Pill shape container */ border-radius: 8px !important;
padding: 6px !important; padding: 4px !important;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important; box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2) !important;
display: inline-flex !important; /* Fit content */ display: flex !important;
flex-wrap: wrap; /* allow wrap on small screens */ flex-wrap: nowrap !important;
justify-content: center; overflow-x: auto !important;
width: auto !important; /* Prevent full width */ -webkit-overflow-scrolling: touch;
margin-top: 2px !important; /* Reduced for modularity */ width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
margin-top: 2px !important;
justify-content: flex-start !important;
scrollbar-width: thin; /* Firefox */
} }
/* Override for the fallback above to be tighter */ /* Subtle scrollbar hint for mobile */
ul.nav.nav-pills.bg-light { ul.nav.nav-pills.bg-light::-webkit-scrollbar {
margin-top: 4px !important; height: 3px;
} }
ul.nav.nav-pills.bg-light::-webkit-scrollbar-thumb {
/* Tighten spacing between 2nd and 3rd level menus */ background: rgba(255, 255, 255, 0.15);
#menu_module_div ul.nav.nav-pills.bg-light { border-radius: 10px;
margin-bottom: 2px !important;
}
#menu_page_div ul.nav.nav-pills.bg-light {
margin-top: -4px !important;
margin-bottom: 12px !important;
} }
ul.nav.nav-pills .nav-item { ul.nav.nav-pills .nav-item {
margin: 0 2px; flex: 0 0 auto !important;
} }
ul.nav.nav-pills .nav-link { ul.nav.nav-pills .nav-link {
border-radius: 50rem !important; border-radius: 6px !important;
padding: 6px 16px !important; padding: 6px 14px !important;
color: #94a3b8 !important; /* Muted text */ color: #94a3b8 !important;
font-weight: 600; font-weight: 600;
transition: all 0.3s ease; transition: all 0.3s ease;
} white-space: nowrap !important;
font-size: 11px !important;
ul.nav.nav-pills .nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: #fff !important;
transform: translateY(-1px);
} }
ul.nav.nav-pills .nav-link.active { ul.nav.nav-pills .nav-link.active {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
color: #fff !important; color: #fff !important;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
}
/* Layout Expansion on Mobile */
.container, .container-fluid, .row, form, #main_container {
width: 100% !important;
max-width: 100% !important;
padding-left: 8px !important;
padding-right: 8px !important;
margin-left: 0 !important;
margin-right: 0 !important;
box-sizing: border-box !important;
} }
/* Card/Table Container Fix */ /* Card/Table Container Fix */
@@ -137,7 +180,7 @@
width: 100% !important; width: 100% !important;
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
border-radius: 12px !important; border-radius: 8px !important;
} }
} }

View File

@@ -40,8 +40,8 @@ body {
/* General Layout Fixes */ /* General Layout Fixes */
.container-fluid { .container-fluid {
padding-left: 8px !important; padding-left: 5px !important;
padding-right: 8px !important; padding-right: 5px !important;
max-width: 100%; max-width: 100%;
} }

View File

@@ -77,6 +77,56 @@
object-fit: cover !important; object-fit: cover !important;
} }
/* Artplayer Container */
#artplayer-container {
width: 100%;
height: 100%;
min-height: 400px;
}
#artplayer-container.art-zoomed .art-video {
object-fit: cover !important;
}
/* Plyr Container */
#plyr-container {
width: 100%;
height: 100%;
}
#plyr-container .plyr {
height: 100%;
}
#plyr-container .plyr--video {
height: 100%;
}
#plyr-container video.vjs-zoomed {
object-fit: cover !important;
}
/* Player Select Dropdown in Header */
#player-select {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s ease;
}
#player-select:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
#player-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3);
}
#player-select option {
background: #1e293b;
color: #f1f5f9;
}
/* Zoom Button */ /* Zoom Button */
.video-zoom-btn { .video-zoom-btn {
position: absolute; position: absolute;

View File

@@ -1,12 +1,3 @@
/**
* Video Modal Component JavaScript
* Reusable video player modal for Anime Downloader
*
* Usage:
* VideoModal.init({ package_name: 'anime_downloader', sub: 'ohli24' });
* VideoModal.openWithPath('/path/to/video.mp4');
*/
var VideoModal = (function() { var VideoModal = (function() {
'use strict'; 'use strict';
@@ -15,28 +6,45 @@ var VideoModal = (function() {
sub: 'ohli24' sub: 'ohli24'
}; };
var videoPlayer = null; var videoPlayer = null; // Video.js instance
var artPlayer = null; // Artplayer instance
var plyrPlayer = null; // Plyr instance
var currentPlayer = 'videojs'; // 'videojs', 'artplayer', 'plyr'
var playlist = []; var playlist = [];
var currentPlaylistIndex = 0; var currentPlaylistIndex = 0;
var currentPlayingPath = ''; var currentPlayingPath = '';
var currentStreamUrl = '';
var isVideoZoomed = false; var isVideoZoomed = false;
/** /**
* Initialize the video modal * Initialize the video modal
* @param {Object} options - Configuration options
* @param {string} options.package_name - Package name (default: 'anime_downloader')
* @param {string} options.sub - Sub-module name (e.g., 'ohli24', 'linkkf')
*/ */
function init(options) { function init(options) {
config = Object.assign(config, options || {}); config = Object.assign(config, options || {});
// Load saved player preference
var savedPlayer = localStorage.getItem('anime_downloader_preferred_player');
if (savedPlayer && ['videojs', 'artplayer', 'plyr'].indexOf(savedPlayer) >= 0) {
currentPlayer = savedPlayer;
$('#player-select').val(currentPlayer);
}
bindEvents(); bindEvents();
console.log('[VideoModal] Initialized with config:', config); console.log('[VideoModal] Initialized with player:', currentPlayer);
} }
/** /**
* Bind all event handlers * Bind all event handlers
*/ */
function bindEvents() { function bindEvents() {
// Player selector change
$('#player-select').off('change').on('change', function() {
var newPlayer = $(this).val();
if (newPlayer !== currentPlayer) {
switchPlayer(newPlayer);
}
});
// Dropdown episode selection // Dropdown episode selection
$('#episode-dropdown').off('change').on('change', function() { $('#episode-dropdown').off('change').on('change', function() {
var index = parseInt($(this).val()); var index = parseInt($(this).val());
@@ -50,10 +58,12 @@ var VideoModal = (function() {
$('#btn-video-zoom').off('click').on('click', function() { $('#btn-video-zoom').off('click').on('click', function() {
isVideoZoomed = !isVideoZoomed; isVideoZoomed = !isVideoZoomed;
if (isVideoZoomed) { if (isVideoZoomed) {
$('#video-player').addClass('vjs-zoomed'); $('#video-player, #plyr-player').addClass('vjs-zoomed');
$('#artplayer-container').addClass('art-zoomed');
$(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress'); $(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress');
} else { } else {
$('#video-player').removeClass('vjs-zoomed'); $('#video-player, #plyr-player').removeClass('vjs-zoomed');
$('#artplayer-container').removeClass('art-zoomed');
$(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand'); $(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand');
} }
}); });
@@ -64,87 +74,81 @@ var VideoModal = (function() {
}); });
$('#videoModal').off('hide.bs.modal').on('hide.bs.modal', function() { $('#videoModal').off('hide.bs.modal').on('hide.bs.modal', function() {
if (videoPlayer) { pauseAllPlayers();
videoPlayer.pause();
}
}); });
$('#videoModal').off('hidden.bs.modal').on('hidden.bs.modal', function() { $('#videoModal').off('hidden.bs.modal').on('hidden.bs.modal', function() {
$('body').removeClass('modal-video-open'); $('body').removeClass('modal-video-open');
if (isVideoZoomed) { if (isVideoZoomed) {
isVideoZoomed = false; isVideoZoomed = false;
$('#video-player').removeClass('vjs-zoomed'); $('#video-player, #plyr-player').removeClass('vjs-zoomed');
$('#artplayer-container').removeClass('art-zoomed');
$('#btn-video-zoom').removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand'); $('#btn-video-zoom').removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand');
} }
}); });
} }
/** /**
* Open modal with a file path (fetches playlist from server) * Switch between players
* @param {string} filePath - Path to the video file
*/ */
function openWithPath(filePath) { function switchPlayer(newPlayer) {
$.ajax({ pauseAllPlayers();
url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath),
type: 'GET', currentPlayer = newPlayer;
dataType: 'json', localStorage.setItem('anime_downloader_preferred_player', newPlayer);
success: function(data) {
playlist = data.playlist || []; // Hide all player containers
currentPlaylistIndex = data.current_index || 0; $('#videojs-container').hide();
currentPlayingPath = filePath; $('#artplayer-container').hide();
$('#plyr-container').hide();
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
initPlayer(streamUrl); // Show selected player and reinitialize with current URL
updatePlaylistUI(); if (currentStreamUrl) {
$('#videoModal').modal('show'); initPlayerWithUrl(currentStreamUrl);
}, }
error: function() {
// Fallback: single file console.log('[VideoModal] Switched to:', newPlayer);
playlist = [{ name: filePath.split('/').pop(), path: filePath }];
currentPlaylistIndex = 0;
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
initPlayer(streamUrl);
updatePlaylistUI();
$('#videoModal').modal('show');
}
});
} }
/** /**
* Open modal with a direct stream URL * Pause all players
* @param {string} streamUrl - Direct URL to stream
* @param {string} title - Optional title
*/ */
function openWithUrl(streamUrl, title) { function pauseAllPlayers() {
playlist = [{ name: title || 'Video', path: streamUrl }]; try {
currentPlaylistIndex = 0; if (videoPlayer) videoPlayer.pause();
initPlayer(streamUrl); } catch(e) {}
updatePlaylistUI(); try {
$('#videoModal').modal('show'); if (artPlayer) artPlayer.pause();
} catch(e) {}
try {
if (plyrPlayer) plyrPlayer.pause();
} catch(e) {}
} }
/** /**
* Open modal with a playlist array * Initialize player with URL based on current player selection
* @param {Array} playlistData - Array of {name, path} objects
* @param {number} startIndex - Index to start playing from
*/ */
function openWithPlaylist(playlistData, startIndex) { function initPlayerWithUrl(streamUrl) {
playlist = playlistData || []; currentStreamUrl = streamUrl;
currentPlaylistIndex = startIndex || 0;
if (playlist.length > 0) { if (currentPlayer === 'videojs') {
var filePath = playlist[currentPlaylistIndex].path; initVideoJS(streamUrl);
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath); } else if (currentPlayer === 'artplayer') {
initPlayer(streamUrl); initArtplayer(streamUrl);
updatePlaylistUI(); } else if (currentPlayer === 'plyr') {
$('#videoModal').modal('show'); initPlyr(streamUrl);
} }
} }
/** /**
* Initialize or update Video.js player * Initialize Video.js player
* @param {string} streamUrl - URL to play
*/ */
function initPlayer(streamUrl) { function initVideoJS(streamUrl) {
// Hide other containers
$('#artplayer-container').hide();
$('#plyr-container').hide();
$('#videojs-container').show();
if (!videoPlayer) { if (!videoPlayer) {
videoPlayer = videojs('video-player', { videoPlayer = videojs('video-player', {
controls: true, controls: true,
@@ -157,22 +161,84 @@ var VideoModal = (function() {
} }
}); });
// Auto-next on video end videoPlayer.on('ended', handleVideoEnded);
videoPlayer.on('ended', function() {
var autoNextEnabled = $('#auto-next-checkbox').is(':checked');
if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) {
currentPlaylistIndex++;
playVideoAtIndex(currentPlaylistIndex);
}
});
} }
videoPlayer.src({ type: 'video/mp4', src: streamUrl }); videoPlayer.src({ type: 'video/mp4', src: streamUrl });
} }
/**
* Initialize Artplayer
*/
function initArtplayer(streamUrl) {
// Hide other containers
$('#videojs-container').hide();
$('#plyr-container').hide();
$('#artplayer-container').show().empty();
if (artPlayer) {
artPlayer.destroy();
artPlayer = null;
}
artPlayer = new Artplayer({
container: '#artplayer-container',
url: streamUrl,
autoplay: false,
pip: true,
screenshot: true,
setting: true,
playbackRate: true,
aspectRatio: true,
fullscreen: true,
fullscreenWeb: true,
theme: '#3b82f6'
});
artPlayer.on('video:ended', handleVideoEnded);
}
/**
* Initialize Plyr player
*/
function initPlyr(streamUrl) {
// Hide other containers
$('#videojs-container').hide();
$('#artplayer-container').hide();
$('#plyr-container').show();
// Set source
$('#plyr-player').attr('src', streamUrl);
if (!plyrPlayer) {
plyrPlayer = new Plyr('#plyr-player', {
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'settings', 'pip', 'fullscreen'],
settings: ['quality', 'speed'],
speed: { selected: 1, options: [0.5, 0.75, 1, 1.25, 1.5, 2] }
});
plyrPlayer.on('ended', handleVideoEnded);
} else {
plyrPlayer.source = {
type: 'video',
sources: [{ src: streamUrl, type: 'video/mp4' }]
};
}
}
/**
* Handle video ended event (auto-next)
*/
function handleVideoEnded() {
var autoNextEnabled = $('#auto-next-checkbox').is(':checked');
if (autoNextEnabled && currentPlaylistIndex < playlist.length - 1) {
currentPlaylistIndex++;
playVideoAtIndex(currentPlaylistIndex);
}
}
/** /**
* Play video at specific playlist index * Play video at specific playlist index
* @param {number} index - Playlist index
*/ */
function playVideoAtIndex(index) { function playVideoAtIndex(index) {
if (index < 0 || index >= playlist.length) return; if (index < 0 || index >= playlist.length) return;
@@ -180,14 +246,73 @@ var VideoModal = (function() {
var item = playlist[index]; var item = playlist[index];
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(item.path); var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(item.path);
if (videoPlayer) { initPlayerWithUrl(streamUrl);
videoPlayer.src({ type: 'video/mp4', src: streamUrl });
videoPlayer.play(); // Try to auto-play
} setTimeout(function() {
if (currentPlayer === 'videojs' && videoPlayer) videoPlayer.play();
else if (currentPlayer === 'artplayer' && artPlayer) artPlayer.play = true;
else if (currentPlayer === 'plyr' && plyrPlayer) plyrPlayer.play();
}, 100);
updatePlaylistUI(); updatePlaylistUI();
} }
/**
* Open modal with a file path (fetches playlist from server)
*/
function openWithPath(filePath) {
$.ajax({
url: '/' + config.package_name + '/ajax/' + config.sub + '/get_playlist?path=' + encodeURIComponent(filePath),
type: 'GET',
dataType: 'json',
success: function(data) {
playlist = data.playlist || [];
currentPlaylistIndex = data.current_index || 0;
currentPlayingPath = filePath;
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
initPlayerWithUrl(streamUrl);
updatePlaylistUI();
$('#videoModal').modal('show');
},
error: function() {
playlist = [{ name: filePath.split('/').pop(), path: filePath }];
currentPlaylistIndex = 0;
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
initPlayerWithUrl(streamUrl);
updatePlaylistUI();
$('#videoModal').modal('show');
}
});
}
/**
* Open modal with a direct stream URL
*/
function openWithUrl(streamUrl, title) {
playlist = [{ name: title || 'Video', path: streamUrl }];
currentPlaylistIndex = 0;
initPlayerWithUrl(streamUrl);
updatePlaylistUI();
$('#videoModal').modal('show');
}
/**
* Open modal with a playlist array
*/
function openWithPlaylist(playlistData, startIndex) {
playlist = playlistData || [];
currentPlaylistIndex = startIndex || 0;
if (playlist.length > 0) {
var filePath = playlist[currentPlaylistIndex].path;
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
initPlayerWithUrl(streamUrl);
updatePlaylistUI();
$('#videoModal').modal('show');
}
}
/** /**
* Update playlist UI (dropdown, external player buttons) * Update playlist UI (dropdown, external player buttons)
*/ */

View File

@@ -5,21 +5,44 @@
<link href="https://vjs.zencdn.net/8.10.0/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/8.10.0/video.min.js"></script> <script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script>
<!-- Artplayer CDN -->
<script src="https://cdn.jsdelivr.net/npm/artplayer/dist/artplayer.js"></script>
<!-- Plyr CDN -->
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
<script src="https://cdn.plyr.io/3.7.8/plyr.js"></script>
<!-- Video Player Modal --> <!-- Video Player Modal -->
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-labelledby="videoModalLabel" aria-hidden="true"> <div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-labelledby="videoModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl" role="document"> <div class="modal-dialog modal-xl" role="document">
<div class="modal-content" style="background: #0f172a; border-radius: 12px;"> <div class="modal-content" style="background: #0f172a; border-radius: 12px;">
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.1);"> <div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.1);">
<h5 class="modal-title" id="videoModalLabel" style="color: #f1f5f9;">비디오 플레이어</h5> <h5 class="modal-title" id="videoModalLabel" style="color: #f1f5f9;">비디오 플레이어</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9;"> <div class="ml-auto d-flex align-items-center">
<span aria-hidden="true">&times;</span> <select id="player-select" class="form-control form-control-sm mr-3" style="width: auto; background: rgba(255,255,255,0.1); color: white; border: 1px solid rgba(255,255,255,0.2);">
</button> <option value="videojs">VideoJS</option>
<option value="artplayer">Artplayer</option>
<option value="plyr">Plyr</option>
</select>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9;">
<span aria-hidden="true">&times;</span>
</button>
</div>
</div> </div>
<div class="modal-body" style="padding: 0;"> <div class="modal-body" style="padding: 0;">
<div class="video-container"> <div class="video-container">
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy m-auto" controls preload="auto" playsinline webkit-playsinline> <!-- Video.js Player -->
<p class="vjs-no-js">JavaScript가 필요합니다.</p> <div id="videojs-container" style="width: 100%; height: 100%;">
</video> <video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy m-auto" controls preload="auto" playsinline webkit-playsinline>
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
</video>
</div>
<!-- Artplayer Container -->
<div id="artplayer-container" style="display: none; width: 100%; height: 100%; min-height: 450px;"></div>
<!-- Plyr Container -->
<div id="plyr-container" style="display: none; width: 100%; height: 100%;">
<video id="plyr-player" playsinline controls style="width: 100%; height: 100%;"></video>
</div>
<!-- 화면 꽉 채우기 토글 버튼 (모바일용) --> <!-- 화면 꽉 채우기 토글 버튼 (모바일용) -->
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절"> <button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
<i class="fa fa-expand"></i> <i class="fa fa-expand"></i>

File diff suppressed because it is too large Load Diff

View File

@@ -11,20 +11,10 @@ body {
background-attachment: fixed !important; background-attachment: fixed !important;
} }
/* Global Container Margin Overrides */ /* Global Container Margin Overrides - mobile_custom.css와 통합 */
#main_container { #main_container {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 15px !important;
padding-right: 15px !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.container, .container-fluid:not(.anilife-common-wrapper) {
width: 100% !important;
max-width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
} }
</style> </style>
@@ -40,7 +30,7 @@ body {
</div> </div>
<form id="program_list"> <form id="program_list">
<div class="card p-2 p-md-4 mb-2 mb-md-4 border-0" style="background: rgba(49, 46, 129, 0.6); backdrop-filter: blur(10px); box-shadow: 0 4px 20px rgba(139, 92, 246, 0.2); border-radius: 16px;"> <div class="card p-1 p-md-4 mb-2 mb-md-4 border-0" style="background: rgba(49, 46, 129, 0.6); backdrop-filter: blur(10px); box-shadow: 0 4px 20px rgba(139, 92, 246, 0.2); border-radius: 16px;">
<div class="form-group mb-0"> <div class="form-group mb-0">
<label for="code" class="text-white font-weight-bold mb-2" style="color: #c4b5fd !important;"> <label for="code" class="text-white font-weight-bold mb-2" style="color: #c4b5fd !important;">
<i class="fa fa-search mr-2" style="color: #a78bfa;"></i>작품 Code <i class="fa fa-search mr-2" style="color: #a78bfa;"></i>작품 Code
@@ -50,14 +40,13 @@ body {
placeholder="URL 또는 코드를 입력하세요" placeholder="URL 또는 코드를 입력하세요"
style="background: rgba(30, 27, 75, 0.8); color: #e0e7ff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); border-radius: 12px 0 0 12px;"> style="background: rgba(30, 27, 75, 0.8); color: #e0e7ff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); border-radius: 12px 0 0 12px;">
<div class="input-group-append"> <div class="input-group-append">
<button id="analysis_btn" class="btn px-2 px-md-4 font-weight-bold" type="button" <button id="analysis_btn" class="btn px-3 font-weight-bold" type="button"
style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white; box-shadow: 0 0 15px rgba(139, 92, 246, 0.4);"> style="background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%); color: white; box-shadow: 0 0 15px rgba(139, 92, 246, 0.4); min-width: 80px;">
<i class="fa fa-cogs mr-1"></i> 분석 <i class="fa fa-cogs mr-1"></i> 분석
</button> </button>
<button id="go_anilife_btn" class="btn px-2 px-md-3" type="button" <button id="go_anilife_btn" class="btn px-3 font-weight-bold" type="button"
style="background: rgba(167, 139, 250, 0.2); border: 1px solid rgba(167, 139, 250, 0.4); color: #c4b5fd; border-radius: 0 12px 12px 0;"> style="background: rgba(167, 139, 250, 0.2); border: 1px solid rgba(167, 139, 250, 0.4); color: #c4b5fd; border-radius: 0 12px 12px 0; min-width: 80px;">
<span class="d-none d-md-inline">Go 애니라이프</span> GO
<span class="d-md-none">Go</span>
</button> </button>
</div> </div>
</div> </div>
@@ -616,10 +605,16 @@ body {
.episode-list-container { .episode-list-container {
margin-top: 20px; margin-top: 20px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 10px; gap: 10px;
} }
@media (max-width: 600px) {
.episode-list-container {
grid-template-columns: 1fr !important;
}
}
/* 에피소드 카드 */ /* 에피소드 카드 */
.episode-card { .episode-card {
display: flex; display: flex;
@@ -775,9 +770,12 @@ body {
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */ /* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
@media (max-width: 768px) { @media (max-width: 768px) {
/* 상단 서브메뉴가 SJVA 메인 navbar에 가려지지 않도록 여백 추가 */ /* Ensure breadcrumb span doesn't force width */
ul.nav.nav-pills.bg-light { #menu_module_div span.nav-link {
margin-top: 60px !important; overflow: hidden;
text-overflow: ellipsis;
max-width: 150px;
white-space: nowrap !important;
} }
/* 입력창 크기 최적화 */ /* 입력창 크기 최적화 */
@@ -809,8 +807,8 @@ body {
.row, form, #program_list, #program_auto_form, #episode_list { .row, form, #program_list, #program_auto_form, #episode_list {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 10px !important; padding-left: 4px !important; /* 10px -> 4px */
padding-right: 10px !important; padding-right: 4px !important; /* 10px -> 4px */
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
@@ -868,8 +866,8 @@ body {
align-items: center !important; align-items: center !important;
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding: 10px 12px !important; padding: 8px 6px !important; /* 좌우 패딩 대폭 축소 */
gap: 10px !important; gap: 6px !important; /* 요소 간 간격 축소 */
margin: 0 !important; margin: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }

View File

@@ -197,7 +197,10 @@
tmp += '</div>'; tmp += '</div>';
tmp += '<div class=\"card-body\">' tmp += '<div class=\"card-body\">'
tmp += '<h5 class=\"card-title\">' + data.anime_list[i].title + '</h5>'; tmp += '<h5 class=\"card-title\">' + data.anime_list[i].title + '</h5>';
tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\">' + data.anime_list[i].title + '</a>'; tmp += '<div class=\"card-actions\">';
tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\"><i class="fa fa-info-circle"></i> 상세</a>';
tmp += '<button type=\"button\" class=\"btn btn-sch btn-add-schedule\" data-code=\"' + data.anime_list[i].code + '\" data-title=\"' + data.anime_list[i].title.replace(/"/g, '&quot;') + '\"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
@@ -262,7 +265,10 @@
tmp += '</div>'; tmp += '</div>';
tmp += '<div class="card-body">' tmp += '<div class="card-body">'
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>'; tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>'; tmp += '<div class="card-actions">';
tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text"><i class="fa fa-info-circle"></i> 상세</a>';
tmp += '<button type="button" class="btn btn-sch btn-add-schedule" data-code="' + data.anime_list[i].code + '" data-title="' + data.anime_list[i].title.replace(/"/g, '&quot;') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
@@ -314,7 +320,10 @@
tmp += '</div>'; tmp += '</div>';
tmp += '<div class="card-body">' tmp += '<div class="card-body">'
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>'; tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>'; tmp += '<div class="card-actions">';
tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text"><i class="fa fa-info-circle"></i> 상세</a>';
tmp += '<button type="button" class="btn btn-sch btn-add-schedule" data-code="' + data.anime_list[i].code + '" data-title="' + data.anime_list[i].title.replace(/"/g, '&quot;') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
@@ -578,6 +587,38 @@
}; };
document.addEventListener("scroll", debounce(onScroll, 300)); document.addEventListener("scroll", debounce(onScroll, 300));
// ================================
// 스케쥴 등록 버튼 핸들러
// ================================
$('body').on('click', '.btn-add-schedule', function(e) {
e.preventDefault();
var code = $(this).data('code');
var title = $(this).data('title');
var btn = $(this);
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_schedule',
type: 'POST',
data: { code: code, title: title },
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success' || ret.ret === 'exist') {
$.notify('<strong>' + (ret.ret === 'exist' ? '이미 등록됨' : '스케쥴 등록 완료') + '</strong>', { type: ret.ret === 'exist' ? 'info' : 'success' });
} else {
$.notify('<strong>등록 실패: ' + (ret.msg || ret.ret) + '</strong>', { type: 'warning' });
}
},
error: function() {
$.notify('<strong>스케쥴 등록 중 오류</strong>', { type: 'danger' });
},
complete: function() {
btn.prop('disabled', false).html('<i class="fa fa-calendar-plus-o"></i> 스케쥴');
}
});
});
</script> </script>
<style> <style>
button.code-button { button.code-button {
@@ -1221,6 +1262,57 @@
color: #fff !important; color: #fff !important;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
} }
/* Card Actions Layout */
.card-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
}
.card-actions .btn {
font-size: 13px;
padding: 8px 12px;
border-radius: 8px;
}
.card-actions .btn-sch {
background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%) !important;
border: none !important;
color: white !important;
}
.card-actions .btn-sch:hover {
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%) !important;
transform: translateY(-1px);
}
/* Reduced Wrapper Margins */
#yommi_wrapper {
max-width: 100% !important;
padding: 10px 8px !important;
margin: 0 auto;
}
@media (min-width: 1200px) {
#yommi_wrapper {
max-width: 95% !important;
padding: 20px 15px !important;
}
}
@media (max-width: 768px) {
.row.infinite-scroll > [class*="col-"] {
padding: 6px !important;
}
.card-body {
padding: 10px !important;
}
.card-title {
font-size: 0.85rem !important;
}
.card-actions .btn {
font-size: 12px;
padding: 6px 10px;
}
}
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -1231,6 +1323,5 @@ $(document).ready(function(){
}, 100); }, 100);
}); });
</script> </script>
</style>
{% endblock %} {% endblock %}

View File

@@ -8,7 +8,12 @@
<div class="glass-card p-4"> <div class="glass-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="text-white font-weight-bold"><i class="bi bi-gear-fill mr-2"></i>Anilife 설정</h2> <h2 class="text-white font-weight-bold"><i class="bi bi-gear-fill mr-2"></i>Anilife 설정</h2>
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}} <div>
<button type="button" class="btn btn-outline-info btn-sm mr-2" id="btn-self-update" title="최신 버전으로 업데이트">
<i class="bi bi-arrow-repeat"></i> 업데이트
</button>
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
</div>
</div> </div>
{{ macros.m_row_start('5') }} {{ macros.m_row_start('5') }}
@@ -17,7 +22,7 @@
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('normal', '일반', true) }} {{ macros.m_tab_head('normal', '일반', true) }}
{{ macros.m_tab_head('auto', '홈화면 자동', false) }} {{ macros.m_tab_head('auto', '자동등록', false) }}
{{ macros.m_tab_head('action', '기타', false) }} {{ macros.m_tab_head('action', '기타', false) }}
{{ macros.m_tab_head_end() }} {{ macros.m_tab_head_end() }}
</nav> </nav>
@@ -26,6 +31,8 @@
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
{{ macros.m_tab_content_start('normal', true) }} {{ macros.m_tab_content_start('normal', true) }}
{{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }} {{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }}
{{ macros.setting_input_text('anilife_proxy_url', '프록시 URL', col='4', value=arg.get('anilife_proxy_url', ''), desc='차단 시 프록시 서버를 입력하세요. 예: http://IP:PORT') }}
{{ macros.setting_input_int('anilife_cache_ttl', 'HTTP 캐시 TTL (초)', value=arg.get('anilife_cache_ttl', 300), desc='HTTP 응답 캐시 유지 시간 (초 단위, 기본: 300초 = 5분)') }}
<!-- 저장 폴더 (탐색 버튼 포함) --> <!-- 저장 폴더 (탐색 버튼 포함) -->
<div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;"> <div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;">
@@ -59,16 +66,73 @@
{{ macros.setting_checkbox('anilife_auto_make_season_folder', '시즌 폴더 생성', value=arg['anilife_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }} {{ macros.setting_checkbox('anilife_auto_make_season_folder', '시즌 폴더 생성', value=arg['anilife_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }}
</div> </div>
{{ macros.setting_checkbox('anilife_uncompleted_auto_enqueue', '자동으로 다시 받기', value=arg['anilife_uncompleted_auto_enqueue'], desc=['On : 플러그인 로딩시 미완료인 항목은 자동으로 다시 받습니다.']) }} {{ macros.setting_checkbox('anilife_uncompleted_auto_enqueue', '자동으로 다시 받기', value=arg['anilife_uncompleted_auto_enqueue'], desc=['On : 플러그인 로딩시 미완료인 항목은 자동으로 다시 받습니다.']) }}
{{ macros.setting_select('anilife_cache_minutes', 'HTML 캐시 시간', [['0', '캐시 없음'], ['5', '5분'], ['10', '10분'], ['15', '15분'], ['30', '30분'], ['60', '1시간']], value=arg.get('anilife_cache_minutes', '5'), desc=['브라우징(요청, 검색) 페이지의 HTML을 캐시합니다.', '0으로 설정하면 캐시를 사용하지 않습니다.']) }}
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
{{ macros.m_tab_content_start('auto', false) }} {{ macros.m_tab_content_start('auto', false) }}
{{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }}
{{ macros.setting_input_text('anilife_interval', '스케쥴링 실행 정보', value=arg['anilife_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }} {{ macros.setting_input_text('anilife_interval', '스케쥴링 실행 정보', value=arg['anilife_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }}
{{ macros.setting_checkbox('anilife_auto_start', '시작시 자동실행', value=arg['anilife_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }} {{ macros.setting_checkbox('anilife_auto_start', '시작시 자동실행', value=arg['anilife_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
{{ macros.setting_input_textarea('anilife_auto_code_list', '자동 다운로드 작품 코드', desc=['all 입력시 모두 받기', '구분자 | 또는 엔터'], value=arg['anilife_auto_code_list'], row='10') }} <!-- 자동 다운로드 작품 코드 - Tag Chips UI -->
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left">
<strong>자동 다운로드할 작품 코드</strong>
</div>
<div class="col-sm-9">
<input type="hidden" id="anilife_auto_code_list" name="anilife_auto_code_list" value="{{arg['anilife_auto_code_list']}}">
<div id="tag_chips_container" class="tag-chips-wrapper mb-2"></div>
<div class="input-group input-group-sm">
<input type="text" id="new_tag_input" class="form-control" placeholder="작품명 입력 후 Enter (all: 모두 받기)">
<div class="input-group-append">
<button type="button" class="btn btn-outline-primary" id="add_tag_btn"><i class="bi bi-plus-lg"></i> 추가</button>
</div>
</div>
<div style="padding-top:5px;"><em class="text-muted">Enter로 추가, X로 삭제, 드래그 순서변경 | all 입력시 모두 받기</em></div>
</div>
</div>
{{ macros.setting_checkbox('anilife_auto_mode_all', '에피소드 모두 받기', value=arg['anilife_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }} {{ macros.setting_checkbox('anilife_auto_mode_all', '에피소드 모두 받기', value=arg['anilife_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
{{ macros.m_tab_content_start('action', false) }}
<div class="p-3" style="background: rgba(0,0,0,0.2); border-radius: 8px;">
<h5 class="text-info mb-3"><i class="bi bi-lightning-charge-fill mr-2"></i>Actions</h5>
{{ macros.setting_buttons([['global_one_execute_btn', '1회 실행']], left='1회 실행' ) }}
<hr style="border-color: rgba(255,255,255,0.1);">
{{ macros.setting_buttons([['global_reset_db_btn', 'DB 초기화']], left='DB정리' ) }}
<hr style="border-color: rgba(255,255,255,0.1);">
<h5 class="text-info mb-3"><i class="bi bi-cpu-fill mr-2"></i>시스템 상태 및 의존성</h5>
<div id="system_check_result" class="mb-3 p-3 rounded" style="background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.05);">
<div class="d-flex align-items-center mb-2">
<span class="mr-2">Chromium/Chrome:</span>
<span id="browser_status_badge" class="badge badge-secondary">확인 중...</span>
</div>
<div id="browser_path_display" class="small text-muted mb-2" style="font-family: monospace;"></div>
<div id="install_guide_section" style="display:none;">
<p class="small text-warning mb-2"><i class="bi bi-exclamation-triangle-fill mr-1"></i>브라우저가 발견되지 않았습니다. Zendriver 기능을 위해 설치가 필요합니다.</p>
<div id="auto_install_div" style="display:none;">
<button type="button" id="auto_install_btn" class="btn btn-sm btn-outline-info mb-2">
<i class="bi bi-download mr-1"></i>자동 설치 (Ubuntu/Docker)
</button>
</div>
<div class="mt-2">
<small class="d-block text-muted mb-1">수동 설치 명령어:</small>
<div class="input-group input-group-sm">
<input type="text" id="manual_install_cmd" class="form-control form-control-sm bg-dark border-secondary text-info" readonly value="apt-get update && apt-get install -y chromium-browser">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="copy_cmd_btn"><i class="bi bi-clipboard"></i></button>
</div>
</div>
</div>
</div>
</div>
<hr style="border-color: rgba(255,255,255,0.1);">
<h5 class="text-info mb-3"><i class="bi bi-browser-chrome mr-2"></i>Zendriver 설정</h5>
{{ macros.setting_input_text('anilife_zendriver_browser_path', '브라우저 경로', value=arg.get('anilife_zendriver_browser_path', ''), desc=['Zendriver가 사용할 Chrome/Chromium 실행 파일 경로입니다.', '위의 시스템 상태에서 자동으로 찾은 경우 비워두셔도 됩니다 (수동 설정 시 우선 적용).']) }}
</div>
{{ macros.m_tab_content_end() }}
</div><!--tab-content--> </div><!--tab-content-->
</form> </form>
</div> </div>
@@ -311,6 +375,59 @@
background: rgba(59, 130, 246, 0.3) !important; background: rgba(59, 130, 246, 0.3) !important;
} }
/* Tag Chips Styles */
.tag-chips-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
min-height: 60px;
background: rgba(0, 0, 0, 0.2);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 8px;
}
.tag-chips-wrapper:empty::before {
content: '작품이 없습니다. 아래에서 추가하세요.';
color: #64748b;
font-style: italic;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3) 0%, rgba(37, 99, 235, 0.4) 100%);
border: 1px solid rgba(96, 165, 250, 0.4);
border-radius: 20px;
font-size: 0.9rem;
color: #e2e8f0;
cursor: grab;
transition: all 0.2s ease;
}
.tag-chip:hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.5) 0%, rgba(37, 99, 235, 0.6) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.tag-chip .tag-text { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-chip .tag-remove {
width: 18px; height: 18px;
background: rgba(239, 68, 68, 0.5);
border-radius: 50%;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 0.75rem;
}
.tag-chip .tag-remove:hover { background: rgba(239, 68, 68, 0.9); }
.tag-chip .tag-index {
width: 20px; height: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
font-size: 0.7rem;
color: #94a3b8;
display: flex; align-items: center; justify-content: center;
}
</style> </style>
@@ -424,6 +541,210 @@ $('#folder_select_btn').on('click', function() {
}); });
function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; } function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; }
// ======================================
// 1회 실행 버튼
// ======================================
$("body").on('click', '#global_one_execute_btn', function(e){
e.preventDefault();
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/immediately_execute',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('스케줄러 1회 실행을 시작합니다.', {type:'success'});
} else {
$.notify(ret.msg || '실행 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// ======================================
// DB 초기화 버튼
// ======================================
$("body").on('click', '#global_reset_db_btn', function(e){
e.preventDefault();
if (!confirm('정말 DB를 초기화하시겠습니까?')) return;
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/reset_db',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('DB가 초기화되었습니다.', {type:'success'});
} else {
$.notify(ret.msg || '초기화 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// ======================================
// 시스템 체크 및 브라우저 설치
// ======================================
function runSystemCheck() {
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/system_check',
type: 'POST',
success: function(ret) {
if (ret.browser_found) {
$('#browser_status_badge').removeClass('badge-secondary badge-danger badge-warning').addClass('badge-success').text('발견됨');
$('#browser_path_display').text('경로: ' + ret.browser_path);
$('#install_guide_section').hide();
} else {
if (ret.snap_error) {
$('#browser_status_badge').removeClass('badge-secondary badge-success badge-danger').addClass('badge-warning').text('스냅 오류');
$('#browser_path_display').html('<span class="text-warning">발견되었으나 Snap 버전입니다. 도커에서 작동하지 않습니다.</span>');
} else {
$('#browser_status_badge').removeClass('badge-secondary badge-success badge-warning').addClass('badge-danger').text('미설치');
$('#browser_path_display').text('');
}
$('#install_guide_section').show();
$('#manual_install_cmd').val(ret.install_cmd);
if (ret.can_install) {
$('#auto_install_div').show();
} else {
$('#auto_install_div').hide();
}
}
}
});
}
// 자동 설치 버튼
$('#auto_install_btn').on('click', function() {
if (!confirm('시스템 브라우저 설치를 시작하시겠습니까?\n(Ubuntu/Debian 기반 도커 환경에서만 작동합니다)')) return;
var btn = $(this);
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin mr-1"></i>설치 중 (최대 10분 소요)...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/install_browser',
type: 'POST',
success: function(ret) {
if (ret.ret === 'success') {
$.notify(ret.msg, {type: 'success'});
if (ret.path) {
$('#anilife_zendriver_browser_path').val(ret.path);
}
runSystemCheck();
} else {
$.notify(ret.msg, {type: 'danger'});
}
},
error: function() {
$.notify('설치 요청 중 오류가 발생했습니다.', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-download mr-1"></i>자동 설치 (Ubuntu/Docker)');
}
});
});
// 명령어 복사 버튼
$('#copy_cmd_btn').on('click', function() {
var copyText = document.getElementById("manual_install_cmd");
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
$.notify('명령어가 복사되었습니다.', {type: 'info'});
});
// 초기 실행 - Action 탭 로드시 시스템 체크
$(document).ready(function() {
runSystemCheck();
initTagChips();
});
// ======================================
// Tag Chips 기능
// ======================================
function initTagChips() {
var value = $('#anilife_auto_code_list').val().trim();
if (value) {
var items = value.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0);
items.forEach(function(item, index) { addTagChip(item, index); });
}
updateTagIndices();
}
function addTagChip(text, index) {
var chip = $('<div class="tag-chip" draggable="true" data-value="'+escapeHtml(text)+'"><span class="tag-index">'+(index+1)+'</span><span class="tag-text" title="'+escapeHtml(text)+'">'+escapeHtml(text)+'</span><span class="tag-remove"><i class="bi bi-x"></i></span></div>');
$('#tag_chips_container').append(chip);
}
function updateHiddenField() {
var values = [];
$('#tag_chips_container .tag-chip').each(function() { values.push($(this).data('value')); });
$('#anilife_auto_code_list').val(values.join('|'));
}
function updateTagIndices() {
$('#tag_chips_container .tag-chip').each(function(i) { $(this).find('.tag-index').text(i+1); });
}
$('#tag_chips_container').on('click', '.tag-remove', function(e) {
e.stopPropagation();
var chip = $(this).closest('.tag-chip');
chip.fadeOut(200, function() { $(this).remove(); updateHiddenField(); updateTagIndices(); });
});
$('#add_tag_btn').on('click', function() { addNewTag(); });
$('#new_tag_input').on('keypress', function(e) { if (e.which === 13) { e.preventDefault(); addNewTag(); } });
function addNewTag() {
var text = $('#new_tag_input').val().trim();
if (!text) { $.notify('작품명을 입력하세요', {type:'warning'}); return; }
var exists = false;
$('#tag_chips_container .tag-chip').each(function() { if ($(this).data('value') === text) exists = true; });
if (exists) { $.notify('이미 등록된 작품입니다', {type:'warning'}); return; }
addTagChip(text, $('#tag_chips_container .tag-chip').length);
updateHiddenField();
$('#new_tag_input').val('');
$.notify('"'+text+'" 추가됨', {type:'success'});
}
var draggedChip = null;
$('#tag_chips_container').on('dragstart', '.tag-chip', function(e) { draggedChip = this; $(this).addClass('dragging'); });
$('#tag_chips_container').on('dragend', '.tag-chip', function() { $(this).removeClass('dragging'); draggedChip = null; updateHiddenField(); updateTagIndices(); });
$('#tag_chips_container').on('dragover', function(e) { e.preventDefault(); var after = getDragAfterElement(this, e.originalEvent.clientX); if (!after) this.appendChild(draggedChip); else this.insertBefore(draggedChip, after); });
function getDragAfterElement(container, x) {
return [...container.querySelectorAll('.tag-chip:not(.dragging)')].reduce((c, el) => { var box = el.getBoundingClientRect(); var offset = x - box.left - box.width/2; return (offset < 0 && offset > c.offset) ? {offset, element: el} : c; }, {offset: Number.NEGATIVE_INFINITY}).element;
}
// ======================================
// 자가 업데이트 기능
// ======================================
$('#btn-self-update').on('click', function() {
if (!confirm('최신 코드를 다운로드하고 플러그인을 리로드하시겠습니까?')) return;
var btn = $(this);
var originalHTML = btn.html();
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/self_update',
type: 'POST',
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success') {
$.notify('<strong>업데이트 완료!</strong> 페이지를 새로고침합니다.', {type: 'success'});
setTimeout(function() { location.reload(); }, 1500);
} else {
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type: 'danger'});
}
},
error: function() {
$.notify('<strong>업데이트 중 오류 발생</strong>', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html(originalHTML);
}
});
});
</script> </script>
<style> <style>

View File

@@ -768,6 +768,12 @@ $(document).ready(function(){
str += '<button class="action-btn btn-play" data-path="' + item.filepath + '" data-filename="' + item.filename + '"><i class="fa fa-play"></i> 재생</button>'; str += '<button class="action-btn btn-play" data-path="' + item.filepath + '" data-filename="' + item.filename + '"><i class="fa fa-play"></i> 재생</button>';
str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>'; str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>';
} }
// [보기] 버튼 추가 - JSON 버튼 왼쪽에 배치
if (item.content_code) {
str += '<button class="action-btn" onclick="location.href=\'/' + package_name + '/' + sub + '/request?code=' + item.content_code + '\'"><i class="fa fa-eye"></i> 보기</button>';
}
str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>'; str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>';
str += '<button class="action-btn" onclick="search_item(\'' + (item.title || '') + '\')"><i class="fa fa-search"></i> 검색</button>'; str += '<button class="action-btn" onclick="search_item(\'' + (item.title || '') + '\')"><i class="fa fa-search"></i> 검색</button>';
str += '<button class="action-btn" onclick="db_remove(' + item.id + ')"><i class="fa fa-trash"></i> 삭제</button>'; str += '<button class="action-btn" onclick="db_remove(' + item.id + ')"><i class="fa fa-trash"></i> 삭제</button>';

View File

@@ -150,6 +150,23 @@ $(document).ready(function(){
socket.on('last', function(data){ status_html(data); button_html(data); }); socket.on('last', function(data){ status_html(data); button_html(data); });
} }
// GDM 전용 소켓 추가 핸들링 (전역 업데이트 수신용)
var gdmSocket = null;
try {
gdmSocket = io.connect(socketUrl + '/gommi_downloader_manager');
gdmSocket.on('status', function(data) {
// 이 모듈과 관련된 작업만 처리
if (data.caller_plugin === PACKAGE_NAME + '_' + MODULE_NAME) {
// GDM 데이터를 큐 형식으로 변환하여 UI 즉시 업데이트
silentFetchList(function(newList) {
smartSyncList(newList);
});
}
});
} catch (e) {
console.error('GDM socket error:', e);
}
on_start(); on_start();
refreshIntervalId = setInterval(autoRefreshList, 3000); refreshIntervalId = setInterval(autoRefreshList, 3000);
}); });
@@ -170,15 +187,13 @@ function silentFetchList(callback) {
function autoRefreshList() { function autoRefreshList() {
silentFetchList(function(data) { silentFetchList(function(data) {
if (data.length !== current_list_length) { // 무조건 스마트 싱크 호출 (내부에서 변경 사항이 있을 때만 업데이트됨)
current_list_length = data.length; smartSyncList(data);
renderList(data);
}
var hasActiveDownload = false; var hasActiveDownload = false;
if (data && data.length > 0) { if (data && data.length > 0) {
for (var j = 0; j < data.length; j++) { for (var j = 0; j < data.length; j++) {
if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED') { if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED' || data[j].status_str === 'ANALYZING') {
hasActiveDownload = true; hasActiveDownload = true;
break; break;
} }
@@ -202,6 +217,51 @@ function on_start() {
}); });
} }
function smartSyncList(data) {
if (!data) return;
if (data.length == 0) {
if (current_list_length !== 0) {
current_list_length = 0;
renderList(data);
}
return;
}
// 1. 기존 리스트에 있는 항목들과 비교하여 업데이트 또는 삭제
var currentIds = data.map(function(item) { return item.idx; });
// 현재 DOM에 있는 항목들 중 사라진 항목 제거
$('#list tr[id^="tr1_"]').each(function() {
var domIdx = $(this).attr('id').replace('tr1_', '');
// GDM ID 등 문자열 포함 가능성 고려하여 타입 맞춤
if (currentIds.indexOf(domIdx) === -1 && currentIds.indexOf(parseInt(domIdx)) === -1) {
$(this).remove();
$('#collapse_' + domIdx).remove();
}
});
// 2. 새로운 리스트 순회하며 업데이트 또는 추가
for (var i = 0; i < data.length; i++) {
var item = data[i];
var row = $('#tr1_' + item.idx);
if (row.length > 0) {
// 이미 존재하는 경우: 상태 및 데이터만 업데이트 (깜빡임 방지)
status_html(item);
button_html(item);
// 파일명이나 다른 필드도 변할 수 있으므로 필요한 경우 갱신
// (여기서는 진행률과 상태가 핵심이므로 status_html에서 처리됨)
} else {
// 새로 추가된 경우: 리스트의 올바른 위치에 삽입 (간단히 append)
$("#list").append(make_item(item));
}
}
current_list_length = data.length;
}
function renderList(data) { function renderList(data) {
$("#list").html(''); $("#list").html('');
if (!data || data.length == 0) { if (!data || data.length == 0) {
@@ -274,8 +334,8 @@ function make_item1(data) {
str += '<td id="status_'+data.idx+'" style="text-align:center;"><span class="badge-status ' + status_class + '">'+ data.status_kor + '</span></td>'; str += '<td id="status_'+data.idx+'" style="text-align:center;"><span class="badge-status ' + status_class + '">'+ data.status_kor + '</span></td>';
var visi = (parseInt(data.percent) > 0) ? 'visible' : 'hidden'; // 진행률 표시: 0%이더라도 다운로드 중이면 바를 표시 (깜빡임 서프레스)
str += '<td><div class="progress custom-progress"><div id="progress_'+data.idx+'" class="progress-bar" style="visibility: '+visi+'; width:'+data.percent+'%">'+data.percent +'%</div></div></td>'; str += '<td><div class="progress custom-progress"><div id="progress_'+data.idx+'" class="progress-bar" style="width:'+data.percent+'%">'+data.percent +'%</div></div></td>';
str += '<td style="text-align:center;">'+ data.duration_str + '</td>'; str += '<td style="text-align:center;">'+ data.duration_str + '</td>';
str += '<td id="current_pf_count_'+data.idx+'" style="text-align:center; color: #f87171;">'+ data.current_pf_count + '</td>'; str += '<td id="current_pf_count_'+data.idx+'" style="text-align:center; color: #f87171;">'+ data.current_pf_count + '</td>';
@@ -334,7 +394,6 @@ function status_html(data) {
progress.style.width = data.percent+ '%'; progress.style.width = data.percent+ '%';
progress.innerHTML = data.percent+ '%'; progress.innerHTML = data.percent+ '%';
progress.style.visibility = 'visible';
var statusEl = document.getElementById("status_" + data.idx); var statusEl = document.getElementById("status_" + data.idx);
if (statusEl) { if (statusEl) {

View File

@@ -49,7 +49,7 @@
const package_name = "{{arg['package_name'] }}"; const package_name = "{{arg['package_name'] }}";
const sub = "{{arg['sub'] }}"; const sub = "{{arg['sub'] }}";
const ohli24_url = "{{arg['ohli24_url']}}"; const ohli24_url = "{{arg['ohli24_url']}}";
// let current_data = ''; var current_data = null;
const params = new Proxy(new URLSearchParams(window.location.search), { const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop), get: (searchParams, prop) => searchParams.get(prop),
@@ -195,45 +195,21 @@
} }
$(function () { $(function () {
// console.log(params.wr_id) // URL 파라미터 처리 (code)
// console.log(findGetParameter('wr_id')) const urlCode = params.code || findGetParameter('code');
// console.log(params.code) const currentCode = "{{arg['linkkf_current_code']}}";
if (params.code === '') {
const targetCode = urlCode || currentCode;
} else {
document.getElementById("code").value = params.code if (targetCode) {
// {#document.getElementById("analysis_btn").click();#} document.getElementById("code").value = targetCode;
// wr_id, bo_table 등이 있으면 같이 전달
analyze(params.wr_id || findGetParameter('wr_id'), params.bo_table || findGetParameter('bo_table'));
} }
});
if ("{{arg['linkkf_current_code']}}" !== "") {
if (params.code === null) {
// console.log('params.code === null')
document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else if (params.code === '') {
document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else {
// console.log('params code exist')
// console.log(params.code)
document.getElementById("code").value = params.code
analyze(params.wr_id, params.bo_table)
// document.getElementById("analysis_btn").click();
// $('#analysis_btn').trigger('click')
}
// 값이 공백이 아니면 분석 버튼 계속 누름
// {#document.getElementById("analysis_btn").click();#}
} else {
}
})
$(document).ready(function () { $(document).ready(function () {
// console.log('wr_id::', params.wr_id) // console.log('wr_id::', params.wr_id)
}); });
// Enter 키로 검색 트리거 // Enter 키로 검색 트리거
@@ -312,26 +288,29 @@
$("body").on('click', '#add_queue_btn', function (e) { $("body").on('click', '#add_queue_btn', function (e) {
e.preventDefault(); e.preventDefault();
data = current_data.episode[$(this).data('idx')]; let episode_data = current_data.episode[$(this).data('idx')];
// console.log('data:::>', data) // console.log('episode_data:::>', episode_data)
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_queue', url: '/' + package_name + '/ajax/' + sub + '/add_queue',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(data)}, data: {data: JSON.stringify(episode_data)},
dataType: "json", dataType: "json",
success: function (data) { success: function (ret) {
// console.log('#add_queue_btn::data >>', data) // console.log('#add_queue_btn::ret >>', ret)
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist') { if (ret.ret == 'enqueue_db_append' || ret.ret == 'enqueue_db_exist' || ret.ret == 'enqueue_gdm_success') {
$.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'}); $.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
} else if (data.ret == 'queue_exist') { } else if (ret.ret == 'queue_exist') {
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'}); $.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
} else if (data.ret == 'db_completed') { } else if (ret.ret == 'db_completed') {
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'}); $.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
} else if (data.ret == 'file_exists') { } else if (ret.ret == 'file_exists') {
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'}); $.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
} else if (ret.ret == 'extract_failed') {
$.notify('<strong>추가 실패: 영상 주소 추출에 실패하였습니다.</strong>', {type: 'warning'});
} else { } else {
$.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'}); const msg = ret.log || '알 수 없는 이유로 추가에 실패하였습니다.';
$.notify('<strong>추가 실패</strong><br>' + msg, {type: 'warning'});
} }
} }
}); });
@@ -340,16 +319,14 @@
$("body").on('click', '#check_download_btn', function (e) { $("body").on('click', '#check_download_btn', function (e) {
e.preventDefault(); e.preventDefault();
all = $('input[id^="checkbox_"]'); let selected_data = [];
let data = []; $('input[id^="checkbox_"]').each(function() {
let idx; if ($(this).prop('checked')) {
for (let i in all) { let idx = parseInt($(this).attr('id').split('_')[1]);
if (all[i].checked) { selected_data.push(current_data.episode[idx]);
idx = parseInt(all[i].id.split('_')[1])
data.push(current_data.episode[idx]);
} }
} });
if (data.length == 0) { if (selected_data.length == 0) {
$.notify('<strong>선택하세요.</strong>', {type: 'warning'}); $.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return; return;
} }
@@ -357,13 +334,43 @@
url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list', url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(data)}, data: {data: JSON.stringify(selected_data)},
dataType: "json", dataType: "json",
success: function (data) { success: function (ret) {
$.notify('<strong>백그라운드로 작업을 추가합니다.</strong>', {type: 'success'}); $.notify('<strong>백그라운드로 작업을 추가합니다.</strong>', {type: 'success'});
} }
}); });
}); });
$("body").on('click', '#down_subtitle_btn', function (e) {
e.preventDefault();
let selected_data = [];
$('input[id^="checkbox_"]').each(function() {
if ($(this).prop('checked')) {
let idx = parseInt($(this).attr('id').split('_')[1]);
selected_data.push(current_data.episode[idx]);
}
});
if (selected_data.length == 0) {
$.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return;
}
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_sub_queue_checked_list',
type: "POST",
cache: false,
data: {data: JSON.stringify(selected_data)},
dataType: "json",
success: function (ret) {
if (ret.ret == "success") {
$.notify('<strong>백그라운드로 자막 다운로드를 시작합니다.</strong>', {type: 'success'});
} else {
const msg = ret.log || '알 수 없는 이유로 요청에 실패하였습니다.';
$.notify('<strong>자막 다운로드 요청 실패: ' + msg + '</strong>', {type: 'warning'});
}
}
});
});
</script> </script>
<style> <style>
#anime_downloader_wrapper { #anime_downloader_wrapper {

View File

@@ -8,7 +8,12 @@
<div class="glass-card p-4"> <div class="glass-card p-4">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="text-white font-weight-bold"><i class="bi bi-gear-fill mr-2"></i>Linkkf 설정</h2> <h2 class="text-white font-weight-bold"><i class="bi bi-gear-fill mr-2"></i>Linkkf 설정</h2>
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}} <div>
<button type="button" class="btn btn-outline-info btn-sm mr-2" id="btn-self-update" title="최신 버전으로 업데이트">
<i class="bi bi-arrow-repeat"></i> 업데이트
</button>
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
</div>
</div> </div>
{{ macros.m_row_start('5') }} {{ macros.m_row_start('5') }}
@@ -17,7 +22,7 @@
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('normal', '일반', true) }} {{ macros.m_tab_head('normal', '일반', true) }}
{{ macros.m_tab_head('auto', '홈화면 자동', false) }} {{ macros.m_tab_head('auto', '자동등록', false) }}
{{ macros.m_tab_head('action', '기타', false) }} {{ macros.m_tab_head('action', '기타', false) }}
{{ macros.m_tab_head_end() }} {{ macros.m_tab_head_end() }}
</nav> </nav>
@@ -63,8 +68,107 @@
{{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }}
{{ macros.setting_input_text('linkkf_interval', '스케쥴링 실행 정보', value=arg['linkkf_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }} {{ macros.setting_input_text('linkkf_interval', '스케쥴링 실행 정보', value=arg['linkkf_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }}
{{ macros.setting_checkbox('linkkf_auto_start', '시작시 자동실행', value=arg['linkkf_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }} {{ macros.setting_checkbox('linkkf_auto_start', '시작시 자동실행', value=arg['linkkf_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
{{ macros.setting_input_textarea('linkkf_auto_code_list', '자동 다운로드 작품 코드', desc=['all 입력시 모두 받기', '구분자 | 또는 엔터'], value=arg['linkkf_auto_code_list'], row='10') }} <!-- 자동 다운로드 작품 코드 - Tag Chips UI -->
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left"><strong>자동 다운로드할 작품 코드</strong></div>
<div class="col-sm-9">
<input type="hidden" id="linkkf_auto_code_list" name="linkkf_auto_code_list" value="{{arg['linkkf_auto_code_list']}}">
<div id="tag_chips_container" class="tag-chips-wrapper mb-2"></div>
<div class="input-group input-group-sm">
<input type="text" id="new_tag_input" class="form-control" placeholder="작품명 입력 후 Enter (all: 모두 받기)">
<div class="input-group-append"><button type="button" class="btn btn-outline-primary" id="add_tag_btn"><i class="bi bi-plus-lg"></i> 추가</button></div>
</div>
<div style="padding-top:5px;"><em class="text-muted">Enter로 추가, X로 삭제, 드래그 순서변경 | all 입력시 모두 받기</em></div>
</div>
</div>
{{ macros.setting_checkbox('linkkf_auto_mode_all', '에피소드 모두 받기', value=arg['linkkf_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }} {{ macros.setting_checkbox('linkkf_auto_mode_all', '에피소드 모두 받기', value=arg['linkkf_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
{{ macros.setting_checkbox('linkkf_auto_download_new', '새 에피소드 자동 다운로드', value=arg['linkkf_auto_download_new'], desc=['On : 새 에피소드 감지 시 자동으로 큐에 추가합니다.', 'Off : 알림만 보내고 다운로드는 수동으로 합니다.']) }}
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left"><strong>모니터링 주기</strong></div>
<div class="col-sm-9">
<select class="form-control form-control-sm col-sm-3" id="linkkf_monitor_interval" name="linkkf_monitor_interval">
<option value="5" {% if arg.get('linkkf_monitor_interval', '10') == '5' %}selected{% endif %}>5분</option>
<option value="10" {% if arg.get('linkkf_monitor_interval', '10') == '10' or not arg.get('linkkf_monitor_interval') %}selected{% endif %}>10분 (기본)</option>
<option value="15" {% if arg.get('linkkf_monitor_interval', '10') == '15' %}selected{% endif %}>15분</option>
<option value="30" {% if arg.get('linkkf_monitor_interval', '10') == '30' %}selected{% endif %}>30분</option>
<option value="60" {% if arg.get('linkkf_monitor_interval', '10') == '60' %}selected{% endif %}>1시간</option>
</select>
<div style="padding-top:5px;"><em class="text-muted">'all' 모드 사용 시 사이트를 확인하는 주기입니다.</em></div>
</div>
</div>
{{ macros.m_tab_content_end() }}
{{ macros.m_tab_content_start('action', false) }}
<div class="row mb-3">
<div class="col-sm-12">
<h5 class="text-white mb-3"><i class="bi bi-lightning-fill mr-2"></i>수동 작업</h5>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-3 set-left"><strong>스케줄러 1회 실행</strong></div>
<div class="col-sm-9">
<button type="button" class="btn btn-outline-success btn-sm" id="global_one_execute_btn">
<i class="bi bi-play-circle mr-1"></i> 1회 실행
</button>
<div style="padding-top:5px;"><em class="text-muted">자동 다운로드 스케줄러를 즉시 1회 실행합니다.</em></div>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-3 set-left"><strong>DB 초기화</strong></div>
<div class="col-sm-9">
<button type="button" class="btn btn-outline-danger btn-sm" id="global_reset_db_btn">
<i class="bi bi-trash mr-1"></i> DB 초기화
</button>
<div style="padding-top:5px;"><em class="text-muted">다운로드 기록 DB를 초기화합니다.</em></div>
</div>
</div>
<hr style="border-color: rgba(255,255,255,0.1); margin: 30px 0;">
<div class="row mb-3">
<div class="col-sm-12">
<h5 class="text-white mb-3"><i class="bi bi-bell-fill mr-2"></i>알림 설정</h5>
</div>
</div>
{{ macros.setting_checkbox('linkkf_notify_enabled', '알림 활성화', value=arg['linkkf_notify_enabled'], desc='새 에피소드가 큐에 추가되면 알림을 보냅니다.') }}
<div class="row mb-3">
<div class="col-sm-12">
<h6 class="text-info mb-2"><i class="bi bi-discord mr-1"></i> Discord</h6>
</div>
</div>
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left"><strong>Discord Webhook URL</strong></div>
<div class="col-sm-9">
<div class="input-group">
<input type="text" class="form-control form-control-sm" id="linkkf_discord_webhook_url" name="linkkf_discord_webhook_url" value="{{arg['linkkf_discord_webhook_url']}}">
<div class="input-group-append">
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy_discord_url_btn" title="URL 복사">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div style="padding-top:5px;"><em class="text-muted">Discord 서버 설정 → 연동 → 웹훅에서 URL을 복사하세요.</em></div>
</div>
</div>
<div class="row mb-3 mt-4">
<div class="col-sm-12">
<h6 class="text-info mb-2"><i class="bi bi-telegram mr-1"></i> Telegram</h6>
</div>
</div>
{{ macros.setting_input_text('linkkf_telegram_bot_token', 'Telegram Bot Token', col='9', value=arg['linkkf_telegram_bot_token'], desc='@BotFather에서 생성한 봇 토큰입니다.') }}
{{ macros.setting_input_text('linkkf_telegram_chat_id', 'Telegram Chat ID', col='4', value=arg['linkkf_telegram_chat_id'], desc='알림을 받을 채팅방 ID (개인: 숫자, 그룹: -숫자)') }}
<div class="row mb-3 mt-3">
<div class="col-sm-3"></div>
<div class="col-sm-9">
<button type="button" class="btn btn-outline-info btn-sm" id="test_notify_btn">
<i class="bi bi-send mr-1"></i> 테스트 알림 전송
</button>
</div>
</div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
</div><!--tab-content--> </div><!--tab-content-->
@@ -319,6 +423,15 @@
.folder-item.selected { .folder-item.selected {
background: rgba(16, 185, 129, 0.3) !important; background: rgba(16, 185, 129, 0.3) !important;
} }
/* Tag Chips Styles */
.tag-chips-wrapper { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; min-height: 60px; background: rgba(0,0,0,0.2); border: 1px dashed rgba(255,255,255,0.15); border-radius: 8px; }
.tag-chips-wrapper:empty::before { content: '작품이 없습니다.'; color: #64748b; font-style: italic; }
.tag-chip { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; background: linear-gradient(135deg, rgba(16,185,129,0.3), rgba(5,150,105,0.4)); border: 1px solid rgba(16,185,129,0.4); border-radius: 20px; font-size: 0.9rem; color: #e2e8f0; cursor: grab; transition: all 0.2s ease; }
.tag-chip:hover { background: linear-gradient(135deg, rgba(16,185,129,0.5), rgba(5,150,105,0.6)); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(16,185,129,0.3); }
.tag-chip .tag-text { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-chip .tag-remove { width: 18px; height: 18px; background: rgba(239,68,68,0.5); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; }
.tag-chip .tag-remove:hover { background: rgba(239,68,68,0.9); }
.tag-chip .tag-index { width: 20px; height: 20px; background: rgba(0,0,0,0.3); border-radius: 50%; font-size: 0.7rem; color: #94a3b8; display: flex; align-items: center; justify-content: center; }
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -345,6 +458,94 @@ $("body").on('click', '#go_btn', function(e){
window.open(url, "_blank"); window.open(url, "_blank");
}); });
// 1회 실행 버튼
$(document).on('click', '#global_one_execute_btn', function(e){
e.preventDefault();
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/immediately_execute',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('스케줄러 1회 실행을 시작합니다.', {type:'success'});
} else {
$.notify(ret.msg || '실행 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// DB 초기화 버튼
$(document).on('click', '#global_reset_db_btn', function(e){
e.preventDefault();
if (!confirm('정말 DB를 초기화하시겠습니까?')) return;
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/reset_db',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('DB가 초기화되었습니다.', {type:'success'});
} else {
$.notify(ret.msg || '초기화 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// Discord Webhook URL 복사 버튼
$(document).on('click', '#copy_discord_url_btn', function(e){
e.preventDefault();
var url = $('#linkkf_discord_webhook_url').val();
if (!url) {
$.notify('복사할 URL이 없습니다.', {type:'warning'});
return;
}
navigator.clipboard.writeText(url).then(function() {
$.notify('URL이 클립보드에 복사되었습니다.', {type:'success'});
}).catch(function() {
// Fallback for older browsers
var temp = $('<input>').val(url).appendTo('body').select();
document.execCommand('copy');
temp.remove();
$.notify('URL이 클립보드에 복사되었습니다.', {type:'success'});
});
});
// 테스트 알림 버튼
$(document).on('click', '#test_notify_btn', function(e){
e.preventDefault();
var btn = $(this);
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin mr-1"></i> 전송 중...');
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/test_notification',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('테스트 알림을 전송했습니다!', {type:'success'});
} else {
$.notify(ret.msg || '알림 전송 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-send mr-1"></i> 테스트 알림 전송');
}
});
});
// ====================================== // ======================================
// 폴더 탐색 기능 // 폴더 탐색 기능
// ====================================== // ======================================
@@ -483,6 +684,154 @@ $(document).ready(function(){
setTimeout(function() { setTimeout(function() {
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible'); $('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
}, 100); }, 100);
initTagChips();
});
// Tag Chips 기능
function initTagChips() {
var value = $('#linkkf_auto_code_list').val().trim();
if (value) {
var items = value.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0);
items.forEach(function(item, index) { addTagChip(item, index); });
}
updateTagIndices();
}
function addTagChip(text, index) {
var chip = $('<div class="tag-chip" draggable="true" data-value="'+escapeHtml(text)+'"><span class="tag-index">'+(index+1)+'</span><span class="tag-text" title="'+escapeHtml(text)+'">'+escapeHtml(text)+'</span><span class="tag-remove"><i class="bi bi-x"></i></span></div>');
$('#tag_chips_container').append(chip);
}
function updateHiddenField() {
var values = [];
$('#tag_chips_container .tag-chip').each(function() { values.push($(this).data('value')); });
$('#linkkf_auto_code_list').val(values.join('|'));
}
function updateTagIndices() {
$('#tag_chips_container .tag-chip').each(function(i) { $(this).find('.tag-index').text(i+1); });
}
$('#tag_chips_container').on('click', '.tag-remove', function(e) {
e.stopPropagation();
var chip = $(this).closest('.tag-chip');
chip.fadeOut(200, function() { $(this).remove(); updateHiddenField(); updateTagIndices(); });
});
$('#add_tag_btn').on('click', function() { addNewTag(); });
$('#new_tag_input').on('keypress', function(e) { if (e.which === 13) { e.preventDefault(); addNewTag(); } });
function addNewTag() {
var text = $('#new_tag_input').val().trim();
if (!text) { $.notify('작품명을 입력하세요', {type:'warning'}); return; }
var exists = false;
$('#tag_chips_container .tag-chip').each(function() { if ($(this).data('value') === text) exists = true; });
if (exists) { $.notify('이미 등록된 작품입니다', {type:'warning'}); return; }
addTagChip(text, $('#tag_chips_container .tag-chip').length);
updateHiddenField();
$('#new_tag_input').val('');
$.notify('"'+text+'" 추가됨', {type:'success'});
}
var draggedChip = null;
$('#tag_chips_container').on('dragstart', '.tag-chip', function(e) { draggedChip = this; $(this).addClass('dragging'); });
$('#tag_chips_container').on('dragend', '.tag-chip', function() { $(this).removeClass('dragging'); draggedChip = null; updateHiddenField(); updateTagIndices(); });
$('#tag_chips_container').on('dragover', function(e) { e.preventDefault(); var after = getDragAfterElement(this, e.originalEvent.clientX); if (!after) this.appendChild(draggedChip); else this.insertBefore(draggedChip, after); });
function getDragAfterElement(container, x) {
return [...container.querySelectorAll('.tag-chip:not(.dragging)')].reduce((c, el) => { var box = el.getBoundingClientRect(); var offset = x - box.left - box.width/2; return (offset < 0 && offset > c.offset) ? {offset, element: el} : c; }, {offset: Number.NEGATIVE_INFINITY}).element;
}
// ======================================
// 자가 업데이트 기능
// ======================================
$(document).on('click', '#btn-self-update', function() {
$('#updateConfirmModal').modal('show');
});
// 실제 업데이트 실행 (모달에서 확인 버튼 클릭 시)
$(document).on('click', '#confirmUpdateBtn', function() {
$('#updateConfirmModal').modal('hide');
var btn = $('#btn-self-update');
var originalHTML = btn.html();
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/self_update',
type: 'POST',
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success') {
$.notify('<strong>업데이트 완료!</strong> 페이지를 새로고침합니다.', {type: 'success'});
setTimeout(function() { location.reload(); }, 1500);
} else {
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type: 'danger'});
}
},
error: function() {
$.notify('<strong>업데이트 중 오류 발생</strong>', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html(originalHTML);
}
});
}); });
</script> </script>
<!-- Update Confirmation Modal (Linkkf Green Theme) -->
<div class="modal fade" id="updateConfirmModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content animate__animated animate__zoomIn" style="background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);">
<div class="modal-body text-center" style="padding: 40px 30px;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; border: 2px solid rgba(16, 185, 129, 0.3);">
<i class="bi bi-arrow-repeat" style="color: #10b981; font-size: 36px;"></i>
</div>
<h4 style="color: #f1f5f9; font-weight: 700; margin-bottom: 12px;">플러그인 업데이트</h4>
<p style="color: #94a3b8; font-size: 15px; margin-bottom: 8px;">최신 코드를 다운로드하고 플러그인을 리로드합니다.</p>
<p style="color: #64748b; font-size: 13px; margin-bottom: 32px;"><i class="bi bi-info-circle"></i> 서버 재시작 없이 즉시 적용됩니다.</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button type="button" class="btn" data-dismiss="modal" style="width: 120px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #94a3b8; border-radius: 10px; padding: 12px 24px; font-weight: 600;">취소</button>
<button type="button" id="confirmUpdateBtn" class="btn" style="width: 140px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; border-radius: 10px; padding: 12px 24px; font-weight: 600; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);">
<i class="bi bi-download"></i> 업데이트
</button>
</div>
</div>
</div>
</div>
</div>
<style>
/* Update Button Enhanced Visibility (Linkkf Green) */
#btn-self-update {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
border: none !important;
color: white !important;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
transition: all 0.2s ease;
}
#btn-self-update:hover:not(:disabled) {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
#btn-self-update:disabled {
background: linear-gradient(135deg, #475569 0%, #334155 100%) !important;
color: #94a3b8 !important;
cursor: not-allowed;
box-shadow: none;
opacity: 0.7;
}
#btn-self-update .bi-arrow-repeat.spin,
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Animate.css for modal */
.animate__animated { animation-duration: 0.3s; }
.animate__zoomIn { animation-name: zoomIn; }
@keyframes zoomIn {
from { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); }
50% { opacity: 1; }
}
</style>
{% endblock %} {% endblock %}

View File

@@ -1,193 +1,159 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
<style> <style>
/* Premium Dark Theme Variables */ /* Match gds_dviewer Log Page Design */
:root {
--bg-color: #0f172a; /* Slate 900 */
--card-bg: #1e293b; /* Slate 800 */
--text-color: #f8fafc; /* Slate 50 */
--text-muted: #94a3b8; /* Slate 400 */
--accent-color: #3b82f6; /* Blue 500 */
--accent-hover: #2563eb; /* Blue 600 */
--terminal-bg: #000000;
--terminal-text: #4ade80; /* Green 400 */
--border-color: #334155; /* Slate 700 */
}
/* Global Override */
body { body {
background-color: var(--bg-color) !important; background: linear-gradient(145deg, #0f172a, #1e293b) !important;
background-image: radial-gradient(circle at top right, #1e293b 0%, transparent 60%), radial-gradient(circle at bottom left, #1e293b 0%, transparent 60%); color: #f8fafc;
color: var(--text-color); font-family: 'Inter', -apple-system, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
/* overflow: hidden 제거 - 모바일 스크롤 허용 */
} }
/* Container & Typography */ .log-card {
.container-fluid { background: linear-gradient(145deg, rgba(20, 30, 48, 0.95), rgba(36, 59, 85, 0.9));
padding: 8px; /* 최소 여백 */ border: 1px solid rgba(100, 150, 180, 0.25);
}
@media (max-width: 768px) {
body {
overflow-x: hidden !important;
overflow-y: auto !important; /* 세로 스크롤 허용 */
}
.container-fluid {
padding: 4px; /* 모바일 더 작은 여백 */
}
.tab-pane {
padding: 8px;
}
.dashboard-card {
margin-top: 8px;
border-radius: 6px;
}
/* 로그 테이블 뷰포트 기반 높이 */
textarea#log, textarea#add {
max-height: 60vh !important;
height: auto !important;
min-height: 300px !important;
}
}
h1, h2, h3, h4, h5, h6 {
color: var(--text-color);
font-weight: 700;
letter-spacing: -0.025em;
}
/* Main Card */
.dashboard-card {
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden; overflow: hidden;
margin-top: 20px; margin-top: 20px;
} }
.log-card-header {
background: transparent;
border-bottom: 1px solid rgba(100, 150, 180, 0.2);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.log-card-header h5 {
margin: 0;
color: #e2e8f0;
font-weight: 600;
}
.log-card-header h5 i {
color: #7dd3fc;
margin-right: 8px;
}
.btn-log {
background: linear-gradient(180deg, rgba(45, 55, 72, 0.95), rgba(35, 45, 60, 0.98));
border: 1px solid rgba(100, 150, 180, 0.25);
color: #7dd3fc;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
border-radius: 6px;
margin-left: 8px;
}
.btn-log:hover {
background: linear-gradient(180deg, rgba(55, 65, 82, 0.95), rgba(45, 55, 70, 0.98));
color: #fff;
}
.btn-log.danger {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
.btn-log.danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.log-container {
height: calc(100vh - 200px);
min-height: 400px;
overflow-y: auto;
padding: 16px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
background: rgba(0, 0, 0, 0.3);
color: #94a3b8;
}
.log-line-error { color: #f87171; }
.log-line-warning { color: #fbbf24; }
.log-line-info { color: #5eead4; }
.log-line-debug { color: #94a3b8; }
/* Tabs Styling */ /* Tabs Styling */
.nav-tabs { .nav-tabs {
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid rgba(100, 150, 180, 0.2);
background-color: rgba(0,0,0,0.2); background: rgba(0, 0, 0, 0.2);
padding: 10px 10px 0 10px; padding: 10px 10px 0 10px;
} }
.nav-tabs .nav-link { .nav-tabs .nav-link {
color: var(--text-muted) !important; color: #94a3b8 !important;
border: none !important; border: none !important;
border-radius: 8px 8px 0 0 !important; border-radius: 8px 8px 0 0 !important;
padding: 10px 20px; padding: 10px 20px;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease;
background: transparent; background: transparent;
} }
.nav-tabs .nav-link:hover { .nav-tabs .nav-link:hover {
color: var(--text-color) !important; color: #e2e8f0 !important;
background-color: rgba(255,255,255,0.05); background: rgba(255, 255, 255, 0.05);
} }
.nav-tabs .nav-link.active { .nav-tabs .nav-link.active {
color: var(--accent-color) !important; color: #7dd3fc !important;
background-color: var(--card-bg) !important; background: rgba(20, 30, 48, 0.95) !important;
border-bottom: 2px solid var(--accent-color) !important; border-bottom: 2px solid #7dd3fc !important;
} }
/* Content Area */
.tab-content {
padding: 0; /* Removing default padding to let terminal fill */
}
.tab-pane {
padding: 20px;
}
/* Terminal Styling */
textarea#log, textarea#add {
background-color: var(--terminal-bg) !important;
color: var(--terminal-text) !important;
border: 1px solid #333;
border-radius: 6px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.5;
padding: 16px;
width: 100%;
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.5);
resize: none; /* Disable manual resize */
overscroll-behavior: contain; /* 스크롤 체인 방지 */
transform: translateZ(0); /* GPU 가속화 */
will-change: scroll-position;
}
textarea#log:focus, textarea#add:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 1px var(--accent-color);
}
/* Controls Bar */
.controls-bar { .controls-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding: 12px 20px; padding: 12px 20px;
background-color: rgba(0,0,0,0.2); background: rgba(0, 0, 0, 0.2);
border-top: 1px solid var(--border-color); border-top: 1px solid rgba(100, 150, 180, 0.2);
} gap: 12px;
/* Toggle Switch */
.form-check-input {
background-color: #334155;
border-color: #475569;
cursor: pointer;
} }
.form-check-input:checked { .form-check-input:checked {
background-color: var(--accent-color); background-color: #7dd3fc;
border-color: var(--accent-color); border-color: #7dd3fc;
} }
.form-check-label { .form-check-label {
color: var(--text-muted); color: #94a3b8;
font-weight: 500; font-weight: 500;
margin-right: 12px;
user-select: none;
} }
/* Buttons */ @media (max-width: 768px) {
.btn-action { .log-container {
background-color: transparent; height: calc(100vh - 180px);
border: 1px solid var(--border-color); min-height: 300px;
color: var(--text-color); padding: 12px;
border-radius: 6px; }
padding: 6px 16px; .log-card-header {
font-size: 14px; flex-direction: column;
font-weight: 500; gap: 12px;
transition: all 0.2s; align-items: flex-start;
margin-left: 12px; }
} }
.btn-action:hover { /* Smooth Load */
background-color: var(--accent-color); .content-cloak {
border-color: var(--accent-color); opacity: 0;
color: white; transition: opacity 0.5s ease-out;
}
.content-cloak.visible {
opacity: 1;
} }
</style> </style>
<div class="container-fluid content-cloak" id="main_container"> <div class="container-fluid content-cloak" id="main_container" style="max-width: 1400px;">
<!-- Header --> <div class="log-card">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">System Logs</h2>
<p class="text-muted mb-0" style="color: var(--text-muted);">Real-time application logs and history.</p>
</div>
</div>
<div class="dashboard-card">
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('old', 'History', true) }} {{ macros.m_tab_head('old', 'History', true) }}
@@ -196,27 +162,20 @@
</nav> </nav>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<!-- Old Logs --> <!-- History Logs -->
{{ macros.m_tab_content_start('old', true) }} {{ macros.m_tab_content_start('old', true) }}
<div> <div class="log-container" id="log-history"></div>
<textarea id="log" rows="30" disabled spellcheck="false"></textarea>
</div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
<!-- New Logs --> <!-- Real-time Logs -->
{{ macros.m_tab_content_start('new', false) }} {{ macros.m_tab_content_start('new', false) }}
<div> <div class="log-container" id="log-realtime"></div>
<textarea id="add" rows="30" disabled spellcheck="false"></textarea>
</div>
<div class="controls-bar"> <div class="controls-bar">
<div class="d-flex align-items-center"> <label class="form-check-label" for="auto_scroll">Auto Scroll</label>
<label class="form-check-label" for="auto_scroll">Auto Scroll</label> <div class="form-check form-switch mb-0">
<div class="form-check form-switch mb-0"> <input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked>
<input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked>
</div>
<button id="clear" class="btn btn-action">Clear Console</button>
</div> </div>
<button id="clear" class="btn-log">Clear</button>
</div> </div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
</div> </div>
@@ -224,90 +183,58 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { function escapeHtml(text) {
// Force fluid layout const div = document.createElement('div');
$("#main_container").removeClass("container").addClass("container-fluid"); div.appendChild(document.createTextNode(text));
return div.innerHTML;
$('#loading').show();
ResizeTextAreaLog()
})
function ResizeTextAreaLog() {
// Dynamic height calculation
ClientHeight = window.innerHeight;
// Adjust calculation based on new layout (header + padding + tabs + toolbars)
// Approx header: 80, padding: 80, tabs: 50, footer: 60 => ~270
var offset = 340;
var newHeight = ClientHeight - offset;
if (newHeight < 400) newHeight = 400; // Min height
$("#log").height(newHeight);
$("#add").height(newHeight);
} }
$(window).resize(function() { function formatLogLine(line) {
ResizeTextAreaLog(); let className = '';
if (line.includes('ERROR')) className = 'log-line-error';
else if (line.includes('WARNING')) className = 'log-line-warning';
else if (line.includes('INFO')) className = 'log-line-info';
else if (line.includes('DEBUG')) className = 'log-line-debug';
return '<div class="' + className + '">' + escapeHtml(line) + '</div>';
}
$(document).ready(function() {
$("#main_container").removeClass("container").addClass("container-fluid");
$('#loading').show();
setTimeout(function() {
$('.content-cloak').addClass('visible');
}, 100);
}); });
var protocol = window.location.protocol; var protocol = window.location.protocol;
var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log"); var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log");
socket.emit("start", {'package':'{{package}}'} ); socket.emit("start", {'package':'{{package}}'});
socket.on('on_start', function(data){
var logEl = document.getElementById("log"); socket.on('on_start', function(data) {
logEl.innerHTML += data.data; var container = document.getElementById("log-history");
logEl.scrollTop = logEl.scrollHeight; var lines = data.data.split('\n');
logEl.style.visibility = 'visible'; var html = '';
$('#loading').hide(); lines.forEach(function(line) {
html += formatLogLine(line);
});
container.innerHTML = html || '<div class="text-muted text-center">로그가 비어 있습니다.</div>';
container.scrollTop = container.scrollHeight;
$('#loading').hide();
}); });
socket.on('add', function(data){ socket.on('add', function(data) {
if (data.package == "{{package}}") { if (data.package == "{{package}}") {
var chk = $('#auto_scroll').is(":checked"); var chk = $('#auto_scroll').is(":checked");
var addEl = document.getElementById("add"); var container = document.getElementById("log-realtime");
addEl.innerHTML += data.data; container.innerHTML += formatLogLine(data.data);
if (chk) addEl.scrollTop = addEl.scrollHeight; if (chk) container.scrollTop = container.scrollHeight;
} }
}); });
$("#clear").click(function(e) { $("#clear").click(function(e) {
e.preventDefault(); e.preventDefault();
document.getElementById("add").innerHTML = ''; document.getElementById("log-realtime").innerHTML = '';
});
</script>
<style>
/* Smooth Load Transition */
.content-cloak,
#menu_module_div,
#menu_page_div {
opacity: 0;
transition: opacity 0.5s ease-out;
}
/* Staggered Delays for Natural Top-Down Flow */
#menu_module_div.visible {
opacity: 1;
transition-delay: 0ms;
}
#menu_page_div.visible {
opacity: 1;
transition-delay: 150ms;
}
.content-cloak.visible {
opacity: 1;
transition-delay: 300ms;
}
</style>
<script type="text/javascript">
$(document).ready(function(){
// Smooth Load Trigger
setTimeout(function() {
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
}, 100);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -3,451 +3,409 @@
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/> <link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/> <link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
<script type="text/javascript">
function globalConfirmModal(title, body, func) {
$("#confirm_title").html(title);
$("#confirm_body").html(body);
// Remove previous handlers to prevent accumulation
$("body").off('click', '#confirm_button').on('click', '#confirm_button', function(e){
e.stopImmediatePropagation();
e.preventDefault();
if (typeof func === 'function') {
func();
}
$("body").off('click', '#confirm_button');
$("#confirm_modal").modal('hide');
});
// Clean up listener when modal is closed (any way)
$("#confirm_modal").one('hidden.bs.modal', function () {
$("body").off('click', '#confirm_button');
$('#confirm_button').removeAttr('onclick');
});
<div id="ohli24_queue_wrapper" class="ohli24-common-wrapper container-fluid mt-4 content-cloak ohli24-queue-page"> $("#confirm_modal").modal();
<div class="glass-card p-4"> }
<!-- 헤더 버튼 그룹 --> </script>
<div class="ohli24-header">
<div class="ohli24-header-left"> <style>
<div class="ohli24-icon-box"> .queue-header-container {
<i class="bi bi-cloud-download-fill text-primary" style="font-size: 1.5rem;"></i> display: flex; justify-content: space-between; align-items: flex-end;
</div> margin-bottom: 20px; border-bottom: 1px solid rgba(16, 185, 129, 0.2); padding-bottom: 10px;
<div> }
<h3 class="ohli24-header-title">다운로드 큐</h3> .queue-title { color: var(--forest-accent); font-weight: 700; margin: 0; }
<span class="ohli24-header-subtitle"><span id="queue_count" class="text-info font-weight-bold">0</span>개의 항목</span> .queue-meta { font-size: 12px; color: #6ee7b7; opacity: 0.6; }
</div>
</div> .custom-queue-table {
<div class="ohli24-header-right d-flex flex-wrap"> background: rgba(6, 78, 59, 0.2); border-collapse: separate; border-spacing: 0 4px; color: #ecfdf5;
<button id="reset_btn" class="btn-modern btn-modern-danger mr-2 mb-2"> }
<i class="bi bi-arrow-counterclockwise mr-1"></i> 전체 초기화 .custom-queue-table thead th {
</button> background: rgba(2, 44, 34, 0.8) !important; color: #6ee7b7; font-size: 13px;
<button id="delete_completed_btn" class="btn-modern btn-modern-warning mr-2 mb-2"> text-transform: uppercase; border: none !important; padding: 12px 8px !important;
<i class="bi bi-trash mr-1"></i> 완료 항목 삭제 text-align: center;
</button> }
</div> .custom-queue-table tbody tr {
</div> background: rgba(6, 78, 59, 0.3); transition: all 0.2s;
}
<!-- 다운로드 목록 --> .custom-queue-table tbody tr:hover { background: rgba(6, 78, 59, 0.5) !important; }
<div id="download_list_div" class="queue-list d-flex flex-column gap-3"></div> .custom-queue-table td { border: none !important; vertical-align: middle !important; padding: 14px 8px !important; }
<!-- 빈 상태 표시 --> /* Badges & Status */
<div id="empty_state" class="empty-state d-flex flex-column align-items-center justify-content-center py-5" style="display: none; min-height: 300px;"> .badge-status {
<div class="empty-icon-wrapper mb-4"> padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 700;
<i class="bi bi-inbox-fill text-muted opacity-20" style="font-size: 5rem;"></i> display: inline-block; min-width: 60px; text-align: center;
</div> }
<h4 class="text-white-50 font-weight-bold mb-2">다운로드 대기 항목 없음</h4> .status-downloading { background: rgba(16, 185, 129, 0.2); color: #34d399; border: 1px solid rgba(16, 185, 129, 0.3); }
<p class="text-muted mb-0">새로운 에피소드를 추가하면 이곳에 실시간으로 나타납니다.</p> .status-wait { background: rgba(251, 191, 36, 0.1); color: #fbbf24; border: 1px solid rgba(251, 191, 36, 0.2); }
<div class="mt-4"> .status-completed { background: rgba(59, 130, 246, 0.2); color: #60a5fa; border: 1px solid rgba(59, 130, 246, 0.3); }
<span class="badge badge-pill badge-dark py-2 px-3" style="background: rgba(255,255,255,0.05); color: #64748b; font-weight: 500;"> .status-fail { background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
<i class="bi bi-info-circle mr-1"></i> 에피소드 목록에서 '다운로드'를 눌러보세요
</span> /* Progress Bar */
</div> .custom-progress { height: 18px; background: rgba(0,0,0,0.4); border-radius: 9px; overflow: hidden; border: 1px solid rgba(16, 185, 129, 0.1); }
.custom-progress .progress-bar {
background: linear-gradient(90deg, #10b981, #059669) !important;
font-weight: 700; font-size: 11px;
transition: width 0.4s ease-out;
box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
}
/* Action Buttons */
.action-btn-mini {
width: 32px; height: 32px; border-radius: 8px; border: none; font-size: 14px;
display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s;
}
.action-btn-mini.btn-danger { background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.action-btn-mini.btn-danger:hover { background: #ef4444; color: white; transform: scale(1.1); }
.action-btn-mini.btn-warning { background: rgba(245, 158, 11, 0.2); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
.action-btn-mini.btn-warning:hover { background: #f59e0b; color: white; transform: scale(1.1); }
.action-btn-group { display: flex; justify-content: center; gap: 4px; }
.header-buttons { display: flex; gap: 10px; align-items: center; }
.queue-btn-top {
display: flex; align-items: center; gap: 6px; padding: 6px 14px;
border: none; border-radius: 8px; font-weight: 600; font-size: 13px;
cursor: pointer; transition: all 0.3s ease;
}
.queue-btn-top.btn-danger { background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.3); }
.queue-btn-top.btn-danger:hover { background: #ef4444; color: white; box-shadow: 0 0 15px rgba(239, 68, 68, 0.4); }
.queue-btn-top.btn-warning { background: rgba(245, 158, 11, 0.2); color: #fbbf24; border: 1px solid rgba(245, 158, 11, 0.3); }
.queue-btn-top.btn-warning:hover { background: #f59e0b; color: white; box-shadow: 0 0 15px rgba(245, 158, 11, 0.4); }
/* Details */
.queue-detail-container { color: #d1fae5; font-size: 13px; }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px 20px; }
.detail-item { display: flex; gap: 10px; align-items: flex-start; }
.detail-item .label { color: #6ee7b7; font-weight: 700; min-width: 100px; opacity: 0.7; font-size: 11px; text-transform: uppercase; }
.detail-item .value { color: #ecfdf5; word-break: break-all; }
/* Smooth Load */
.content-cloak { opacity: 0; transition: opacity 0.5s ease-out; }
.content-cloak.visible { opacity: 1; }
</style>
<div class="content-cloak">
<div class="queue-header-container">
<h4 class="queue-title"><i class="fa fa-download"></i> 다운로드 큐</h4>
<div class="header-buttons">
<button id="reset_btn" class="queue-btn-top btn-danger">
<i class="fa fa-refresh"></i> 초기화
</button>
<button id="delete_completed_btn" class="queue-btn-top btn-warning">
<i class="fa fa-trash"></i> 완료 삭제
</button>
</div> </div>
<div class="queue-meta">실시간 동기화 활성화됨 (3초 주기)</div>
</div>
<div class="table-responsive-custom">
<table id="result_table" class="table custom-queue-table tableRowHover">
<thead>
<tr>
<th style="width:5%;">IDX</th>
<th style="width:8%;">Plugin</th>
<th style="width:10%;">시작시간</th>
<th style="width:25%;">파일명</th>
<th style="width:8%;">상태</th>
<th style="width:18%;">진행률</th>
<th style="width:6%;">길이</th>
<th style="width:5%;">PF</th>
<th style="width:10%;">현재 상태</th>
<th style="width:5%;">Action</th>
</tr>
</thead>
<tbody id="list"></tbody>
</table>
</div> </div>
</div> </div>
<style>
/* Premium Glassmorphism UI */
.ohli24-queue-page {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.icon-box {
width: 48px;
height: 48px;
background: rgba(59, 130, 246, 0.1);
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(59, 130, 246, 0.2);
}
/* Modern Buttons */
.btn-modern {
border: none;
border-radius: 12px;
padding: 10px 18px;
font-weight: 600;
font-size: 0.9rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-flex;
align-items: center;
justify-content: center;
color: white;
text-transform: none;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.btn-modern:hover {
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.btn-modern:active {
transform: translateY(0);
}
.btn-modern-danger { background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%); }
.btn-modern-warning { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); }
.btn-modern-info { background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%); }
.btn-modern-secondary { background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.1); }
/* Queue Items */
.queue-item {
background: rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
padding: 16px;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 15px;
}
.queue-item:hover {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
transform: translateX(4px);
}
.item-number {
font-family: 'JetBrains Mono', monospace;
font-weight: 700;
color: #64748b;
font-size: 1.1rem;
min-width: 40px;
}
.item-info {
flex: 1;
min-width: 0;
}
.item-filename {
color: #f1f5f9;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.item-meta {
font-size: 0.8rem;
color: #94a3b8;
}
/* Modern Progress Bar */
.progress-container {
flex: 1.5;
min-width: 150px;
}
.progress-wrapper {
position: relative;
height: 28px;
background: rgba(0, 0, 0, 0.3);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.progress-bar {
height: 100%;
border-radius: 14px;
transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s ease;
}
.status-waiting { background: linear-gradient(90deg, #64748b, #475569); }
.status-downloading {
background: linear-gradient(90deg, #3b82f6, #2563eb);
box-shadow: 0 0 10px rgba(59, 130, 246, 0.3);
}
.status-completed {
background: linear-gradient(90deg, #10b981, #059669);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.3);
}
.status-failed { background: linear-gradient(90deg, #ef4444, #dc2626); }
.progress-label {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
pointer-events: none;
padding: 0 10px;
white-space: nowrap;
overflow: hidden;
}
/* Actions */
.cancel-btn {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #f87171;
padding: 6px 12px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: 600;
transition: all 0.2s;
cursor: pointer;
}
.cancel-btn:hover {
background: #ef4444;
color: white;
}
@media (max-width: 992px) {
.queue-item { flex-direction: column; align-items: stretch; }
.progress-container { flex: none; width: 100%; }
.item-actions { display: flex; justify-content: flex-end; }
}
.empty-state {
animation: fadeIn 0.8s ease-out;
}
.empty-icon-wrapper i {
display: inline-block;
transition: transform 0.3s ease;
}
.empty-state:hover .empty-icon-wrapper i {
transform: translateY(-10px);
color: #3b82f6 !important;
opacity: 0.4 !important;
}
</style>
<script src="{{ url_for('.static', filename='js/sjva_ui14.js') }}"></script>
<script type="text/javascript"> <script type="text/javascript">
var package_name = "{{arg['package_name'] }}"; var PACKAGE_NAME = "{{ arg['package_name'] }}";
var sub = "{{arg['sub'] }}"; var MODULE_NAME = "{{ arg['module_name'] }}";
var current_data = null;
$(document).ready(function () {
// Force parent container to be fluid to allow full width
$("#main_container").removeClass("container").addClass("container-fluid");
// Smooth Load Trigger
setTimeout(function() {
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
}, 100);
var socket = io.connect(window.location.href);
socket.on('on_start', (data) => {})
socket.on('start', function (data) {
on_start();
});
socket.on('list_refresh', function (data) {
on_start()
});
socket.on('status', function (data) {
on_status(data)
});
on_start();
var refreshIntervalId = setInterval(silentRefresh, 3000);
});
var current_list_length = 0; var current_list_length = 0;
var refreshIntervalId = null;
function silentRefresh() { $(document).ready(function(){
$.ajax({ $(".content-cloak").addClass("visible");
url: '/' + package_name + '/ajax/' + sub + '/entity_list',
type: "POST", var protocol = location.protocol;
cache: false, var socketUrl = protocol + "//" + document.domain + ":" + location.port;
global: false,
data: {}, // Queue 전용 소켓 시도
dataType: "json", var queueSocket = null;
success: function (data) { try {
// 목록 길이 변경 시 전체 다시 그리기 queueSocket = io.connect(socketUrl + '/anime_downloader/linkkf/queue');
if (data.length !== current_list_length) { } catch (e) {
current_list_length = data.length; console.error('Queue socket error:', e);
make_download_list(data); }
} else {
// 진행률만 업데이트 (전체 다시 그리기 없이) var frameworkSocket = null;
for (var i = 0; i < data.length; i++) { try {
var item = data[i]; frameworkSocket = io.connect(socketUrl + '/framework');
var progressBar = document.getElementById("progress_" + item.entity_id); frameworkSocket.on('linkkf_status', function(data) {
if (progressBar) { status_html(data);
progressBar.style.width = item.ffmpeg_percent + '%'; });
var label = item.ffmpeg_status_kor; } catch (e) {
if (item.ffmpeg_percent != 0) label += " (" + item.ffmpeg_percent + "%)"; console.error('Framework socket error:', e);
if (item.current_speed) label += " " + item.current_speed; }
var labelEl = document.getElementById("progress_" + item.entity_id + "_label");
if (labelEl) labelEl.innerHTML = label; var socket = queueSocket;
if (socket) {
// 상태 클래스 업데이트 socket.on('status_change', function(data) { button_html(data); });
var statusClass = getStatusClass(item.ffmpeg_status_kor); socket.on('status', function(data){ status_html(data); });
$(progressBar).removeClass('status-waiting status-downloading status-completed status-failed').addClass(statusClass); socket.on('last', function(data){ status_html(data); button_html(data); });
} }
}
} // GDM 전용 소켓 추가 핸들링 (전역 업데이트 수신용)
var gdmSocket = null;
var hasActive = false; try {
for (var i = 0; i < data.length; i++) { gdmSocket = io.connect(socketUrl + '/gommi_downloader_manager');
if (data[i].ffmpeg_status_kor === '다운로드중' || data[i].ffmpeg_status_kor === '대기중' || data[i].ffmpeg_status_kor === '추출중') { gdmSocket.on('status', function(data) {
hasActive = true; // 이 모듈과 관련된 작업만 처리
break; if (data.caller_plugin === PACKAGE_NAME + '_' + MODULE_NAME) {
} // GDM 데이터를 큐 형식으로 변환하여 UI 업데이트
} // 수동 새로고침 없이 실시간 반영을 위해 renderList 호출 주기를 짧게 하거나 직접 UI 갱신
silentFetchList(function(newList) {
if (!hasActive && refreshIntervalId) { renderList(newList);
clearInterval(refreshIntervalId); });
refreshIntervalId = null;
}
if (hasActive && !refreshIntervalId) {
refreshIntervalId = setInterval(silentRefresh, 2000);
}
} }
}); });
} catch (e) {
console.error('GDM socket error:', e);
} }
function on_start() { on_start();
$.ajax({ refreshIntervalId = setInterval(autoRefreshList, 3000);
url: '/' + package_name + '/ajax/' + sub + '/entity_list', });
type: "POST",
cache: false,
data: {},
dataType: "json",
success: function (data) {
current_list_length = data.length;
make_download_list(data)
}
});
}
function on_status(data) { var refreshIntervalId = null;
var entity_id = data.entity_id;
var percent = data.ffmpeg_percent;
var status_kor = data.ffmpeg_status_kor;
var speed = data.current_speed;
var progressBar = document.getElementById("progress_" + entity_id);
if (progressBar != null) {
// Update percentage and label
progressBar.style.width = percent + '%';
var label = status_kor;
if (percent != 0) label += " (" + percent + "%)";
if (speed) label += " " + speed;
document.getElementById("progress_" + entity_id + "_label").innerHTML = label;
// Real-time status class (color) update
var statusClass = getStatusClass(status_kor);
$(progressBar).removeClass('status-waiting status-downloading status-completed status-failed').addClass(statusClass);
// Auto-refresh list if completed to show 'Watch' button or other actions
if (status_kor === '완료' || status_kor === 'completed') {
// Throttle refresh to avoid flickering if multiple complete
if (window.statusRefreshTimeout) clearTimeout(window.statusRefreshTimeout);
window.statusRefreshTimeout = setTimeout(on_start, 1000);
}
}
}
function getStatusClass(status) { function silentFetchList(callback) {
if (status === '다운로드중') return 'status-downloading'; $.ajax({
if (status === '완료' || status === 'completed') return 'status-completed'; url: '/' + PACKAGE_NAME + '/ajax/' + MODULE_NAME + '/command',
if (status === '실패' || status === 'FAILED') return 'status-failed'; type: 'POST',
return 'status-waiting'; cache: false,
} global: false,
data: {command: 'list'},
dataType: 'json',
success: function(data) { if (callback) callback(data); }
});
}
function make_download_list(data) { function autoRefreshList() {
document.getElementById('queue_count').textContent = data.length; silentFetchList(function(data) {
if (data.length !== current_list_length) {
if (data.length === 0) { current_list_length = data.length;
document.getElementById('download_list_div').style.display = 'none'; renderList(data);
document.getElementById('empty_state').style.display = 'flex';
return;
} }
document.getElementById('download_list_div').style.display = 'flex'; var hasActiveDownload = false;
document.getElementById('empty_state').style.display = 'none'; if (data && data.length > 0) {
for (var j = 0; j < data.length; j++) {
if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED') {
hasActiveDownload = true;
break;
}
}
}
if (!hasActiveDownload && refreshIntervalId) {
clearInterval(refreshIntervalId);
refreshIntervalId = null;
}
if (hasActiveDownload && !refreshIntervalId) {
refreshIntervalId = setInterval(autoRefreshList, 3000);
}
});
}
function on_start() {
silentFetchList(function(data) {
current_list_length = data.length;
renderList(data);
});
}
function renderList(data) {
$("#list").html('');
if (!data || data.length == 0) {
$("#list").html("<tr><td colspan='10' style='text-align:center; padding: 40px; color: #6ee7b7;'>다운로드 대기 중인 작업이 없습니다.</td></tr>");
} else {
var str = ''; var str = '';
for (var i in data) { for(var i in data) {
var item = data[i]; str += make_item(data[i]);
var statusClass = getStatusClass(item.ffmpeg_status_kor);
var label = item.ffmpeg_status_kor;
if (item.ffmpeg_percent != 0) {
label += ' (' + item.ffmpeg_percent + '%)';
}
str += '<div class="queue-item">';
str += '<div class="item-number">#' + item.entity_id + '</div>';
str += '<div class="item-info">';
str += '<div class="item-filename">' + (item.filename || '파일명 없음') + '</div>';
str += '<div class="item-meta">';
str += '<span class="item-time"><i class="fa fa-clock-o"></i> ' + item.created_time + '</span>';
str += '</div>';
str += '</div>';
str += '<div class="progress-container">';
str += '<div class="progress-wrapper">';
str += '<div id="progress_' + item.entity_id + '" class="progress-bar ' + statusClass + '" style="width: ' + item.ffmpeg_percent + '%;"></div>';
str += '<span id="progress_' + item.entity_id + '_label" class="progress-label">' + label + '</span>';
str += '</div>';
str += '</div>';
str += '<div class="item-actions">';
str += '<button class="cancel-btn" data-id="' + item.entity_id + '" onclick="cancelItem(' + item.entity_id + ')"><i class="fa fa-times"></i> 취소</button>';
str += '</div>';
str += '</div>';
} }
document.getElementById("download_list_div").innerHTML = str; $("#list").html(str);
} }
}
function cancelItem(entity_id) { $("body").on('click', '#stop_btn', function(e){
queue_command({'command': 'cancel', 'entity_id': entity_id}); e.stopPropagation(); e.preventDefault();
} globalSendCommand('stop', $(this).data('idx'), null, null, function(ret){
autoRefreshList();
$("body").on('click', '#reset_btn', function (e) {
e.preventDefault();
if (!confirm('정말 큐를 초기화하시겠습니까?')) return;
queue_command({'command': 'reset', 'entity_id': -1});
}); });
});
$("body").on('click', '#delete_completed_btn', function (e) { $("body").on('click', '#reset_btn', function(e){
e.preventDefault(); e.preventDefault();
queue_command({'command': 'delete_completed', 'entity_id': -1}); globalConfirmModal('초기화 하시겠습니까?', function(){
}); globalSendCommand('reset', null, null, null, function(ret){
autoRefreshList();
function queue_command(data) {
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/queue_command',
type: "POST",
cache: false,
data: data,
dataType: "json",
success: function (ret) {
if (ret.ret == 'notify') {
$.notify('<strong>' + ret.log + '</strong>', {type: 'warning'});
}
on_start();
}
}); });
}
$("body").on('click', '#go_ffmpeg_btn', function (e) {
e.preventDefault();
$(location).attr('href', '/ffmpeg')
}); });
});
$("body").on('click', '#delete_completed_btn', function(e){
e.preventDefault();
globalSendCommand('delete_completed', null, null, null, function(ret){
autoRefreshList();
});
});
$("body").on('click', '#delete_btn', function(e){
e.stopPropagation(); e.preventDefault();
let idx = $(this).data('idx');
globalSendCommand('remove', idx, null, null, function(ret){
autoRefreshList();
});
});
function refresh_item(data) {
if (!data || !data.idx) {
autoRefreshList();
return;
}
$('#tr1_'+data.idx).html(make_item1(data));
$('#collapse_'+data.idx).html(make_item2(data));
}
function make_item(data) {
var str = '<tr id="tr1_'+data.idx+'" style="cursor: pointer;" data-toggle="collapse" data-target="#collapse_'+ data.idx + '" aria-expanded="false" >';
str += make_item1(data);
str += '</tr>';
str += '<tr class="collapse tableRowHoverOff" id="collapse_' + data.idx + '">';
str += make_item2(data);
str += '</tr>';
return str;
}
function make_item1(data) {
var str = '<td style="text-align:center;">'+ data.idx + '</td>';
str += '<td style="text-align:center;"><span class="badge badge-info">'+ data.callback_id + '</span></td>';
str += '<td style="text-align:center; font-size: 12px; color: #6ee7b7;">'+ data.start_time + '</td>';
str += '<td style="font-weight: 600;">'+ data.filename + '</td>';
var status_class = 'status-wait';
if (data.status_str === 'DOWNLOADING') status_class = 'status-downloading';
else if (data.status_str === 'COMPLETED') status_class = 'status-completed';
else if (data.status_str === 'STOP') status_class = 'status-fail';
str += '<td id="status_'+data.idx+'" style="text-align:center;"><span class="badge-status ' + status_class + '">'+ data.status_kor + '</span></td>';
var visi = (parseInt(data.percent) > 0) ? 'visible' : 'hidden';
str += '<td><div class="progress custom-progress"><div id="progress_'+data.idx+'" class="progress-bar" style="visibility: '+visi+'; width:'+data.percent+'%">'+data.percent +'%</div></div></td>';
str += '<td style="text-align:center;">'+ data.duration_str + '</td>';
str += '<td id="current_pf_count_'+data.idx+'" style="text-align:center; color: #f87171;">'+ data.current_pf_count + '</td>';
str += '<td style="text-align:center; font-size: 13px;"><div id="current_speed_'+data.idx+'">'+ data.current_speed + '</div><div id="download_time_'+data.idx+'" style="font-size: 11px; opacity: 0.6;">'+ data.download_time + '</div></td>';
str += '<td id="button_'+data.idx+'" style="text-align:center; padding: 10px 4px !important;">';
str += '<div class="action-btn-group">';
if (data.status_str == 'DOWNLOADING' || data.status_str == 'WAITING') {
str += '<button id="stop_btn" class="action-btn-mini btn-danger" data-idx="'+data.idx+'" title="중지"><i class="fa fa-stop"></i></button>';
} else {
str += '<button id="delete_btn" class="action-btn-mini btn-warning" data-idx="'+data.idx+'" title="삭제"><i class="fa fa-times"></i></button>';
}
str += '</div>';
str += '</td>';
return str;
}
function make_item2(data) {
var str = '<td colspan="10" style="background: rgba(0,0,0,0.2); padding: 15px !important;">';
str += '<div id="detail_'+data.idx+'" class="queue-detail-container">';
str += get_detail(data);
str += '</div>';
str += '</td>';
return str;
}
function get_detail(data) {
var str = '<div class="detail-grid">';
str += '<div class="detail-item"><span class="label">파일 경로</span><span class="value">' + data.save_fullpath + '</span></div>';
str += '<div class="detail-item"><span class="label">URL</span><span class="value text-truncate">' + data.url + '</span></div>';
str += '<div class="detail-item"><span class="label">진행 상황</span><span class="value">' + data.percent+ '% (' + data.current_duration + ' / ' + data.duration + ')</span></div>';
str += '<div class="detail-item"><span class="label">비트레이트</span><span class="value">' + data.current_bitrate + '</span></div>';
str += '<div class="detail-item"><span class="label">허용 에러(PF)</span><span class="value">' + data.max_pf_count + '</span></div>';
if (data.status_str == 'COMPLETED') {
str += '<div class="detail-item"><span class="label">파일 크기</span><span class="value">' + data.filesize_str + '</span></div>';
str += '<div class="detail-item"><span class="label">최종 속도</span><span class="value">' + (data.download_speed || 'N/A') + '</span></div>';
}
str += '</div>';
return str;
}
function button_html(data) {
var str = '<div class="action-btn-group">';
if (data.status_str == 'DOWNLOADING' || data.status_str == 'WAITING') {
str += '<button id="stop_btn" class="action-btn-mini btn-danger" data-idx="'+data.idx+'" title="중지"><i class="fa fa-stop"></i></button>';
} else {
str += '<button id="delete_btn" class="action-btn-mini btn-warning" data-idx="'+data.idx+'" title="삭제"><i class="fa fa-times"></i></button>';
}
str += '</div>';
$("#button_" + data.idx).html(str);
}
function status_html(data) {
var progress = document.getElementById("progress_" + data.idx);
if (!progress) return;
progress.style.width = data.percent+ '%';
progress.innerHTML = data.percent+ '%';
progress.style.visibility = 'visible';
var statusEl = document.getElementById("status_" + data.idx);
if (statusEl) {
var status_class = 'status-wait';
if (data.status_str === 'DOWNLOADING') status_class = 'status-downloading';
else if (data.status_str === 'COMPLETED') status_class = 'status-completed';
else if (data.status_str === 'STOP') status_class = 'status-fail';
statusEl.innerHTML = '<span class="badge-status ' + status_class + '">'+ data.status_kor + '</span>';
}
var pfEl = document.getElementById("current_pf_count_" + data.idx);
if (pfEl) pfEl.innerHTML = data.current_pf_count;
var speedEl = document.getElementById("current_speed_" + data.idx);
if (speedEl) speedEl.innerHTML = data.current_speed;
var timeEl = document.getElementById("download_time_" + data.idx);
if (timeEl) timeEl.innerHTML = data.download_time;
var detailEl = document.getElementById("detail_" + data.idx);
if (detailEl) detailEl.innerHTML = get_detail(data);
}
</script> </script>
</script> {% endblock %}
{% endblock %}

View File

@@ -213,9 +213,14 @@
let epThumbSrc = data.episode[i].thumbnail || ''; let epThumbSrc = data.episode[i].thumbnail || '';
let epTitle = data.episode[i].title || ''; let epTitle = data.episode[i].title || '';
// 에피소드 번호 추출 (title에서 "N화" 패턴 찾기) // 에피소드 번호 추출: 백엔드 epi_no 우선, 없으면 정규식, 마지막으로 인덱스
let epNumMatch = epTitle.match(/(\d+)화/); let epNumText = '';
let epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화'; if (data.episode[i].epi_no !== undefined && data.episode[i].epi_no !== null) {
epNumText = data.episode[i].epi_no + '화';
} else {
let epNumMatch = epTitle.match(/(\d+(?:\.\d+)?)[\s\.\…화회]*$/);
epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화';
}
str += '<div class="episode-card">'; str += '<div class="episode-card">';
str += '<div class="episode-thumb">'; str += '<div class="episode-thumb">';

View File

@@ -17,6 +17,9 @@
</div> </div>
</div> </div>
<div class="ohli24-header-right"> <div class="ohli24-header-right">
<button type="button" class="btn btn-outline-info btn-sm mr-2" id="btn-self-update" title="최신 버전으로 업데이트">
<i class="bi bi-arrow-repeat"></i> 업데이트
</button>
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}} {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
</div> </div>
</div> </div>
@@ -27,7 +30,7 @@
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('normal', '일반', true) }} {{ macros.m_tab_head('normal', '일반', true) }}
{{ macros.m_tab_head('auto', '홈화면 자동', false) }} {{ macros.m_tab_head('auto', '자동등록', false) }}
{{ macros.m_tab_head('action', '기타', false) }} {{ macros.m_tab_head('action', '기타', false) }}
{{ macros.m_tab_head_end() }} {{ macros.m_tab_head_end() }}
</nav> </nav>
@@ -79,7 +82,34 @@
{{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }}
{{ macros.setting_input_text('ohli24_interval', '스케쥴링 실행 정보', value=arg['ohli24_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }} {{ macros.setting_input_text('ohli24_interval', '스케쥴링 실행 정보', value=arg['ohli24_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }}
{{ macros.setting_checkbox('ohli24_auto_start', '시작시 자동실행', value=arg['ohli24_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }} {{ macros.setting_checkbox('ohli24_auto_start', '시작시 자동실행', value=arg['ohli24_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
{{ macros.setting_input_textarea('ohli24_auto_code_list', '자동 다운로드 작품 코드', desc=['구분자 | 또는 엔터'], value=arg['ohli24_auto_code_list'], row='10') }} <!-- 자동 다운로드 작품 코드 - Tag Chips UI -->
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left">
<strong>자동 다운로드할 작품 코드</strong>
</div>
<div class="col-sm-9">
<!-- 숨겨진 실제 값 필드 (DB 저장용, | 구분) -->
<input type="hidden" id="ohli24_auto_code_list" name="ohli24_auto_code_list" value="{{arg['ohli24_auto_code_list']}}">
<!-- Tag Chips 컨테이너 -->
<div id="tag_chips_container" class="tag-chips-wrapper mb-2">
<!-- 태그들이 여기에 동적으로 추가됨 -->
</div>
<!-- 새 태그 입력 -->
<div class="input-group input-group-sm">
<input type="text" id="new_tag_input" class="form-control" placeholder="작품명 입력 후 Enter 또는 추가 버튼">
<div class="input-group-append">
<button type="button" class="btn btn-outline-primary" id="add_tag_btn">
<i class="bi bi-plus-lg"></i> 추가
</button>
</div>
</div>
<div style="padding-top:5px;">
<em class="text-muted">Enter로 추가, 태그 X로 삭제, 드래그로 순서 변경 가능</em>
</div>
</div>
</div>
{{ macros.setting_checkbox('ohli24_auto_mode_all', '에피소드 모두 받기', value=arg['ohli24_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }} {{ macros.setting_checkbox('ohli24_auto_mode_all', '에피소드 모두 받기', value=arg['ohli24_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
@@ -305,6 +335,93 @@
.folder-item i { .folder-item i {
font-size: 1.1rem; font-size: 1.1rem;
} }
/* Tag Chips Styles */
.tag-chips-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
min-height: 60px;
background: rgba(0, 0, 0, 0.2);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 8px;
transition: all 0.3s ease;
}
.tag-chips-wrapper:empty::before {
content: '작품이 없습니다. 아래에서 추가하세요.';
color: #64748b;
font-style: italic;
}
.tag-chips-wrapper.drag-over {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3) 0%, rgba(37, 99, 235, 0.4) 100%);
border: 1px solid rgba(96, 165, 250, 0.4);
border-radius: 20px;
font-size: 0.9rem;
color: #e2e8f0;
cursor: grab;
transition: all 0.2s ease;
user-select: none;
}
.tag-chip:hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.5) 0%, rgba(37, 99, 235, 0.6) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.tag-chip.dragging {
opacity: 0.5;
cursor: grabbing;
}
.tag-chip .tag-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-chip .tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: rgba(239, 68, 68, 0.5);
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.75rem;
}
.tag-chip .tag-remove:hover {
background: rgba(239, 68, 68, 0.9);
transform: scale(1.1);
}
.tag-chip .tag-index {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
font-size: 0.7rem;
color: #94a3b8;
}
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -323,6 +440,9 @@ $(document).ready(function(){
}, 100); }, 100);
use_collapse('ohli24_auto_make_folder'); use_collapse('ohli24_auto_make_folder');
// Tag Chips 초기화
initTagChips();
}); });
$('#ani365_auto_make_folder').change(function() { $('#ani365_auto_make_folder').change(function() {
@@ -584,6 +704,259 @@ $(document).ready(function() {
runSystemCheck(); runSystemCheck();
}); });
// ======================================
// Tag Chips 기능
// ======================================
function initTagChips() {
var hiddenField = $('#ohli24_auto_code_list');
var container = $('#tag_chips_container');
// 초기 값 파싱 (| 또는 줄바꿈으로 구분)
var value = hiddenField.val().trim();
if (value) {
var items = value.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0);
items.forEach(function(item, index) {
addTagChip(item, index);
});
}
updateTagIndices();
}
function addTagChip(text, index) {
var container = $('#tag_chips_container');
var chip = $(`
<div class="tag-chip" draggable="true" data-value="${escapeHtml(text)}">
<span class="tag-index">${index + 1}</span>
<span class="tag-text" title="${escapeHtml(text)}">${escapeHtml(text)}</span>
<span class="tag-remove" title="삭제"><i class="bi bi-x"></i></span>
</div>
`);
container.append(chip);
}
function updateHiddenField() {
var container = $('#tag_chips_container');
var values = [];
container.find('.tag-chip').each(function() {
values.push($(this).data('value'));
});
$('#ohli24_auto_code_list').val(values.join('|'));
}
function updateTagIndices() {
$('#tag_chips_container .tag-chip').each(function(index) {
$(this).find('.tag-index').text(index + 1);
});
}
// 태그 삭제
$('#tag_chips_container').on('click', '.tag-remove', function(e) {
e.stopPropagation();
var chip = $(this).closest('.tag-chip');
var text = chip.data('value');
chip.fadeOut(200, function() {
$(this).remove();
updateHiddenField();
updateTagIndices();
});
$.notify('"' + text + '" 삭제됨', {type: 'info'});
});
// 새 태그 추가 (버튼)
$('#add_tag_btn').on('click', function() {
addNewTag();
});
// 새 태그 추가 (엔터키)
$('#new_tag_input').on('keypress', function(e) {
if (e.which === 13) {
e.preventDefault();
addNewTag();
}
});
function addNewTag() {
var input = $('#new_tag_input');
var text = input.val().trim();
if (!text) {
$.notify('작품명을 입력하세요', {type: 'warning'});
return;
}
// 중복 체크
var exists = false;
$('#tag_chips_container .tag-chip').each(function() {
if ($(this).data('value') === text) {
exists = true;
return false;
}
});
if (exists) {
$.notify('이미 등록된 작품입니다', {type: 'warning'});
return;
}
var count = $('#tag_chips_container .tag-chip').length;
addTagChip(text, count);
updateHiddenField();
input.val('');
$.notify('"' + text + '" 추가됨', {type: 'success'});
}
// 드래그 앤 드롭 순서 변경
var draggedChip = null;
$('#tag_chips_container').on('dragstart', '.tag-chip', function(e) {
draggedChip = this;
$(this).addClass('dragging');
e.originalEvent.dataTransfer.effectAllowed = 'move';
});
$('#tag_chips_container').on('dragend', '.tag-chip', function(e) {
$(this).removeClass('dragging');
draggedChip = null;
updateHiddenField();
updateTagIndices();
});
$('#tag_chips_container').on('dragover', function(e) {
e.preventDefault();
e.originalEvent.dataTransfer.dropEffect = 'move';
$(this).addClass('drag-over');
var afterElement = getDragAfterElement(this, e.originalEvent.clientX);
if (afterElement == null) {
this.appendChild(draggedChip);
} else {
this.insertBefore(draggedChip, afterElement);
}
});
$('#tag_chips_container').on('dragleave', function(e) {
$(this).removeClass('drag-over');
});
$('#tag_chips_container').on('drop', function(e) {
e.preventDefault();
$(this).removeClass('drag-over');
});
function getDragAfterElement(container, x) {
var chips = [...container.querySelectorAll('.tag-chip:not(.dragging)')];
return chips.reduce((closest, child) => {
var box = child.getBoundingClientRect();
var offset = x - box.left - box.width / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// ======================================
// 자가 업데이트 기능
// ======================================
$(document).on('click', '#btn-self-update', function() {
$('#updateConfirmModal').modal('show');
});
// 실제 업데이트 실행 (이벤트 위임 - 모달이 스크립트 이후에 있으므로)
$(document).on('click', '#confirmUpdateBtn', function() {
$('#updateConfirmModal').modal('hide');
var btn = $('#btn-self-update');
var originalHTML = btn.html();
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/self_update',
type: 'POST',
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success') {
if (ret.needs_restart) {
$.notify('<strong>⚠️ 모델 변경 감지!</strong><br>서버 재시작이 필요합니다.', {type: 'warning', delay: 10000});
} else {
$.notify('<strong>✅ 업데이트 완료!</strong><br>페이지를 새로고침하세요.', {type: 'success', delay: 5000});
}
} else {
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type: 'danger'});
}
},
error: function() {
$.notify('<strong>업데이트 중 오류 발생</strong>', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html(originalHTML);
}
});
});
</script> </script>
<!-- Update Confirmation Modal -->
<div class="modal fade" id="updateConfirmModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content animate__animated animate__zoomIn" style="background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);">
<div class="modal-body text-center" style="padding: 40px 30px;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; border: 2px solid rgba(59, 130, 246, 0.3);">
<i class="bi bi-arrow-repeat" style="color: #3b82f6; font-size: 36px;"></i>
</div>
<h4 style="color: #f1f5f9; font-weight: 700; margin-bottom: 12px;">플러그인 업데이트</h4>
<p style="color: #94a3b8; font-size: 15px; margin-bottom: 8px;">최신 코드를 다운로드하고 플러그인을 리로드합니다.</p>
<p style="color: #64748b; font-size: 13px; margin-bottom: 32px;"><i class="bi bi-info-circle"></i> 서버 재시작 없이 즉시 적용됩니다.</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button type="button" class="btn" data-dismiss="modal" style="width: 120px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #94a3b8; border-radius: 10px; padding: 12px 24px; font-weight: 600;">취소</button>
<button type="button" id="confirmUpdateBtn" class="btn" style="width: 140px; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); border: none; color: white; border-radius: 10px; padding: 12px 24px; font-weight: 600; box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);">
<i class="bi bi-download"></i> 업데이트
</button>
</div>
</div>
</div>
</div>
</div>
<style>
/* Update Button Enhanced Visibility */
#btn-self-update {
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%) !important;
border: none !important;
color: white !important;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
transition: all 0.2s ease;
}
#btn-self-update:hover:not(:disabled) {
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4);
}
#btn-self-update:disabled {
background: linear-gradient(135deg, #475569 0%, #334155 100%) !important;
color: #94a3b8 !important;
cursor: not-allowed;
box-shadow: none;
opacity: 0.7;
}
#btn-self-update .bi-arrow-repeat.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Animate.css for modal */
.animate__zoomIn {
animation-duration: 0.3s;
}
</style>
{% endblock %} {% endblock %}

View File

@@ -1,61 +1,85 @@
import requests
import asyncio import json
import zendriver as zd import re
import sys import sys
import os
import subprocess
async def test(): def test_fetch():
print("=== Zendriver Google Chrome Debug (v0.5.14) ===") url = "https://playv2.sub3.top/r2/play.php?&id=n20&url=405686s1"
headers = {
"Referer": "https://linkkf.live/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"
}
# Check possible paths daemon_url = "http://127.0.0.1:19876/fetch"
bin_paths = ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium-browser"] payload = {
"url": url,
"headers": headers,
"timeout": 30
}
for browser_bin in bin_paths: print(f"Fetching {url} via daemon...")
if not os.path.exists(browser_bin): try:
continue resp = requests.post(daemon_url, json=payload, timeout=40)
if resp.status_code != 200:
print(f"\n>>> Testing binary: {browser_bin}") print(f"Error: HTTP {resp.status_code}")
print(resp.text)
return
# 1. Version Check data = resp.json()
try: if not data.get("success"):
out = subprocess.check_output([browser_bin, "--version"], stderr=subprocess.STDOUT).decode() print(f"Fetch failed: {data.get('error')}")
print(f"Version: {out.strip()}") return
except Exception as e:
print(f"Version check failed: {e}") html = data.get("html", "")
if hasattr(e, 'output'): print(f"Fetch success. Length: {len(html)}")
print(f"Output: {e.output.decode()}")
# Save for inspection
# 2. Minimum execution test (Headless + No Sandbox) with open("linkkf_player_test.html", "w", encoding="utf-8") as f:
print("--- Direct Execution Test ---") f.write(html)
try: print("Saved to linkkf_player_test.html")
cmd = [browser_bin, "--headless", "--no-sandbox", "--disable-gpu", "--user-data-dir=/tmp/test_chrome", "--about:blank"]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # Try regex patterns from mod_linkkf.py
await asyncio.sleep(3) patterns = [
if proc.poll() is None: r"url:\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]",
print("SUCCESS: Browser process is alive!") r"<source[^>]+src=['\"]([^'\"]*\.m3u8[^'\"]*)['\"]",
proc.terminate() r"src\s*=\s*['\"]([^'\"]*\.m3u8[^'\"]*)['\"]",
r"url\s*:\s*['\"]([^'\"]+)['\"]"
]
found = False
for p in patterns:
match = re.search(p, html, re.IGNORECASE)
if match:
url_found = match.group(1)
if ".m3u8" in url_found or "m3u8" in p:
print(f"Pattern '{p}' found: {url_found}")
found = True
if not found:
print("No m3u8 found with existing patterns.")
# Search for any .m3u8
any_m3u8 = re.findall(r"['\"]([^'\"]*\.m3u8[^'\"]*)['\"]", html)
if any_m3u8:
print(f"Generic search found {len(any_m3u8)} m3u8 links:")
for m in any_m3u8[:5]:
print(f" - {m}")
else: else:
stdout, stderr = proc.communicate() print("No .m3u8 found in generic search either.")
print(f"FAIL: Browser process died (code {proc.returncode})") # Check for other video extensions or potential indicators
print(f"STDERR: {stderr.decode()}") if "Artplayer" in html:
except Exception as e: print("Artplayer detected.")
print(f"Execution test failed: {e}") if "video" in html:
print("Video tag found.")
# Check for 'cache/'
if "cache/" in html:
print("Found 'cache/' keyword.")
cache_links = re.findall(r"['\"]([^'\"]*cache/[^'\"]*)['\"]", html)
for c in cache_links:
print(f" - Possible cache link: {c}")
# 3. Zendriver Test except Exception as e:
print("--- Zendriver Integration Test ---") print(f"Exception: {e}")
try:
browser = await zd.start(
browser_executable_path=browser_bin,
headless=True,
sandbox=False
)
print("SUCCESS: Zendriver connected!")
await browser.stop()
# If we found one that works, we can stop
print("\n!!! This path works. Set this in the plugin settings or leave empty if it is the first found.")
except Exception as e:
print(f"Zendriver failed: {e}")
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(test()) test_fetch()