Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deb964704d |
@@ -134,8 +134,6 @@ dmypy.json
|
||||
config.yaml
|
||||
lib2/
|
||||
.vscode/
|
||||
.agents/
|
||||
.agent/
|
||||
memo.txt
|
||||
*.zip
|
||||
flaskfarm.sh
|
||||
@@ -153,8 +151,6 @@ lib/support/site/wavve.py
|
||||
lib/support/site/seezn.py
|
||||
lib/support/site/kakaotv.py
|
||||
*.bat
|
||||
docs/
|
||||
error_logs/
|
||||
output/
|
||||
*.mkv
|
||||
|
||||
@@ -168,4 +164,3 @@ bin/
|
||||
debug_*.py
|
||||
check_top_api.py
|
||||
d_ohli24.py
|
||||
dev_scratch/
|
||||
|
||||
@@ -8,28 +8,22 @@
|
||||
## 🚀 주요 기능 (Key Features)
|
||||
|
||||
* **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드.
|
||||
* **자막 최적화**:
|
||||
* **자막 전용 다운로드 (Linkkf)**: 영상 없이 자막(VTT)만 별도로 추출하여 SRT로 변환 후 다운로드할 수 있습니다.
|
||||
* **자막 합침 (Muxing)**: 다운로드된 외부 자막(SRT)을 MP4 컨테이너에 자동으로 삽입하여 범용성을 높입니다.
|
||||
* **강력한 우회 기술 (Anti-Bot Bypass)**:
|
||||
* **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다.
|
||||
* **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결)
|
||||
* **스마트 다운로드 큐**: `ffmpeg` 및 `yt-dlp` 기반의 큐 시스템으로 안정적인 이어받기 및 재시도를 지원합니다.
|
||||
* **사용자 편의성 및 보안**:
|
||||
* **보안 스트리밍 (Secure Streaming)**: 외부 플레이어(MXPlayer, VLC 등) 연동 시 API 키를 노출하지 않고 **임시 스트리밍 토큰(5분 만료)**을 발급하여 안전한 시청 환경을 제공합니다.
|
||||
* **범용 플레이어 연동**: IINA, PotPlayer, VLC, nPlayer, Infuse, MX Player 등 8종 이상의 외부 플레이어와 정교한 URL Scheme/Intent 연동을 지원합니다.
|
||||
* **사용자 편의성**:
|
||||
* **Proxy 설정**: IP 차단 시 손쉽게 우회할 수 있도록 웹 설정 UI에서 프록시 서버를 지정할 수 있습니다.
|
||||
* **모듈형 테마 시스템**: CSS 변수와 동적 로딩을 활용하여 데스크탑/모바일 모두에 최적화된 사이트별 테마를 제공합니다.
|
||||
* **실시간 피드백**: 다운로드 상태, 중복 파일 감지 등을 실시간 알림으로 제공합니다.
|
||||
* **반응형 UI**: 데스크탑과 모바일 모두에 최적화된 Glassmorphism 디자인.
|
||||
|
||||
---
|
||||
|
||||
## 📺 지원 사이트 (Supported Sites)
|
||||
|
||||
### 1. Ohli24 (애니24)
|
||||
* **특징**: 강력한 인프라와 다양한 라이브러리.
|
||||
* **기술**: `Zendriver`(Daemon모드) 및 `Camoufox`를 이용한 브라우저 에뮬레이션 우회.
|
||||
* **기능**: 검색, 목록 조회, 자동 다운로드, **임시 토큰 기반 보안 스트리밍**.
|
||||
* **특징**: 가장 강력한 보안(Cloudflare)이 적용된 사이트.
|
||||
* **기술**: `curl_cffi`를 이용한 Full Browser Impersonation 적용.
|
||||
* **기능**: 검색, 목록 조회, 자동 다운로드.
|
||||
|
||||
### 2. Linkkf (링크애니)
|
||||
* **특징**: 빠른 업데이트 속도.
|
||||
@@ -44,16 +38,8 @@
|
||||
## 🛠 설치 및 문제 해결 (Troubleshooting)
|
||||
|
||||
### 필수 요구 사항
|
||||
|
||||
* **Python 패키지 (Dependencies)**:
|
||||
* `curl_cffi`: TLS Fingerprint 변조를 통한 Cloudflare 및 보안 사이트 우회용.
|
||||
* `Zendriver`: 가벼운 브라우저 데몬 모드를 통한 고속 크롤링 지원.
|
||||
* `Camoufox`: 강력한 안티봇 탐지 우회를 위한 고보안 브라우저 에뮬레이터.
|
||||
* `yt-dlp`: 스트리밍 영상(HLS/DASH) 다운로드 핵심 엔진.
|
||||
* 기타: `requests`, `lxml`, `beautifulsoup4`, `flask-login` 등.
|
||||
* **시스템 도구 (System Tools)**:
|
||||
* **ffmpeg**: 영상/음성 병합 및 **자막 합침** 기능을 위한 필수 도구 (시스템 PATH 등록 필요).
|
||||
* **Browser (Chrome/Chromium)**: Zendriver 및 Camoufox 구동을 위한 브라우저 환경 필요.
|
||||
* **Python 패키지**: `curl_cffi`, `lxml`, `requests`, `yt-dlp`
|
||||
* **시스템 도구**: `ffmpeg` (시스템 경로에 설치되어 있어야 함)
|
||||
|
||||
### 자주 묻는 질문 (FAQ)
|
||||
|
||||
@@ -84,274 +70,7 @@
|
||||
|
||||
## 📝 변경 이력 (Changelog)
|
||||
|
||||
### v0.7.24 (2026-04-01)
|
||||
- **모바일 UI 가독성 및 설정 화면 정리**:
|
||||
- Ohli24 요청 화면의 액션 버튼 대비를 높이고, `Go` 버튼이 현재 작품의 원본 상세 페이지로 새 탭 이동하도록 보강했습니다.
|
||||
- 모바일 토스트 알림의 폭, 내부 여백, 닫기 버튼 위치를 조정해 화면 밖으로 넘치거나 텍스트가 들러붙어 보이던 문제를 완화했습니다.
|
||||
- Anilife/Linkkf 설정 화면에서 모바일 시 `업데이트`, `설정 저장` 버튼을 제목 아래로 재배치하고, 탭 콘텐츠 좌우 보더가 길게 보이던 문제를 정리했습니다.
|
||||
|
||||
### v0.7.23 (2026-04-01)
|
||||
- **루트 정리 및 개발 보관 폴더 분리**:
|
||||
- 루트에 흩어져 있던 `test_*`, 디버그 스크립트, 임시 HTML/노트북/구버전 백업 파일을 `dev_scratch/` 폴더로 이동해 작업용 산출물을 한곳에 모았습니다.
|
||||
- `dev_scratch/` 는 `.gitignore` 에 추가하여 로컬 검증 파일이 저장소 루트를 다시 어지르지 않도록 정리했습니다.
|
||||
- 루트에 있던 `2026-03-23.history.md` 도 `docs/history/` 로 이동해 작업 이력 위치를 일관되게 맞췄습니다.
|
||||
|
||||
### v0.7.20 (2026-03-23)
|
||||
- **Ohli24 캐시/목록 파싱 보강**:
|
||||
- 목록/검색 캐시 저장 전에 실제로 파싱 가능한 `href + title` 행이 있는지 검사하여 skeleton/불완전 HTML이 캐시에 남지 않도록 했습니다.
|
||||
- 목록, 자동목록, 검색 파서를 중첩 DOM 구조에 대응하도록 수정하고, 제목/링크가 없는 행은 전체 실패 대신 건너뛰도록 변경했습니다.
|
||||
|
||||
### v0.7.19 (2026-03-23)
|
||||
- **Ohli24 캐시 검증 1차 적용**:
|
||||
- 브라우징 캐시 사용 전 HTML 유효성 검사를 추가하여 차단 페이지나 비정상 응답이 10분간 재사용되지 않도록 했습니다.
|
||||
- 목록/검색/상세 URL 유형별로 기본 시그니처를 검사하고, `Just a moment`, `Cloudflare`, `captcha` 등 차단 마커가 있으면 캐시를 즉시 폐기합니다.
|
||||
|
||||
### v0.7.18 (2026-03-23)
|
||||
- **Zendriver 업데이트**:
|
||||
- 자동 설치 대상 버전을 `zendriver==0.15.3`으로 명시하여 환경별 최신 패치 차이로 인한 동작 편차를 줄였습니다.
|
||||
- 수동 설치 안내 문구도 동일한 버전으로 맞춰 문제 재현과 대응을 단순화했습니다.
|
||||
|
||||
### 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의 `&` 등 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)
|
||||
- **보안 스트리밍 토큰 시스템 도입**:
|
||||
- 외부 플레이어 연동 시 API 키 노출 방지를 위한 **임시 토큰(TTL 5분)** 발급 로직 구현
|
||||
- 인증 없이 접근 가능한 `/normal/` 라우트를 통한 보안 스트리밍 엔드포인트 추가
|
||||
- **외부 플레이어 연동 고도화**:
|
||||
- **VLC Android 지원**: `vlc://` 대신 Android Intent 형식을 적용하여 재생 오류 해결
|
||||
- **MXPlayer 최적화**: MIME 타입 명시 및 한글 파일명 URL 인코딩 안정화 (RFC 5987 대응)
|
||||
- **모바일 UI/UX 미세 조정**:
|
||||
- 외부 플레이어 아이콘 오버플로우 수정 (모바일에서 두 줄로 자동 줄바꿈)
|
||||
- 비디오 플레이어 모달 내 영상 세로 중앙 정렬 보강
|
||||
- 다운로드 목록 페이지(`list`)의 카드 여백 최적화 (`p-4` → `p-3`)
|
||||
- **버그 수정**:
|
||||
- `Content-Disposition` 헤더의 한글 파일명 유니코드 오류 해결
|
||||
|
||||
### v0.5.2 (2026-01-04)
|
||||
- **재사용 가능한 비디오 모달 컴포넌트 도입**:
|
||||
- `templates/anime_downloader/components/video_modal.html` - 공통 모달 HTML
|
||||
- `static/js/video_modal.js` - VideoModal JavaScript 모듈
|
||||
- `static/css/video_modal.css` - 비디오 모달 전용 스타일시트
|
||||
- **Alist 스타일 UI 개선**:
|
||||
- **에피소드 드롭다운**: 파란색 하이라이트 배경의 에피소드 선택기
|
||||
- **자동 다음 토글 스위치**: iOS 스타일 슬라이더 토글
|
||||
- **외부 플레이어 버튼**: IINA, PotPlayer, VLC, nPlayer, Infuse, OmniPlayer, MX Player, MPV 지원
|
||||
- **코드 재사용성 향상**:
|
||||
- Ohli24 list 페이지에서 인라인 코드 ~145줄 → ~9줄로 축소
|
||||
- `VideoModal.init()` API로 간편 초기화 및 `openWithPlaylist()` 메서드 지원
|
||||
|
||||
### v0.5.1 (2026-01-04)
|
||||
- **Ohli24 레이아웃 표준화**:
|
||||
- 모든 Ohli24 페이지(Setting, Search, Queue, List, Request)에 일관된 1400px max-width 및 중앙 정렬 적용
|
||||
- `ohli24.css`에 공통 wrapper(`.ohli24-common-wrapper`) 및 헤더 스타일 추가
|
||||
- List/Queue 페이지 `visible` 클래스 누락 수정 (content-cloak 트리거 추가)
|
||||
- Request 페이지 에피소드 카드 순차 렌더링 문제 해결 (`requestAnimationFrame` 사용)
|
||||
- **Anilife 폴백 체인 개선**:
|
||||
- `get_html` 함수에 **Zendriver subprocess 폴백** 추가 (Daemon 실패 시 자동 전환)
|
||||
- Playwright 폴백을 **Camoufox**로 변경 (더 강력한 안티봇 우회)
|
||||
- 3단계 폴백: Zendriver Daemon → Zendriver Subprocess → Camoufox
|
||||
- **Anilife 반응형 레이아웃**:
|
||||
- Request 페이지에 `anilife-common-wrapper` 클래스 적용
|
||||
- 모바일(`<992px`): 100% 너비 / 데스크탑(`≥992px`): 1400px max-width
|
||||
- **Linkkf CSS 준비**:
|
||||
- `linkkf.css`에 공통 wrapper 스타일 추가 (향후 레이아웃 표준화 대비)
|
||||
|
||||
### v0.5.0 (2026-01-03)
|
||||
- **Ohli24 비디오 플레이어 UI 전면 개편**:
|
||||
- **프리미엄 글래스모피즘 디자인**: 플레이어 모달 및 플레이리스트 컨트롤에 투명 유리 테마 적용
|
||||
- **Video.js 8.10.0 업그레이드**: 최신 엔진으로 안정성 및 재생 성능 최적화
|
||||
- **"Scale to Fill" (줌) 기능**: 모바일 전체화면 시 검은 여백을 없애고 화면을 가득 채우는 기능 추가
|
||||
- **중앙 재생 버튼 개선**: 모바일에 최적화된 대형 중앙 재생 버튼 및 아이콘 정렬 수정
|
||||
- **Anilife / Ohli24 검색 엔진 고도화**:
|
||||
- **Zendriver Daemon 최적화**: 매 요청마다 브라우저를 띄우지 않고 백그라운드 프로세스 활용 (응답 속도 2~3초로 단축)
|
||||
- **완결 카테고리 & 년도별 필터링**: Ohli24 검색에 '완결' 버튼 추가 및 년도별(2020~2025) 상세 필터링 지원
|
||||
- **모던 로딩 UI**: 시각적으로 세련된 멀티 링 프리로더 및 글래스모피즘 AJAX 스피너 도입
|
||||
- **Python 3.14 및 최신 스택 지원**:
|
||||
- Flask 3.1.2, SQLAlchemy 2.0.45 등 최신 라이브러리 호환성 확보 및 `AssertionError` 경고 제거
|
||||
- **안정성 및 UX 강화**:
|
||||
- **Enter 키 검색**: 모든 분석/검색 페이지에서 Enter 키 지원
|
||||
- **Zendriver 자동 설치**: 환경에 패키지가 없을 경우 실행 시 자동 설치
|
||||
- **타입 힌트 리팩토링**: `mod_ohli24.py`, `mod_anilife.py` 전반에 엄격한 타입 힌트 적용
|
||||
|
||||
### v0.4.18 (2026-01-03)
|
||||
- **Ohli24 4단계 폴백 체인 구현**: `curl_cffi` → `cloudscraper` → `Zendriver` → `Camoufox`
|
||||
- **현재 전략**: 가볍고 빠른 `Zendriver`와 풀 브라우저 `Camoufox` 조합으로 클라우드플레어 완전 우회
|
||||
|
||||
|
||||
### v0.4.17 (2026-01-02)
|
||||
- **Ohli24 디자인 고도화 (전반적 UI 개선)**:
|
||||
- **썸네일 에피소드 배지**: 이미지 좌측 상단에 글래스모피즘 스타일의 세련된 에피소드 번호 배지(앰버 컬러) 추가
|
||||
- **액션 버튼 디자인**: 목록 페이지 버튼("작품보기", "보기", "삭제" 등)을 미니멀한 `.btn-minimal` 디자인으로 개편
|
||||
- **모바일 UX 최적화**: 모바일에서 "보기"(블루), "삭제"(레드) 버튼에 선명한 색상을 부여하여 가독성 및 조작성 증대
|
||||
- **데스크탑 레이아웃**: 시작/완료 날짜와 액션 버튼 사이의 간격을 대폭 늘려(Horizontal Separation) 시각적 균형 확보
|
||||
- **인터렉션**: 호버 효과 및 블루 그래디언트 강조로 프리미엄 피드백 제공
|
||||
|
||||
|
||||
### v0.4.15 (2026-01-02)
|
||||
- **Ohli24 날짜 표시 및 디자인 개선**:
|
||||
- 요청일(시작) 및 완료일 배지에 색상 구분 적용 (Slate/Green) 및 "시작:", "완료:" 라벨 추가
|
||||
- 모바일 뷰에서 날짜 배지가 카드 내부에 깔끔하게 스택되도록 레이아웃 최적화
|
||||
- **Ohli24 검색 UI 모바일 최적화**:
|
||||
- 모바일 화면에서 검색 필터(정렬, 옵션) 및 버튼의 크기를 줄여 공간 효율성 증대
|
||||
- 검색창과 버튼 배치를 모바일에 맞게 2단 레이아웃으로 조정
|
||||
|
||||
### v0.4.13 (2026-01-02)
|
||||
- **Ohli24 CSS 사이드 이펙트 수정**:
|
||||
- 페이지별 독립 wrapper (`.ohli24-list-page`, `.ohli24-request-page`, `.ohli24-queue-page`) 적용으로 스타일 간섭 완전 차단
|
||||
- 요청(Request) 페이지의 에피소드 카드 가로 정렬 레이아웃 복구 및 최적화
|
||||
- 요청 페이지 내 불필요한 인라인 스타일 제거 및 외부 CSS로 일원화
|
||||
- **안정성 강화**:
|
||||
- **Ohli24**: 큐 추가 시 즉시 메타데이터 파싱 및 DB 동기화 로직 강화
|
||||
- **FfmpegQueue**: 로컬 파일 존재 시 DB 상태 동기화 누락 수정
|
||||
- **검색**: Ohli24 검색 결과 유효성 체크 로직 추가로 런타임 오류 방지
|
||||
- **모듈 검색 지원**: `model_base.py` 내 Ohli24 및 Linkkf 전용 검색 지원 추가
|
||||
|
||||
### v0.4.5 (2026-01-02)
|
||||
- **CSS 테마 아키텍처 전면 개편**:
|
||||
- 사이트별 독립 테마 파일 분리 (`anilife.css`, `linkkf.css`, `ohli24.css`)
|
||||
- 공통 모바일 로직 및 알림 스타일을 `mobile_custom.css`로 통합 (중복 코드 ~2,000줄 제거)
|
||||
- Jinja2 템플릿을 활용한 테마 동적 로딩 시스템 구현
|
||||
- **백엔드 안정성 및 UX 강화**:
|
||||
- **Anilife**: `get_series_info` 파싱 로직 개선으로 `IndexError` 방지 및 크롤링 안정성 확보
|
||||
- **중복 다운로드 알림**: 이미 파일이 존재하거나 DB에 기록이 있을 경우 사용자에게 명확한 알림 메시지 출력
|
||||
- **CSS 호환성**: `line-clamp` 속성 크로스 브라우저 호환성 패치 적용
|
||||
|
||||
### v0.4.3 (2026-01-02)
|
||||
### v0.5.1 (2026-01-02)
|
||||
- **모바일 UX 대폭 개선**:
|
||||
- 시스템 알림(bootstrap-notify) 커스텀 스타일링 (사이트별 테마 색상 적용)
|
||||
- Anilife: Cosmic Violet / Linkkf: Forest Green / Ohli24: Slate Blue
|
||||
@@ -416,3 +135,4 @@
|
||||
- **CDN 보안 우회**: cdndania.com 쿠키 기반 인증 처리 (`curl_cffi` 세션 유지)
|
||||
- **CdndaniaDownloader**: 별도 프로세스 기반 HLS 세그먼트 다운로더 추가
|
||||
- **프록시 지원 강화**: 세그먼트 다운로드 시 프록시 적용
|
||||
|
||||
|
||||
@@ -4,15 +4,9 @@
|
||||
# @Site :
|
||||
# @File : __init__
|
||||
# @Software: PyCharm
|
||||
from .setup import P
|
||||
blueprint = P.blueprint
|
||||
menu = P.menu
|
||||
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()
|
||||
# from .plugin import P
|
||||
# blueprint = P.blueprint
|
||||
# menu = P.menu
|
||||
# plugin_load = P.logic.plugin_load
|
||||
# plugin_unload = P.logic.plugin_unload
|
||||
# plugin_info = P.plugin_info
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
title: "애니 다운로더"
|
||||
version: 0.7.24
|
||||
version: "0.4.3"
|
||||
package_name: "anime_downloader"
|
||||
developer: "projectdx"
|
||||
description: "anime downloader"
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
#!/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))
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Camoufox 기반 Ohli24 HTML 페칭 스크립트
|
||||
- Cloudflare 우회를 위한 헤드리스 브라우저 폴백
|
||||
- curl_cffi 실패 시 사용
|
||||
- JSON 출력으로 안정적인 IPC
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
|
||||
|
||||
async def fetch_html(url: str, timeout: int = 30) -> dict:
|
||||
"""AsyncCamoufox로 HTML 페칭"""
|
||||
try:
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Camoufox not installed: {e}", "html": ""}
|
||||
|
||||
result = {"success": False, "html": "", "elapsed": 0}
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
|
||||
try:
|
||||
async with AsyncCamoufox(headless=True) as browser:
|
||||
page = await browser.new_page()
|
||||
|
||||
# 불필요한 리소스 차단 (속도 향상)
|
||||
async def intercept(route):
|
||||
resource_type = route.request.resource_type
|
||||
if resource_type in ["image", "media", "font"]:
|
||||
await route.abort()
|
||||
else:
|
||||
await route.continue_()
|
||||
|
||||
await page.route("**/*", intercept)
|
||||
|
||||
try:
|
||||
# 페이지 로드
|
||||
await page.goto(url, wait_until="domcontentloaded", timeout=timeout * 1000)
|
||||
|
||||
# HTML 추출
|
||||
html = await page.content()
|
||||
elapsed = asyncio.get_event_loop().time() - start_time
|
||||
|
||||
result.update({
|
||||
"success": True,
|
||||
"html": html,
|
||||
"elapsed": round(elapsed, 2)
|
||||
})
|
||||
|
||||
finally:
|
||||
await page.close()
|
||||
|
||||
except Exception as e:
|
||||
result["error"] = str(e)
|
||||
result["elapsed"] = round(asyncio.get_event_loop().time() - start_time, 2)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"success": False, "error": "Usage: python camoufox_ohli24.py <url>", "html": ""}))
|
||||
sys.exit(1)
|
||||
|
||||
target_url = sys.argv[1]
|
||||
timeout_sec = int(sys.argv[2]) if len(sys.argv) > 2 else 30
|
||||
|
||||
try:
|
||||
res = asyncio.run(fetch_html(target_url, timeout_sec))
|
||||
print(json.dumps(res, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e), "html": "", "elapsed": 0}))
|
||||
@@ -72,7 +72,7 @@ class Crawler:
|
||||
referer: str = None,
|
||||
engine: str = "chrome",
|
||||
stealth: bool = False,
|
||||
) -> str:
|
||||
):
|
||||
try:
|
||||
from playwright.async_api import async_playwright
|
||||
# from playwright.sync_api import sync_playwright
|
||||
|
||||
@@ -9,10 +9,10 @@ from datetime import datetime
|
||||
import requests
|
||||
|
||||
# from flaskfarm.lib.plugin import get_model_setting
|
||||
from support.expand.ffmpeg import SupportFfmpeg
|
||||
from flaskfarm.lib.support.expand.ffmpeg import SupportFfmpeg
|
||||
|
||||
# from flaskfarm.lib.system.setup import SystemModelSetting
|
||||
from tool import ToolUtil
|
||||
from flaskfarm.lib.tool import ToolUtil
|
||||
|
||||
# from flaskfarm.lib.system.setup import P as SM
|
||||
# from flaskfarm.lib.system.mod_setting import ModuleSetting as SM
|
||||
@@ -223,33 +223,13 @@ class FfmpegQueue(object):
|
||||
# 다운로드 방법 확인
|
||||
download_method = P.ModelSetting.get(f"{self.name}_download_method")
|
||||
|
||||
# 미완성 다운로드 감지 (Frag 파일, .ytdl 파일, .part 파일)
|
||||
# 이런 파일이 있으면 이어받기 허용
|
||||
# .ytdl 파일이 있거나, ytdlp/aria2c 모드인 경우 '파일 있음'으로 건너뛰지 않음 (이어받기 허용)
|
||||
is_ytdlp = download_method in ['ytdlp', 'aria2c']
|
||||
has_ytdl_file = os.path.exists(filepath + ".ytdl")
|
||||
has_part_file = os.path.exists(filepath + ".part")
|
||||
|
||||
# Frag 파일 존재 여부 확인 (같은 폴더에 Frag* 파일이 있으면 미완성)
|
||||
has_frag_files = False
|
||||
try:
|
||||
import glob
|
||||
dirname = os.path.dirname(filepath)
|
||||
if dirname and os.path.exists(dirname):
|
||||
frag_pattern = os.path.join(dirname, "*Frag*")
|
||||
has_frag_files = len(glob.glob(frag_pattern)) > 0
|
||||
if has_frag_files:
|
||||
logger.info(f"[Resume] Found Frag files in {dirname}, allowing re-download")
|
||||
except Exception as e:
|
||||
logger.debug(f"Frag check error: {e}")
|
||||
|
||||
is_incomplete = has_ytdl_file or has_part_file or has_frag_files
|
||||
|
||||
if os.path.exists(filepath) and not (is_ytdlp or is_incomplete):
|
||||
logger.info(f"File already exists: {filepath}")
|
||||
entity.ffmpeg_status = 8 # COMPLETED_EXIST
|
||||
if os.path.exists(filepath) and not (is_ytdlp or has_ytdl_file):
|
||||
entity.ffmpeg_status_kor = "파일 있음"
|
||||
entity.ffmpeg_percent = 100
|
||||
entity.download_completed()
|
||||
entity.refresh_status()
|
||||
continue
|
||||
dirname = os.path.dirname(filepath)
|
||||
@@ -606,22 +586,8 @@ class FfmpegQueue(object):
|
||||
ret["ret"] = "refresh"
|
||||
elif cmd == "reset":
|
||||
if self.download_queue is not None:
|
||||
# 큐 비우기 (표준 Queue와 gevent Queue 모두 호환)
|
||||
try:
|
||||
# 표준 Queue의 경우
|
||||
if hasattr(self.download_queue, 'mutex'):
|
||||
with self.download_queue.mutex:
|
||||
self.download_queue.queue.clear()
|
||||
else:
|
||||
# gevent Queue의 경우 - 하나씩 꺼내서 비우기
|
||||
while not self.download_queue.empty():
|
||||
try:
|
||||
self.download_queue.get_nowait()
|
||||
except:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Queue clear error (non-critical): {e}")
|
||||
|
||||
with self.download_queue.mutex:
|
||||
self.download_queue.queue.clear()
|
||||
for _ in self.entity_list:
|
||||
# 다운로드중 상태인 경우에만 중지 시도
|
||||
if _.ffmpeg_status == 5:
|
||||
|
||||
@@ -32,8 +32,10 @@ def download(url, file_name):
|
||||
def read_file(filename):
|
||||
try:
|
||||
import codecs
|
||||
with codecs.open(filename, 'r', encoding='utf8') as ifp:
|
||||
return ifp.read()
|
||||
ifp = codecs.open(filename, 'r', encoding='utf8')
|
||||
data = ifp.read()
|
||||
ifp.close()
|
||||
return data
|
||||
except Exception as exception:
|
||||
logger.error('Exception:%s', exception)
|
||||
logger.error(traceback.format_exc())
|
||||
@@ -56,29 +58,20 @@ class Util(object):
|
||||
|
||||
@staticmethod
|
||||
def change_text_for_use_filename(text):
|
||||
# 1. Remove/replace Windows-forbidden characters
|
||||
text = re.sub('[\\/:*?"<>|]', ' ', text)
|
||||
|
||||
# 2. Remove consecutive dots (.. → .)
|
||||
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('.')
|
||||
|
||||
# text = text.replace('/', '')
|
||||
# 2021-07-31 X:X
|
||||
# text = text.replace(':', ' ')
|
||||
text = re.sub('[\\/:*?\"<>|]', ' ', text).strip()
|
||||
text = re.sub("\s{2,}", ' ', text)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def write_file(data, filename):
|
||||
try:
|
||||
import codecs
|
||||
with codecs.open(filename, 'w', encoding='utf8') as ofp:
|
||||
ofp.write(data)
|
||||
ofp = codecs.open(filename, 'w', encoding='utf8')
|
||||
ofp.write(data)
|
||||
ofp.close()
|
||||
except Exception as exception:
|
||||
logger.debug('Exception:%s', exception)
|
||||
logger.debug(traceback.format_exc())
|
||||
|
||||
@@ -172,11 +172,17 @@ class YtdlpDownloader:
|
||||
# 주의: --external-downloader aria2c는 HLS 프래그먼트에서 오버헤드가 크므로 제거함
|
||||
|
||||
|
||||
# 1.5 브라우저 위장 설정 (User-Agent)
|
||||
# --impersonate 옵션은 curl-impersonate 라이브러리가 필요하므로 수동 UA 사용
|
||||
user_agent = self.headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
cmd += ['--user-agent', user_agent]
|
||||
logger.debug(f"Using manual User-Agent: {user_agent}")
|
||||
# 1.5 환경별 브라우저 위장 설정 (Impersonate)
|
||||
# macOS에서는 고급 위장 기능을 사용하되, 종속성 문제가 잦은 Linux/Docker에서는 UA 수동 지정
|
||||
is_mac = platform.system() == 'Darwin'
|
||||
if is_mac:
|
||||
cmd += ['--impersonate', 'chrome-120']
|
||||
logger.debug("Using yt-dlp --impersonate chrome-120 (macOS detected)")
|
||||
else:
|
||||
# Docker/Linux: impersonate 라이브러리 부재 가능하므로 UA 수동 설정
|
||||
user_agent = self.headers.get('User-Agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36')
|
||||
cmd += ['--user-agent', user_agent]
|
||||
logger.debug(f"Using manual User-Agent on {platform.system()}: {user_agent}")
|
||||
|
||||
# 2. 프록시 설정
|
||||
if self.proxy:
|
||||
@@ -336,14 +342,10 @@ class YtdlpDownloader:
|
||||
elapsed = time.time() - self.start_time
|
||||
self.elapsed_time = self.format_time(elapsed)
|
||||
|
||||
# [최적화] 10% 단위로만 로그 출력 (로그 부하 감소)
|
||||
# [최적화] 진행률이 1% 이상 차이나거나, 100%인 경우에만 콜백 호출 (로그 부하 감소)
|
||||
if self.callback and (int(new_percent) > int(self.percent) or new_percent >= 100):
|
||||
old_tens = int(self.percent) // 10
|
||||
new_tens = int(new_percent) // 10
|
||||
self.percent = new_percent
|
||||
# 10% 단위가 변경되었거나 100%일 때만 로그 출력
|
||||
if new_tens > old_tens or new_percent >= 100:
|
||||
logger.info(f"[yt-dlp] {int(self.percent)}% speed={self.current_speed}")
|
||||
logger.info(f"[yt-dlp progress] {int(self.percent)}% speed={self.current_speed}")
|
||||
self.callback(percent=int(self.percent), current=int(self.percent), total=100, speed=self.current_speed, elapsed=self.elapsed_time)
|
||||
else:
|
||||
self.percent = new_percent
|
||||
|
||||
@@ -1,531 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Zendriver 데몬 서버
|
||||
- 브라우저를 상시 유지하여 빠른 HTML 페칭
|
||||
- HTTP API로 요청 받아 처리
|
||||
- 4~6초 → 2~3초 속도 향상 기대
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import signal
|
||||
import time
|
||||
import os
|
||||
import traceback
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from threading import Thread, Lock
|
||||
from typing import Any, Optional, 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"
|
||||
|
||||
# 로그 설정
|
||||
def log_debug(msg):
|
||||
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
log_msg = f"[{timestamp}] {msg}"
|
||||
print(log_msg)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(log_msg + "\n")
|
||||
|
||||
# Zendriver 내부 로그 연동
|
||||
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
|
||||
browser: Optional[Any] = None
|
||||
browser_lock: Lock = Lock()
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
manual_browser_path: Optional[str] = None
|
||||
|
||||
|
||||
def find_browser_executable() -> List[str]:
|
||||
"""시스템에서 브라우저 실행 파일 찾기 (OS별 대응)"""
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
# 수동 설정된 경로 최우선
|
||||
if manual_browser_path and os.path.exists(manual_browser_path):
|
||||
return [manual_browser_path]
|
||||
|
||||
system = platform.system()
|
||||
app_dirs = ["/Applications", "/Volumes/WD/Users/Applications"]
|
||||
common_paths = []
|
||||
|
||||
if system == "Darwin": # Mac
|
||||
for base in app_dirs:
|
||||
common_paths.extend([
|
||||
f"{base}/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
f"{base}/Chromium.app/Contents/MacOS/Chromium",
|
||||
f"{base}/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
])
|
||||
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)
|
||||
if found and found not in candidates:
|
||||
candidates.append(found)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
class ZendriverHandler(BaseHTTPRequestHandler):
|
||||
"""HTTP 요청 핸들러"""
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
"""로그 출력 억제"""
|
||||
pass
|
||||
|
||||
def do_POST(self) -> None:
|
||||
"""POST 요청 처리 (/fetch, /health, /shutdown)"""
|
||||
global browser, loop
|
||||
|
||||
if self.path == "/fetch":
|
||||
try:
|
||||
content_length: int = int(self.headers.get('Content-Length', 0))
|
||||
if content_length == 0:
|
||||
self._send_json(400, {"success": False, "error": "Empty body"})
|
||||
return
|
||||
|
||||
body_bytes: bytes = self.rfile.read(content_length)
|
||||
body: str = body_bytes.decode('utf-8')
|
||||
data: Dict[str, Any] = json.loads(body)
|
||||
|
||||
url: Optional[str] = data.get("url")
|
||||
headers: Optional[Dict[str, str]] = data.get("headers")
|
||||
timeout: int = cast(int, data.get("timeout", 30))
|
||||
|
||||
if not url:
|
||||
self._send_json(400, {"success": False, "error": "Missing 'url' parameter"})
|
||||
return
|
||||
|
||||
# 비동기 fetch 실행
|
||||
if loop:
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
fetch_with_browser(url, timeout, headers), loop
|
||||
)
|
||||
# 시놀로지 등 느린 환경을 위해 타임아웃 마진을 15초 -> 45초로 확장
|
||||
result: Dict[str, Any] = future.result(timeout=timeout + 45)
|
||||
self._send_json(200, result)
|
||||
else:
|
||||
self._send_json(500, {"success": False, "error": "Event loop not ready"})
|
||||
|
||||
except Exception as e:
|
||||
log_debug(f"[Handler] Error: {e}\n{traceback.format_exc()}")
|
||||
self._send_json(500, {
|
||||
"success": False,
|
||||
"error": str(e) or e.__class__.__name__,
|
||||
"traceback": traceback.format_exc()
|
||||
})
|
||||
|
||||
elif self.path == "/health":
|
||||
self._send_json(200, {"status": "ok", "browser_ready": browser is not None})
|
||||
|
||||
elif self.path == "/shutdown":
|
||||
self._send_json(200, {"status": "shutting_down"})
|
||||
Thread(target=lambda: (time.sleep(0.5), os._exit(0))).start()
|
||||
|
||||
else:
|
||||
self._send_json(404, {"error": "Not found"})
|
||||
|
||||
def do_GET(self) -> None:
|
||||
"""GET 요청 처리 (/health)"""
|
||||
if self.path == "/health":
|
||||
self._send_json(200, {"status": "ok", "browser_ready": browser is not None})
|
||||
else:
|
||||
self._send_json(404, {"error": "Not found"})
|
||||
|
||||
def _send_json(self, status_code: int, data: Dict[str, Any]) -> None:
|
||||
"""JSON 응답 전송"""
|
||||
try:
|
||||
self.send_response(status_code)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, ensure_ascii=False).encode('utf-8'))
|
||||
except Exception as e:
|
||||
log_debug(f"[Handler] Failed to send response: {e}")
|
||||
|
||||
|
||||
async def ensure_browser() -> Any:
|
||||
"""브라우저 인스턴스 확인/생성"""
|
||||
global browser
|
||||
|
||||
with browser_lock:
|
||||
if browser is None:
|
||||
try:
|
||||
# 존재하는 후보군 가져오기
|
||||
candidates = find_browser_executable()
|
||||
if not candidates:
|
||||
log_debug("[ZendriverDaemon] No browser candidates found!")
|
||||
return None
|
||||
|
||||
# 리눅스/도커 성능 분석용 로그
|
||||
import platform
|
||||
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'
|
||||
|
||||
log_debug(f"[ZendriverDaemon] Environment: Python {sys.version.split()[0]} on {platform.system()} (UID: {uid})")
|
||||
|
||||
browser_args = [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--disable-software-rasterizer",
|
||||
"--remote-allow-origins=*",
|
||||
"--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",
|
||||
"--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(' ', '_')}")
|
||||
|
||||
# 기존 락(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:
|
||||
log_debug(f"[ZendriverDaemon] Failed to start browser: {e}")
|
||||
browser = None
|
||||
raise
|
||||
|
||||
return browser
|
||||
|
||||
|
||||
async def fetch_with_browser(url: str, timeout: int = 30, headers: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||||
"""상시 대기 브라우저로 HTML 페칭 (탭 유지 방식, 헤더 지원)"""
|
||||
global browser
|
||||
|
||||
result: Dict[str, Any] = {"success": False, "html": "", "elapsed": 0.0}
|
||||
start_time: float = time.time()
|
||||
|
||||
try:
|
||||
init_start = time.time()
|
||||
await ensure_browser()
|
||||
init_elapsed = time.time() - init_start
|
||||
|
||||
if browser is None:
|
||||
result["error"] = "Browser not available"
|
||||
return result
|
||||
|
||||
log_debug(f"[ZendriverDaemon] Fetching URL: {url} (Init: {init_elapsed:.2f}s)")
|
||||
|
||||
try:
|
||||
nav_start = time.time()
|
||||
# zendriver는 browser.get(url)로 페이지 로드
|
||||
|
||||
page: Any = None
|
||||
html_content = ""
|
||||
nav_elapsed = 0.0
|
||||
poll_elapsed = 0.0
|
||||
|
||||
# 페이지 로드 시도
|
||||
try:
|
||||
# zendriver/core/browser.py:304 에서 self.targets가 비어있을 때 StopIteration 발생 가능
|
||||
# 이를 방지하기 위해 tabs가 생길 때까지 잠시 대기하거나 직접 생성 시도
|
||||
|
||||
# 탭(페이지) 확보
|
||||
page = None
|
||||
for attempt in range(5):
|
||||
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
|
||||
|
||||
# 컨텐츠 완전 로드 대기 (폴링)
|
||||
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}")
|
||||
|
||||
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({
|
||||
"success": True,
|
||||
"html": html_content,
|
||||
"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] Success in {total_elapsed:.2f}s (Nav: {nav_elapsed:.2f}s, Poll: {poll_elapsed:.2f}s, Length: {len(html_content)})")
|
||||
else:
|
||||
length = len(html_content) if html_content else 0
|
||||
result["error"] = f"Short response: {length} 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
|
||||
|
||||
# 탭 정리: 닫지 말고 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:
|
||||
log_debug("[ZendriverDaemon] StopIteration caught during browser.get, resetting browser")
|
||||
browser = None
|
||||
raise
|
||||
|
||||
except BaseException as e:
|
||||
# StopIteration 등 모든 예외 캐치
|
||||
err_msg: str = str(e) or e.__class__.__name__
|
||||
result["error"] = err_msg
|
||||
result["elapsed"] = round(time.time() - start_time, 2)
|
||||
log_debug(f"[ZendriverDaemon] Exception during fetch: {err_msg}")
|
||||
if not isinstance(e, asyncio.CancelledError):
|
||||
log_debug(traceback.format_exc())
|
||||
|
||||
# 브라우저 오류 시 재시작 플래그
|
||||
if "browser" in err_msg.lower() or "closed" in err_msg.lower() or "stopiteration" in err_msg.lower():
|
||||
log_debug("[ZendriverDaemon] Resetting browser due to critical error")
|
||||
browser = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def run_async_loop() -> None:
|
||||
"""비동기 이벤트 루프 실행"""
|
||||
global loop
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
log_debug("[ZendriverDaemon] Async loop started")
|
||||
|
||||
# 브라우저 미리 시작
|
||||
try:
|
||||
await ensure_browser()
|
||||
except Exception as e:
|
||||
log_debug(f"[ZendriverDaemon] Initial browser start failed: {e}")
|
||||
|
||||
# 루프 유지
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
def run_server() -> None:
|
||||
"""HTTP 서버 실행"""
|
||||
try:
|
||||
server: HTTPServer = HTTPServer(('127.0.0.1', DAEMON_PORT), ZendriverHandler)
|
||||
log_debug(f"[ZendriverDaemon] HTTP server starting on port {DAEMON_PORT}")
|
||||
server.serve_forever()
|
||||
except Exception as e:
|
||||
log_debug(f"[ZendriverDaemon] HTTP server error: {e}")
|
||||
|
||||
|
||||
def signal_handler(sig: int, frame: Any) -> None:
|
||||
"""종료 시그널 처리"""
|
||||
global browser
|
||||
log_debug("\n[ZendriverDaemon] Shutdown signal received")
|
||||
if browser:
|
||||
try:
|
||||
if loop and loop.is_running():
|
||||
future = asyncio.run_coroutine_threadsafe(browser.stop(), loop)
|
||||
future.result(timeout=5)
|
||||
except Exception as e:
|
||||
log_debug(f"[ZendriverDaemon] Error during browser stop: {e}")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 인자 처리
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--browser_path", type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.browser_path:
|
||||
manual_browser_path = args.browser_path
|
||||
log_debug(f"[ZendriverDaemon] Manual browser path set: {manual_browser_path}")
|
||||
|
||||
# 시그널 핸들러 등록
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# 비동기 루프를 별도 스레드에서 실행
|
||||
async_thread: Thread = Thread(target=lambda: asyncio.run(run_async_loop()), daemon=True)
|
||||
async_thread.start()
|
||||
|
||||
# HTTP 서버 실행 (메인 스레드)
|
||||
time.sleep(2) # 초기화 대기
|
||||
run_server()
|
||||
@@ -1,197 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Zendriver 기반 Ohli24 HTML 페칭 스크립트
|
||||
- Chrome DevTools Protocol 사용 (탐지 어려움)
|
||||
- Cloudflare 우회를 위한 헤드리스 브라우저 폴백
|
||||
- curl_cffi/cloudscraper 실패 시 사용
|
||||
- JSON 출력으로 안정적인 IPC
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
|
||||
|
||||
def find_browser_executable(manual_path=None):
|
||||
"""시스템에서 브라우저 실행 파일 찾기 (OS별 대응)"""
|
||||
import platform
|
||||
|
||||
# 수동 설정 시 우선
|
||||
if manual_path and os.path.exists(manual_path):
|
||||
return manual_path
|
||||
|
||||
system = platform.system()
|
||||
app_dirs = ["/Applications", "/Volumes/WD/Users/Applications"]
|
||||
common_paths = []
|
||||
|
||||
if system == "Darwin": # Mac
|
||||
for base in app_dirs:
|
||||
common_paths.extend([
|
||||
f"{base}/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
f"{base}/Chromium.app/Contents/MacOS/Chromium",
|
||||
f"{base}/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
])
|
||||
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)
|
||||
if found and found not in candidates:
|
||||
candidates.append(found)
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
async def fetch_html(url: str, timeout: int = 60, browser_path: str = None) -> dict:
|
||||
"""Zendriver로 HTML 페칭"""
|
||||
try:
|
||||
import zendriver as zd
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Zendriver not installed: {e}. Run: pip install zendriver==0.15.3", "html": ""}
|
||||
|
||||
result = {"success": False, "html": "", "elapsed": 0}
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
browser = None
|
||||
|
||||
# 실행 가능한 브라우저 후보들 찾기
|
||||
candidates = find_browser_executable(browser_path)
|
||||
if not candidates:
|
||||
return {"success": False, "error": "No browser executable found", "html": ""}
|
||||
|
||||
# 사용자 데이터 디렉토리 설정 (Mac/Root 권한 이슈 대응)
|
||||
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(
|
||||
headless=True,
|
||||
browser_executable_path=exec_path,
|
||||
no_sandbox=True,
|
||||
user_data_dir=user_data_dir,
|
||||
browser_args=browser_args
|
||||
)
|
||||
|
||||
page = await browser.get(url)
|
||||
|
||||
# 페이지 로드 대기 - 지능형 폴링 (최대 10초)
|
||||
# 1. 리스트 페이지는 바로 반환, 2. 에피소드 페이지는 플레이어 로딩 대기
|
||||
max_wait = 10
|
||||
poll_interval = 0.2 # 1.0s -> 0.2s로 단축하여 반응속도 향상
|
||||
waited = 0
|
||||
html = ""
|
||||
|
||||
while waited < max_wait:
|
||||
await asyncio.sleep(poll_interval)
|
||||
waited += poll_interval
|
||||
html = await page.get_content()
|
||||
|
||||
# 리스트 페이지 마커 확인 (발견 즉시 탈출)
|
||||
if "post-list" in html or "list-box" in html or "post-row" in html:
|
||||
# log_debug(f"[Zendriver] List page detected in {waited:.1f}s")
|
||||
break
|
||||
|
||||
# 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()
|
||||
return result
|
||||
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
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print(json.dumps({"success": False, "error": "Usage: python zendriver_ohli24.py <url>", "html": ""}))
|
||||
sys.exit(1)
|
||||
|
||||
target_url = sys.argv[1]
|
||||
timeout_sec = int(sys.argv[2]) if len(sys.argv) > 2 else 60
|
||||
manual_path = sys.argv[3] if len(sys.argv) > 3 else None
|
||||
|
||||
try:
|
||||
res = asyncio.run(fetch_html(target_url, timeout_sec, manual_path))
|
||||
print(json.dumps(res, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e), "html": "", "elapsed": 0}))
|
||||
@@ -1,14 +1,10 @@
|
||||
from flask import render_template, request, jsonify
|
||||
from plugin import PluginModuleBase
|
||||
from flaskfarm.lib.plugin import PluginModuleBase
|
||||
import framework
|
||||
import os, traceback, time, json
|
||||
from datetime import datetime
|
||||
|
||||
class AnimeModuleBase(PluginModuleBase):
|
||||
# 업데이트 체크 캐싱 (클래스 레벨)
|
||||
_last_update_check = 0
|
||||
_latest_version = None
|
||||
|
||||
def __init__(self, P, setup_default=None, **kwargs):
|
||||
super(AnimeModuleBase, self).__init__(P, **kwargs)
|
||||
self.P = P # Ensure P is available via self.P
|
||||
@@ -135,59 +131,6 @@ class AnimeModuleBase(PluginModuleBase):
|
||||
arg3 = request.form.get('arg3') or request.args.get('arg3')
|
||||
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}"})
|
||||
|
||||
except Exception as e:
|
||||
@@ -234,7 +177,7 @@ class AnimeModuleBase(PluginModuleBase):
|
||||
namespace = f"/{self.P.package_name}/{self.name}/queue"
|
||||
|
||||
# 큐 페이지 소켓에 직접 emit
|
||||
socketio.emit(refresh_type, data, namespace=namespace)
|
||||
socketio.emit(refresh_type, data, namespace=namespace, broadcast=True)
|
||||
|
||||
except Exception as e:
|
||||
self.P.logger.error(f"socketio_callback error: {e}")
|
||||
@@ -255,101 +198,3 @@ class AnimeModuleBase(PluginModuleBase):
|
||||
except Exception as 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
|
||||
|
||||
@@ -114,7 +114,6 @@ class AnimeQueueEntity(FfmpegQueueEntity):
|
||||
self.ffmpeg_status = 7
|
||||
self.ffmpeg_status_kor = "완료"
|
||||
self.end_time = datetime.now()
|
||||
self._update_db_status()
|
||||
return
|
||||
|
||||
if self.filepath and os.path.exists(self.filepath):
|
||||
@@ -127,50 +126,11 @@ class AnimeQueueEntity(FfmpegQueueEntity):
|
||||
self.ffmpeg_status = 7
|
||||
self.ffmpeg_status_kor = "완료"
|
||||
self.end_time = datetime.now()
|
||||
self._update_db_status()
|
||||
except Exception as e:
|
||||
self.P.logger.error(f"Download completed error: {e}")
|
||||
self.ffmpeg_status = 4
|
||||
self.ffmpeg_status_kor = "이동 실패"
|
||||
|
||||
def _update_db_status(self):
|
||||
"""Update DB status to completed - generic method for all sites."""
|
||||
try:
|
||||
from framework import app
|
||||
with app.app_context():
|
||||
# Get the web_list_model from module_logic
|
||||
model_class = getattr(self.module_logic, 'web_list_model', None)
|
||||
if model_class is None:
|
||||
return
|
||||
|
||||
# Try to find the DB entity
|
||||
db_entity = None
|
||||
info = getattr(self, 'info', {})
|
||||
|
||||
# Anilife uses _id
|
||||
if hasattr(model_class, 'get_by_anilife_id') and info.get('_id'):
|
||||
db_entity = model_class.get_by_anilife_id(info['_id'])
|
||||
# Ohli24 uses _id (via get_by_ohli24_id)
|
||||
elif hasattr(model_class, 'get_by_ohli24_id') and info.get('_id'):
|
||||
db_entity = model_class.get_by_ohli24_id(info['_id'])
|
||||
# Linkkf uses _id (via get_by_linkkf_id)
|
||||
elif hasattr(model_class, 'get_by_linkkf_id') and info.get('_id'):
|
||||
db_entity = model_class.get_by_linkkf_id(info['_id'])
|
||||
# Other modules might use get_by_id with db_id
|
||||
elif hasattr(model_class, 'get_by_id') and info.get('db_id'):
|
||||
db_entity = model_class.get_by_id(info['db_id'])
|
||||
elif hasattr(model_class, 'get_by_content_code') and info.get('content_code'):
|
||||
db_entity = model_class.query.filter_by(content_code=info['content_code'], episode_no=info.get('episode_no')).first()
|
||||
|
||||
if db_entity is not None:
|
||||
db_entity.status = "completed"
|
||||
db_entity.completed_time = datetime.now()
|
||||
db_entity.filename = getattr(self, 'filename', None)
|
||||
db_entity.save()
|
||||
logger.info(f"[{self.module_logic.name}] DB status updated to 'completed': {info.get('title', 'Unknown')}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update DB status: {e}")
|
||||
|
||||
def info_dict(self, tmp):
|
||||
"""Default valid implementation"""
|
||||
return tmp
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# anime_downloader 플러그인 구조 분석
|
||||
|
||||
## 📌 개요
|
||||
|
||||
FlaskFarm용 **애니메이션 다운로드 플러그인**으로, 여러 애니메이션 스트리밍 사이트에서 콘텐츠를 검색하고 다운로드할 수 있는 기능을 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 전체 구조
|
||||
|
||||
```
|
||||
anime_downloader/
|
||||
├── __init__.py # 플러그인 초기화 (현재 비활성화됨)
|
||||
├── setup.py # 플러그인 설정 및 메뉴 구조, 모듈 로드
|
||||
├── info.yaml # 플러그인 메타데이터 (이름, 버전, 개발자)
|
||||
├── mod_ohli24.py # 애니24 사이트 모듈 (1,542줄)
|
||||
├── mod_anilife.py # 애니라이프 사이트 모듈 (1,322줄)
|
||||
├── mod_linkkf.py # 링크애니 사이트 모듈 (1,449줄)
|
||||
├── lib/ # 공용 라이브러리
|
||||
│ ├── crawler.py # 웹 크롤링 엔진 (Playwright, Selenium, Cloudscraper)
|
||||
│ ├── ffmpeg_queue_v1.py# FFmpeg 다운로드 큐 관리
|
||||
│ ├── util.py # 유틸리티 함수 (파일명 정리, 타이밍 등)
|
||||
│ └── misc.py # 비동기 실행 헬퍼 함수
|
||||
├── templates/ # HTML 템플릿 (18개 파일)
|
||||
├── static/ # CSS, JS, 이미지 리소스
|
||||
├── bin/ # 플랫폼별 바이너리 (Darwin, Linux)
|
||||
├── nest_api/ # 애니 API 관련 (서브디렉토리)
|
||||
└── yommi_api/ # 커스텀 API 관련
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 핵심 컴포넌트
|
||||
|
||||
### 1. setup.py - 플러그인 엔트리포인트
|
||||
|
||||
| 항목 | 설명 |
|
||||
|------|------|
|
||||
| `__menu` | 3개 사이트별 서브메뉴 (설정, 요청, 큐, 검색, 목록) |
|
||||
| `setting` | DB 사용, 기본 설정, 홈 모듈(`ohli24`) 지정 |
|
||||
| `P` | FlaskFarm 플러그인 인스턴스 생성 |
|
||||
| 모듈 로드 | `LogicOhli24`, `LogicAniLife`, `LogicLinkkf` |
|
||||
|
||||
### 2. 사이트 모듈 (mod_*.py)
|
||||
|
||||
각 모듈은 동일한 구조를 따릅니다:
|
||||
|
||||
| 클래스 | 역할 |
|
||||
|--------|------|
|
||||
| `LogicXxx` | 사이트별 비즈니스 로직 (검색, 시리즈 정보, 다운로드 추가) |
|
||||
| `XxxQueueEntity` | 다운로드 큐 항목 (에피소드 정보, 상태 관리) |
|
||||
| `ModelXxxItem` | SQLAlchemy DB 모델 (다운로드 기록 저장) |
|
||||
|
||||
**LogicXxx 주요 메서드:**
|
||||
|
||||
- `process_menu()` / `process_ajax()` - 웹 요청 처리
|
||||
- `get_series_info()` - 시리즈/에피소드 정보 파싱
|
||||
- `get_anime_info()` / `get_search_result()` - 목록/검색
|
||||
- `add()` - 다운로드 큐에 추가
|
||||
- `scheduler_function()` - 자동 다운로드 스케줄러
|
||||
- `plugin_load()` / `plugin_unload()` - 생명주기 관리
|
||||
|
||||
### 3. lib/crawler.py - 웹 크롤링 엔진
|
||||
|
||||
| 메서드 | 기술 |
|
||||
|--------|------|
|
||||
| `get_html_requests()` | 기본 requests 요청 |
|
||||
| `get_html_playwright()` | Playwright 비동기 (헤드리스 브라우저) |
|
||||
| `get_html_playwright_sync()` | Playwright 동기 |
|
||||
| `get_html_selenium()` | Selenium WebDriver |
|
||||
| `get_html_cloudflare()` | Cloudscraper (CF 우회) |
|
||||
|
||||
### 4. lib/ffmpeg_queue_v1.py - 다운로드 큐
|
||||
|
||||
| 클래스 | 역할 |
|
||||
|--------|------|
|
||||
| `FfmpegQueueEntity` | 개별 다운로드 항목 (URL, 파일경로, 상태) |
|
||||
| `FfmpegQueue` | 큐 관리자 (스레드 기반 다운로드, 동시 다운로드 수 제어) |
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 지원 사이트 (3개)
|
||||
|
||||
| 모듈 | 사이트 | URI |
|
||||
|------|--------|-----|
|
||||
| `mod_ohli24.py` | 애니24 (ohli24) | `/ohli24` |
|
||||
| `mod_anilife.py` | 애니라이프 | `/anilife` |
|
||||
| `mod_linkkf.py` | 링크애니 | `/linkkf` |
|
||||
|
||||
---
|
||||
|
||||
## 📄 템플릿 구조
|
||||
|
||||
각 사이트별로 6개 템플릿 제공:
|
||||
|
||||
- `*_setting.html` - 사이트 설정
|
||||
- `*_request.html` - 다운로드 요청 페이지
|
||||
- `*_queue.html` - 다운로드 큐 현황
|
||||
- `*_search.html` - 검색 인터페이스
|
||||
- `*_list.html` - 다운로드 목록
|
||||
- `*_category.html` - 카테고리 탐색
|
||||
|
||||
---
|
||||
|
||||
## 🔄 동작 흐름
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[사용자] --> B[검색/탐색]
|
||||
B --> C[시리즈 선택]
|
||||
C --> D[에피소드 선택]
|
||||
D --> E[다운로드 큐 추가]
|
||||
E --> F[FfmpegQueue]
|
||||
F --> G[FFmpeg 다운로드]
|
||||
G --> H[DB 기록 저장]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
1. **개발 모드**: `setup.py`에서 `DEFINE_DEV = True`로 설정되어 직접 모듈 import
|
||||
2. **`__init__.py` 비활성화**: 현재 주석 처리되어 `setup.py`가 실제 엔트리포인트 역할
|
||||
3. **크롤링 기술 혼용**: Cloudflare 우회를 위해 Playwright, Selenium, cloudscraper 등 다양한 기술 사용
|
||||
@@ -0,0 +1,102 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# @Time : 2022/02/08 2:57 PM
|
||||
# @Author : yommi
|
||||
# @Site :
|
||||
# @File : plugin
|
||||
# @Software: PyCharm
|
||||
|
||||
import os, traceback
|
||||
|
||||
# third-party
|
||||
from flask import Blueprint
|
||||
|
||||
# sjva 공용
|
||||
from framework.logger import get_logger
|
||||
from framework import app, path_data
|
||||
from plugin import get_model_setting, Logic, default_route, PluginUtil
|
||||
|
||||
|
||||
#######################################################################
|
||||
|
||||
|
||||
class P(object):
|
||||
package_name = __name__.split(".")[0]
|
||||
logger = get_logger(package_name)
|
||||
blueprint = Blueprint(
|
||||
package_name,
|
||||
package_name,
|
||||
url_prefix="/%s" % package_name,
|
||||
template_folder=os.path.join(os.path.dirname(__file__), "templates"),
|
||||
static_folder="static",
|
||||
)
|
||||
menu = {
|
||||
"main": [package_name, "애니 다운로드"],
|
||||
"sub": [
|
||||
["ohli24", "OHLI24"],
|
||||
["linkkf", "LINKKF"],
|
||||
["anilife", "애니라이프"],
|
||||
["log", "로그"],
|
||||
],
|
||||
"category": "vod",
|
||||
"sub2": {
|
||||
"ohli24": [
|
||||
["setting", "설정"],
|
||||
["request", "요청"],
|
||||
["queue", "큐"],
|
||||
["category", "검색"],
|
||||
["list", "목록"],
|
||||
],
|
||||
"linkkf": [
|
||||
["setting", "설정"],
|
||||
["request", "요청"],
|
||||
["queue", "큐"],
|
||||
["category", "검색"],
|
||||
["list", "목록"],
|
||||
],
|
||||
"anilife": [
|
||||
["setting", "설정"],
|
||||
["request", "요청"],
|
||||
["category", "검색"],
|
||||
["queue", "큐"],
|
||||
["list", "목록"],
|
||||
],
|
||||
},
|
||||
}
|
||||
plugin_info = {
|
||||
"version": "0.1.2.0",
|
||||
"name": "anime_downloader",
|
||||
"category_name": "vod",
|
||||
"icon": "",
|
||||
"developer": "soju6jan && projectdx",
|
||||
"description": "비디오 다운로드",
|
||||
"home": "http://yommi.duckdns.org:20080/projectdx/anime-downloader",
|
||||
"more": "",
|
||||
}
|
||||
ModelSetting = get_model_setting(package_name, logger)
|
||||
logic = None
|
||||
module_list = None
|
||||
home_module = "ohli24"
|
||||
|
||||
|
||||
# 초기화 함수
|
||||
def initialize():
|
||||
try:
|
||||
app.config["SQLALCHEMY_BINDS"][P.package_name] = "sqlite:///%s" % (
|
||||
os.path.join(
|
||||
path_data, "db", "{package_name}.db".format(package_name=P.package_name)
|
||||
)
|
||||
)
|
||||
PluginUtil.make_info_json(P.plugin_info, __file__)
|
||||
from .logic_ohli24 import LogicOhli24
|
||||
from .logic_anilife import LogicAniLife
|
||||
|
||||
# P.module_list = [LogicOhli24(P), LogicLinkkf(P)]
|
||||
P.module_list = [LogicOhli24(P), LogicAniLife(P)]
|
||||
P.logic = Logic(P)
|
||||
default_route(P)
|
||||
except Exception as e:
|
||||
P.logger.error("Exception:%s", e)
|
||||
P.logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
initialize()
|
||||
@@ -0,0 +1,810 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="kr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name='robots' content='noindex, follow, max-image-preview:large' />
|
||||
<script type='text/javascript' id='mv_jquery-js-extra'>
|
||||
/* <![CDATA[ */
|
||||
var mv_base = {"ajaxurl":"https:\/\/linkkf.live\/wp-admin\/admin-ajax.php"};
|
||||
/* ]]> */
|
||||
</script>
|
||||
<script type='text/javascript' src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js' id='mv_jquery-js'></script>
|
||||
<script type='text/javascript' src='https://linkkf.live/wp-content/themes/kfbeta16/assets/js/layer/layer.js' id='mv_layui-js'></script>
|
||||
<script type='text/javascript' src='https://linkkf.live/wp-content/themes/kfbeta16/assets/js/mytheme-ui.js' id='mv_mytheme_ui-js'></script>
|
||||
<script type='text/javascript' src='https://linkkf.live/wp-content/themes/kfbeta16/assets/js/mytheme-cms.js' id='mv_mytheme_cms-js'></script>
|
||||
<script type='text/javascript'>
|
||||
/* <![CDATA[ */
|
||||
var taqyeem = {"ajaxurl":"https://linkkf.live/wp-admin/admin-ajax.php" , "your_rating":"Your Rating:"};
|
||||
/* ]]> */
|
||||
</script>
|
||||
<link rel="icon" href="https://linkkf.live/wp-content/uploads/2020/09/cropped-logo-32x32.png" sizes="32x32" />
|
||||
<link rel="icon" href="https://linkkf.live/wp-content/uploads/2020/09/cropped-logo-192x192.png" sizes="192x192" />
|
||||
<link rel="apple-touch-icon" href="https://linkkf.live/wp-content/uploads/2020/09/cropped-logo-180x180.png" />
|
||||
<meta name="msapplication-TileImage" content="https://linkkf.live/wp-content/uploads/2020/09/cropped-logo-270x270.png" />
|
||||
<link href="https://linkkf.live/wp-content/themes/kfbeta16/style.css" rel="stylesheet" type="text/css" />
|
||||
<link rel="author" href="https://1.bp.blogspot.com/-yjRTBw8mtN4/X2f2XSQ8o5I/AAAAAAAAKn8/oCxwBIPZo5AEgaR-Ak7tVvPJiHEHKTjpQCLcBGAsYHQ/s52/logo.png" />
|
||||
|
||||
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script async src="https://www.googletagmanager.com/gtag/js?id=G-YKQT0BN847"></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-YKQT0BN847');
|
||||
</script>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
|
||||
#masthead {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 0 16px; height: 56px; background-color: #202020;
|
||||
top: 0; z-index: 100; border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.header-left { display: flex; align-items: center; gap: 15px; min-width: 150px; z-index: 2; }
|
||||
.logo-text { font-size: 18px; font-weight: bold; color: #fff; letter-spacing: -0.5px; display: flex; align-items: center; gap: 5px; }
|
||||
|
||||
.header-center { flex: 0 1 700px; display: flex; justify-content: center; margin: 0 20px; }
|
||||
|
||||
.yt-search-form { display: flex; width: 100%; max-width: 600px; position: relative; }
|
||||
|
||||
.yt-search-input {
|
||||
width: 100%; padding: 0 15px; height: 40px; background-color: #121212;
|
||||
border: 1px solid #303030; border-right: none; border-radius: 40px 0 0 40px;
|
||||
color: #fff; font-size: 16px; outline: none; box-shadow: inset 0 1px 2px #000000;
|
||||
}
|
||||
.yt-search-input:focus { border-color: #1c62b9; }
|
||||
|
||||
.yt-search-btn {
|
||||
width: 64px; height: 40px; background-color: #222; border: 1px solid #303030;
|
||||
border-radius: 0 40px 40px 0; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.yt-search-btn:hover { background-color: #303030; }
|
||||
.yt-search-btn svg { fill: #fff; width: 24px; height: 24px; }
|
||||
|
||||
.mobile-back-btn {
|
||||
display: none; background: transparent; border: none; padding: 10px; margin-right: 5px;
|
||||
}
|
||||
.mobile-back-btn svg { fill: #fff; width: 24px; height: 24px; }
|
||||
|
||||
.header-right { display: flex; align-items: center; gap: 10px; min-width: auto; justify-content: flex-end; z-index: 2; }
|
||||
|
||||
.mobile-search-trigger {
|
||||
display: none; background: transparent; border: none; padding: 8px; border-radius: 50%;
|
||||
}
|
||||
.mobile-search-trigger:active { background-color: #2a2a2a; }
|
||||
.mobile-search-trigger svg { fill: #fff; width: 24px; height: 24px; }
|
||||
|
||||
.auth-btn-link {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 12px; border: 1px solid #303030; border-radius: 18px;
|
||||
color: #3ea6ff; font-size: 14px; font-weight: 500; background: rgba(38, 56, 80, 0.3);
|
||||
}
|
||||
|
||||
.user-avatar-wrap {
|
||||
width: 32px; height: 32px; border-radius: 50%; overflow: hidden;
|
||||
cursor: pointer; background: #333; border: 1px solid #444;
|
||||
display: block;
|
||||
}
|
||||
.user-avatar-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.header-avatar-letter {
|
||||
width: 100%; height: 100%; display: flex;
|
||||
justify-content: center; align-items: center;
|
||||
color: #fff; font-weight: bold; text-transform: uppercase;
|
||||
font-size: 14px; user-select: none;
|
||||
}
|
||||
|
||||
.btn-logout-mini {
|
||||
font-size: 12px; color: #aaa; background: transparent; border: none; cursor: pointer; padding: 5px;
|
||||
}
|
||||
|
||||
#sub-nav {
|
||||
display: flex; justify-content: center; gap: 10px; padding: 10px 15px;
|
||||
background-color: #202020; border-bottom: 1px solid #2a2a2a;
|
||||
overflow-x: auto; white-space: nowrap; top: 56px; z-index: 99;
|
||||
}
|
||||
#sub-nav::-webkit-scrollbar { display: none; }
|
||||
.nav-chip {
|
||||
display: flex; align-items: center; gap: 6px; padding: 6px 14px;
|
||||
background-color: #222; border: 1px solid #333; color: #fff;
|
||||
border-radius: 8px; font-size: 14px; font-weight: 500;
|
||||
}
|
||||
.nav-chip.active { background-color: #f1f1f1; color: #0f0f0f; }
|
||||
.nav-chip svg { width: 16px; height: 16px; fill: currentColor; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-center { display: none; }
|
||||
.mobile-search-trigger { display: block; }
|
||||
.header-right { min-width: auto; }
|
||||
|
||||
body.search-active .header-left { display: none; }
|
||||
body.search-active .header-right { display: none; }
|
||||
|
||||
body.search-active .header-center {
|
||||
display: flex; flex: 1; margin: 0; padding: 0 5px;
|
||||
animation: fadeIn 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
body.search-active .mobile-back-btn { display: block; }
|
||||
body.search-active .yt-search-input { border-radius: 20px 0 0 20px; }
|
||||
body.search-active .yt-search-btn { border-radius: 0 20px 20px 0; background: #2a2a2a; }
|
||||
|
||||
#sub-nav { justify-content: flex-start; padding-left: 10px; }
|
||||
.auth-btn-link span { display: none; }
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.auth-modal { display: none; position: fixed; z-index: 9999; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(3px); }
|
||||
.auth-content { background-color: #1e1e1e; margin: 10% auto; padding: 30px; border: 1px solid #444; width: 350px; border-radius: 10px; position: relative; color: #eee; }
|
||||
.close-auth { position: absolute; top: 10px; right: 15px; cursor: pointer; font-size: 24px; }
|
||||
.auth-form input { width: 100%; padding: 12px; margin: 10px 0; background: #2a2a2a; border: 1px solid #444; color: #fff; border-radius: 4px; box-sizing: border-box;}
|
||||
.submit-btn { width: 100%; background: #0073aa; color: #fff; padding: 12px; border: none; border-radius: 4px; font-weight: bold; margin-top: 10px; }
|
||||
.auth-switch { text-align: center; margin-top: 15px; font-size: 0.9em; color: #aaa; }
|
||||
.auth-msg { text-align: center; margin-top: 10px; min-height: 20px; font-size: 0.9em; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header id="masthead">
|
||||
<div class="header-left">
|
||||
<a href="/" class="logo-text"><strong>Linkkf</strong></a>
|
||||
<div style="display:flex; gap:8px; margin-left:10px; opacity:0.7;">
|
||||
<a href="https://www.youtube.com/@linkkfanime" target="_blank"><img src="https://cdn.jsdelivr.net/gh/756751uosmaqy/wpp@main/yt.png" width="18" height="18"></a>
|
||||
<a href="https://x.com/linkkfcom" target="_blank"><img src="https://cdn.jsdelivr.net/gh/756751uosmaqy/wpp@main/tw.png" width="18" height="18"></a>
|
||||
<a href="https://www.tiktok.com/@linkkfani" target="_blank"><img src="https://cdn.jsdelivr.net/gh/756751uosmaqy/wpp@main/tt.png" width="18" height="18"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<form role="search" method="get" class="yt-search-form" action="https://linkkf.live/">
|
||||
<button type="button" class="mobile-back-btn" onclick="toggleMobileSearch(false)">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"></path></svg>
|
||||
</button>
|
||||
|
||||
<input type="text" class="yt-search-input" placeholder="검색 ..." value="원펀맨" name="s" id="masthead-search-term">
|
||||
|
||||
<button type="submit" class="yt-search-btn">
|
||||
<svg viewBox="0 0 24 24"><path d="M20.87,20.17l-5.59-5.59C16.35,13.35,17,11.75,17,10c0-3.87-3.13-7-7-7s-7,3.13-7,7s3.13,7,7,7c1.75,0,3.35-0.65,4.58-1.71 l5.59,5.59L20.87,20.17z M10,16c-3.31,0-6-2.69-6-6s2.69-6,6-6s6,2.69,6,6S13.31,16,10,16z"></path></svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button class="mobile-search-trigger" onclick="toggleMobileSearch(true)">
|
||||
<svg viewBox="0 0 24 24"><path d="M20.87,20.17l-5.59-5.59C16.35,13.35,17,11.75,17,10c0-3.87-3.13-7-7-7s-7,3.13-7,7s3.13,7,7,7c1.75,0,3.35-0.65,4.58-1.71 l5.59,5.59L20.87,20.17z M10,16c-3.31,0-6-2.69-6-6s2.69-6,6-6s6,2.69,6,6S13.31,16,10,16z"></path></svg>
|
||||
</button>
|
||||
|
||||
<div id="guest-buttons" style="display:flex;">
|
||||
<a href="/login/" class="auth-btn-link">
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z"/></svg>
|
||||
<span>로그인</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div id="user-info" style="display:none; align-items:center; gap:10px;">
|
||||
<a href="/login/" class="user-avatar-wrap" id="header-avatar-link" title="user-info">
|
||||
<img id="header-user-avatar" src="" alt="Avatar">
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav id="sub-nav">
|
||||
<a href="/" class="nav-chip ">
|
||||
<svg viewBox="0 0 24 24"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg> 홈
|
||||
</a>
|
||||
<a href="/rr/" class="nav-chip">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg> 리뷰
|
||||
</a>
|
||||
<a href="/gg" class="nav-chip">
|
||||
<svg viewBox="0 0 24 24"><path d="M19 3h-1V1h-2v2H8V1H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V8h14v11zM7 10h5v5H7z"/></svg> 추천
|
||||
</a>
|
||||
<a href="/favorites/" class="nav-chip ">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg> 찜 목록
|
||||
</a>
|
||||
<a href="/history/" class="nav-chip ">
|
||||
<svg viewBox="0 0 24 24"><path d="M13 3c-4.97 0-9 4.03-9 9H1l3.89 3.89.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42C8.27 19.99 10.51 21 13 21c4.97 0 9-4.03 9-9s-4.03-9-9-9zm-1 5v5l4.28 2.54.72-1.21-3.5-2.08V8H12z"/></svg> 시청 기록
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
<div id="auth-modal" class="auth-modal">
|
||||
<div class="auth-content">
|
||||
<span class="close-auth" onclick="closeAuthModal()">×</span>
|
||||
|
||||
<div id="form-login" class="auth-form">
|
||||
<h2>로그인</h2>
|
||||
<input type="email" id="login-email" placeholder="이메일">
|
||||
<input type="password" id="login-pass" placeholder="비밀번호">
|
||||
<button onclick="handleLogin()" class="submit-btn">로그인</button>
|
||||
<p class="auth-switch">계정이 없으신가요? <a href="#" onclick="switchAuth('register')">회원가입</a></p>
|
||||
</div>
|
||||
|
||||
<div id="form-register" class="auth-form" style="display:none;">
|
||||
<h2>회원가입</h2>
|
||||
<input type="text" id="reg-name" placeholder="닉네임">
|
||||
<input type="email" id="reg-email" placeholder="이메일">
|
||||
<input type="password" id="reg-pass" placeholder="비밀번호">
|
||||
<button onclick="handleRegister()" class="submit-btn">회원가입</button>
|
||||
<p class="auth-switch">이미 계정이 있으신가요? <a href="#" onclick="switchAuth('login')">로그인</a></p>
|
||||
</div>
|
||||
|
||||
<p id="auth-msg" class="auth-msg"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const AUTH_API = 'https://ro2.ani19962004.top/api/auth.php';
|
||||
|
||||
function toggleMobileSearch(show) {
|
||||
if (show) {
|
||||
document.body.classList.add('search-active');
|
||||
setTimeout(() => document.getElementById('masthead-search-term').focus(), 100);
|
||||
} else {
|
||||
document.body.classList.remove('search-active');
|
||||
}
|
||||
}
|
||||
|
||||
function stringToHeaderColor(str) {
|
||||
let hash = 0; for (let i = 0; i < str.length; i++) hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
const c = (hash & 0x00FFFFFF).toString(16).toUpperCase();
|
||||
return '#' + '00000'.substring(0, 6 - c.length) + c;
|
||||
}
|
||||
|
||||
function createHeaderLetterAvatar(name, container) {
|
||||
const letter = name ? name.charAt(0) : 'U';
|
||||
const div = document.createElement('div');
|
||||
div.className = 'header-avatar-letter';
|
||||
div.textContent = letter;
|
||||
div.style.backgroundColor = stringToHeaderColor(name || 'User');
|
||||
container.appendChild(div);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userStr = localStorage.getItem('anime_user');
|
||||
const guestBtns = document.getElementById('guest-buttons');
|
||||
const userInfo = document.getElementById('user-info');
|
||||
|
||||
if(userStr) {
|
||||
const user = JSON.parse(userStr);
|
||||
if (guestBtns) guestBtns.style.display = 'none';
|
||||
if (userInfo) {
|
||||
userInfo.style.display = 'flex';
|
||||
const avatarWrap = document.getElementById('header-avatar-link');
|
||||
const avatarImg = document.getElementById('header-user-avatar');
|
||||
const oldLetter = avatarWrap.querySelector('.header-avatar-letter');
|
||||
if(oldLetter) oldLetter.remove();
|
||||
|
||||
if (user.avatar_path && user.avatar_path.length > 5) {
|
||||
avatarImg.style.display = 'block';
|
||||
avatarImg.src = user.avatar_path;
|
||||
avatarImg.onerror = function() {
|
||||
this.style.display = 'none';
|
||||
createHeaderLetterAvatar(user.name, avatarWrap);
|
||||
};
|
||||
} else {
|
||||
avatarImg.style.display = 'none';
|
||||
createHeaderLetterAvatar(user.name, avatarWrap);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (guestBtns) guestBtns.style.display = 'flex';
|
||||
if (userInfo) userInfo.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
function showAuthModal(type) { document.getElementById('auth-modal').style.display = 'block'; switchAuth(type); }
|
||||
function closeAuthModal() { document.getElementById('auth-modal').style.display = 'none'; }
|
||||
function switchAuth(type) { document.getElementById('form-login').style.display = (type=='login')?'block':'none'; document.getElementById('form-register').style.display = (type=='register')?'block':'none'; document.getElementById('auth-msg').textContent = ''; }
|
||||
async function handleLogin(){ /* ... ... */ }
|
||||
async function handleRegister(){ /* ... ... */ }
|
||||
|
||||
function doLogout(){ if(confirm('로그아웃 ? 0.o ?')){localStorage.removeItem('anime_user'); window.location.href='/';}}
|
||||
|
||||
</script>
|
||||
|
||||
<div id="body">
|
||||
<title>원펀맨 애니 자막 Linkkf</title>
|
||||
|
||||
<style>
|
||||
|
||||
#primary .entry-header {
|
||||
background: none; border: none; padding: 0 0 15px 0;
|
||||
margin-bottom: 25px; border-bottom: 3px solid #0073aa;
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
}
|
||||
#primary .entry-title {
|
||||
color: #ffffff; font-size: 2.2em; font-weight: 700;
|
||||
margin: 0; padding: 0; line-height: 1.3;
|
||||
}
|
||||
#primary .search-total-count {
|
||||
font-size: 1.1em; font-weight: 500; color: #aaa; margin-top: 5px;
|
||||
}
|
||||
|
||||
.external-search-container {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin: 30px auto;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
#external-search-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#external-search-input {
|
||||
flex-grow: 1;
|
||||
padding: 10px 20px;
|
||||
background-color: #121212;
|
||||
color: #fff;
|
||||
border: 1px solid #303030;
|
||||
border-right: none;
|
||||
border-radius: 40px 0 0 40px;
|
||||
font-size: 1.1em;
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
#external-search-input:focus {
|
||||
border-color: #1c62b9;
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
#external-search-form button {
|
||||
padding: 10px 25px;
|
||||
background-color: #222222;
|
||||
color: #aaa;
|
||||
border: 1px solid #303030;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#external-search-form button:hover {
|
||||
background-color: #333333;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
.ext-json-grid { display: grid; gap: 15px; margin-top: 20px; grid-template-columns: repeat(2, 1fr); }
|
||||
@media (min-width: 768px) { .ext-json-grid { grid-template-columns: repeat(4, 1fr); } }
|
||||
|
||||
.ext-json-grid .loading, .ext-json-grid .error {
|
||||
font-style: italic; color: #777; font-size: 1.2em; padding: 20px 0; grid-column: 1 / -1; text-align: center;
|
||||
}
|
||||
.ext-json-grid .error { color: #d9534f; }
|
||||
|
||||
|
||||
.ext-json-item { background-color: #2a2a2a; border-radius: 8px; overflow: hidden; border: 1px solid #333; transition: transform 0.2s ease; }
|
||||
.ext-json-item:hover { transform: translateY(-5px); }
|
||||
.ext-json-item a { text-decoration: none; }
|
||||
|
||||
.ext-json-thumb { position: relative; display: block; aspect-ratio: 16 / 9; background-color: #1a1a1a; }
|
||||
.ext-json-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
|
||||
.ext-json-noti { position: absolute; top: 8px; left: 8px; background-color: #5bb7fe; color: white; padding: 3px 8px; border-radius: 4px; font-size: 0.8em; font-weight: bold; z-index: 2; }
|
||||
|
||||
.ext-json-info { padding: 12px; }
|
||||
.ext-json-name { font-size: 0.9em; color: #eee; margin: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.ext-json-info .ext-json-meta { font-size: 0.8em; color: #999; margin-top: 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ext-json-info .ext-json-meta .ext-json-season { margin-left: 5px; padding-left: 5px; border-left: 1px solid #555; }
|
||||
|
||||
|
||||
@keyframes skeleton-shimmer {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
background-color: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
.skeleton-animate {
|
||||
background: #2a2a2a;
|
||||
background: linear-gradient(90deg, #2a2a2a 25%, #3a3a3a 50%, #2a2a2a 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: skeleton-shimmer 1.5s infinite linear;
|
||||
}
|
||||
|
||||
.skeleton-thumb {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skeleton-info { padding: 12px; }
|
||||
|
||||
.skeleton-text {
|
||||
height: 14px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skeleton-title { width: 85%; height: 18px; margin-bottom: 12px; }
|
||||
.skeleton-meta { width: 50%; }
|
||||
/* ---------------------------- */
|
||||
|
||||
/* Pagination */
|
||||
#pagination-container.pagination-dark { display: flex; justify-content: center; align-items: center; gap: 8px; margin-top: 30px; padding: 15px; background: #2a2a2a; border-radius: 8px; font-family: Arial, sans-serif; border: 1px solid #333; }
|
||||
#pagination-container.pagination-dark button { padding: 12px 12px; background-color: #444; color: #eee; border: 1px solid #555; border-radius: 4px; cursor: pointer; font-size: 0.9em; }
|
||||
#pagination-container.pagination-dark button:hover:not(:disabled) { background-color: #0073aa; color: #fff; border-color: #0073aa; }
|
||||
#pagination-container.pagination-dark button:disabled { background-color: #333; color: #777; border-color: #444; cursor: not-allowed; }
|
||||
#pagination-container.pagination-dark .page-info { color: #ccc; font-size: 0.9em; }
|
||||
#pagination-container.pagination-dark input[type="number"] { width: 50px; padding: 7px; text-align: center; background-color: #333; color: #fff; border: 1px solid #555; border-radius: 4px; margin: 0 5px; -moz-appearance: textfield; }
|
||||
#pagination-container.pagination-dark input[type="number"]::-webkit-outer-spin-button, #pagination-container.pagination-dark input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
</style>
|
||||
|
||||
<div id="primary" class="content-area">
|
||||
<main id="main" class="site-main">
|
||||
|
||||
<article>
|
||||
<header class="entry-header">
|
||||
<h1 class="entry-title" id="search-page-title">검색: 원펀맨</h1>
|
||||
<span class="search-total-count" id="search-total-count-display" style="display: none;"></span>
|
||||
</header>
|
||||
|
||||
<div class="entry-content">
|
||||
|
||||
<div class="external-search-container">
|
||||
<form id="external-search-form" action="https://linkkf.live/" method="get">
|
||||
<input type="text" name="s" id="external-search-input"
|
||||
placeholder="......"
|
||||
value="원펀맨" required>
|
||||
<button type="submit"> 검색 </button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="results-grid" class="ext-json-grid">
|
||||
</div>
|
||||
|
||||
<div id="pagination-container" class="pagination-dark" style="display: none;">
|
||||
<button id="page-first" disabled>« « «</button>
|
||||
<button id="page-prev" disabled>‹ </button>
|
||||
<span class="page-info">
|
||||
page <input type="number" id="page-input" min="1" value="1" disabled> / <span id="page-total">1</span>
|
||||
</span>
|
||||
<button id="page-next" disabled> ›</button>
|
||||
<button id="page-last" disabled> » » »</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
const API_SEARCH_URL = 'https://linkkf.5imgdarr.top/api/search.php';
|
||||
const POSTS_PER_PAGE = 20;
|
||||
|
||||
// DOM Elements
|
||||
const searchForm = document.getElementById('external-search-form');
|
||||
const searchInput = document.getElementById('external-search-input');
|
||||
const pageTitle = document.getElementById('search-page-title');
|
||||
|
||||
const resultsContainer = document.getElementById('results-grid');
|
||||
const paginationContainer = document.getElementById('pagination-container');
|
||||
const btnFirst = document.getElementById('page-first');
|
||||
const btnPrev = document.getElementById('page-prev');
|
||||
const btnNext = document.getElementById('page-next');
|
||||
const btnLast = document.getElementById('page-last');
|
||||
const pageInput = document.getElementById('page-input');
|
||||
const pageTotal = document.getElementById('page-total');
|
||||
const totalCountDisplay = document.getElementById('search-total-count-display');
|
||||
|
||||
let currentKeyword = "원펀맨";
|
||||
let currentPage = 1;
|
||||
let totalPages = 1;
|
||||
|
||||
|
||||
function showSkeleton() {
|
||||
|
||||
let skeletonHTML = '';
|
||||
for(let i=0; i<8; i++) {
|
||||
skeletonHTML += `
|
||||
<div class="skeleton-item">
|
||||
<div class="skeleton-thumb skeleton-animate"></div>
|
||||
<div class="skeleton-info">
|
||||
<div class="skeleton-text skeleton-title skeleton-animate"></div>
|
||||
<div class="skeleton-text skeleton-meta skeleton-animate"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
resultsContainer.innerHTML = skeletonHTML;
|
||||
}
|
||||
// ---------------------------------
|
||||
|
||||
function getPageFromURL() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const pageFromUrl = parseInt(urlParams.get('page'), 10);
|
||||
if (!isNaN(pageFromUrl) && pageFromUrl > 0) return pageFromUrl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function updateURL(keyword, page) {
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('s', keyword);
|
||||
|
||||
if (page > 1) {
|
||||
newUrl.searchParams.set('page', page);
|
||||
} else {
|
||||
newUrl.searchParams.delete('page');
|
||||
}
|
||||
|
||||
if (newUrl.href !== window.location.href) {
|
||||
history.pushState({s: keyword, page: page}, '', newUrl.href);
|
||||
}
|
||||
|
||||
pageTitle.textContent = ' o.o? ' + keyword;
|
||||
}
|
||||
|
||||
async function fetchResults(page) {
|
||||
if (!currentKeyword) {
|
||||
resultsContainer.innerHTML = '<p class="error">....</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (page < 1) page = 1;
|
||||
if (page > totalPages && totalPages > 1) page = totalPages;
|
||||
currentPage = page;
|
||||
|
||||
updateURL(currentKeyword, page);
|
||||
|
||||
showSkeleton();
|
||||
|
||||
paginationContainer.style.display = 'none';
|
||||
totalCountDisplay.style.display = 'none';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('keyword', currentKeyword);
|
||||
params.append('page', page);
|
||||
params.append('limit', POSTS_PER_PAGE);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_SEARCH_URL}?${params.toString()}`);
|
||||
if (!response.ok) { throw new Error(` HTTP: ${response.status}`); }
|
||||
const data = await response.json();
|
||||
|
||||
if (data.status === 'success') {
|
||||
displayResults(data.data);
|
||||
displayPagination(data.pagination);
|
||||
|
||||
if (data.pagination.total_results != null) {
|
||||
totalCountDisplay.textContent = `( + ${data.pagination.total_results} anime )`;
|
||||
totalCountDisplay.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
resultsContainer.innerHTML = '<p class="error"> ....</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(' :', error);
|
||||
resultsContainer.innerHTML = `<p class="error"> ....0.o </p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function displayResults(items) {
|
||||
if (!items || items.length === 0) {
|
||||
resultsContainer.innerHTML = '<p class="error"> ...</p>';
|
||||
return;
|
||||
}
|
||||
let htmlOutput = '';
|
||||
items.forEach(item => {
|
||||
const postUrl = `https://linkkf.live/${item.postid}`;
|
||||
const thumbHtml = item.thumb ? `<img src="https://rez1.ims1.top/350x/${item.thumb}" alt="${item.name}" loading="lazy">` : '';
|
||||
const notiHtml = item.postnoti ? `<span class="ext-json-noti">${item.postnoti}</span>` : '';
|
||||
|
||||
const year = item.year || 'N/A';
|
||||
const season = item.seasontype || 'N/A';
|
||||
|
||||
htmlOutput += `
|
||||
<div class="ext-json-item">
|
||||
<a href="${postUrl}" title="${item.name}">
|
||||
<div class="ext-json-thumb">${thumbHtml}${notiHtml}</div>
|
||||
<div class="ext-json-info">
|
||||
<h3 class="ext-json-name">${item.name}</h3>
|
||||
<div class="ext-json-meta">
|
||||
<span class="ext-json-year">${year}</span>
|
||||
<span class="ext-json-season">${season}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
});
|
||||
resultsContainer.innerHTML = htmlOutput;
|
||||
}
|
||||
|
||||
function displayPagination(pagination) {
|
||||
const { current_page, total_pages } = pagination;
|
||||
currentPage = current_page; totalPages = total_pages;
|
||||
if (total_pages <= 1) { paginationContainer.style.display = 'none'; return; }
|
||||
|
||||
paginationContainer.style.display = 'flex';
|
||||
pageInput.value = current_page; pageInput.max = total_pages; pageTotal.textContent = total_pages;
|
||||
|
||||
const isFirstPage = (current_page === 1);
|
||||
btnFirst.disabled = isFirstPage; btnPrev.disabled = isFirstPage;
|
||||
const isLastPage = (current_page === total_pages);
|
||||
btnNext.disabled = isLastPage; btnLast.disabled = isLastPage;
|
||||
pageInput.disabled = false;
|
||||
}
|
||||
|
||||
searchForm.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const newKeyword = searchInput.value.trim();
|
||||
if (newKeyword) {
|
||||
currentKeyword = newKeyword;
|
||||
fetchResults(1);
|
||||
}
|
||||
});
|
||||
|
||||
btnFirst.addEventListener('click', () => { if (currentPage > 1) fetchResults(1); });
|
||||
btnPrev.addEventListener('click', () => { if (currentPage > 1) fetchResults(currentPage - 1); });
|
||||
btnNext.addEventListener('click', () => { if (currentPage < totalPages) fetchResults(currentPage + 1); });
|
||||
btnLast.addEventListener('click', () => { if (currentPage < totalPages) fetchResults(totalPages); });
|
||||
pageInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
let targetPage = parseInt(pageInput.value, 10);
|
||||
if (isNaN(targetPage)) { pageInput.value = currentPage; return; }
|
||||
if (targetPage < 1) targetPage = 1;
|
||||
if (targetPage > totalPages) targetPage = totalPages;
|
||||
fetchResults(targetPage);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
window.addEventListener('popstate', function(event) {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const page = parseInt(urlParams.get('page'), 10) || 1;
|
||||
const keyword = urlParams.get('s') || '';
|
||||
|
||||
if (keyword) {
|
||||
currentKeyword = keyword;
|
||||
searchInput.value = keyword;
|
||||
|
||||
|
||||
showSkeleton();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('keyword', currentKeyword);
|
||||
params.append('page', page);
|
||||
params.append('limit', POSTS_PER_PAGE);
|
||||
|
||||
fetch(`${API_SEARCH_URL}?${params.toString()}`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.status === 'success') {
|
||||
displayResults(data.data);
|
||||
displayPagination(data.pagination);
|
||||
if (data.pagination.total_results != null) {
|
||||
totalCountDisplay.textContent = `( + ${data.pagination.total_results} anime )`;
|
||||
totalCountDisplay.style.display = 'block';
|
||||
}
|
||||
} else { resultsContainer.innerHTML = '<p class="error"> 0o0 .</p>'; }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const initialPage = getPageFromURL();
|
||||
if (currentKeyword) {
|
||||
fetchResults(initialPage);
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<nav id="head-menu" role="navigation">
|
||||
<ul>
|
||||
|
||||
<li> <a href="/" rel="home"style="
|
||||
height: 30px;"> <span style='font-size:13px;'>😀</span> </br> <strong>메인</strong></a> </li>
|
||||
<li> <a href="https://linkkf.live/anime-list/" rel="home"style="
|
||||
height: 30px;
|
||||
"> <span style='font-size:13px;'>😐</span> </br> <strong>신작</strong></a> </li>
|
||||
|
||||
<li> <a href="https://linkkf.live/menulist" rel="home"style="
|
||||
height: 30px;
|
||||
"> <span style='font-size:13px;'>😶</span> </br> <strong>장르</strong></a> </li>
|
||||
|
||||
|
||||
|
||||
<li> <a href="https://linkkf.live/topanime" rel="home"style="
|
||||
height: 30px;
|
||||
"> <span style='font-size:13px;'>🌟</span> </br> <strong>TOP</strong></a> </li>
|
||||
|
||||
|
||||
<li> <a href="https://linkkf.tv" rel="home"style="
|
||||
height: 30px;
|
||||
"> <span style='font-size:13px;'> 😆 </span> </br> <strong>링크2</strong></a> </li>
|
||||
|
||||
</ul></nav>
|
||||
|
||||
|
||||
<article style="height:53px;width:100%px;">
|
||||
|
||||
</article>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
<div id="toast-notification" class="toast-container">
|
||||
<div class="toast-icon"></div>
|
||||
<div class="toast-message"> 0.o </div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
bottom: 130px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(100px);
|
||||
background-color: #1e1e1e;
|
||||
color: #fff;
|
||||
padding: 12px 25px;
|
||||
border-radius: 50px;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transition: all 0.4s cubic-bezier(0.68, -0.55, 0.27, 1.55);
|
||||
border: 1px solid #333;
|
||||
min-width: 250px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-container.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
|
||||
.toast-success .toast-icon { color: #4caf50; }
|
||||
.toast-error .toast-icon { color: #f44336; }
|
||||
.toast-info .toast-icon { color: #2196f3; }
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -112,9 +112,6 @@ setting = {
|
||||
|
||||
from plugin import *
|
||||
import os
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
import traceback
|
||||
from flask import render_template
|
||||
|
||||
@@ -146,46 +143,6 @@ class LogicGuide(PluginModuleBase):
|
||||
DEFINE_DEV = True
|
||||
|
||||
P = create_plugin_instance(setting)
|
||||
|
||||
REQUIRED_PACKAGES = [
|
||||
{"import_name": "bs4", "package_name": "beautifulsoup4"},
|
||||
{"import_name": "requests_cache", "package_name": "requests-cache"},
|
||||
{"import_name": "cloudscraper", "package_name": "cloudscraper"},
|
||||
{"import_name": "PIL", "package_name": "Pillow"},
|
||||
{"import_name": "lxml", "package_name": "lxml"},
|
||||
{"import_name": "aiohttp", "package_name": "aiohttp"},
|
||||
{"import_name": "jsbeautifier", "package_name": "jsbeautifier"},
|
||||
{"import_name": "curl_cffi", "package_name": "curl-cffi"},
|
||||
{"import_name": "loguru", "package_name": "loguru"},
|
||||
{"import_name": "zendriver", "package_name": "zendriver==0.15.3"},
|
||||
]
|
||||
|
||||
|
||||
def ensure_dependencies():
|
||||
missing_packages = []
|
||||
for dependency in REQUIRED_PACKAGES:
|
||||
try:
|
||||
importlib.import_module(dependency["import_name"])
|
||||
except ImportError:
|
||||
missing_packages.append(dependency)
|
||||
|
||||
if not missing_packages:
|
||||
return
|
||||
|
||||
package_names = [dependency["package_name"] for dependency in missing_packages]
|
||||
P.logger.warning(f"Missing plugin dependencies detected: {package_names}")
|
||||
|
||||
try:
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install"] + package_names)
|
||||
P.logger.info(f"Plugin dependencies installed successfully: {package_names}")
|
||||
except Exception as e:
|
||||
P.logger.error(f"Failed to install plugin dependencies: {package_names}")
|
||||
P.logger.error(f"Install error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
ensure_dependencies()
|
||||
|
||||
try:
|
||||
if DEFINE_DEV:
|
||||
from .mod_ohli24 import LogicOhli24
|
||||
@@ -209,3 +166,4 @@ try:
|
||||
except Exception as e:
|
||||
P.logger.error(f'Exception: {str(e)}')
|
||||
P.logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/* Anilife Theme Variables */
|
||||
:root {
|
||||
--notify-bg: rgba(30, 27, 75, 0.95); /* Cosmic Violet */
|
||||
--notify-border: rgba(139, 92, 246, 0.4);
|
||||
--notify-success-bg: rgba(6, 78, 59, 0.95);
|
||||
--notify-warning-bg: rgba(120, 53, 15, 0.95);
|
||||
--notify-danger-bg: rgba(127, 29, 29, 0.95);
|
||||
}
|
||||
|
||||
/* Cosmic Violet Body Background - Override theme CSS */
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%) !important;
|
||||
background-attachment: fixed !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 */
|
||||
.anilife-common-wrapper {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Desktop: 1400px max-width */
|
||||
@media (min-width: 992px) {
|
||||
.anilife-common-wrapper {
|
||||
max-width: 1400px;
|
||||
}
|
||||
}
|
||||
|
||||
.anilife-common-wrapper.content-cloak {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.anilife-common-wrapper.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/* Linkkf Theme Variables & Overrides */
|
||||
:root {
|
||||
--forest-950: #022c22;
|
||||
--forest-900: #064e3b;
|
||||
--forest-800: #065f46;
|
||||
--forest-500: #10b981;
|
||||
--forest-400: #34d399;
|
||||
--forest-100: #d1fae5;
|
||||
--accent-amber: #fbbf24;
|
||||
|
||||
/* Shared Notify Variables */
|
||||
--notify-bg: rgba(2, 44, 34, 0.95);
|
||||
--notify-border: rgba(16, 185, 129, 0.3);
|
||||
--notify-success-bg: rgba(6, 78, 59, 0.95);
|
||||
--notify-warning-bg: rgba(120, 53, 15, 0.95);
|
||||
--notify-danger-bg: rgba(127, 29, 29, 0.95);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--forest-950) !important;
|
||||
color: #ecfdf5;
|
||||
}
|
||||
|
||||
/* Shared Nav-Pills Styles */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(6, 78, 59, 0.4) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
color: #d1fae5 !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 8px 20px !important;
|
||||
border-radius: 50rem !important;
|
||||
transition: all 0.3s ease !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link:hover {
|
||||
background-color: rgba(16, 185, 129, 0.1) !important;
|
||||
color: #fff !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Common Layout Wrapper */
|
||||
.linkkf-common-wrapper {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.linkkf-common-wrapper.content-cloak {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.linkkf-common-wrapper.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Mobile Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
ul.nav.nav-pills.bg-light {
|
||||
width: 100% !important;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
/* Shared Mobile Custom CSS for Anime Downloader */
|
||||
|
||||
/* Custom Notify Styling - Mobile Default */
|
||||
.bootstrap-notify-container,
|
||||
[data-notify="container"] {
|
||||
position: fixed !important;
|
||||
max-width: calc(100vw - 12px) !important;
|
||||
width: calc(100vw - 12px) !important;
|
||||
right: auto !important;
|
||||
left: 6px !important;
|
||||
top: 10px !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
padding: 12px 38px 12px 16px !important;
|
||||
border-radius: 12px !important;
|
||||
background: var(--notify-bg) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border: 1px solid var(--notify-border) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
|
||||
color: #e0e7ff !important;
|
||||
font-size: 13px !important;
|
||||
line-height: 1.45 !important;
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
/* Desktop Notify: 오른쪽 정렬, 적절한 사이즈 */
|
||||
@media (min-width: 769px) {
|
||||
.bootstrap-notify-container,
|
||||
[data-notify="container"] {
|
||||
max-width: 450px !important;
|
||||
min-width: 300px !important;
|
||||
width: auto !important;
|
||||
right: 20px !important;
|
||||
left: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
[data-notify="container"].alert-success {
|
||||
border-color: rgba(16, 185, 129, 0.4) !important;
|
||||
background: var(--notify-success-bg, rgba(6, 78, 59, 0.95)) !important;
|
||||
}
|
||||
|
||||
[data-notify="container"].alert-warning {
|
||||
border-color: rgba(245, 158, 11, 0.4) !important;
|
||||
background: var(--notify-warning-bg, rgba(120, 53, 15, 0.95)) !important;
|
||||
}
|
||||
|
||||
[data-notify="container"].alert-danger {
|
||||
border-color: rgba(239, 68, 68, 0.4) !important;
|
||||
background: var(--notify-danger-bg, rgba(127, 29, 29, 0.95)) !important;
|
||||
}
|
||||
|
||||
[data-notify="message"] {
|
||||
color: #e0e7ff !important;
|
||||
display: block !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
white-space: normal !important;
|
||||
word-break: keep-all !important;
|
||||
overflow-wrap: anywhere !important;
|
||||
}
|
||||
|
||||
[data-notify="title"] {
|
||||
color: #fff !important;
|
||||
font-weight: 600 !important;
|
||||
display: block !important;
|
||||
margin-bottom: 4px !important;
|
||||
padding-right: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
margin-left: 0 !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
[data-notify="icon"] {
|
||||
display: inline-block !important;
|
||||
margin-right: 10px !important;
|
||||
margin-left: 0 !important;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
[data-notify="dismiss"] {
|
||||
position: absolute !important;
|
||||
top: 8px !important;
|
||||
right: 10px !important;
|
||||
margin: 0 !important;
|
||||
padding: 2px 6px !important;
|
||||
color: rgba(255, 255, 255, 0.88) !important;
|
||||
font-size: 18px !important;
|
||||
line-height: 1 !important;
|
||||
opacity: 0.9 !important;
|
||||
}
|
||||
|
||||
[data-notify="dismiss"]:hover,
|
||||
[data-notify="dismiss"]:focus {
|
||||
color: #ffffff !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Common Mobile Responsive Fixes - Comprehensive Normalization */
|
||||
@media (max-width: 768px) {
|
||||
.bootstrap-notify-container,
|
||||
[data-notify="container"] {
|
||||
left: 4px !important;
|
||||
right: auto !important;
|
||||
width: calc(100vw - 8px) !important;
|
||||
max-width: calc(100vw - 8px) !important;
|
||||
top: 8px !important;
|
||||
margin: 0 !important;
|
||||
transform: none !important;
|
||||
padding: 11px 34px 11px 14px !important;
|
||||
font-size: 12.5px !important;
|
||||
border-radius: 10px !important;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.32) !important;
|
||||
}
|
||||
|
||||
.bootstrap-notify-container > *,
|
||||
[data-notify="container"] > * {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
[data-notify="title"] {
|
||||
font-size: 14px !important;
|
||||
margin-bottom: 3px !important;
|
||||
padding-left: 2px !important;
|
||||
}
|
||||
|
||||
[data-notify="message"] {
|
||||
font-size: 12.5px !important;
|
||||
line-height: 1.4 !important;
|
||||
padding-left: 2px !important;
|
||||
}
|
||||
|
||||
[data-notify="dismiss"] {
|
||||
top: 7px !important;
|
||||
right: 8px !important;
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
*, ::before, ::after {
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
background-color: rgba(30, 41, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 8px !important;
|
||||
padding: 4px !important;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2) !important;
|
||||
display: flex !important;
|
||||
flex-wrap: nowrap !important;
|
||||
overflow-x: auto !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
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 */
|
||||
}
|
||||
|
||||
/* Subtle scrollbar hint for mobile */
|
||||
ul.nav.nav-pills.bg-light::-webkit-scrollbar {
|
||||
height: 3px;
|
||||
}
|
||||
ul.nav.nav-pills.bg-light::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-item {
|
||||
flex: 0 0 auto !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
border-radius: 6px !important;
|
||||
padding: 6px 14px !important;
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Card/Table Container Fix */
|
||||
.card, .table-responsive {
|
||||
width: 100% !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global Navigation Spacing Adjustments (Plugin Specific) */
|
||||
#menu_module_div {
|
||||
padding-top: 52px !important; /* Adjusted for compact navbar (~52px) */
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#menu_module_div .nav-pills,
|
||||
#menu_page_div .nav-pills {
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 2px !important;
|
||||
}
|
||||
|
||||
/* Tighten main container for desktop */
|
||||
@media (min-width: 769px) {
|
||||
#main_container {
|
||||
margin-top: 0 !important;
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,665 +0,0 @@
|
||||
/* Ohli24 Theme Variables & Overrides */
|
||||
:root {
|
||||
--slate-950: #0f172a;
|
||||
--slate-900: #0f172a;
|
||||
--slate-800: #1e293b;
|
||||
--slate-700: #334155;
|
||||
--blue-500: #3b82f6;
|
||||
--blue-600: #2563eb;
|
||||
--amber-400: #facc15;
|
||||
|
||||
/* Shared Notify Variables */
|
||||
--notify-bg: rgba(15, 23, 42, 0.95);
|
||||
--notify-border: rgba(59, 130, 246, 0.3);
|
||||
--notify-success-bg: rgba(6, 78, 59, 0.95);
|
||||
--notify-warning-bg: rgba(120, 53, 15, 0.95);
|
||||
--notify-danger-bg: rgba(127, 29, 29, 0.95);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--slate-950) !important;
|
||||
color: #f1f5f9;
|
||||
background-image: linear-gradient(135deg, #1f2937, #111827, #0f172a) !important;
|
||||
background-attachment: fixed !important;
|
||||
font-family: 'NamumSquareNeo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* Glass Card Container */
|
||||
.glass-card {
|
||||
background: rgba(30, 41, 59, 0.7) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
border-radius: 16px !important;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease !important;
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
border-color: rgba(96, 165, 250, 0.5) !important;
|
||||
}
|
||||
|
||||
/* General Layout Fixes */
|
||||
.container-fluid {
|
||||
padding-left: 3px !important;
|
||||
padding-right: 3px !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ohli24-common-wrapper {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.ohli24-common-wrapper.content-cloak {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ohli24-common-wrapper.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Common Header Styles */
|
||||
.ohli24-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.ohli24-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ohli24-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);
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.ohli24-header-title {
|
||||
margin-bottom: 0;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ohli24-header-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* Nav Pills Customization (Shared styling, overrides in mobile_custom.css) */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(30, 41, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto !important;
|
||||
margin-bottom: 2px; /* Pull secondary pills or content closer */
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
color: #94a3b8 !important;
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 20px !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4) !important;
|
||||
}
|
||||
|
||||
/* Search Bar Styling */
|
||||
.search-container {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-select, .custom-input {
|
||||
background-color: rgba(15, 23, 42, 0.6) !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2) !important;
|
||||
color: #e2e8f0 !important;
|
||||
border-radius: 8px !important;
|
||||
height: 38px !important;
|
||||
}
|
||||
|
||||
.custom-btn {
|
||||
border-radius: 8px !important;
|
||||
padding: 0 16px !important;
|
||||
height: 38px !important;
|
||||
font-weight: 600 !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Responsive Input Group for Search */
|
||||
.input-group-responsive {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.input-group-responsive .form-control {
|
||||
height: 100% !important;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.input-group-responsive .btn {
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.input-group-responsive {
|
||||
height: 42px;
|
||||
}
|
||||
.input-group-responsive .form-control {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-search { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); color: white !important; }
|
||||
.btn-reset { background: rgba(148, 163, 184, 0.1); color: #94a3b8 !important; }
|
||||
|
||||
/* Request Page Buttons */
|
||||
.ohli24-request-page .input-group .btn,
|
||||
.ohli24-request-page .mobile-action-buttons .btn {
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: -0.01em;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
border-width: 1px !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .input-group .btn i,
|
||||
.ohli24-request-page .mobile-action-buttons .btn i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-primary-action,
|
||||
.ohli24-request-page .ohli24-watch-action {
|
||||
color: #ffffff !important;
|
||||
border: 1px solid rgba(96, 165, 250, 0.55) !important;
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important;
|
||||
box-shadow: 0 8px 22px rgba(37, 99, 235, 0.28);
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-primary-action:hover,
|
||||
.ohli24-request-page .ohli24-watch-action:hover,
|
||||
.ohli24-request-page .ohli24-primary-action:focus,
|
||||
.ohli24-request-page .ohli24-watch-action:focus {
|
||||
color: #ffffff !important;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
border-color: rgba(147, 197, 253, 0.9) !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-secondary-action {
|
||||
color: #f8fafc !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.5) !important;
|
||||
background: rgba(30, 41, 59, 0.92) !important;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-secondary-action:hover,
|
||||
.ohli24-request-page .ohli24-secondary-action:focus {
|
||||
color: #ffffff !important;
|
||||
background: rgba(51, 65, 85, 0.98) !important;
|
||||
border-color: rgba(226, 232, 240, 0.75) !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-muted-action {
|
||||
color: #e2e8f0 !important;
|
||||
border: 1px solid rgba(100, 116, 139, 0.65) !important;
|
||||
background: rgba(51, 65, 85, 0.65) !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-muted-action:hover,
|
||||
.ohli24-request-page .ohli24-muted-action:focus {
|
||||
color: #ffffff !important;
|
||||
background: rgba(71, 85, 105, 0.9) !important;
|
||||
border-color: rgba(203, 213, 225, 0.65) !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-danger-action {
|
||||
color: #fecaca !important;
|
||||
border: 1px solid rgba(248, 113, 113, 0.45) !important;
|
||||
background: rgba(69, 10, 10, 0.35) !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .ohli24-danger-action:hover,
|
||||
.ohli24-request-page .ohli24-danger-action:focus {
|
||||
color: #ffffff !important;
|
||||
border-color: rgba(252, 165, 165, 0.75) !important;
|
||||
background: rgba(127, 29, 29, 0.78) !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .mobile-action-buttons {
|
||||
row-gap: 10px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ohli24-request-page.ohli24-common-wrapper {
|
||||
margin-top: 0.5rem !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page {
|
||||
padding-left: 1px !important;
|
||||
padding-right: 1px !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .glass-card {
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .input-group-append .btn {
|
||||
padding-left: 12px !important;
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
|
||||
.ohli24-request-page .mobile-action-buttons .btn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 112px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Episode Card (Mobile-First) - Scoped to List Page */
|
||||
.ohli24-list-page .episode-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-card-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* Key for mobile stacking */
|
||||
padding: 12px;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-thumb {
|
||||
width: 60px;
|
||||
height: 80px;
|
||||
flex: 0 0 60px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background-color: #1e293b;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.ohli24-list-page .episode-main-info {
|
||||
flex: 1;
|
||||
min-width: calc(100% - 84px); /* Force wrapping if needed, but allow sitting next to thumb */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-title {
|
||||
color: #f1f5f9;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-subtitle {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* Episode Right Column / Actions - Wraps to 100% on mobile */
|
||||
.ohli24-list-page .episode-right-col {
|
||||
flex: 0 0 100%; /* Full width on mobile */
|
||||
margin-top: 4px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-actions .btn {
|
||||
font-size: 12px !important;
|
||||
padding: 8px 6px !important;
|
||||
text-align: center;
|
||||
border-radius: 6px !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 6px; border-radius: 4px; display: inline-block; }
|
||||
.status-completed { background: rgba(16, 185, 129, 0.2); color: #34d399; }
|
||||
.status-wait { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
|
||||
|
||||
/* Date Info Styling */
|
||||
.date-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.date-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.date-tag i { font-size: 12px; }
|
||||
.date-tag span { font-weight: 500; }
|
||||
|
||||
.date-tag-req {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: #94a3b8;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.date-tag-done {
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
}
|
||||
|
||||
/* Episode Badge Overlay on Thumbnail */
|
||||
.ohli24-list-page .episode-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 10;
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
color: #fbbf24 !important; /* High-visibility Amber */
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(251, 191, 36, 0.4);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.5);
|
||||
pointer-events: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Persistent Mobile Colors for action buttons */
|
||||
@media (max-width: 768px) {
|
||||
.ohli24-list-page .btn-watch-primary {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
box-shadow: 0 4px 10px rgba(37, 99, 235, 0.4) !important;
|
||||
font-weight: 700 !important;
|
||||
}
|
||||
|
||||
.ohli24-list-page .btn-remove-alt {
|
||||
background: rgba(220, 38, 38, 0.15) !important;
|
||||
border: 1px solid rgba(220, 38, 38, 0.4) !important;
|
||||
color: #f87171 !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.ohli24-list-page .btn-request {
|
||||
background: rgba(59, 130, 246, 0.1) !important;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3) !important;
|
||||
color: #93c5fd !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Desktop Adaptations - Scoped to List Page */
|
||||
@media (min-width: 768px) {
|
||||
.ohli24-list-page .episode-card { margin-bottom: 10px; }
|
||||
|
||||
.ohli24-list-page .episode-card-body {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-top-info {
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-thumb { width: 60px; height: 80px; }
|
||||
|
||||
.ohli24-list-page .episode-main-info { justify-content: center; }
|
||||
|
||||
.ohli24-list-page .episode-title { font-size: 16px; margin-bottom: 2px; }
|
||||
|
||||
.ohli24-list-page .file-path { margin-top: 2px; padding: 4px 8px; display: inline-block; max-width: 100%; }
|
||||
|
||||
.ohli24-list-page .episode-right-col {
|
||||
flex: 1; /* Allow to take available space */
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
padding-left: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between; /* Push dates left, buttons right */
|
||||
min-width: 450px; /* Increase min-width for better spacing */
|
||||
}
|
||||
|
||||
.ohli24-list-page .episode-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ohli24-list-page .btn-minimal {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #cbd5e1 !important;
|
||||
font-size: 11px !important;
|
||||
padding: 5px 12px !important;
|
||||
border-radius: 8px !important;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 500 !important;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ohli24-list-page .btn-minimal i { font-size: 12px; opacity: 0.8; }
|
||||
|
||||
.ohli24-list-page .btn-minimal:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
/* Primary Watch Button */
|
||||
.ohli24-list-page .btn-watch-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.ohli24-list-page .btn-watch-primary:hover {
|
||||
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%) !important;
|
||||
box-shadow: 0 6px 15px rgba(37, 99, 235, 0.4);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Danger Hover for Remove */
|
||||
.ohli24-list-page .btn-remove-alt:hover {
|
||||
background: rgba(239, 68, 68, 0.15) !important;
|
||||
border-color: rgba(239, 68, 68, 0.3) !important;
|
||||
color: #f87171 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Queue Specific Styles - Scoped to Queue Page */
|
||||
.ohli24-queue-page .queue-header {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.9) 0%, rgba(15, 23, 42, 0.95) 100%);
|
||||
border-radius: 12px;
|
||||
padding: 15px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ohli24-queue-page .queue-item {
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
padding: 15px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.ohli24-queue-page .progress-wrapper {
|
||||
position: relative;
|
||||
height: 36px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.ohli24-queue-page .progress-bar { height: 100%; transition: width 0.3s ease; }
|
||||
.ohli24-queue-page .status-waiting { background: linear-gradient(90deg, #94a3b8, #64748b); }
|
||||
.ohli24-queue-page .status-downloading { background: linear-gradient(90deg, #3b82f6, #60a5fa); }
|
||||
.ohli24-queue-page .status-completed { background: linear-gradient(90deg, #22c55e, #4ade80); }
|
||||
.ohli24-queue-page .status-failed { background: linear-gradient(90deg, #dc2626, #ef4444); }
|
||||
|
||||
/* Progress Label - Text ON TOP of progress bar */
|
||||
.ohli24-queue-page .progress-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
white-space: nowrap;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Common Modal Fixes */
|
||||
.modal-content {
|
||||
background: rgba(15, 23, 42, 0.95) !important;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2) !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.ohli24-queue-page .progress-wrapper {
|
||||
width: 100%;
|
||||
height: 40px; /* Even taller on mobile for better visibility */
|
||||
border-radius: 20px;
|
||||
}
|
||||
.ohli24-queue-page .progress-label {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.ohli24-queue-page .queue-item { flex-direction: column; align-items: stretch; }
|
||||
.ohli24-queue-page ul.nav.nav-pills.bg-light { margin-top: 4px !important; }
|
||||
|
||||
/* Search Container Mobile Refinements */
|
||||
.ohli24-list-page .search-container {
|
||||
padding: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
.ohli24-list-page .search-group-left {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
.ohli24-list-page .search-group-right {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.ohli24-list-page .custom-select {
|
||||
height: 32px !important;
|
||||
font-size: 12px !important;
|
||||
padding: 0 4px !important;
|
||||
flex: 1;
|
||||
}
|
||||
.ohli24-list-page .custom-input {
|
||||
height: 32px !important;
|
||||
font-size: 13px !important;
|
||||
flex: 1;
|
||||
}
|
||||
.ohli24-list-page .custom-btn {
|
||||
height: 32px !important;
|
||||
padding: 0 10px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
/* Persistent Mobile Colors for specific buttons */
|
||||
.ohli24-list-page .btn-watch-primary {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
border: none !important;
|
||||
color: white !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.ohli24-list-page .btn-remove-alt {
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
border-color: rgba(239, 68, 68, 0.3) !important;
|
||||
color: #f87171 !important;
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
/**
|
||||
* Video Modal Component Styles
|
||||
* Reusable video player modal for Anime Downloader
|
||||
*/
|
||||
|
||||
/* Modal - Desktop: Centered, Mobile: Fullscreen */
|
||||
#videoModal .modal-dialog {
|
||||
max-width: 1100px;
|
||||
margin: 30px auto;
|
||||
height: 80vh; /* Reduced from 85vh */
|
||||
}
|
||||
#videoModal .modal-content {
|
||||
border-radius: 12px !important;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%; /* Fill the 85vh height */
|
||||
max-height: calc(100vh - 60px);
|
||||
}
|
||||
#videoModal .modal-header {
|
||||
flex-shrink: 0;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
#videoModal .modal-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0; /* Crucial for flex nested items */
|
||||
}
|
||||
|
||||
/* Close Button - Prominent */
|
||||
#videoModal .close {
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
color: #fff;
|
||||
text-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
opacity: 1;
|
||||
padding: 0;
|
||||
margin: -5px -5px -5px auto;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(239, 68, 68, 0.8);
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
#videoModal .close:hover {
|
||||
background: rgba(239, 68, 68, 1);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Video Container */
|
||||
.video-container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
flex: 1; /* Stretch to fill space */
|
||||
display: flex;
|
||||
align-items: center !important; /* Force center */
|
||||
justify-content: center !important;
|
||||
min-height: 350px; /* Reduced from 450px */
|
||||
}
|
||||
.video-container .video-js {
|
||||
margin: auto !important;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* Zoomed state via CSS class */
|
||||
.video-container .video-js.vjs-zoomed {
|
||||
object-fit: cover !important;
|
||||
max-height: 100% !important;
|
||||
}
|
||||
.video-container .video-js.vjs-zoomed video {
|
||||
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 */
|
||||
.video-zoom-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 10;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.video-container:hover .video-zoom-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.video-zoom-btn.active {
|
||||
background: #3b82f6;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* Episode Selector Row (Alist Style) */
|
||||
.episode-selector-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Episode Dropdown */
|
||||
.episode-dropdown-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.episode-dropdown {
|
||||
width: 100%;
|
||||
padding: 10px 40px 10px 14px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
.episode-dropdown:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.5);
|
||||
}
|
||||
.episode-dropdown:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4);
|
||||
}
|
||||
.episode-dropdown option {
|
||||
background: #1e293b;
|
||||
color: #f1f5f9;
|
||||
padding: 10px;
|
||||
}
|
||||
.dropdown-arrow {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Auto-Next Toggle Switch */
|
||||
.auto-next-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.auto-next-toggle input {
|
||||
display: none;
|
||||
}
|
||||
.toggle-label {
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.auto-next-toggle input:checked ~ .toggle-label {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
background: #334155;
|
||||
border-radius: 12px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.toggle-switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||
}
|
||||
.auto-next-toggle input:checked ~ .toggle-switch {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
}
|
||||
.auto-next-toggle input:checked ~ .toggle-switch::after {
|
||||
left: 22px;
|
||||
}
|
||||
|
||||
/* External Players Section */
|
||||
.external-players {
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.external-players-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap !important; /* Force wrap */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
}
|
||||
.ext-player-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
||||
border-radius: 10px;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ext-player-btn:hover {
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
border-color: rgba(59, 130, 246, 0.5);
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.ext-player-btn img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Video.js Theme Overrides */
|
||||
.video-js.vjs-theme-fantasy .vjs-big-play-button {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%) !important;
|
||||
border: none !important;
|
||||
width: 90px !important;
|
||||
height: 90px !important;
|
||||
line-height: 90px !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow: 0 0 30px rgba(37, 99, 235, 0.6) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
.video-js.vjs-theme-fantasy .vjs-big-play-button .vjs-icon-placeholder:before {
|
||||
font-size: 60px !important;
|
||||
line-height: 90px !important;
|
||||
}
|
||||
.video-js .vjs-control-bar {
|
||||
background: rgba(15, 23, 42, 0.8) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
#videoModal .modal-dialog {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
height: 100%;
|
||||
}
|
||||
#videoModal .modal-content {
|
||||
border-radius: 0 !important;
|
||||
max-height: 92vh; /* Use max-height instead of fixed 100vh to avoid taking too much space */
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
#video-player {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
.video-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 250px; /* Fixed height for video area on mobile */
|
||||
}
|
||||
.playlist-controls {
|
||||
padding-bottom: 25px; /* Mobile safe area */
|
||||
}
|
||||
.video-zoom-btn {
|
||||
opacity: 0.8;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
}
|
||||
.episode-selector-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
}
|
||||
.auto-next-toggle {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.external-players-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap; /* Force wrap */
|
||||
justify-content: center;
|
||||
gap: 8px; /* Increased gap slightly for better spacing when wrapped */
|
||||
padding: 5px 0;
|
||||
}
|
||||
.ext-player-btn {
|
||||
padding: 6px;
|
||||
min-width: 44px; /* Ensure touch targets are large enough */
|
||||
}
|
||||
.ext-player-btn img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
@@ -1,459 +0,0 @@
|
||||
var VideoModal = (function() {
|
||||
'use strict';
|
||||
|
||||
var config = {
|
||||
package_name: 'anime_downloader',
|
||||
sub: 'ohli24'
|
||||
};
|
||||
|
||||
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 currentPlaylistIndex = 0;
|
||||
var currentPlayingPath = '';
|
||||
var currentStreamUrl = '';
|
||||
var isVideoZoomed = false;
|
||||
|
||||
/**
|
||||
* Initialize the video modal
|
||||
*/
|
||||
function init(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();
|
||||
console.log('[VideoModal] Initialized with player:', currentPlayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind all event handlers
|
||||
*/
|
||||
function bindEvents() {
|
||||
// Player selector change
|
||||
$('#player-select').off('change').on('change', function() {
|
||||
var newPlayer = $(this).val();
|
||||
if (newPlayer !== currentPlayer) {
|
||||
switchPlayer(newPlayer);
|
||||
}
|
||||
});
|
||||
|
||||
// Dropdown episode selection
|
||||
$('#episode-dropdown').off('change').on('change', function() {
|
||||
var index = parseInt($(this).val());
|
||||
if (index !== currentPlaylistIndex && index >= 0 && index < playlist.length) {
|
||||
currentPlaylistIndex = index;
|
||||
playVideoAtIndex(index);
|
||||
}
|
||||
});
|
||||
|
||||
// Video zoom button
|
||||
$('#btn-video-zoom').off('click').on('click', function() {
|
||||
isVideoZoomed = !isVideoZoomed;
|
||||
if (isVideoZoomed) {
|
||||
$('#video-player, #plyr-player').addClass('vjs-zoomed');
|
||||
$('#artplayer-container').addClass('art-zoomed');
|
||||
$(this).addClass('active').find('i').removeClass('fa-expand').addClass('fa-compress');
|
||||
} else {
|
||||
$('#video-player, #plyr-player').removeClass('vjs-zoomed');
|
||||
$('#artplayer-container').removeClass('art-zoomed');
|
||||
$(this).removeClass('active').find('i').removeClass('fa-compress').addClass('fa-expand');
|
||||
}
|
||||
});
|
||||
|
||||
// Modal events
|
||||
$('#videoModal').off('show.bs.modal').on('show.bs.modal', function() {
|
||||
$('body').addClass('modal-video-open');
|
||||
});
|
||||
|
||||
$('#videoModal').off('hide.bs.modal').on('hide.bs.modal', function() {
|
||||
pauseAllPlayers();
|
||||
});
|
||||
|
||||
$('#videoModal').off('hidden.bs.modal').on('hidden.bs.modal', function() {
|
||||
$('body').removeClass('modal-video-open');
|
||||
if (isVideoZoomed) {
|
||||
isVideoZoomed = false;
|
||||
$('#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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch between players
|
||||
*/
|
||||
function switchPlayer(newPlayer) {
|
||||
pauseAllPlayers();
|
||||
|
||||
currentPlayer = newPlayer;
|
||||
localStorage.setItem('anime_downloader_preferred_player', newPlayer);
|
||||
|
||||
// Hide all player containers
|
||||
$('#videojs-container').hide();
|
||||
$('#artplayer-container').hide();
|
||||
$('#plyr-container').hide();
|
||||
|
||||
// Show selected player and reinitialize with current URL
|
||||
if (currentStreamUrl) {
|
||||
initPlayerWithUrl(currentStreamUrl);
|
||||
}
|
||||
|
||||
console.log('[VideoModal] Switched to:', newPlayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause all players
|
||||
*/
|
||||
function pauseAllPlayers() {
|
||||
try {
|
||||
if (videoPlayer) videoPlayer.pause();
|
||||
} catch(e) {}
|
||||
try {
|
||||
if (artPlayer) artPlayer.pause();
|
||||
} catch(e) {}
|
||||
try {
|
||||
if (plyrPlayer) plyrPlayer.pause();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize player with URL based on current player selection
|
||||
*/
|
||||
function initPlayerWithUrl(streamUrl) {
|
||||
currentStreamUrl = streamUrl;
|
||||
|
||||
if (currentPlayer === 'videojs') {
|
||||
initVideoJS(streamUrl);
|
||||
} else if (currentPlayer === 'artplayer') {
|
||||
initArtplayer(streamUrl);
|
||||
} else if (currentPlayer === 'plyr') {
|
||||
initPlyr(streamUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Video.js player
|
||||
*/
|
||||
function initVideoJS(streamUrl) {
|
||||
// Hide other containers
|
||||
$('#artplayer-container').hide();
|
||||
$('#plyr-container').hide();
|
||||
$('#videojs-container').show();
|
||||
|
||||
if (!videoPlayer) {
|
||||
videoPlayer = videojs('video-player', {
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
preload: 'auto',
|
||||
fluid: true,
|
||||
playbackRates: [0.5, 1, 1.5, 2],
|
||||
controlBar: {
|
||||
skipButtons: { forward: 10, backward: 10 }
|
||||
}
|
||||
});
|
||||
|
||||
videoPlayer.on('ended', handleVideoEnded);
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
function playVideoAtIndex(index) {
|
||||
if (index < 0 || index >= playlist.length) return;
|
||||
currentPlaylistIndex = index;
|
||||
var item = playlist[index];
|
||||
var streamUrl = '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(item.path);
|
||||
|
||||
initPlayerWithUrl(streamUrl);
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
function updatePlaylistUI() {
|
||||
if (!playlist || playlist.length === 0) return;
|
||||
|
||||
var currentFile = playlist[currentPlaylistIndex];
|
||||
|
||||
// Update dropdown
|
||||
var $dropdown = $('#episode-dropdown');
|
||||
if ($dropdown.find('option').length !== playlist.length) {
|
||||
var optionsHtml = '';
|
||||
for (var i = 0; i < playlist.length; i++) {
|
||||
optionsHtml += '<option value="' + i + '">' + playlist[i].name + '</option>';
|
||||
}
|
||||
$dropdown.html(optionsHtml);
|
||||
}
|
||||
$dropdown.val(currentPlaylistIndex);
|
||||
|
||||
// Update external player buttons
|
||||
updateExternalPlayerButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update external player buttons
|
||||
*/
|
||||
function updateExternalPlayerButtons() {
|
||||
var currentFile = playlist[currentPlaylistIndex];
|
||||
if (!currentFile || !currentFile.path) return;
|
||||
|
||||
// For internal Video.js player: use stream_video (session auth)
|
||||
// For external players: fetch token and use /normal/ route (no auth)
|
||||
var filePath = currentFile.path;
|
||||
var filename = currentFile.name || 'video.mp4';
|
||||
var imgBase = '/' + config.package_name + '/static/img/players/';
|
||||
|
||||
// First, show loading state
|
||||
$('#external-player-buttons').html('<span class="text-muted">Loading...</span>');
|
||||
|
||||
// Fetch a streaming token for external players
|
||||
$.ajax({
|
||||
url: '/' + config.package_name + '/ajax/' + config.sub + '/generate_stream_token?path=' + encodeURIComponent(filePath),
|
||||
type: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
if (data.ret === 'success' && data.token) {
|
||||
var tokenUrl = window.location.origin + '/' + config.package_name + '/normal/' + config.sub + '/stream_with_token?token=' + data.token;
|
||||
renderExternalPlayerButtons(tokenUrl, filename, imgBase);
|
||||
} else {
|
||||
// Fallback: use stream_video with path (may require auth)
|
||||
console.warn('[VideoModal] Token generation failed, using fallback');
|
||||
var fallbackUrl = window.location.origin + '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
renderExternalPlayerButtons(fallbackUrl, filename, imgBase);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
// Fallback: use stream_video with path
|
||||
console.warn('[VideoModal] Token generation error, using fallback');
|
||||
var fallbackUrl = window.location.origin + '/' + config.package_name + '/ajax/' + config.sub + '/stream_video?path=' + encodeURIComponent(filePath);
|
||||
renderExternalPlayerButtons(fallbackUrl, filename, imgBase);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render external player buttons with the given stream URL
|
||||
*/
|
||||
function renderExternalPlayerButtons(streamUrl, filename, imgBase) {
|
||||
var encodedUrl = encodeURIComponent(streamUrl);
|
||||
var doubleEncodedUrl = encodeURIComponent(encodedUrl);
|
||||
|
||||
var players = [
|
||||
{ name: 'IINA', img: imgBase + 'iina.webp', url: 'iina://weblink?url=' + encodedUrl },
|
||||
{ name: 'PotPlayer', img: imgBase + 'potplayer.webp', url: 'potplayer://' + streamUrl },
|
||||
{ name: 'VLC', img: imgBase + 'vlc.webp', url: 'intent:' + streamUrl + '#Intent;package=org.videolan.vlc;type=video/*;end' },
|
||||
{ name: 'nPlayer', img: imgBase + 'nplayer.webp', url: 'nplayer-' + streamUrl },
|
||||
{ name: 'Infuse', img: imgBase + 'infuse.webp', url: 'infuse://x-callback-url/play?url=' + streamUrl },
|
||||
{ name: 'OmniPlayer', img: imgBase + 'omniplayer.webp', url: 'omniplayer://weblink?url=' + streamUrl },
|
||||
{ name: 'MX Player', img: imgBase + 'mxplayer.webp', url: 'intent:' + streamUrl + '#Intent;package=com.mxtech.videoplayer.ad;type=video/mp4;S.title=' + encodeURIComponent(filename) + ';end' },
|
||||
{ name: 'MPV', img: imgBase + 'mpv.webp', url: 'mpv://' + doubleEncodedUrl },
|
||||
];
|
||||
|
||||
var html = '';
|
||||
for (var i = 0; i < players.length; i++) {
|
||||
var p = players[i];
|
||||
html += '<a href="' + p.url + '" class="ext-player-btn" title="' + p.name + '">';
|
||||
html += '<img src="' + p.img + '" alt="' + p.name + '">';
|
||||
html += '</a>';
|
||||
}
|
||||
|
||||
$('#external-player-buttons').html(html);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
function close() {
|
||||
$('#videoModal').modal('hide');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current playlist
|
||||
*/
|
||||
function getPlaylist() {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current index
|
||||
*/
|
||||
function getCurrentIndex() {
|
||||
return currentPlaylistIndex;
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
init: init,
|
||||
openWithPath: openWithPath,
|
||||
openWithUrl: openWithUrl,
|
||||
openWithPlaylist: openWithPlaylist,
|
||||
playVideoAtIndex: playVideoAtIndex,
|
||||
close: close,
|
||||
getPlaylist: getPlaylist,
|
||||
getCurrentIndex: getCurrentIndex
|
||||
};
|
||||
})();
|
||||
|
||||
// Auto-initialize when DOM is ready (uses global package_name and sub variables)
|
||||
$(document).ready(function() {
|
||||
// Use global variables if available (set by each page)
|
||||
var pkgName = (typeof package_name !== 'undefined') ? package_name : 'anime_downloader';
|
||||
var subName = (typeof sub !== 'undefined') ? sub : 'ohli24';
|
||||
|
||||
VideoModal.init({ package_name: pkgName, sub: subName });
|
||||
|
||||
// Auto-bind btn-watch click handler
|
||||
$('body').on('click', '.btn-watch', function(e) {
|
||||
e.preventDefault();
|
||||
var filePath = $(this).data('path');
|
||||
if (filePath) {
|
||||
VideoModal.openWithPath(filePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
<!-- Video Modal Component for Anime Downloader -->
|
||||
<!-- Usage: include 'anime_downloader/components/video_modal.html' in your template -->
|
||||
|
||||
<!-- Video.js CDN -->
|
||||
<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>
|
||||
|
||||
<!-- 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 -->
|
||||
<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-content" style="background: #0f172a; border-radius: 12px;">
|
||||
<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>
|
||||
<div class="ml-auto d-flex align-items-center">
|
||||
<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);">
|
||||
<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">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 0;">
|
||||
<div class="video-container">
|
||||
<!-- Video.js Player -->
|
||||
<div id="videojs-container" style="width: 100%; height: 100%;">
|
||||
<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="화면 비율 조절">
|
||||
<i class="fa fa-expand"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- 플레이리스트 컨트롤 UI (Alist 스타일) -->
|
||||
<div class="playlist-controls">
|
||||
<!-- 에피소드 선택 드롭다운 + 자동 다음 토글 -->
|
||||
<div class="episode-selector-row">
|
||||
<div class="episode-dropdown-wrapper">
|
||||
<select id="episode-dropdown" class="episode-dropdown">
|
||||
<!-- JavaScript에서 옵션 동적 생성 -->
|
||||
</select>
|
||||
<svg class="dropdown-arrow" viewBox="0 0 15 15" aria-hidden="true">
|
||||
<path d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<label class="auto-next-toggle">
|
||||
<input type="checkbox" id="auto-next-checkbox" checked>
|
||||
<span class="toggle-label">자동 다음</span>
|
||||
<span class="toggle-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 외부 플레이어 버튼 -->
|
||||
<div class="external-players">
|
||||
<div class="external-players-grid" id="external-player-buttons">
|
||||
<!-- 버튼들은 JavaScript에서 동적 생성 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/video_modal.css') }}"/>
|
||||
<script src="{{ url_for('.static', filename='js/video_modal.js') }}"></script>
|
||||
@@ -1,7 +1,4 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<div id="preloader">
|
||||
<div class='demo'>
|
||||
<!-- <div class="loader-inner">-->
|
||||
@@ -920,6 +917,20 @@
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
body { padding-top: 10px !important; }
|
||||
ul.nav.nav-pills.bg-light {
|
||||
margin-top: 50px !important;
|
||||
margin-bottom: 10px !important;
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
ul.nav.nav-pills .nav-link {
|
||||
padding: 6px 12px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
{% block content %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.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') }}"/>
|
||||
|
||||
<style>
|
||||
body {
|
||||
@@ -330,10 +328,45 @@
|
||||
font-size: 11px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Notify Styling for Mobile */
|
||||
.bootstrap-notify-container,
|
||||
[data-notify="container"] {
|
||||
max-width: 90vw !important;
|
||||
width: auto !important;
|
||||
right: 5vw !important;
|
||||
left: 5vw !important;
|
||||
padding: 12px 16px !important;
|
||||
border-radius: 10px !important;
|
||||
background: rgba(30, 27, 75, 0.95) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
|
||||
color: #e0e7ff !important;
|
||||
font-size: 13px !important;
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
[data-notify="container"].alert-success {
|
||||
border-color: rgba(16, 185, 129, 0.4) !important;
|
||||
background: rgba(6, 78, 59, 0.95) !important;
|
||||
}
|
||||
[data-notify="container"].alert-warning {
|
||||
border-color: rgba(245, 158, 11, 0.4) !important;
|
||||
background: rgba(120, 53, 15, 0.95) !important;
|
||||
}
|
||||
[data-notify="container"].alert-danger {
|
||||
border-color: rgba(239, 68, 68, 0.4) !important;
|
||||
background: rgba(127, 29, 29, 0.95) !important;
|
||||
}
|
||||
[data-notify="message"] {
|
||||
color: #e0e7ff !important;
|
||||
}
|
||||
[data-notify="title"] {
|
||||
color: #fff !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
<div class="content-cloak">
|
||||
<form id="form_search" class="form-inline" style="text-align:left; width:100%;">
|
||||
<div class="search-container">
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
|
||||
|
||||
<style>
|
||||
/* Cosmic Violet 배경 (즉시 적용) */
|
||||
body {
|
||||
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%) !important;
|
||||
background-attachment: fixed !important;
|
||||
}
|
||||
|
||||
/* Global Container Margin Overrides - mobile_custom.css와 통합 */
|
||||
/* Global Container Margin Overrides */
|
||||
#main_container {
|
||||
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 {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="yommi_wrapper" class="container-fluid mt-2 mt-md-4 mx-auto anilife-common-wrapper content-cloak">
|
||||
<div id="yommi_wrapper" class="container-fluid mt-2 mt-md-4 mx-auto content-cloak" style="max-width: 100%;">
|
||||
<div id="preloader" class="loader">
|
||||
<div class="loader-inner">
|
||||
<div class="loader-line-wrap"><div class="loader-line"></div></div>
|
||||
@@ -30,7 +30,7 @@ body {
|
||||
</div>
|
||||
|
||||
<form id="program_list">
|
||||
<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="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="form-group mb-0">
|
||||
<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
|
||||
@@ -40,13 +40,14 @@ body {
|
||||
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;">
|
||||
<div class="input-group-append">
|
||||
<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); min-width: 80px;">
|
||||
<button id="analysis_btn" class="btn px-2 px-md-4 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);">
|
||||
<i class="fa fa-cogs mr-1"></i> 분석
|
||||
</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; min-width: 80px;">
|
||||
GO
|
||||
<button id="go_anilife_btn" class="btn px-2 px-md-3" 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;">
|
||||
<span class="d-none d-md-inline">Go 애니라이프</span>
|
||||
<span class="d-md-none">Go</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -127,14 +128,11 @@ body {
|
||||
// window.addEventListener("DOMContentLoaded", wait_seconds);
|
||||
|
||||
function analyze(wr_id, bo_table) {
|
||||
// 분석 시작 시 preloader 표시
|
||||
const preloader = document.getElementById('preloader');
|
||||
if (preloader) {
|
||||
preloader.style.display = 'block';
|
||||
preloader.style.opacity = '1';
|
||||
}
|
||||
|
||||
// e.preventDefault();
|
||||
// e.stopPropagation()
|
||||
// e.preventDefault();
|
||||
const code = document.getElementById("code").value
|
||||
// console.log(code)
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + sub + '/analysis',
|
||||
type: "POST",
|
||||
@@ -142,29 +140,14 @@ body {
|
||||
data: {code: code, wr_id: wr_id, bo_table: bo_table},
|
||||
dataType: "json",
|
||||
success: function (ret) {
|
||||
// 분석 완료 시 preloader 숨기기
|
||||
if (preloader) {
|
||||
preloader.style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
preloader.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
|
||||
if (ret.ret === 'success' && ret.data != null) {
|
||||
// {#// console.log(ret.code)#}
|
||||
// console.log(ret.data)
|
||||
make_program(ret.data)
|
||||
$("#loader").css("display", 'none')
|
||||
} else {
|
||||
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
// 에러 시에도 preloader 숨기기
|
||||
if (preloader) {
|
||||
preloader.style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
preloader.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -339,34 +322,21 @@ body {
|
||||
})
|
||||
|
||||
$(document).ready(function () {
|
||||
// content-cloak visible 클래스 추가 (즉시)
|
||||
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
|
||||
// DOM 로딩 완료 후 preloader 숨기기
|
||||
const preloader = document.getElementById('preloader');
|
||||
|
||||
// URL에 code가 없으면 preloader 숨기기 (있으면 analyze 함수에서 처리)
|
||||
const hasAutoAnalysis = params.code || "{{arg['anilife_current_code']}}";
|
||||
if (!hasAutoAnalysis) {
|
||||
const preloader = document.getElementById('preloader');
|
||||
setTimeout(function() {
|
||||
if (preloader) {
|
||||
preloader.style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
preloader.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
setTimeout(function() {
|
||||
if (preloader) {
|
||||
preloader.style.opacity = '0';
|
||||
setTimeout(function() {
|
||||
preloader.style.display = 'none';
|
||||
}, 300);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
$("#loader").css("display", 'none');
|
||||
});
|
||||
|
||||
// Enter 키로 검색 트리거
|
||||
$("#code").on('keypress', function (e) {
|
||||
if (e.which === 13) { // Enter key
|
||||
e.preventDefault();
|
||||
$("#analysis_btn").click();
|
||||
}
|
||||
});
|
||||
|
||||
$("#analysis_btn").unbind("click").bind('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation()
|
||||
@@ -428,10 +398,8 @@ body {
|
||||
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
|
||||
} else if (data.ret == 'db_completed') {
|
||||
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
|
||||
} else if (data.ret == 'file_exists') {
|
||||
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
|
||||
} else {
|
||||
$.notify('<strong>추가 실패</strong><br>' + data.log, {type: 'warning'});
|
||||
$.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -605,16 +573,10 @@ body {
|
||||
.episode-list-container {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.episode-list-container {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 에피소드 카드 */
|
||||
.episode-card {
|
||||
display: flex;
|
||||
@@ -770,12 +732,9 @@ body {
|
||||
|
||||
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
|
||||
@media (max-width: 768px) {
|
||||
/* Ensure breadcrumb span doesn't force width */
|
||||
#menu_module_div span.nav-link {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 150px;
|
||||
white-space: nowrap !important;
|
||||
/* 상단 서브메뉴가 SJVA 메인 navbar에 가려지지 않도록 여백 추가 */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
margin-top: 60px !important;
|
||||
}
|
||||
|
||||
/* 입력창 크기 최적화 */
|
||||
@@ -807,8 +766,8 @@ body {
|
||||
.row, form, #program_list, #program_auto_form, #episode_list {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 4px !important; /* 10px -> 4px */
|
||||
padding-right: 4px !important; /* 10px -> 4px */
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
@@ -866,8 +825,8 @@ body {
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding: 8px 6px !important; /* 좌우 패딩 대폭 축소 */
|
||||
gap: 6px !important; /* 요소 간 간격 축소 */
|
||||
padding: 10px 12px !important;
|
||||
gap: 10px !important;
|
||||
margin: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
@@ -1071,7 +1030,8 @@ body {
|
||||
}
|
||||
|
||||
.loader {
|
||||
background: transparent;
|
||||
background: rgb(0, 0, 0, 0.8);
|
||||
background: radial-gradient(#222, #000);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
@@ -1138,35 +1098,35 @@ body {
|
||||
}
|
||||
|
||||
.loader-line-wrap:nth-child(1) .loader-line {
|
||||
border-color: #a78bfa; /* Light Violet */
|
||||
border-color: hsl(0, 80%, 60%);
|
||||
height: 90px;
|
||||
width: 90px;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
.loader-line-wrap:nth-child(2) .loader-line {
|
||||
border-color: #8b5cf6; /* Violet */
|
||||
border-color: hsl(60, 80%, 60%);
|
||||
height: 76px;
|
||||
width: 76px;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.loader-line-wrap:nth-child(3) .loader-line {
|
||||
border-color: #7c3aed; /* Purple */
|
||||
border-color: hsl(120, 80%, 60%);
|
||||
height: 62px;
|
||||
width: 62px;
|
||||
top: 21px;
|
||||
}
|
||||
|
||||
.loader-line-wrap:nth-child(4) .loader-line {
|
||||
border-color: #6d28d9; /* Deep Purple */
|
||||
border-color: hsl(180, 80%, 60%);
|
||||
height: 48px;
|
||||
width: 48px;
|
||||
top: 28px;
|
||||
}
|
||||
|
||||
.loader-line-wrap:nth-child(5) .loader-line {
|
||||
border-color: #5b21b6; /* Dark Violet */
|
||||
border-color: hsl(240, 80%, 60%);
|
||||
height: 34px;
|
||||
width: 34px;
|
||||
top: 35px;
|
||||
@@ -1305,6 +1265,30 @@ $(document).ready(function(){
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
/* Mobile Margin Fix */
|
||||
@media (max-width: 768px) {
|
||||
body { overflow-x: hidden !important; padding: 0 !important; margin: 0 !important; }
|
||||
.container, .container-fluid, .row, form, #program_list, #program_auto_form, #episode_list, .queue-container, #yommi_wrapper, #main_container {
|
||||
width: 100% !important; max-width: 100% !important;
|
||||
padding-left: 4px !important; padding-right: 4px !important;
|
||||
margin-left: 0 !important; margin-right: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.form-group, .form-inline, [class*="col-"] {
|
||||
flex: 0 0 100% !important; max-width: 100% !important; width: 100% !important;
|
||||
padding-left: 0 !important; padding-right: 0 !important;
|
||||
}
|
||||
.row { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.card, .card.p-4, .card.p-lg-5, .card.border-light {
|
||||
width: calc(100% - 8px) !important; max-width: 100% !important;
|
||||
padding: 8px !important; margin: 4px !important;
|
||||
border-radius: 12px !important; box-sizing: border-box !important;
|
||||
}
|
||||
.badge {
|
||||
white-space: normal !important; text-align: left !important;
|
||||
line-height: 1.4 !important; height: auto !important; display: inline-block !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
|
||||
<div id="preloader" class="content-cloak">
|
||||
<div class='demo'>
|
||||
<!-- <div class="loader-inner">-->
|
||||
@@ -197,10 +193,7 @@
|
||||
tmp += '</div>';
|
||||
tmp += '<div class=\"card-body\">'
|
||||
tmp += '<h5 class=\"card-title\">' + data.anime_list[i].title + '</h5>';
|
||||
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, '"') + '\"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
|
||||
tmp += '</div>';
|
||||
tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\">' + data.anime_list[i].title + '</a>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
@@ -265,10 +258,7 @@
|
||||
tmp += '</div>';
|
||||
tmp += '<div class="card-body">'
|
||||
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
|
||||
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, '"') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
|
||||
tmp += '</div>';
|
||||
tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
@@ -320,10 +310,7 @@
|
||||
tmp += '</div>';
|
||||
tmp += '<div class="card-body">'
|
||||
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
|
||||
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, '"') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
|
||||
tmp += '</div>';
|
||||
tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
tmp += '</div>';
|
||||
@@ -587,38 +574,6 @@
|
||||
};
|
||||
|
||||
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>
|
||||
<style>
|
||||
button.code-button {
|
||||
@@ -847,7 +802,6 @@
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1262,57 +1216,6 @@
|
||||
color: #fff !important;
|
||||
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>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -1323,5 +1226,47 @@ $(document).ready(function(){
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
/* Mobile Margin Fix */
|
||||
@media (max-width: 768px) {
|
||||
body { overflow-x: hidden !important; padding: 0 !important; margin: 0 !important; padding-top: 10px !important; }
|
||||
ul.nav.nav-pills.bg-light {
|
||||
margin-top: 50px !important;
|
||||
margin-bottom: 10px !important;
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
ul.nav.nav-pills .nav-link {
|
||||
padding: 6px 12px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
.container, .container-fluid, .row, form, #program_list, #program_auto_form, #episode_list, .queue-container, #yommi_wrapper, #main_container {
|
||||
width: 100% !important; max-width: 100% !important;
|
||||
padding-left: 4px !important; padding-right: 4px !important;
|
||||
margin-left: 0 !important; margin-right: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.form-group, .form-inline, [class*="col-"] {
|
||||
flex: 0 0 100% !important; max-width: 100% !important; width: 100% !important;
|
||||
padding-left: 0 !important; padding-right: 0 !important;
|
||||
}
|
||||
.row { margin-left: 0 !important; margin-right: 0 !important; }
|
||||
.card, .card.p-4, .card.p-lg-5, .card.border-light {
|
||||
width: calc(100% - 8px) !important; max-width: 100% !important;
|
||||
padding: 8px !important; margin: 4px !important;
|
||||
border-radius: 12px !important; box-sizing: border-box !important;
|
||||
}
|
||||
.badge {
|
||||
white-space: normal !important; text-align: left !important;
|
||||
line-height: 1.4 !important; height: auto !important; display: inline-block !important;
|
||||
}
|
||||
|
||||
/* Search Button Size Reduction */
|
||||
.input-group #btn_search {
|
||||
padding: 8px 14px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<div id="anilife_setting_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%;">
|
||||
|
||||
<div class="glass-card p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 anilife-setting-header">
|
||||
<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>
|
||||
<div class="anilife-setting-actions">
|
||||
<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>
|
||||
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
|
||||
</div>
|
||||
|
||||
{{ macros.m_row_start('5') }}
|
||||
@@ -22,7 +14,7 @@
|
||||
<nav>
|
||||
{{ macros.m_tab_head_start() }}
|
||||
{{ 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_end() }}
|
||||
</nav>
|
||||
@@ -31,8 +23,6 @@
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
{{ 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('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;">
|
||||
@@ -66,73 +56,16 @@
|
||||
{{ macros.setting_checkbox('anilife_auto_make_season_folder', '시즌 폴더 생성', value=arg['anilife_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }}
|
||||
</div>
|
||||
{{ 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_start('auto', false) }}
|
||||
{{ 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_checkbox('anilife_auto_start', '시작시 자동실행', value=arg['anilife_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
|
||||
<!-- 자동 다운로드 작품 코드 - 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_input_textarea('anilife_auto_code_list', '자동 다운로드할 작품 코드', desc=['all 입력시 모두 받기', '구분자 | 또는 엔터'], value=arg['anilife_auto_code_list'], row='10') }}
|
||||
{{ 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_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-->
|
||||
</form>
|
||||
</div>
|
||||
@@ -375,124 +308,23 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.anilife-setting-header {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
body { padding-top: 10px !important; }
|
||||
ul.nav.nav-pills.bg-light {
|
||||
margin-top: 50px !important;
|
||||
margin-bottom: 10px !important;
|
||||
width: 100% !important;
|
||||
display: flex !important;
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.anilife-setting-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.anilife-setting-actions .btn,
|
||||
.anilife-setting-actions #globalSettingSaveBtn {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.anilife-setting-actions > div {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#anilife_auto_make_folder_div {
|
||||
border-left: none !important;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
#anilife_setting_wrapper .tab-pane {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
/* 이 페이지에서는 모바일 스크롤 힌트/세로 라인 제거 */
|
||||
#menu_module_div::after,
|
||||
#menu_page_div::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
background: none !important;
|
||||
}
|
||||
|
||||
#menu_module_div,
|
||||
#menu_page_div,
|
||||
#anilife_setting_wrapper,
|
||||
#anilife_setting_wrapper .glass-card {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#anilife_setting_wrapper .row,
|
||||
#anilife_setting_wrapper .col-sm-9,
|
||||
#anilife_setting_wrapper .col-sm-3 {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
ul.nav.nav-pills .nav-link {
|
||||
padding: 6px 12px !important;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
.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>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
var package_name = "{{arg['package_name'] }}";
|
||||
var sub = "{{arg['sub'] }}";
|
||||
@@ -603,210 +435,6 @@ $('#folder_select_btn').on('click', function() {
|
||||
});
|
||||
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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
|
||||
<div class="input-group mb-2">
|
||||
<input
|
||||
|
||||
@@ -1,13 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<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="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css"/>
|
||||
|
||||
<style>
|
||||
/* Search Container */
|
||||
:root {
|
||||
--forest-950: #022c22;
|
||||
--forest-900: #064e3b;
|
||||
--forest-800: #065f46;
|
||||
--forest-500: #10b981;
|
||||
--forest-400: #34d399;
|
||||
--forest-100: #d1fae5;
|
||||
--accent-amber: #fbbf24;
|
||||
}
|
||||
|
||||
body { background-color: var(--forest-950); color: #ecfdf5; font-family: 'Inter', 'Noto Sans KR', sans-serif; }
|
||||
|
||||
/* Layout Expansion */
|
||||
#main_container, .container, .container-fluid, .content-cloak {
|
||||
max-width: 100% !important;
|
||||
padding-left: 5px !important;
|
||||
padding-right: 5px !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.content-cloak { padding-top: 10px; }
|
||||
|
||||
/* Navigation (Tabs) Optimization */
|
||||
/* Navigation Menu Override */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(6, 78, 59, 0.4) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
color: #d1fae5 !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 8px 20px !important;
|
||||
border-radius: 50rem !important;
|
||||
transition: all 0.3s ease !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link:hover {
|
||||
background-color: rgba(16, 185, 129, 0.1) !important;
|
||||
color: #fff !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Search Container */
|
||||
.search-container {
|
||||
background: rgba(6, 78, 59, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
@@ -348,6 +402,52 @@ $(document).ready(function(){
|
||||
--accent-amber: #fbbf24;
|
||||
}
|
||||
|
||||
body { background-color: var(--forest-950); color: #ecfdf5; font-family: 'Inter', 'Noto Sans KR', sans-serif; }
|
||||
|
||||
/* Layout Expansion */
|
||||
#main_container, .container, .container-fluid, .content-cloak {
|
||||
max-width: 100% !important;
|
||||
padding-left: 5px !important;
|
||||
padding-right: 5px !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
.content-cloak { padding-top: 10px; }
|
||||
|
||||
/* Navigation (Tabs) Optimization */
|
||||
/* Navigation Menu Override */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(6, 78, 59, 0.4) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
color: #d1fae5 !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 8px 20px !important;
|
||||
border-radius: 50rem !important;
|
||||
transition: all 0.3s ease !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link:hover {
|
||||
background-color: rgba(16, 185, 129, 0.1) !important;
|
||||
color: #fff !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3) !important;
|
||||
}
|
||||
|
||||
/* Search Container */
|
||||
.search-container {
|
||||
@@ -547,6 +647,42 @@ $(document).ready(function(){
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom Notify Styling (Forest Theme) */
|
||||
.bootstrap-notify-container,
|
||||
[data-notify="container"] {
|
||||
max-width: 90vw !important;
|
||||
width: auto !important;
|
||||
right: 5vw !important;
|
||||
left: 5vw !important;
|
||||
padding: 12px 16px !important;
|
||||
border-radius: 10px !important;
|
||||
background: rgba(2, 44, 34, 0.95) !important;
|
||||
backdrop-filter: blur(10px) !important;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3) !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4) !important;
|
||||
color: #ecfdf5 !important;
|
||||
font-size: 13px !important;
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
[data-notify="container"].alert-success {
|
||||
border-color: rgba(52, 211, 153, 0.4) !important;
|
||||
background: rgba(6, 78, 59, 0.95) !important;
|
||||
}
|
||||
[data-notify="container"].alert-warning {
|
||||
border-color: rgba(251, 191, 36, 0.4) !important;
|
||||
background: rgba(120, 53, 15, 0.95) !important;
|
||||
}
|
||||
[data-notify="container"].alert-danger {
|
||||
border-color: rgba(239, 68, 68, 0.4) !important;
|
||||
background: rgba(127, 29, 29, 0.95) !important;
|
||||
}
|
||||
[data-notify="message"] {
|
||||
color: #ecfdf5 !important;
|
||||
}
|
||||
[data-notify="title"] {
|
||||
color: #fff !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -768,12 +904,6 @@ $(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-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="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>';
|
||||
|
||||
@@ -1,9 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--forest-950: #022c22;
|
||||
--forest-900: #064e3b;
|
||||
--forest-800: #065f46;
|
||||
--forest-500: #10b981;
|
||||
--forest-400: #34d399;
|
||||
--forest-accent: #6ee7b7;
|
||||
}
|
||||
|
||||
body { background-color: var(--forest-950) !important; color: #ecfdf5; }
|
||||
|
||||
/* Layout Expansion */
|
||||
#main_container, .container, .container-fluid, #anime_downloader_wrapper {
|
||||
max-width: 100% !important;
|
||||
padding-left: 5px !important;
|
||||
padding-right: 5px !important;
|
||||
margin: 0 auto !important;
|
||||
}
|
||||
|
||||
/* Navigation Menu Override */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(6, 78, 59, 0.4) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
color: #d1fae5 !important;
|
||||
font-weight: 600 !important;
|
||||
padding: 8px 20px !important;
|
||||
border-radius: 50rem !important;
|
||||
transition: all 0.3s ease !important;
|
||||
border: 1px solid transparent !important;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link:hover {
|
||||
background-color: rgba(16, 185, 129, 0.1) !important;
|
||||
color: #fff !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3) !important;
|
||||
}
|
||||
|
||||
.queue-header-container {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
margin-bottom: 20px; border-bottom: 1px solid rgba(16, 185, 129, 0.2); padding-bottom: 10px;
|
||||
@@ -150,23 +201,6 @@ $(document).ready(function(){
|
||||
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();
|
||||
refreshIntervalId = setInterval(autoRefreshList, 3000);
|
||||
});
|
||||
@@ -187,13 +221,15 @@ function silentFetchList(callback) {
|
||||
|
||||
function autoRefreshList() {
|
||||
silentFetchList(function(data) {
|
||||
// 무조건 스마트 싱크 호출 (내부에서 변경 사항이 있을 때만 업데이트됨)
|
||||
smartSyncList(data);
|
||||
if (data.length !== current_list_length) {
|
||||
current_list_length = data.length;
|
||||
renderList(data);
|
||||
}
|
||||
|
||||
var hasActiveDownload = false;
|
||||
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' || data[j].status_str === 'ANALYZING') {
|
||||
if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED') {
|
||||
hasActiveDownload = true;
|
||||
break;
|
||||
}
|
||||
@@ -217,51 +253,6 @@ 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) {
|
||||
$("#list").html('');
|
||||
if (!data || data.length == 0) {
|
||||
@@ -334,8 +325,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>';
|
||||
|
||||
// 진행률 표시: 0%이더라도 다운로드 중이면 바를 표시 (깜빡임 서프레스)
|
||||
str += '<td><div class="progress custom-progress"><div id="progress_'+data.idx+'" class="progress-bar" style="width:'+data.percent+'%">'+data.percent +'%</div></div></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>';
|
||||
@@ -394,6 +385,7 @@ function status_html(data) {
|
||||
|
||||
progress.style.width = data.percent+ '%';
|
||||
progress.innerHTML = data.percent+ '%';
|
||||
progress.style.visibility = 'visible';
|
||||
|
||||
var statusEl = document.getElementById("status_" + data.idx);
|
||||
if (statusEl) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<div id="anime_downloader_wrapper" class="content-cloak" style="max-width: 100%;">
|
||||
<div id="preloader">
|
||||
<div class='demo'>
|
||||
@@ -49,7 +46,7 @@
|
||||
const package_name = "{{arg['package_name'] }}";
|
||||
const sub = "{{arg['sub'] }}";
|
||||
const ohli24_url = "{{arg['ohli24_url']}}";
|
||||
var current_data = null;
|
||||
// let current_data = '';
|
||||
|
||||
const params = new Proxy(new URLSearchParams(window.location.search), {
|
||||
get: (searchParams, prop) => searchParams.get(prop),
|
||||
@@ -195,29 +192,45 @@
|
||||
}
|
||||
|
||||
$(function () {
|
||||
// URL 파라미터 처리 (code)
|
||||
const urlCode = params.code || findGetParameter('code');
|
||||
const currentCode = "{{arg['linkkf_current_code']}}";
|
||||
// console.log(params.wr_id)
|
||||
// console.log(findGetParameter('wr_id'))
|
||||
// console.log(params.code)
|
||||
if (params.code === '') {
|
||||
|
||||
const targetCode = urlCode || currentCode;
|
||||
|
||||
if (targetCode) {
|
||||
document.getElementById("code").value = targetCode;
|
||||
// wr_id, bo_table 등이 있으면 같이 전달
|
||||
analyze(params.wr_id || findGetParameter('wr_id'), params.bo_table || findGetParameter('bo_table'));
|
||||
} else {
|
||||
document.getElementById("code").value = params.code
|
||||
// {#document.getElementById("analysis_btn").click();#}
|
||||
}
|
||||
});
|
||||
|
||||
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 () {
|
||||
// console.log('wr_id::', params.wr_id)
|
||||
});
|
||||
|
||||
// Enter 키로 검색 트리거
|
||||
$("#code").on('keypress', function (e) {
|
||||
if (e.which === 13) { // Enter key
|
||||
e.preventDefault();
|
||||
$("#analysis_btn").click();
|
||||
}
|
||||
// console.log('wr_id::', params.wr_id)
|
||||
|
||||
});
|
||||
|
||||
$("#analysis_btn").unbind("click").bind('click', function (e) {
|
||||
@@ -288,29 +301,24 @@
|
||||
|
||||
$("body").on('click', '#add_queue_btn', function (e) {
|
||||
e.preventDefault();
|
||||
let episode_data = current_data.episode[$(this).data('idx')];
|
||||
// console.log('episode_data:::>', episode_data)
|
||||
data = current_data.episode[$(this).data('idx')];
|
||||
// console.log('data:::>', data)
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + sub + '/add_queue',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
data: {data: JSON.stringify(episode_data)},
|
||||
data: {data: JSON.stringify(data)},
|
||||
dataType: "json",
|
||||
success: function (ret) {
|
||||
// console.log('#add_queue_btn::ret >>', ret)
|
||||
if (ret.ret == 'enqueue_db_append' || ret.ret == 'enqueue_db_exist' || ret.ret == 'enqueue_gdm_success') {
|
||||
success: function (data) {
|
||||
// console.log('#add_queue_btn::data >>', data)
|
||||
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist') {
|
||||
$.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
|
||||
} else if (ret.ret == 'queue_exist') {
|
||||
} else if (data.ret == 'queue_exist') {
|
||||
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
|
||||
} else if (ret.ret == 'db_completed') {
|
||||
} else if (data.ret == 'db_completed') {
|
||||
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
|
||||
} else if (ret.ret == 'file_exists') {
|
||||
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
|
||||
} else if (ret.ret == 'extract_failed') {
|
||||
$.notify('<strong>추가 실패: 영상 주소 추출에 실패하였습니다.</strong>', {type: 'warning'});
|
||||
} else {
|
||||
const msg = ret.log || '알 수 없는 이유로 추가에 실패하였습니다.';
|
||||
$.notify('<strong>추가 실패</strong><br>' + msg, {type: 'warning'});
|
||||
$.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -319,14 +327,16 @@
|
||||
|
||||
$("body").on('click', '#check_download_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]);
|
||||
all = $('input[id^="checkbox_"]');
|
||||
let data = [];
|
||||
let idx;
|
||||
for (let i in all) {
|
||||
if (all[i].checked) {
|
||||
idx = parseInt(all[i].id.split('_')[1])
|
||||
data.push(current_data.episode[idx]);
|
||||
}
|
||||
});
|
||||
if (selected_data.length == 0) {
|
||||
}
|
||||
if (data.length == 0) {
|
||||
$.notify('<strong>선택하세요.</strong>', {type: 'warning'});
|
||||
return;
|
||||
}
|
||||
@@ -334,43 +344,13 @@
|
||||
url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
data: {data: JSON.stringify(selected_data)},
|
||||
data: {data: JSON.stringify(data)},
|
||||
dataType: "json",
|
||||
success: function (ret) {
|
||||
success: function (data) {
|
||||
$.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>
|
||||
<style>
|
||||
#anime_downloader_wrapper {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<!--<div id="preloader"></div>-->
|
||||
<div id="linkkf_search_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%; padding-left: 5px; padding-right: 5px;">
|
||||
<div id="anime_downloader_wrapper" class="content-cloak">
|
||||
<div id="preloader" class="loader">
|
||||
<div class="loader-inner">
|
||||
<div class="loader-line-wrap">
|
||||
@@ -981,7 +978,6 @@
|
||||
padding: 0 !important;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1147,8 +1143,46 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Navigation Menu Override */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(30, 41, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto !important;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-item {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 20px !important;
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
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 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
</style>
|
||||
<!-- Removing redundant bootstrap load to prevent navbar height/color overrides -->
|
||||
<link href="{{ url_for('.static', filename='css/bootstrap.min.css') }}" type="text/css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css" />
|
||||
|
||||
<style>
|
||||
@@ -1175,6 +1209,46 @@
|
||||
opacity: 1;
|
||||
transition-delay: 300ms;
|
||||
}
|
||||
|
||||
/* Navigation Menu Override (Top Sub-menu) */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(6, 78, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(16, 185, 129, 0.08);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto !important;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-item { margin: 0 2px; }
|
||||
ul.nav.nav-pills .nav-link {
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 20px !important;
|
||||
color: #6ee7b7 !important;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
ul.nav.nav-pills .nav-link:hover {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: #fff !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
ul.nav.nav-pills .nav-link.active {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Smooth Load */
|
||||
.content-cloak, #menu_module_div, #menu_page_div { opacity: 0; transition: opacity 0.5s ease-out; }
|
||||
.content-cloak.visible, #menu_module_div.visible, #menu_page_div.visible { opacity: 1; }
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<div id="linkkf_setting_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%; padding-left: 5px; padding-right: 5px;">
|
||||
|
||||
<div class="glass-card p-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4 linkkf-setting-header">
|
||||
<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>
|
||||
<div class="linkkf-setting-actions">
|
||||
<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>
|
||||
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
|
||||
</div>
|
||||
|
||||
{{ macros.m_row_start('5') }}
|
||||
@@ -22,7 +14,7 @@
|
||||
<nav>
|
||||
{{ macros.m_tab_head_start() }}
|
||||
{{ 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_end() }}
|
||||
</nav>
|
||||
@@ -68,107 +60,8 @@
|
||||
{{ 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_checkbox('linkkf_auto_start', '시작시 자동실행', value=arg['linkkf_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
|
||||
<!-- 자동 다운로드 작품 코드 - 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_input_textarea('linkkf_auto_code_list', '자동 다운로드할 작품 코드', desc=['all 입력시 모두 받기', '구분자 | 또는 엔터'], value=arg['linkkf_auto_code_list'], row='10') }}
|
||||
{{ 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() }}
|
||||
|
||||
</div><!--tab-content-->
|
||||
@@ -217,44 +110,6 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
.linkkf-setting-header {
|
||||
flex-direction: column !important;
|
||||
align-items: flex-start !important;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.linkkf-setting-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.linkkf-setting-actions .btn,
|
||||
.linkkf-setting-actions #globalSettingSaveBtn {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.linkkf-setting-actions > div {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
#linkkf_auto_make_folder_div {
|
||||
border-left: none !important;
|
||||
margin-left: 0 !important;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
#linkkf_setting_wrapper .tab-pane {
|
||||
border-left: none !important;
|
||||
border-right: none !important;
|
||||
border-bottom: none !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Global Background */
|
||||
body {
|
||||
font-family: 'NamumSquareNeo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif;
|
||||
@@ -461,15 +316,6 @@
|
||||
.folder-item.selected {
|
||||
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>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -496,94 +342,6 @@ $("body").on('click', '#go_btn', function(e){
|
||||
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> 테스트 알림 전송');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================
|
||||
// 폴더 탐색 기능
|
||||
// ======================================
|
||||
@@ -722,154 +480,6 @@ $(document).ready(function(){
|
||||
setTimeout(function() {
|
||||
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
|
||||
}, 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>
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -1,159 +1,193 @@
|
||||
{% extends "base.html" %}
|
||||
{% 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>
|
||||
/* Match gds_dviewer Log Page Design */
|
||||
body {
|
||||
background: linear-gradient(145deg, #0f172a, #1e293b) !important;
|
||||
color: #f8fafc;
|
||||
font-family: 'Inter', -apple-system, sans-serif;
|
||||
/* Premium Dark Theme Variables */
|
||||
: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 */
|
||||
}
|
||||
|
||||
.log-card {
|
||||
background: linear-gradient(145deg, rgba(20, 30, 48, 0.95), rgba(36, 59, 85, 0.9));
|
||||
border: 1px solid rgba(100, 150, 180, 0.25);
|
||||
/* Global Override */
|
||||
body {
|
||||
background-color: var(--bg-color) !important;
|
||||
background-image: radial-gradient(circle at top right, #1e293b 0%, transparent 60%), radial-gradient(circle at bottom left, #1e293b 0%, transparent 60%);
|
||||
color: var(--text-color);
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
/* overflow: hidden 제거 - 모바일 스크롤 허용 */
|
||||
}
|
||||
|
||||
/* Container & Typography */
|
||||
.container-fluid {
|
||||
padding: 8px; /* 최소 여백 */
|
||||
}
|
||||
|
||||
@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;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
overflow: hidden;
|
||||
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 */
|
||||
.nav-tabs {
|
||||
border-bottom: 1px solid rgba(100, 150, 180, 0.2);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
padding: 10px 10px 0 10px;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #94a3b8 !important;
|
||||
color: var(--text-muted) !important;
|
||||
border: none !important;
|
||||
border-radius: 8px 8px 0 0 !important;
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: #e2e8f0 !important;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-color) !important;
|
||||
background-color: rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #7dd3fc !important;
|
||||
background: rgba(20, 30, 48, 0.95) !important;
|
||||
border-bottom: 2px solid #7dd3fc !important;
|
||||
color: var(--accent-color) !important;
|
||||
background-color: var(--card-bg) !important;
|
||||
border-bottom: 2px solid var(--accent-color) !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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 20px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-top: 1px solid rgba(100, 150, 180, 0.2);
|
||||
gap: 12px;
|
||||
background-color: rgba(0,0,0,0.2);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Toggle Switch */
|
||||
.form-check-input {
|
||||
background-color: #334155;
|
||||
border-color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: #7dd3fc;
|
||||
border-color: #7dd3fc;
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.form-check-label {
|
||||
color: #94a3b8;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-right: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.log-container {
|
||||
height: calc(100vh - 180px);
|
||||
min-height: 300px;
|
||||
padding: 12px;
|
||||
}
|
||||
.log-card-header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
/* Buttons */
|
||||
.btn-action {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
border-radius: 6px;
|
||||
padding: 6px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
/* Smooth Load */
|
||||
.content-cloak {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
.content-cloak.visible {
|
||||
opacity: 1;
|
||||
.btn-action:hover {
|
||||
background-color: var(--accent-color);
|
||||
border-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<div class="container-fluid content-cloak" id="main_container" style="max-width: 1400px;">
|
||||
<div class="log-card">
|
||||
<div class="container-fluid content-cloak" id="main_container">
|
||||
<!-- Header -->
|
||||
<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>
|
||||
{{ macros.m_tab_head_start() }}
|
||||
{{ macros.m_tab_head('old', 'History', true) }}
|
||||
@@ -162,20 +196,27 @@
|
||||
</nav>
|
||||
|
||||
<div class="tab-content" id="nav-tabContent">
|
||||
<!-- History Logs -->
|
||||
<!-- Old Logs -->
|
||||
{{ macros.m_tab_content_start('old', true) }}
|
||||
<div class="log-container" id="log-history"></div>
|
||||
<div>
|
||||
<textarea id="log" rows="30" disabled spellcheck="false"></textarea>
|
||||
</div>
|
||||
{{ macros.m_tab_content_end() }}
|
||||
|
||||
<!-- Real-time Logs -->
|
||||
<!-- New Logs -->
|
||||
{{ macros.m_tab_content_start('new', false) }}
|
||||
<div class="log-container" id="log-realtime"></div>
|
||||
<div>
|
||||
<textarea id="add" rows="30" disabled spellcheck="false"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="controls-bar">
|
||||
<label class="form-check-label" for="auto_scroll">Auto Scroll</label>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked>
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="form-check-label" for="auto_scroll">Auto Scroll</label>
|
||||
<div class="form-check form-switch mb-0">
|
||||
<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>
|
||||
<button id="clear" class="btn-log">Clear</button>
|
||||
</div>
|
||||
{{ macros.m_tab_content_end() }}
|
||||
</div>
|
||||
@@ -183,58 +224,90 @@
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(text));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatLogLine(line) {
|
||||
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);
|
||||
// Force fluid layout
|
||||
$("#main_container").removeClass("container").addClass("container-fluid");
|
||||
|
||||
$('#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() {
|
||||
ResizeTextAreaLog();
|
||||
});
|
||||
|
||||
var protocol = window.location.protocol;
|
||||
var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log");
|
||||
|
||||
socket.emit("start", {'package':'{{package}}'});
|
||||
|
||||
socket.on('on_start', function(data) {
|
||||
var container = document.getElementById("log-history");
|
||||
var lines = data.data.split('\n');
|
||||
var html = '';
|
||||
lines.forEach(function(line) {
|
||||
html += formatLogLine(line);
|
||||
});
|
||||
container.innerHTML = html || '<div class="text-muted text-center">로그가 비어 있습니다.</div>';
|
||||
container.scrollTop = container.scrollHeight;
|
||||
$('#loading').hide();
|
||||
socket.emit("start", {'package':'{{package}}'} );
|
||||
socket.on('on_start', function(data){
|
||||
var logEl = document.getElementById("log");
|
||||
logEl.innerHTML += data.data;
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
logEl.style.visibility = 'visible';
|
||||
$('#loading').hide();
|
||||
});
|
||||
|
||||
socket.on('add', function(data) {
|
||||
if (data.package == "{{package}}") {
|
||||
var chk = $('#auto_scroll').is(":checked");
|
||||
var container = document.getElementById("log-realtime");
|
||||
container.innerHTML += formatLogLine(data.data);
|
||||
if (chk) container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
socket.on('add', function(data){
|
||||
if (data.package == "{{package}}") {
|
||||
var chk = $('#auto_scroll').is(":checked");
|
||||
var addEl = document.getElementById("add");
|
||||
addEl.innerHTML += data.data;
|
||||
if (chk) addEl.scrollTop = addEl.scrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
$("#clear").click(function(e) {
|
||||
e.preventDefault();
|
||||
document.getElementById("log-realtime").innerHTML = '';
|
||||
e.preventDefault();
|
||||
document.getElementById("add").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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<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/video_modal.css') }}"/>
|
||||
|
||||
<div id="preloader">
|
||||
<div class="demo">
|
||||
<!-- <div class="loader-inner">-->
|
||||
@@ -24,28 +20,16 @@
|
||||
<!-- </div>-->
|
||||
</div>
|
||||
</div>
|
||||
<div id="ohli24_request_wrapper" class="ohli24-common-wrapper container-fluid mt-4 content-cloak ohli24-request-page">
|
||||
<div id="yommi_wrapper" class="container-fluid mt-2 mt-md-4 mx-auto" style="max-width: 100%;">
|
||||
<form id="program_list">
|
||||
<div class="glass-card p-4 mb-4">
|
||||
<div class="ohli24-header">
|
||||
<div class="ohli24-header-left">
|
||||
<div class="ohli24-icon-box">
|
||||
<i class="bi bi-play-circle-fill text-primary" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="ohli24-header-title">Ohli24 요청</h3>
|
||||
<span class="ohli24-header-subtitle">작품 분석 및 개별 에피소드 다운로드</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr style="border-color: rgba(255,255,255,0.05); margin-bottom: 25px;">
|
||||
<div class="card p-2 p-md-4 mb-2 mb-md-4 border-light" style="background: rgba(30,30,40,0.6); backdrop-filter: blur(10px); box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
<div class="form-group mb-0">
|
||||
<label for="code" class="text-white font-weight-bold mb-2"><i class="fa fa-search mr-2"></i>작품 Code / 제목</label>
|
||||
<div class="input-group input-group-lg">
|
||||
<input type="text" id="code" name="code" class="form-control border-0" placeholder="URL 또는 제목을 입력하세요 (예: 녹을 먹는 비스코)" style="background: rgba(0,0,0,0.4); color: #fff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);">
|
||||
<div class="input-group-append">
|
||||
<button type="button" id="analysis_btn" class="btn btn-primary px-4 font-weight-bold ohli24-primary-action" style="box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);">분석</button>
|
||||
<a id="go_ohli24_btn" class="btn btn-outline-light px-3 ohli24-secondary-action d-inline-flex align-items-center" href="{{ arg['ohli24_url'] or 'https://ani.ohli24.com' }}" target="_blank" rel="noopener noreferrer">Go</a>
|
||||
<button id="analysis_btn" class="btn btn-primary px-4 font-weight-bold" style="box-shadow: 0 0 10px rgba(59, 130, 246, 0.5);">분석</button>
|
||||
<button id="go_ohli24_btn" class="btn btn-outline-light px-3">Go OHLI24</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mt-2 text-muted small">
|
||||
@@ -60,9 +44,6 @@
|
||||
</form>
|
||||
</div>
|
||||
<!--전체-->
|
||||
<!-- Video Modal Component -->
|
||||
{% include 'anime_downloader/components/video_modal.html' %}
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
<script src="{{ url_for('.static', filename='js/sjva_ui14.js') }}"></script>
|
||||
|
||||
@@ -70,7 +51,7 @@
|
||||
const package_name = "{{arg['package_name'] }}";
|
||||
const sub = "{{arg['sub'] }}";
|
||||
const ohli24_url = "{{arg['ohli24_url']}}";
|
||||
// let current_data = '';
|
||||
{#let current_data = '';#}
|
||||
let accessibleCount = 1;
|
||||
|
||||
|
||||
@@ -78,39 +59,6 @@
|
||||
get: (searchParams, prop) => searchParams.get(prop),
|
||||
})
|
||||
|
||||
function resolveGoTargetUrl(rawValue) {
|
||||
const baseUrl = (ohli24_url && ohli24_url.trim()) ? ohli24_url.trim().replace(/\/$/, '') : 'https://ani.ohli24.com';
|
||||
const raw = (rawValue || '').trim();
|
||||
|
||||
if (!raw) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
if (raw.startsWith('/c/') || raw.startsWith('/e/')) {
|
||||
return baseUrl + raw;
|
||||
}
|
||||
|
||||
if (raw.includes('/c/')) {
|
||||
return baseUrl + '/c/' + raw.split('/c/')[1];
|
||||
}
|
||||
|
||||
if (raw.includes('/e/')) {
|
||||
return baseUrl + '/e/' + raw.split('/e/')[1];
|
||||
}
|
||||
|
||||
// 분석 결과의 code 값 또는 수동 입력 code는 작품 상세(/c/)로 연결
|
||||
return baseUrl + '/c/' + raw;
|
||||
}
|
||||
|
||||
function updateGoButtonHref(rawValue) {
|
||||
const targetUrl = resolveGoTargetUrl(rawValue);
|
||||
$('#go_ohli24_btn').attr('href', targetUrl);
|
||||
}
|
||||
|
||||
const loader = document.getElementById("preloader");
|
||||
|
||||
const dismissLoadingScreen = function () {
|
||||
@@ -140,7 +88,7 @@
|
||||
}
|
||||
|
||||
function analyze(wr_id, bo_table) {
|
||||
// e.preventDefault();
|
||||
{#e.preventDefault();#}
|
||||
|
||||
const code = document.getElementById("code").value
|
||||
// // console.log(code)
|
||||
@@ -170,13 +118,12 @@
|
||||
|
||||
function make_program(data) {
|
||||
current_data = data;
|
||||
updateGoButtonHref(data.code || $('#code').val() || params.code);
|
||||
// // console.log(data);
|
||||
|
||||
let str = "";
|
||||
|
||||
// 1. Info Card (Top)
|
||||
str += `<div class='glass-card p-4 mb-4'>`;
|
||||
str += `<div class='card p-2 p-md-4 mb-3 mb-md-4 border-light' style="background: rgba(30,30,40,0.6); backdrop-filter: blur(10px);">`;
|
||||
str += `<div class="row">`;
|
||||
|
||||
// Poster
|
||||
@@ -226,14 +173,14 @@
|
||||
|
||||
// Standard Actions
|
||||
if (data.first_exist_filepath) {
|
||||
str += `<button type="button" class="btn btn-success btn-sm mr-2 btn-watch ohli24-watch-action" data-path="${data.first_exist_filepath.replace(/\\/g, '\\\\')}"><i class="fa fa-play"></i> 보기</button>`;
|
||||
str += `<button type="button" class="btn btn-success btn-sm mr-2" onclick="play_video('${data.first_exist_filepath.replace(/\\/g, '\\\\')}', '${data.first_exist_filename}')"><i class="fa fa-play"></i> 재생</button>`;
|
||||
}
|
||||
str += `<button id="check_download_btn" class="btn btn-primary btn-sm ohli24-primary-action"><i class="fa fa-download"></i> 선택 다운로드</button>`;
|
||||
str += `<button id="all_check_on_btn" class="btn btn-outline-light btn-sm ohli24-secondary-action">전체 선택</button>`;
|
||||
str += `<button id="all_check_off_btn" class="btn btn-outline-secondary btn-sm ohli24-muted-action">해제</button>`;
|
||||
str += `<button id="check_download_btn" class="btn btn-primary btn-sm"><i class="fa fa-download"></i> 선택 다운로드</button>`;
|
||||
str += `<button id="all_check_on_btn" class="btn btn-outline-light btn-sm">전체 선택</button>`;
|
||||
str += `<button id="all_check_off_btn" class="btn btn-outline-secondary btn-sm">해제</button>`;
|
||||
|
||||
// Scheduling (Heart)
|
||||
str += `<button id="add_whitelist" class="btn btn-outline-danger btn-sm ohli24-danger-action" style="min-width: 120px;"><i class="bi bi-heart"></i> 스케쥴링 추가</button>`;
|
||||
str += `<button id="add_whitelist" class="btn btn-outline-danger btn-sm" style="min-width: 120px;"><i class="bi bi-heart"></i> 스케쥴링 추가</button>`;
|
||||
|
||||
str += `</div>`; // End Action Toolbar
|
||||
|
||||
@@ -247,14 +194,9 @@
|
||||
let epThumbSrc = data.episode[i].thumbnail || '';
|
||||
let epTitle = data.episode[i].title || '';
|
||||
|
||||
// 에피소드 번호 추출: 백엔드 epi_no 우선, 없으면 정규식, 마지막으로 인덱스
|
||||
let epNumText = '';
|
||||
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) + '화';
|
||||
}
|
||||
// 에피소드 번호 추출 (title에서 "N화" 패턴 찾기)
|
||||
let epNumMatch = epTitle.match(/(\d+)화/);
|
||||
let epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화';
|
||||
|
||||
str += '<div class="episode-card">';
|
||||
str += '<div class="episode-thumb">';
|
||||
@@ -279,17 +221,11 @@
|
||||
}
|
||||
str += '</div>';
|
||||
|
||||
// 먼저 HTML을 삽입하고 레이아웃이 완료된 후 bootstrapToggle 적용
|
||||
document.getElementById("episode_list").innerHTML = str;
|
||||
|
||||
// 다음 프레임에서 bootstrapToggle 적용 (모든 카드가 먼저 표시됨)
|
||||
requestAnimationFrame(function() {
|
||||
$('input[id^="checkbox_"]').bootstrapToggle();
|
||||
});
|
||||
$('input[id^="checkbox_"]').bootstrapToggle()
|
||||
}
|
||||
|
||||
$(function () {
|
||||
updateGoButtonHref(params.code || $('#code').val());
|
||||
// // console.log(params.wr_id)
|
||||
// // console.log(findGetParameter('wr_id'))
|
||||
// // console.log(params.code)
|
||||
@@ -329,7 +265,7 @@
|
||||
// // console.log("{{ arg['code'] }}")
|
||||
// // console.log('wr_id::', params.wr_id)
|
||||
if (document.getElementById("code").value !== "") {
|
||||
// document.getElementById("analysis_btn").click()
|
||||
{#document.getElementById("analysis_btn").click()#}
|
||||
$('#analysis_btn').click();
|
||||
}
|
||||
|
||||
@@ -338,13 +274,6 @@
|
||||
|
||||
});
|
||||
|
||||
// Enter 키로 검색 트리거
|
||||
$("#code").on('keypress', function (e) {
|
||||
if (e.which === 13) { // Enter key
|
||||
e.preventDefault();
|
||||
$("#analysis_btn").click();
|
||||
}
|
||||
});
|
||||
|
||||
$("#analysis_btn").unbind("click").bind('click', function (e) {
|
||||
e.preventDefault();
|
||||
@@ -365,9 +294,8 @@
|
||||
dataType: "json",
|
||||
success: function (ret) {
|
||||
if (ret.ret === 'success' && ret.data != null) {
|
||||
// console.log(ret.code)
|
||||
// {#// // console.log(ret.code)#}
|
||||
// // console.log(ret.data)
|
||||
updateGoButtonHref((ret.data && ret.data.code) || code);
|
||||
make_program(ret.data)
|
||||
} else {
|
||||
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||
@@ -410,6 +338,11 @@
|
||||
});
|
||||
});
|
||||
|
||||
$("body").on('click', '#go_ohli24_btn', function (e) {
|
||||
e.preventDefault();
|
||||
window.open("{{arg['ohli24_url']}}", "_blank");
|
||||
});
|
||||
|
||||
$("body").on('click', '#all_check_on_btn', function (e) {
|
||||
e.preventDefault();
|
||||
$('input[id^="checkbox_"]').bootstrapToggle('on')
|
||||
@@ -432,16 +365,14 @@
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
// // console.log('#add_queue_btn::data >>', data)
|
||||
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist' || data.ret == 'enqueue_gdm_success') {
|
||||
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist') {
|
||||
$.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
|
||||
} else if (data.ret == 'queue_exist') {
|
||||
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
|
||||
} else if (data.ret == 'db_completed') {
|
||||
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
|
||||
} else if (data.ret == 'file_exists') {
|
||||
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
|
||||
} else {
|
||||
$.notify('<strong>추가 실패</strong><br>' + data.log, {type: 'warning'});
|
||||
$.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -749,8 +680,8 @@
|
||||
.row, form, #program_list, #program_auto_form, #episode_list {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
padding-left: 2px !important;
|
||||
padding-right: 2px !important;
|
||||
padding-left: 4px !important;
|
||||
padding-right: 4px !important;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
box-sizing: border-box !important;
|
||||
@@ -803,16 +734,16 @@
|
||||
}
|
||||
|
||||
/* ===== 상단 정보 카드 ===== */
|
||||
.glass-card {
|
||||
width: calc(100% - 4px) !important;
|
||||
.card.p-lg-5, .card.border-light {
|
||||
width: calc(100% - 10px) !important;
|
||||
max-width: 100% !important;
|
||||
padding: 8px !important;
|
||||
margin: 2px !important;
|
||||
padding: 6px !important; /* Reduced from 15px */
|
||||
margin: 5px !important;
|
||||
border-radius: 12px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.glass-card > .row {
|
||||
.card.p-lg-5 > .row {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
width: 100% !important;
|
||||
@@ -1375,51 +1306,40 @@ $(document).ready(function(){
|
||||
}, 100);
|
||||
});
|
||||
</script>
|
||||
<!-- Video Player Modal -->
|
||||
<!-- Video Player Modal (Copied from List page) -->
|
||||
<div class="modal fade" id="videoModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl" role="document">
|
||||
<div class="modal-content" style="background: #0f172a; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1);">
|
||||
<div class="modal-dialog modal-lg" style="max-width: 90%; margin: 1.75rem auto;">
|
||||
<div class="modal-content" style="background: #0f172a; border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 16px;">
|
||||
<div class="modal-header" style="border-bottom: 1px solid rgba(255,255,255,0.05); padding: 15px 20px;">
|
||||
<h5 class="modal-title" style="color: #f1f5f9; font-weight: 700; display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fa fa-play-circle" style="color: #3b82f6;"></i>
|
||||
<h5 class="modal-title" style="color: #ecfdf5; font-weight: 700; display: flex; align-items: center; gap: 10px;">
|
||||
<i class="fa fa-youtube-play" style="color: #10b981;"></i>
|
||||
<span id="current-video-title">Video Player</span>
|
||||
</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #f1f5f9; opacity: 1;">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #94a3b8; text-shadow: none; opacity: 1;">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="video-container" style="position: relative; overflow: hidden; background: #000;">
|
||||
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" playsinline webkit-playsinline style="width: 100%; height: auto; max-height: 80vh;">
|
||||
<p class="vjs-no-js">JavaScript가 필요합니다.</p>
|
||||
</video>
|
||||
<!-- 화면 꽉 채우기 토글 버튼 -->
|
||||
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절">
|
||||
<i class="fa fa-expand"></i>
|
||||
<div class="modal-body p-0" style="display: flex; flex-direction: column; height: 80vh;">
|
||||
<div style="flex-grow: 1; background: black; position: relative;">
|
||||
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-forest" controls preload="auto" style="width: 100%; height: 100%;"></video>
|
||||
</div>
|
||||
|
||||
<!-- Playlist Control Bar -->
|
||||
<div style="height: 60px; background: rgba(6, 78, 59, 0.3); border-top: 1px solid rgba(16, 185, 129, 0.1); display: flex; align-items: center; justify-content: space-between; padding: 0 20px;">
|
||||
<div style="display: flex; align-items: center; gap: 15px;">
|
||||
<button id="btn-prev-ep" class="playlist-nav-btn" title="이전 에피소드"><i class="fa fa-step-backward"></i></button>
|
||||
<button id="btn-next-ep" class="playlist-nav-btn" title="다음 에피소드"><i class="fa fa-step-forward"></i></button>
|
||||
<span id="playlist-progress" style="color: #d1fae5; font-weight: 600; font-size: 14px;"></span>
|
||||
</div>
|
||||
<button id="btn-toggle-playlist" class="playlist-toggle-btn active">
|
||||
<i class="fa fa-list"></i> 플레이리스트
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 플레이리스트 컨트롤 UI -->
|
||||
<div class="playlist-controls">
|
||||
<div class="playlist-header">
|
||||
<button id="btn-prev-ep" class="nav-btn" title="이전 에피소드">
|
||||
<i class="fa fa-chevron-left"></i>
|
||||
</button>
|
||||
<div class="playing-info">
|
||||
<div id="playlist-progress" class="progress-text"></div>
|
||||
</div>
|
||||
<button id="btn-next-ep" class="nav-btn" title="다음 에피소드">
|
||||
<i class="fa fa-chevron-right"></i>
|
||||
</button>
|
||||
<div class="control-group">
|
||||
<button id="btn-toggle-playlist" class="action-btn active" title="목록 토글">
|
||||
<i class="fa fa-list-ul"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playlist-list-container" class="playlist-drawer" style="display: block;">
|
||||
<div id="playlist-list" class="playlist-grid"></div>
|
||||
<!-- Playlist Drawer -->
|
||||
<div id="playlist-list-container" style="height: 0px; background: rgba(2, 44, 34, 0.95); transition: height 0.3s ease; overflow-y: auto; border-top: 1px solid rgba(16, 185, 129, 0.1); display: none;">
|
||||
<div id="playlist-list" style="padding: 10px;">
|
||||
<!-- JS generated items -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1427,97 +1347,87 @@ $(document).ready(function(){
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<link href="https://vjs.zencdn.net/7.20.3/video-js.css" rel="stylesheet" />
|
||||
<script src="https://vjs.zencdn.net/7.20.3/video.min.js"></script>
|
||||
|
||||
<style>
|
||||
/* Video Player Enhancements */
|
||||
.playlist-controls {
|
||||
padding: 16px;
|
||||
background: linear-gradient(to bottom, rgba(30, 41, 59, 0.98), rgba(15, 23, 42, 1));
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
/* VideoJS Customization */
|
||||
.vjs-theme-forest { --vjs-theme-forest--emerald: #10b981; }
|
||||
.video-js.vjs-theme-forest .vjs-big-play-button { background-color: #10b981; border-color: #34d399; }
|
||||
|
||||
.playlist-nav-btn {
|
||||
width: 40px; height: 40px; background: linear-gradient(135deg, #10b981, #059669);
|
||||
border: none; border-radius: 50%; color: white; font-size: 14px;
|
||||
cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.playlist-nav-btn:hover { transform: scale(1.1); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); }
|
||||
.playlist-nav-btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }
|
||||
|
||||
.playlist-toggle-btn {
|
||||
padding: 8px 14px; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
border-radius: 8px; color: #6ee7b7; font-size: 13px; cursor: pointer; transition: all 0.2s ease;
|
||||
}
|
||||
.playlist-toggle-btn:hover { background: rgba(16, 185, 129, 0.2); color: #ecfdf5; }
|
||||
.playlist-toggle-btn.active { background: rgba(16, 185, 129, 0.3); border-color: #10b981; color: #10b981; }
|
||||
|
||||
.playlist-item {
|
||||
padding: 10px 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s;
|
||||
color: #d1fae5; font-size: 13px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
.playlist-item:hover { background: rgba(16, 185, 129, 0.1); color: #fff; }
|
||||
.playlist-item.active { background: rgba(16, 185, 129, 0.2); color: #10b981; font-weight: 600; border-left: 3px solid #10b981; }
|
||||
|
||||
/* ========== Mobile Video Modal Fix ========== */
|
||||
@media (max-width: 768px) {
|
||||
/* 모달 크기 조정 */
|
||||
#videoModal .modal-dialog {
|
||||
margin: 10px auto !important;
|
||||
max-width: calc(100vw - 20px) !important;
|
||||
}
|
||||
|
||||
.playlist-header { display: flex; align-items: center; gap: 15px; }
|
||||
.playing-info { flex: 1; min-width: 0; }
|
||||
.progress-text { color: #94a3b8; font-size: 13px; font-weight: 600; }
|
||||
|
||||
.nav-btn, .action-btn {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
width: 36px; height: 36px;
|
||||
border-radius: 10px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav-btn:hover, .action-btn:hover { background: rgba(59, 130, 246, 0.2); border-color: #3b82f6; }
|
||||
.action-btn.active { background: #3b82f6; box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); }
|
||||
|
||||
.playlist-drawer {
|
||||
margin-top: 15px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
/* 모달 바디 높이 조정 */
|
||||
#videoModal .modal-body {
|
||||
height: auto !important;
|
||||
max-height: 75vh !important;
|
||||
}
|
||||
|
||||
.playlist-grid { display: flex; flex-direction: column; gap: 4px; }
|
||||
.playlist-item {
|
||||
padding: 10px 15px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
cursor: pointer; transition: all 0.2s;
|
||||
font-size: 13px; color: #cbd5e1;
|
||||
}
|
||||
.playlist-item:hover { background: rgba(255, 255, 255, 0.08); transform: translateX(5px); }
|
||||
.playlist-item.active { background: rgba(59, 130, 246, 0.15); border: 1px solid rgba(59, 130, 246, 0.3); color: #60a5fa; font-weight: 700; }
|
||||
|
||||
/* Zoom Button overlay */
|
||||
.video-zoom-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
z-index: 10;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
color: white;
|
||||
width: 38px; height: 38px;
|
||||
border-radius: 10px;
|
||||
opacity: 0; transition: opacity 0.3s;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.video-container:hover .video-zoom-btn { opacity: 1; }
|
||||
.video-zoom-btn.active { background: #3b82f6; border-color: transparent; }
|
||||
|
||||
/* Video.js skin overrides */
|
||||
.video-js.vjs-theme-fantasy .vjs-big-play-button {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%) !important;
|
||||
border: none !important;
|
||||
width: 90px !important; height: 90px !important;
|
||||
line-height: 90px !important;
|
||||
border-radius: 50% !important;
|
||||
box-shadow: 0 0 30px rgba(37, 99, 235, 0.6) !important;
|
||||
transition: all 0.3s ease !important;
|
||||
}
|
||||
.video-js.vjs-theme-fantasy .vjs-big-play-button .vjs-icon-placeholder:before {
|
||||
font-size: 60px !important;
|
||||
line-height: 90px !important;
|
||||
/* 비디오 높이 제한 */
|
||||
#video-player, .video-js {
|
||||
max-height: 40vh !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#videoModal .modal-dialog { width: 100% !important; margin: 0 !important; max-width: 100% !important; }
|
||||
#videoModal .modal-content { border-radius: 0 !important; height: 100vh; display: flex; flex-direction: column; }
|
||||
.video-container { flex: 1; display: flex; align-items: center; }
|
||||
.playlist-controls { padding-bottom: 25px; }
|
||||
.video-zoom-btn { opacity: 0.8; top: 10px; right: 10px; }
|
||||
/* 플레이리스트 높이 제한 */
|
||||
#playlist-list-container {
|
||||
max-height: 20vh !important;
|
||||
}
|
||||
|
||||
/* 플레이리스트 컨트롤 간소화 */
|
||||
.playlist-nav-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
.playlist-toggle-btn {
|
||||
padding: 6px 10px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
#playlist-progress {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 초소형 모바일 (400px 미만) */
|
||||
@media (max-width: 400px) {
|
||||
#video-player, .video-js {
|
||||
max-height: 35vh !important;
|
||||
}
|
||||
|
||||
#playlist-list-container {
|
||||
max-height: 15vh !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
@@ -1552,21 +1462,6 @@ function play_video(path, filename) {
|
||||
playVideoAtIndex(currentPlaylistIndex + 1);
|
||||
}
|
||||
});
|
||||
|
||||
// 비디오 줌/확장 처리
|
||||
$('#btn-video-zoom').click(function() {
|
||||
const $video = $('#video-player_html5_api');
|
||||
$(this).toggleClass('active');
|
||||
if ($(this).hasClass('active')) {
|
||||
$video.css({'object-fit': 'cover', 'height': '100%'});
|
||||
$(this).find('i').removeClass('fa-expand').addClass('fa-compress');
|
||||
$.notify('화면을 꽉 채웠습니다.', {type: 'info', delay: 1000});
|
||||
} else {
|
||||
$video.css({'object-fit': 'contain', 'height': 'auto'});
|
||||
$(this).find('i').removeClass('fa-compress').addClass('fa-expand');
|
||||
$.notify('원래 비율로 복원되었습니다.', {type: 'info', delay: 1000});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
playVideoAtIndex(currentPlaylistIndex);
|
||||
@@ -1662,7 +1557,13 @@ $('#btn-next-ep').click(function() { playVideoAtIndex(currentPlaylistIndex + 1);
|
||||
$('#btn-toggle-playlist').click(function() {
|
||||
$(this).toggleClass('active');
|
||||
var container = $('#playlist-list-container');
|
||||
container.slideToggle(300);
|
||||
if (container.is(':visible')) {
|
||||
container.css('height', '0');
|
||||
setTimeout(function() { container.hide(); }, 300);
|
||||
} else {
|
||||
container.show();
|
||||
setTimeout(function() { container.css('height', '200px'); }, 10);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,42 +1,30 @@
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
|
||||
<!-- Modern UI Loader -->
|
||||
<div id="preloader" class="loader-bg">
|
||||
<div class="modern-loader">
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="loader-text">Loading Ohli24</div>
|
||||
<div id="preloader" class="loader">
|
||||
<div class="loader-inner">
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
<div class="loader-line-wrap">
|
||||
<div class="loader-line"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AJAX Loading Spinner (Global) -->
|
||||
<div id="ajax_loader" class="ajax-loader-container" style="display: none;">
|
||||
<div class="ajax-spinner"></div>
|
||||
</div>
|
||||
<div id="ohli24_search_wrapper" class="ohli24-common-wrapper container-fluid content-cloak ohli24-search-page">
|
||||
<div id="yommi_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%;">
|
||||
<!-- Search Section -->
|
||||
<div class="glass-card p-4 mb-4">
|
||||
<div class="ohli24-header">
|
||||
<div class="ohli24-header-left">
|
||||
<div class="ohli24-icon-box">
|
||||
<i class="bi bi-search text-primary" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="ohli24-header-title">Ohli24 검색</h3>
|
||||
<span class="ohli24-header-subtitle">애니메이션 검색 및 탐색</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="border-color: rgba(255,255,255,0.05); margin-bottom: 25px;">
|
||||
|
||||
<div class="card p-4 mb-4 border-0" style="background: rgba(30,30,40,0.6); backdrop-filter: blur(10px); border-radius: 16px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8 mx-auto">
|
||||
<div class="input-group input-group-responsive">
|
||||
<div class="input-group input-group-lg">
|
||||
<input
|
||||
id="input_search"
|
||||
type="search"
|
||||
@@ -56,23 +44,10 @@
|
||||
|
||||
<!-- Category Buttons -->
|
||||
<div class="row mt-4 justify-content-center">
|
||||
<div id="anime_category" class="d-flex flex-wrap justify-content-center" role="group">
|
||||
<button id="ing" type="button" class="btn btn-success btn-pill px-4 mx-1 active active-glow mb-2">방영중</button>
|
||||
<button id="fin" type="button" class="btn btn-outline-light btn-pill px-4 mx-1 active-glow mb-2">완결</button>
|
||||
<button id="theater" type="button" class="btn btn-outline-primary btn-pill px-4 mx-1 active-glow mb-2">극장판</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year Selection (Sub-category) -->
|
||||
<div id="year_filter_row" class="row mt-3 justify-content-center animate__animated animate__fadeIn" style="display: none;">
|
||||
<div id="year_category" class="d-flex flex-wrap justify-content-center" role="group">
|
||||
<button data-sca="" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2 active">전체</button>
|
||||
<button data-sca="2025" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2">2025</button>
|
||||
<button data-sca="2024" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2">2024</button>
|
||||
<button data-sca="2023" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2">2023</button>
|
||||
<button data-sca="2022" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2">2022</button>
|
||||
<button data-sca="2021" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2">2021</button>
|
||||
<button data-sca="2020" type="button" class="btn btn-sm btn-outline-secondary btn-pill px-3 mx-1 mb-2">2020</button>
|
||||
<div id="anime_category" class="d-flex flex-wrap justify-content-center gap-2" role="group">
|
||||
<button id="ing" type="button" class="btn btn-outline-success btn-pill px-4 mx-1 active-glow">방영중</button>
|
||||
<button id="theater" type="button" class="btn btn-outline-primary btn-pill px-4 mx-1 active-glow">극장판</button>
|
||||
<button id="complete_anilist" type="button" class="btn btn-outline-light btn-pill px-4 mx-1 active-glow">완결</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,7 +57,7 @@
|
||||
<div id="airing_list"></div>
|
||||
</form>
|
||||
<form id="screen_movie_list_form">
|
||||
<div id="screen_movie_list"></div>
|
||||
<div id="screen_movie_list" class="container-fluid px-0"></div>
|
||||
</form>
|
||||
|
||||
<form id="program_auto_form">
|
||||
@@ -107,8 +82,7 @@
|
||||
// let current_data = null;
|
||||
let page = 1;
|
||||
let next_page = Number
|
||||
let current_cate = 'ing'
|
||||
let current_sca = ''
|
||||
let current_cate = ''
|
||||
let current_query = ''
|
||||
|
||||
const loader = document.getElementById("preloader");
|
||||
@@ -119,7 +93,7 @@
|
||||
|
||||
const wait3seconds = function () {
|
||||
// REFERENCE: https://www.w3schools.com/jsref/met_win_settimeout.asp
|
||||
const result = setTimeout(dismissLoadingScreen, 1000);
|
||||
const result = setTimeout(dismissLoadingScreen, 2000);
|
||||
};
|
||||
|
||||
window.addEventListener("load", wait3seconds);
|
||||
@@ -134,12 +108,10 @@
|
||||
observer.observe();
|
||||
|
||||
|
||||
const get_anime_list = (type, page, sca = '') => {
|
||||
// console.log(`type: ${type}, page: ${page}, sca: ${sca}`)
|
||||
const get_anime_list = (type, page) => {
|
||||
// console.log(`type: ${type}, page: ${page}`)
|
||||
let url = ''
|
||||
let data = {"page": page, "type": type}
|
||||
if (sca) data.sca = sca;
|
||||
current_sca = sca;
|
||||
|
||||
switch (type) {
|
||||
case 'ing':
|
||||
@@ -162,38 +134,31 @@
|
||||
break;
|
||||
}
|
||||
|
||||
$('#ajax_loader').show();
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: "POST",
|
||||
data: data,
|
||||
cache: false,
|
||||
global: false,
|
||||
dataType: "json",
|
||||
complete: () => {
|
||||
$('#ajax_loader').hide();
|
||||
},
|
||||
success: (ret) => {
|
||||
let current_screen_movie_data = ret
|
||||
// console.log('ret::>', ret)
|
||||
|
||||
if (ret.ret === 'success') {
|
||||
if (current_screen_movie_data !== '') {
|
||||
if (type === "ing") {
|
||||
make_airing_list(ret.data, page)
|
||||
// observer.observe();
|
||||
{#observer.observe();#}
|
||||
} else if (type === "fin") {
|
||||
make_screen_movie_list(ret.data, page)
|
||||
// observer.observe();
|
||||
{#observer.observe();#}
|
||||
} else if (type === "theater") {
|
||||
make_screen_movie_list(ret.data, page)
|
||||
// observer.observe();
|
||||
{#observer.observe();#}
|
||||
} else {
|
||||
make_screen_movie_list(ret.data, page)
|
||||
}
|
||||
// div_visible = true
|
||||
// // console.log(div_visible)
|
||||
} else {
|
||||
$.notify('<strong>데이터를 불러오지 못했습니다.</strong><br>' + (ret.log || ''), {type: 'danger'});
|
||||
{#div_visible = true#}
|
||||
{#// console.log(div_visible)#}
|
||||
}
|
||||
next_page = page + 1
|
||||
}
|
||||
@@ -234,11 +199,6 @@
|
||||
}
|
||||
|
||||
function make_airing_list(data, page) {
|
||||
if (!data || !data.anime_list) {
|
||||
console.error("Invalid data received for airing list:", data);
|
||||
$.notify('<strong>데이터를 불러오지 못했습니다.</strong>', {type: 'danger'});
|
||||
return;
|
||||
}
|
||||
let str = ''
|
||||
|
||||
str += '<div class="d-flex justify-content-between align-items-center mb-3">';
|
||||
@@ -274,11 +234,6 @@
|
||||
}
|
||||
|
||||
function make_search_result_list(data, page) {
|
||||
if (!data || !data.anime_list) {
|
||||
console.error("Invalid data received for search result list:", data);
|
||||
$.notify('<strong>검색 결과를 불러오지 못했습니다.</strong>', {type: 'danger'});
|
||||
return;
|
||||
}
|
||||
let str = ''
|
||||
let list = data.anime_list || [];
|
||||
|
||||
@@ -319,15 +274,10 @@
|
||||
}
|
||||
|
||||
function make_screen_movie_list(data, page) {
|
||||
if (!data || !data.anime_list) {
|
||||
console.error("Invalid data received for screen movie list:", data);
|
||||
return;
|
||||
}
|
||||
let str = '';
|
||||
let str = '';
|
||||
|
||||
str += '<div class="d-flex justify-content-between align-items-center mb-3">';
|
||||
let title = current_cate === 'movie' ? '극장판' : (current_cate === 'theater' ? '극장판(구)' : '완결 애니메이션');
|
||||
if (current_sca) title += ' (' + current_sca + ')';
|
||||
str += '<h5 class="text-white font-weight-bold border-left pl-3" style="border-width: 4px !important; border-color: #ffd700 !important;">' + title + '</h5>';
|
||||
str += '<button type="button" class="btn btn-sm btn-dark rounded-pill px-3">Page <span class="badge badge-warning ml-1">' + page + '</span></button>';
|
||||
str += '</div>';
|
||||
@@ -398,18 +348,13 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
$('#ajax_loader').show();
|
||||
$.ajax({
|
||||
url: "/" + package_name + "/ajax/" + sub + "/search",
|
||||
type: "POST",
|
||||
cache: false,
|
||||
global: false,
|
||||
data: {query: query, type: current_cate, page: page},
|
||||
// dataType: "json",
|
||||
contentType: "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
complete: () => {
|
||||
$('#ajax_loader').hide();
|
||||
},
|
||||
success: function (ret) {
|
||||
if (ret.ret) {
|
||||
// console.log('ret:::', ret)
|
||||
@@ -424,44 +369,24 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Category button active state handling
|
||||
function updateActiveButton(id) {
|
||||
$('#anime_category button').removeClass('active btn-success btn-light btn-primary text-dark');
|
||||
$('#anime_category button#ing').addClass('btn-outline-success');
|
||||
$('#anime_category button#fin').addClass('btn-outline-light');
|
||||
$('#anime_category button#theater').addClass('btn-outline-primary');
|
||||
|
||||
const btn = $('#' + id);
|
||||
btn.addClass('active');
|
||||
if (id === 'ing') btn.removeClass('btn-outline-success').addClass('btn-success');
|
||||
else if (id === 'fin') btn.removeClass('btn-outline-light').addClass('btn-light text-dark');
|
||||
else if (id === 'theater') btn.removeClass('btn-outline-primary').addClass('btn-primary');
|
||||
}
|
||||
|
||||
$('#anime_category #ing').on("click", function () {
|
||||
updateActiveButton('ing');
|
||||
// {#// console.log(this.id)#}
|
||||
// let spinner = document.getElementById('spinner');
|
||||
// spinner.style.visibility = 'visible';
|
||||
get_anime_list("ing", 1)
|
||||
})
|
||||
|
||||
$('#anime_category #fin').on("click", function () {
|
||||
updateActiveButton('fin');
|
||||
$('#year_filter_row').fadeIn();
|
||||
get_anime_list("fin", 1, current_sca)
|
||||
$('#anime_category #complete_anilist').on("click", function () {
|
||||
// {#// console.log(this.id)#}
|
||||
// let spinner = document.getElementById('spinner');
|
||||
// spinner.style.visibility = 'visible';
|
||||
get_anime_list("fin", 1)
|
||||
})
|
||||
|
||||
$('#year_category button').on("click", function() {
|
||||
$('#year_category button').removeClass('active btn-secondary').addClass('btn-outline-secondary');
|
||||
$(this).removeClass('btn-outline-secondary').addClass('active btn-secondary');
|
||||
current_sca = $(this).data('sca');
|
||||
get_anime_list("fin", 1, current_sca);
|
||||
});
|
||||
|
||||
$('#anime_category #ing, #anime_category #theater').on("click", function() {
|
||||
$('#year_filter_row').hide();
|
||||
});
|
||||
|
||||
$('#anime_category #theater').on("click", function () {
|
||||
updateActiveButton('theater');
|
||||
// {#// console.log(this.id)#}
|
||||
// let spinner = document.getElementById('spinner');
|
||||
// spinner.style.visibility = 'visible';
|
||||
get_anime_list("theater", 1)
|
||||
})
|
||||
|
||||
@@ -470,17 +395,12 @@
|
||||
e.preventDefault();
|
||||
const code = document.getElementById("code").value
|
||||
// console.log(code)
|
||||
$('#ajax_loader').show();
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + sub + '/analysis',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
global: false,
|
||||
data: {code: code},
|
||||
dataType: "json",
|
||||
complete: () => {
|
||||
$('#ajax_loader').hide();
|
||||
},
|
||||
success: function (ret) {
|
||||
if (ret.ret === 'success' && ret.data != null) {
|
||||
// // console.log(ret.code)
|
||||
@@ -546,14 +466,14 @@
|
||||
data: {data: JSON.stringify(data)},
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist' || data.ret == 'enqueue_gdm_success') {
|
||||
if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist') {
|
||||
$.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
|
||||
} else if (data.ret == 'queue_exist') {
|
||||
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
|
||||
} else if (data.ret == 'db_completed') {
|
||||
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
|
||||
} else {
|
||||
$.notify('<strong>추가 실패</strong><br>' + data.log, {type: 'warning'});
|
||||
$.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'});
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -563,9 +483,8 @@
|
||||
// const observer = lozad(el); // passing a `NodeList` (e.g. `document.querySelectorAll()`) is also valid
|
||||
// observer.observe();
|
||||
const loadNextAnimes = (cate, page) => {
|
||||
// spinner.style.display = "block";
|
||||
{#spinner.style.display = "block";#}
|
||||
let data = {type: cate, page: String(page)};
|
||||
if (current_sca) data.sca = current_sca;
|
||||
let url = ''
|
||||
switch (cate) {
|
||||
case 'ing':
|
||||
@@ -593,7 +512,6 @@
|
||||
break;
|
||||
}
|
||||
|
||||
$('#ajax_loader').show();
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
cache: "no-cache",
|
||||
@@ -617,10 +535,7 @@
|
||||
page++;
|
||||
next_page++;
|
||||
})
|
||||
.catch((error) => console.error("Error:", error))
|
||||
.finally(() => {
|
||||
$('#ajax_loader').hide();
|
||||
});
|
||||
.catch((error) => console.error("Error:", error));
|
||||
};
|
||||
|
||||
|
||||
@@ -628,7 +543,7 @@
|
||||
console.dir(e.target.scrollingElement.scrollHeight);
|
||||
const {scrollTop, scrollHeight, clientHeight} = e.target.scrollingElement;
|
||||
if (Math.round(scrollHeight - scrollTop) <= clientHeight) {
|
||||
// document.getElementById("spinner").style.display = "block";
|
||||
{#document.getElementById("spinner").style.display = "block";#}
|
||||
// console.log("loading");
|
||||
// console.log("now page::> ", page);
|
||||
// console.log("next_page::> ", String(next_page));
|
||||
@@ -647,7 +562,12 @@
|
||||
document.addEventListener("scroll", debounce(onScroll, 300));
|
||||
</script>
|
||||
<style>
|
||||
/* Responsive Grid System */
|
||||
body {
|
||||
font-family: 'NamumSquareNeo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif;
|
||||
background-image: linear-gradient(135deg, #1f2937, #111827, #0f172a);
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Responsive Grid System */
|
||||
.anime-grid {
|
||||
@@ -670,6 +590,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Navigation Menu Override */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(30, 41, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto !important;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-item {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 20px !important;
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
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 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
/* Glassmorphism Cards */
|
||||
.glass-card {
|
||||
background: rgba(30, 41, 59, 0.7) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08) !important;
|
||||
border-radius: 16px !important;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease !important;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.glass-card:hover {
|
||||
transform: translateY(-5px);
|
||||
@@ -731,67 +700,19 @@
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Modern Premium Preloader */
|
||||
.loader-bg {
|
||||
/* Preloader (Original but styled) */
|
||||
#preloader {
|
||||
background-color: #0f172a;
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: #0f172a;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.modern-loader {
|
||||
position: relative;
|
||||
width: 120px; height: 120px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
position: absolute;
|
||||
width: 100%; height: 100%;
|
||||
border: 4px solid transparent;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1.5s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
}
|
||||
|
||||
.spinner-ring:nth-child(2) { border-top-color: #00d4ff; animation-delay: -0.3s; width: 80%; height: 80%; }
|
||||
.spinner-ring:nth-child(3) { border-top-color: #fbbf24; animation-delay: -0.6s; width: 60%; height: 60%; }
|
||||
|
||||
.loader-text {
|
||||
position: absolute;
|
||||
bottom: -40px;
|
||||
color: #94a3b8;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
||||
@keyframes pulse { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
|
||||
|
||||
/* AJAX Global Spinner */
|
||||
.ajax-loader-container {
|
||||
position: fixed;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(15, 23, 42, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 25px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ajax-spinner {
|
||||
width: 50px; height: 50px;
|
||||
border: 4px solid rgba(59, 130, 246, 0.1);
|
||||
border-left-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
.loader-inner {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
@@ -854,6 +775,39 @@
|
||||
transition-delay: 300ms;
|
||||
}
|
||||
|
||||
/* Navigation Menu Override (Top Sub-menu) */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(30, 41, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50rem !important;
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
width: auto !important;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-item { margin: 0 2px; }
|
||||
ul.nav.nav-pills .nav-link {
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 20px !important;
|
||||
color: #94a3b8 !important;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
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 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -868,7 +822,7 @@ $(document).ready(function(){
|
||||
/* Mobile Margin Fix */
|
||||
@media (max-width: 768px) {
|
||||
body { overflow-x: hidden !important; padding: 0 !important; margin: 0 !important; }
|
||||
.container, .container-fluid, .row, form, #program_list, #program_auto_form, #episode_list, .queue-container, #ohli24_search_wrapper, #main_container {
|
||||
.container, .container-fluid, .row, form, #program_list, #program_auto_form, #episode_list, .queue-container, #yommi_wrapper, #main_container {
|
||||
width: 100% !important; max-width: 100% !important;
|
||||
padding-left: 4px !important; padding-right: 4px !important;
|
||||
margin-left: 0 !important; margin-right: 0 !important;
|
||||
|
||||
@@ -1,27 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
|
||||
<link rel="stylesheet" href="{{ url_for('.static', filename='css/' ~ arg['sub'] ~ '.css') }}"/>
|
||||
|
||||
<div id="ohli24_setting_wrapper" class="ohli24-common-wrapper container-fluid mt-4 content-cloak">
|
||||
<div id="ohli24_setting_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%;">
|
||||
|
||||
<div class="glass-card p-4">
|
||||
<div class="ohli24-header">
|
||||
<div class="ohli24-header-left">
|
||||
<div class="ohli24-icon-box">
|
||||
<i class="bi bi-gear-fill text-primary" style="font-size: 1.5rem;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="ohli24-header-title">Ohli24 설정</h3>
|
||||
<span class="ohli24-header-subtitle">플러그인 설정 및 시스템 상태</span>
|
||||
</div>
|
||||
</div>
|
||||
<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', '설정 저장']])}}
|
||||
</div>
|
||||
<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>Ohli24 설정</h2>
|
||||
{{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
|
||||
</div>
|
||||
|
||||
{{ macros.m_row_start('5') }}
|
||||
@@ -30,7 +14,7 @@
|
||||
<nav>
|
||||
{{ macros.m_tab_head_start() }}
|
||||
{{ 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_end() }}
|
||||
</nav>
|
||||
@@ -75,41 +59,13 @@
|
||||
{{ macros.setting_checkbox('ohli24_auto_make_season_folder', '시즌 폴더 생성', value=arg['ohli24_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }}
|
||||
</div>
|
||||
{{ macros.setting_checkbox('ohli24_uncompleted_auto_enqueue', '자동으로 다시 받기', value=arg['ohli24_uncompleted_auto_enqueue'], desc=['On : 플러그인 로딩시 미완료인 항목은 자동으로 다시 받습니다.']) }}
|
||||
{{ macros.setting_select('ohli24_cache_minutes', 'HTML 캐시 시간', [['0', '캐시 없음'], ['5', '5분'], ['10', '10분'], ['15', '15분'], ['30', '30분'], ['60', '1시간']], value=arg.get('ohli24_cache_minutes', '5'), desc=['브라우징(요청, 검색) 페이지의 HTML을 캐시합니다.', '0으로 설정하면 캐시를 사용하지 않습니다.', '다운로드 루틴은 캐시를 사용하지 않습니다.']) }}
|
||||
{{ macros.m_tab_content_end() }}
|
||||
|
||||
{{ macros.m_tab_content_start('auto', false) }}
|
||||
{{ 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_checkbox('ohli24_auto_start', '시작시 자동실행', value=arg['ohli24_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
|
||||
<!-- 자동 다운로드 작품 코드 - 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_input_textarea('ohli24_auto_code_list', '자동 다운로드할 작품 코드', desc=['구분자 | 또는 엔터'], value=arg['ohli24_auto_code_list'], row='10') }}
|
||||
{{ macros.setting_checkbox('ohli24_auto_mode_all', '에피소드 모두 받기', value=arg['ohli24_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
|
||||
{{ macros.m_tab_content_end() }}
|
||||
|
||||
@@ -119,37 +75,6 @@
|
||||
{{ 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('ohli24_zendriver_browser_path', '브라우저 경로', value=arg.get('ohli24_zendriver_browser_path', ''), desc=['Zendriver가 사용할 Chrome/Chromium 실행 파일 경로입니다.', '위의 시스템 상태에서 자동으로 찾은 경우 비워두셔도 됩니다 (수동 설정 시 우선 적용).']) }}
|
||||
</div>
|
||||
{{ macros.m_tab_content_end() }}
|
||||
|
||||
@@ -202,6 +127,105 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
/* Navigation Menu Override (Top Sub-menu) */
|
||||
ul.nav.nav-pills.bg-light {
|
||||
background-color: rgba(30, 41, 59, 0.6) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 50rem !important; /* Pill shape container */
|
||||
padding: 6px !important;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2) !important;
|
||||
display: inline-flex !important; /* Fit content */
|
||||
flex-wrap: wrap; /* allow wrap on small screens */
|
||||
justify-content: center;
|
||||
width: auto !important; /* Prevent full width */
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-item {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
ul.nav.nav-pills .nav-link {
|
||||
border-radius: 50rem !important;
|
||||
padding: 8px 20px !important;
|
||||
color: #94a3b8 !important; /* Muted text */
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
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 {
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%) !important;
|
||||
color: #fff !important;
|
||||
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
/* Global Background */
|
||||
body {
|
||||
font-family: 'NamumSquareNeo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif;
|
||||
background-image: linear-gradient(135deg, #1f2937, #111827, #0f172a);
|
||||
color: #e2e8f0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Glass Card Container */
|
||||
.glass-card {
|
||||
background: rgba(30, 41, 59, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* Tabs Styling */
|
||||
.nav-tabs {
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link {
|
||||
color: #94a3b8;
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link:hover {
|
||||
color: #e2e8f0;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.nav-tabs .nav-link.active {
|
||||
color: #60a5fa !important;
|
||||
background: rgba(30, 41, 59, 0.8) !important;
|
||||
@@ -335,93 +359,6 @@
|
||||
.folder-item i {
|
||||
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>
|
||||
|
||||
<script type="text/javascript">
|
||||
@@ -440,9 +377,6 @@ $(document).ready(function(){
|
||||
}, 100);
|
||||
|
||||
use_collapse('ohli24_auto_make_folder');
|
||||
|
||||
// Tag Chips 초기화
|
||||
initTagChips();
|
||||
});
|
||||
|
||||
$('#ani365_auto_make_folder').change(function() {
|
||||
@@ -627,336 +561,6 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ======================================
|
||||
// 시스템 체크 및 브라우저 설치
|
||||
// ======================================
|
||||
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) {
|
||||
$('#ohli24_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'});
|
||||
});
|
||||
|
||||
// 초기 실행
|
||||
$(document).ready(function() {
|
||||
// Action 탭이 활성화될 때 체크 (또는 그냥 로딩 시 한 번)
|
||||
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>
|
||||
|
||||
<!-- 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 %}
|
||||
@@ -0,0 +1,360 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"metadata": {
|
||||
"collapsed": true
|
||||
},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<class 'datetime.date'>\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from datetime import date, datetime\n",
|
||||
"\n",
|
||||
"print(date)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"2022-06-07\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"print(date.today())"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"outputs": [
|
||||
{
|
||||
"ename": "RuntimeError",
|
||||
"evalue": "asyncio.run() cannot be called from a running event loop",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001B[0;31m---------------------------------------------------------------------------\u001B[0m",
|
||||
"\u001B[0;31mRuntimeError\u001B[0m Traceback (most recent call last)",
|
||||
"\u001B[0;32m/var/folders/9l/5sls926d02g2kzqb70_0c7rc0000gn/T/ipykernel_40530/2714877972.py\u001B[0m in \u001B[0;36m<module>\u001B[0;34m\u001B[0m\n\u001B[1;32m 52\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 53\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 54\u001B[0;31m \u001B[0masyncio\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mrun\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mmain\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/asyncio/runners.py\u001B[0m in \u001B[0;36mrun\u001B[0;34m(main, debug)\u001B[0m\n\u001B[1;32m 31\u001B[0m \"\"\"\n\u001B[1;32m 32\u001B[0m \u001B[0;32mif\u001B[0m \u001B[0mevents\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_get_running_loop\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m \u001B[0;32mis\u001B[0m \u001B[0;32mnot\u001B[0m \u001B[0;32mNone\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 33\u001B[0;31m raise RuntimeError(\n\u001B[0m\u001B[1;32m 34\u001B[0m \"asyncio.run() cannot be called from a running event loop\")\n\u001B[1;32m 35\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;31mRuntimeError\u001B[0m: asyncio.run() cannot be called from a running event loop"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from playwright.sync_api import sync_playwright\n",
|
||||
"from playwright.async_api import async_playwright\n",
|
||||
"# from playwright_stealth import stealth_sync\n",
|
||||
"import asyncio\n",
|
||||
"\n",
|
||||
"async def run(pw):\n",
|
||||
"\n",
|
||||
" browser = await pw.chromium.launch(headless=False)\n",
|
||||
" # context = browser.new_context(\n",
|
||||
" # user_agent=ua,\n",
|
||||
" # )\n",
|
||||
"\n",
|
||||
"\n",
|
||||
" url = 'https://anilife.live/h/live?p=5aaf4907-da62-4b98-ba8f-59066a0dc0f4&a=none&player=jawcloud'\n",
|
||||
" #\n",
|
||||
" # if referer is not None:\n",
|
||||
" # LogicAniLife.headers[\"Referer\"] = referer\n",
|
||||
"\n",
|
||||
" # context = browser.new_context(extra_http_headers=LogicAniLife.headers)\n",
|
||||
" context = await browser.new_context()\n",
|
||||
" # LogicAniLife.headers[\"Cookie\"] = cookie_value\n",
|
||||
"\n",
|
||||
" # context.set_extra_http_headers(LogicAniLife.headers)\n",
|
||||
"\n",
|
||||
" page = await context.new_page()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
" # page.on(\"request\", set_cookie)\n",
|
||||
" # stealth_sync(page)\n",
|
||||
" page.goto(\n",
|
||||
" url, wait_until=\"networkidle\"\n",
|
||||
" )\n",
|
||||
" page.wait_for_timeout(10000)\n",
|
||||
" # time.sleep(1)\n",
|
||||
" # page.reload()\n",
|
||||
"\n",
|
||||
" # time.sleep(10)\n",
|
||||
" cookies = context.cookies\n",
|
||||
" # print(cookies)\n",
|
||||
"\n",
|
||||
" # print(page.content())\n",
|
||||
" vod_url = page.evaluate(\n",
|
||||
" \"\"\"() => {\n",
|
||||
" return console.log(vodUrl_1080p) }\"\"\"\n",
|
||||
" )\n",
|
||||
" print(vod_url)\n",
|
||||
"\n",
|
||||
"async def main():\n",
|
||||
" async with async_playwright() as p:\n",
|
||||
" await main_run(p)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"asyncio.run(main())"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"outputs": [
|
||||
{
|
||||
"ename": "Error",
|
||||
"evalue": "Executable doesn't exist at /Users/yommi/Library/Caches/ms-playwright/chromium-939194/chrome-mac/Chromium.app/Contents/MacOS/Chromium\n╔═════════════════════════════════════════════════════════════════════════╗\n║ Looks like Playwright Test or Playwright was just installed or updated. ║\n║ Please run the following command to download new browsers: ║\n║ ║\n║ playwright install ║\n║ ║\n║ <3 Playwright Team ║\n╚═════════════════════════════════════════════════════════════════════════╝",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001B[0;31m---------------------------------------------------------------------------\u001B[0m",
|
||||
"\u001B[0;31mError\u001B[0m Traceback (most recent call last)",
|
||||
"\u001B[0;32m/var/folders/9l/5sls926d02g2kzqb70_0c7rc0000gn/T/ipykernel_67283/2756312828.py\u001B[0m in \u001B[0;36m<module>\u001B[0;34m\u001B[0m\n\u001B[1;32m 57\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 58\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 59\u001B[0;31m \u001B[0masyncio\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mrun\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mmain\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/site-packages/nest_asyncio.py\u001B[0m in \u001B[0;36mrun\u001B[0;34m(future, debug)\u001B[0m\n\u001B[1;32m 30\u001B[0m \u001B[0mloop\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0masyncio\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mget_event_loop\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 31\u001B[0m \u001B[0mloop\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mset_debug\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mdebug\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 32\u001B[0;31m \u001B[0;32mreturn\u001B[0m \u001B[0mloop\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mrun_until_complete\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mfuture\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 33\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 34\u001B[0m \u001B[0;32mif\u001B[0m \u001B[0msys\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mversion_info\u001B[0m \u001B[0;34m>=\u001B[0m \u001B[0;34m(\u001B[0m\u001B[0;36m3\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0;36m6\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0;36m0\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/site-packages/nest_asyncio.py\u001B[0m in \u001B[0;36mrun_until_complete\u001B[0;34m(self, future)\u001B[0m\n\u001B[1;32m 68\u001B[0m raise RuntimeError(\n\u001B[1;32m 69\u001B[0m 'Event loop stopped before Future completed.')\n\u001B[0;32m---> 70\u001B[0;31m \u001B[0;32mreturn\u001B[0m \u001B[0mf\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mresult\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 71\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 72\u001B[0m \u001B[0;32mdef\u001B[0m \u001B[0m_run_once\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mself\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/asyncio/futures.py\u001B[0m in \u001B[0;36mresult\u001B[0;34m(self)\u001B[0m\n\u001B[1;32m 176\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m__log_traceback\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0;32mFalse\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 177\u001B[0m \u001B[0;32mif\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_exception\u001B[0m \u001B[0;32mis\u001B[0m \u001B[0;32mnot\u001B[0m \u001B[0;32mNone\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m--> 178\u001B[0;31m \u001B[0;32mraise\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_exception\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 179\u001B[0m \u001B[0;32mreturn\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_result\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 180\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/asyncio/tasks.py\u001B[0m in \u001B[0;36m__step\u001B[0;34m(***failed resolving arguments***)\u001B[0m\n\u001B[1;32m 278\u001B[0m \u001B[0;31m# We use the `send` method directly, because coroutines\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 279\u001B[0m \u001B[0;31m# don't have `__iter__` and `__next__` methods.\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m--> 280\u001B[0;31m \u001B[0mresult\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0mcoro\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0msend\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;32mNone\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 281\u001B[0m \u001B[0;32melse\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 282\u001B[0m \u001B[0mresult\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0mcoro\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mthrow\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mexc\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/var/folders/9l/5sls926d02g2kzqb70_0c7rc0000gn/T/ipykernel_67283/2756312828.py\u001B[0m in \u001B[0;36mmain\u001B[0;34m()\u001B[0m\n\u001B[1;32m 54\u001B[0m \u001B[0;32masync\u001B[0m \u001B[0;32mdef\u001B[0m \u001B[0mmain\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 55\u001B[0m \u001B[0;32masync\u001B[0m \u001B[0;32mwith\u001B[0m \u001B[0masync_playwright\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m \u001B[0;32mas\u001B[0m \u001B[0mp\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 56\u001B[0;31m \u001B[0;32mawait\u001B[0m \u001B[0mmain_run\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mp\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 57\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 58\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/var/folders/9l/5sls926d02g2kzqb70_0c7rc0000gn/T/ipykernel_67283/2756312828.py\u001B[0m in \u001B[0;36mmain_run\u001B[0;34m(playwright)\u001B[0m\n\u001B[1;32m 11\u001B[0m \u001B[0;32masync\u001B[0m \u001B[0;32mdef\u001B[0m \u001B[0mmain_run\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mplaywright\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 12\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 13\u001B[0;31m \u001B[0mbrowser\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0;32mawait\u001B[0m \u001B[0mplaywright\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mchromium\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mlaunch\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mheadless\u001B[0m\u001B[0;34m=\u001B[0m\u001B[0;32mFalse\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 14\u001B[0m \u001B[0;31m# context = browser.new_context(\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 15\u001B[0m \u001B[0;31m# user_agent=ua,\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/site-packages/playwright/async_api/_generated.py\u001B[0m in \u001B[0;36mlaunch\u001B[0;34m(self, executable_path, channel, args, ignore_default_args, handle_sigint, handle_sigterm, handle_sighup, timeout, env, headless, devtools, proxy, downloads_path, slow_mo, traces_dir, chromium_sandbox, firefox_user_prefs)\u001B[0m\n\u001B[1;32m 11600\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 11601\u001B[0m return mapping.from_impl(\n\u001B[0;32m> 11602\u001B[0;31m await self._async(\n\u001B[0m\u001B[1;32m 11603\u001B[0m \u001B[0;34m\"browser_type.launch\"\u001B[0m\u001B[0;34m,\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 11604\u001B[0m self._impl_obj.launch(\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/site-packages/playwright/_impl/_browser_type.py\u001B[0m in \u001B[0;36mlaunch\u001B[0;34m(self, executablePath, channel, args, ignoreDefaultArgs, handleSIGINT, handleSIGTERM, handleSIGHUP, timeout, env, headless, devtools, proxy, downloadsPath, slowMo, tracesDir, chromiumSandbox, firefoxUserPrefs)\u001B[0m\n\u001B[1;32m 86\u001B[0m \u001B[0mparams\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0mlocals_to_params\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mlocals\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 87\u001B[0m \u001B[0mnormalize_launch_params\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mparams\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 88\u001B[0;31m \u001B[0;32mreturn\u001B[0m \u001B[0mfrom_channel\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;32mawait\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_channel\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0msend\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m\"launch\"\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mparams\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 89\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 90\u001B[0m async def launch_persistent_context(\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/site-packages/playwright/_impl/_connection.py\u001B[0m in \u001B[0;36msend\u001B[0;34m(self, method, params)\u001B[0m\n\u001B[1;32m 37\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 38\u001B[0m \u001B[0;32masync\u001B[0m \u001B[0;32mdef\u001B[0m \u001B[0msend\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mself\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mmethod\u001B[0m\u001B[0;34m:\u001B[0m \u001B[0mstr\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mparams\u001B[0m\u001B[0;34m:\u001B[0m \u001B[0mDict\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0;32mNone\u001B[0m\u001B[0;34m)\u001B[0m \u001B[0;34m->\u001B[0m \u001B[0mAny\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 39\u001B[0;31m \u001B[0;32mreturn\u001B[0m \u001B[0;32mawait\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0minner_send\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mmethod\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mparams\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0;32mFalse\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 40\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 41\u001B[0m \u001B[0;32masync\u001B[0m \u001B[0;32mdef\u001B[0m \u001B[0msend_return_as_dict\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mself\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mmethod\u001B[0m\u001B[0;34m:\u001B[0m \u001B[0mstr\u001B[0m\u001B[0;34m,\u001B[0m \u001B[0mparams\u001B[0m\u001B[0;34m:\u001B[0m \u001B[0mDict\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0;32mNone\u001B[0m\u001B[0;34m)\u001B[0m \u001B[0;34m->\u001B[0m \u001B[0mAny\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/site-packages/playwright/_impl/_connection.py\u001B[0m in \u001B[0;36minner_send\u001B[0;34m(self, method, params, return_as_dict)\u001B[0m\n\u001B[1;32m 61\u001B[0m \u001B[0;32mif\u001B[0m \u001B[0;32mnot\u001B[0m \u001B[0mcallback\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mfuture\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mdone\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 62\u001B[0m \u001B[0mcallback\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mfuture\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mcancel\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m---> 63\u001B[0;31m \u001B[0mresult\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0mnext\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0miter\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0mdone\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0mresult\u001B[0m\u001B[0;34m(\u001B[0m\u001B[0;34m)\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 64\u001B[0m \u001B[0;31m# Protocol now has named return values, assume result is one level deeper unless\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 65\u001B[0m \u001B[0;31m# there is explicit ambiguity.\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;32m/Volumes/WD/Users/yommi/miniforge3/envs/ani-downloader/lib/python3.8/asyncio/futures.py\u001B[0m in \u001B[0;36mresult\u001B[0;34m(self)\u001B[0m\n\u001B[1;32m 176\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m__log_traceback\u001B[0m \u001B[0;34m=\u001B[0m \u001B[0;32mFalse\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 177\u001B[0m \u001B[0;32mif\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_exception\u001B[0m \u001B[0;32mis\u001B[0m \u001B[0;32mnot\u001B[0m \u001B[0;32mNone\u001B[0m\u001B[0;34m:\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0;32m--> 178\u001B[0;31m \u001B[0;32mraise\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_exception\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[0m\u001B[1;32m 179\u001B[0m \u001B[0;32mreturn\u001B[0m \u001B[0mself\u001B[0m\u001B[0;34m.\u001B[0m\u001B[0m_result\u001B[0m\u001B[0;34m\u001B[0m\u001B[0;34m\u001B[0m\u001B[0m\n\u001B[1;32m 180\u001B[0m \u001B[0;34m\u001B[0m\u001B[0m\n",
|
||||
"\u001B[0;31mError\u001B[0m: Executable doesn't exist at /Users/yommi/Library/Caches/ms-playwright/chromium-939194/chrome-mac/Chromium.app/Contents/MacOS/Chromium\n╔═════════════════════════════════════════════════════════════════════════╗\n║ Looks like Playwright Test or Playwright was just installed or updated. ║\n║ Please run the following command to download new browsers: ║\n║ ║\n║ playwright install ║\n║ ║\n║ <3 Playwright Team ║\n╚═════════════════════════════════════════════════════════════════════════╝"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# !playwright install\n",
|
||||
"\n",
|
||||
"from playwright.sync_api import sync_playwright\n",
|
||||
"from playwright.async_api import async_playwright\n",
|
||||
"# from playwright_stealth import stealth_sync\n",
|
||||
"import asyncio\n",
|
||||
"import nest_asyncio\n",
|
||||
"nest_asyncio.apply()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"async def main_run(playwright):\n",
|
||||
"\n",
|
||||
" browser = await playwright.chromium.launch(headless=False)\n",
|
||||
" # context = browser.new_context(\n",
|
||||
" # user_agent=ua,\n",
|
||||
" # )\n",
|
||||
"\n",
|
||||
"\n",
|
||||
" url = 'https://sir.kr'\n",
|
||||
" #\n",
|
||||
" # if referer is not None:\n",
|
||||
" # LogicAniLife.headers[\"Referer\"] = referer\n",
|
||||
"\n",
|
||||
" # context = browser.new_context(extra_http_headers=LogicAniLife.headers)\n",
|
||||
" context = await browser.new_context()\n",
|
||||
" # LogicAniLife.headers[\"Cookie\"] = cookie_value\n",
|
||||
"\n",
|
||||
" # context.set_extra_http_headers(LogicAniLife.headers)\n",
|
||||
"\n",
|
||||
" page = await context.new_page()\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"\n",
|
||||
" # page.on(\"request\", set_cookie)\n",
|
||||
" # stealth_sync(page)\n",
|
||||
" page.goto(\n",
|
||||
" url, wait_until=\"networkidle\"\n",
|
||||
" )\n",
|
||||
" page.wait_for_timeout(10000)\n",
|
||||
" # time.sleep(1)\n",
|
||||
" # page.reload()\n",
|
||||
"\n",
|
||||
" # time.sleep(10)\n",
|
||||
" cookies = context.cookies\n",
|
||||
" # print(cookies)\n",
|
||||
"\n",
|
||||
" # print(page.content())\n",
|
||||
" vod_url = page.evaluate(\n",
|
||||
" \"\"\"() => {\n",
|
||||
" return console.log(vodUrl_1080p) }\"\"\"\n",
|
||||
" )\n",
|
||||
" print(vod_url)\n",
|
||||
"\n",
|
||||
"async def main():\n",
|
||||
" async with async_playwright() as p:\n",
|
||||
" await main_run(p)\n",
|
||||
"\n",
|
||||
"\n",
|
||||
"asyncio.run(main())"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<module 'select' from '/Users/yommi/.pyenv/versions/3.10.7/lib/python3.10/lib-dynload/select.cpython-310-darwin.so'>\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import select\n",
|
||||
"\n",
|
||||
"print(select)"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"['KQ_EV_ADD', 'KQ_EV_CLEAR', 'KQ_EV_DELETE', 'KQ_EV_DISABLE', 'KQ_EV_ENABLE', 'KQ_EV_EOF', 'KQ_EV_ERROR', 'KQ_EV_FLAG1', 'KQ_EV_ONESHOT', 'KQ_EV_SYSFLAGS', 'KQ_FILTER_AIO', 'KQ_FILTER_PROC', 'KQ_FILTER_READ', 'KQ_FILTER_SIGNAL', 'KQ_FILTER_TIMER', 'KQ_FILTER_VNODE', 'KQ_FILTER_WRITE', 'KQ_NOTE_ATTRIB', 'KQ_NOTE_CHILD', 'KQ_NOTE_DELETE', 'KQ_NOTE_EXEC', 'KQ_NOTE_EXIT', 'KQ_NOTE_EXTEND', 'KQ_NOTE_FORK', 'KQ_NOTE_LINK', 'KQ_NOTE_LOWAT', 'KQ_NOTE_PCTRLMASK', 'KQ_NOTE_PDATAMASK', 'KQ_NOTE_RENAME', 'KQ_NOTE_REVOKE', 'KQ_NOTE_TRACK', 'KQ_NOTE_TRACKERR', 'KQ_NOTE_WRITE', 'PIPE_BUF', 'POLLERR', 'POLLHUP', 'POLLIN', 'POLLNVAL', 'POLLOUT', 'POLLPRI', 'POLLRDBAND', 'POLLRDNORM', 'POLLWRBAND', 'POLLWRNORM', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', 'error', 'kevent', 'kqueue', 'poll', 'select']\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"print(dir(select))"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"_CountingAttr(counter=23, _default=Factory(factory=<class 'select.kqueue'>, takes_self=False), repr=True, eq=True, order=True, hash=None, init=True, on_setattr=None, metadata={})\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import attr\n",
|
||||
"\n",
|
||||
"_kqueue = attr.ib(factory=select.kqueue)\n",
|
||||
"\n",
|
||||
"print(_kqueue)"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"outputs": [],
|
||||
"source": [],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"outputs": [],
|
||||
"source": [],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"용사 파티에서 추방된 비스트테이머, 최강종의 고양이귀 소녀와 만나다.S01E06.1080p-OHNI24.mp4\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import os\n",
|
||||
"\n",
|
||||
"t_str = \"/data/anime_downloader/ohli24/용사 파티에서 추방된 비스트테이머, 최강종의 고양이귀 소녀와 만나다/Season 1/용사 파티에서 추방된 비스트테이머, 최강종의 고양이귀 소녀와 만나다.S01E06.1080p-OHNI24.mp4\"\n",
|
||||
"\n",
|
||||
"filename = os.path.basename(t_str)\n",
|
||||
"print(filename)"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"thumbnails.vtt\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"srt_url = \"https://cdn1-nydoodle.xyz/cdn/down/e2dbe79957ca1ba094c80cfd4c7604cb/thumbnails.vtt\"\n",
|
||||
"\n",
|
||||
"print(srt_url.split('/')[-1])\n"
|
||||
],
|
||||
"metadata": {
|
||||
"collapsed": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 2
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython2",
|
||||
"version": "2.7.6"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 0
|
||||
}
|
||||