Compare commits

..
1 Commits
Author SHA1 Message Date
projectdx deb964704d docs: Update README.md with v0.5.1 changelog 2026-01-02 15:40:23 +09:00
56 changed files with 5512 additions and 10626 deletions
-5
View File
@@ -134,8 +134,6 @@ dmypy.json
config.yaml config.yaml
lib2/ lib2/
.vscode/ .vscode/
.agents/
.agent/
memo.txt memo.txt
*.zip *.zip
flaskfarm.sh flaskfarm.sh
@@ -153,8 +151,6 @@ lib/support/site/wavve.py
lib/support/site/seezn.py lib/support/site/seezn.py
lib/support/site/kakaotv.py lib/support/site/kakaotv.py
*.bat *.bat
docs/
error_logs/
output/ output/
*.mkv *.mkv
@@ -168,4 +164,3 @@ bin/
debug_*.py debug_*.py
check_top_api.py check_top_api.py
d_ohli24.py d_ohli24.py
dev_scratch/
+9 -289
View File
@@ -8,28 +8,22 @@
## 🚀 주요 기능 (Key Features) ## 🚀 주요 기능 (Key Features)
* **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드. * **다중 사이트 지원**: Ohli24, Anilife, Linkkf 등 다양한 소스에서 영상 검색 및 다운로드.
* **자막 최적화**:
* **자막 전용 다운로드 (Linkkf)**: 영상 없이 자막(VTT)만 별도로 추출하여 SRT로 변환 후 다운로드할 수 있습니다.
* **자막 합침 (Muxing)**: 다운로드된 외부 자막(SRT)을 MP4 컨테이너에 자동으로 삽입하여 범용성을 높입니다.
* **강력한 우회 기술 (Anti-Bot Bypass)**: * **강력한 우회 기술 (Anti-Bot Bypass)**:
* **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다. * **TLS Fingerprint 변조**: `curl_cffi`를 사용하여 실제 Chrome 브라우저처럼 위장, Cloudflare 및 각종 봇 차단을 무력화합니다.
* **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결) * **CDN 자동 감지**: 스트리밍 서버(CDN)의 도메인이 수시로 변경되더라도 자동으로 감지하여 대응합니다. (예: 14B 가짜 파일 문제 해결)
* **스마트 다운로드 큐**: `ffmpeg``yt-dlp` 기반의 큐 시스템으로 안정적인 이어받기 및 재시도를 지원합니다. * **스마트 다운로드 큐**: `ffmpeg``yt-dlp` 기반의 큐 시스템으로 안정적인 이어받기 및 재시도를 지원합니다.
* **사용자 편의성 및 보안**: * **사용자 편의성**:
* **보안 스트리밍 (Secure Streaming)**: 외부 플레이어(MXPlayer, VLC 등) 연동 시 API 키를 노출하지 않고 **임시 스트리밍 토큰(5분 만료)**을 발급하여 안전한 시청 환경을 제공합니다.
* **범용 플레이어 연동**: IINA, PotPlayer, VLC, nPlayer, Infuse, MX Player 등 8종 이상의 외부 플레이어와 정교한 URL Scheme/Intent 연동을 지원합니다.
* **Proxy 설정**: IP 차단 시 손쉽게 우회할 수 있도록 웹 설정 UI에서 프록시 서버를 지정할 수 있습니다. * **Proxy 설정**: IP 차단 시 손쉽게 우회할 수 있도록 웹 설정 UI에서 프록시 서버를 지정할 수 있습니다.
* **모듈형 테마 시스템**: CSS 변수와 동적 로딩을 활용하여 데스크탑/모바일 모두에 최적화된 사이트별 테마를 제공합니다. * **반응형 UI**: 데스크탑모바일 모두에 최적화된 Glassmorphism 디자인.
* **실시간 피드백**: 다운로드 상태, 중복 파일 감지 등을 실시간 알림으로 제공합니다.
--- ---
## 📺 지원 사이트 (Supported Sites) ## 📺 지원 사이트 (Supported Sites)
### 1. Ohli24 (애니24) ### 1. Ohli24 (애니24)
* **특징**: 강력한 인프라와 다양한 라이브러리. * **특징**: 가장 강력한 보안(Cloudflare)이 적용된 사이트.
* **기술**: `Zendriver`(Daemon모드) 및 `Camoufox`를 이용한 브라우저 에뮬레이션 우회. * **기술**: `curl_cffi`를 이용한 Full Browser Impersonation 적용.
* **기능**: 검색, 목록 조회, 자동 다운로드, **임시 토큰 기반 보안 스트리밍**. * **기능**: 검색, 목록 조회, 자동 다운로드.
### 2. Linkkf (링크애니) ### 2. Linkkf (링크애니)
* **특징**: 빠른 업데이트 속도. * **특징**: 빠른 업데이트 속도.
@@ -44,16 +38,8 @@
## 🛠 설치 및 문제 해결 (Troubleshooting) ## 🛠 설치 및 문제 해결 (Troubleshooting)
### 필수 요구 사항 ### 필수 요구 사항
* **Python 패키지**: `curl_cffi`, `lxml`, `requests`, `yt-dlp`
* **Python 패키지 (Dependencies)**: * **시스템 도구**: `ffmpeg` (시스템 경로에 설치되어 있어야 함)
* `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 구동을 위한 브라우저 환경 필요.
### 자주 묻는 질문 (FAQ) ### 자주 묻는 질문 (FAQ)
@@ -84,274 +70,7 @@
## 📝 변경 이력 (Changelog) ## 📝 변경 이력 (Changelog)
### v0.7.24 (2026-04-01) ### v0.5.1 (2026-01-02)
- **모바일 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)
- **모바일 UX 대폭 개선**: - **모바일 UX 대폭 개선**:
- 시스템 알림(bootstrap-notify) 커스텀 스타일링 (사이트별 테마 색상 적용) - 시스템 알림(bootstrap-notify) 커스텀 스타일링 (사이트별 테마 색상 적용)
- Anilife: Cosmic Violet / Linkkf: Forest Green / Ohli24: Slate Blue - Anilife: Cosmic Violet / Linkkf: Forest Green / Ohli24: Slate Blue
@@ -416,3 +135,4 @@
- **CDN 보안 우회**: cdndania.com 쿠키 기반 인증 처리 (`curl_cffi` 세션 유지) - **CDN 보안 우회**: cdndania.com 쿠키 기반 인증 처리 (`curl_cffi` 세션 유지)
- **CdndaniaDownloader**: 별도 프로세스 기반 HLS 세그먼트 다운로더 추가 - **CdndaniaDownloader**: 별도 프로세스 기반 HLS 세그먼트 다운로더 추가
- **프록시 지원 강화**: 세그먼트 다운로드 시 프록시 적용 - **프록시 지원 강화**: 세그먼트 다운로드 시 프록시 적용
+6 -12
View File
@@ -4,15 +4,9 @@
# @Site : # @Site :
# @File : __init__ # @File : __init__
# @Software: PyCharm # @Software: PyCharm
from .setup import P # from .plugin import P
blueprint = P.blueprint # blueprint = P.blueprint
menu = P.menu # menu = P.menu
plugin_info = P.plugin_info # plugin_load = P.logic.plugin_load
# plugin_unload = P.logic.plugin_unload
def plugin_load(): # plugin_info = P.plugin_info
if P.logic:
P.logic.plugin_load()
def plugin_unload():
if P.logic:
P.logic.plugin_unload()
+1 -1
View File
@@ -1,5 +1,5 @@
title: "애니 다운로더" title: "애니 다운로더"
version: 0.7.24 version: "0.4.3"
package_name: "anime_downloader" package_name: "anime_downloader"
developer: "projectdx" developer: "projectdx"
description: "anime downloader" description: "anime downloader"
-122
View File
@@ -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))
-74
View File
@@ -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}))
+1 -1
View File
@@ -72,7 +72,7 @@ class Crawler:
referer: str = None, referer: str = None,
engine: str = "chrome", engine: str = "chrome",
stealth: bool = False, stealth: bool = False,
) -> str: ):
try: try:
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
# from playwright.sync_api import sync_playwright # from playwright.sync_api import sync_playwright
+4 -38
View File
@@ -9,10 +9,10 @@ from datetime import datetime
import requests import requests
# from flaskfarm.lib.plugin import get_model_setting # 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 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.setup import P as SM
# from flaskfarm.lib.system.mod_setting import ModuleSetting 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") download_method = P.ModelSetting.get(f"{self.name}_download_method")
# 미완성 다운로드 감지 (Frag 파일, .ytdl 파일, .part 파일) # .ytdl 파일이 있거나, ytdlp/aria2c 모드인 경우 '파일 있음'으로 건너뛰지 않음 (이어받기 허용)
# 이런 파일이 있으면 이어받기 허용
is_ytdlp = download_method in ['ytdlp', 'aria2c'] is_ytdlp = download_method in ['ytdlp', 'aria2c']
has_ytdl_file = os.path.exists(filepath + ".ytdl") has_ytdl_file = os.path.exists(filepath + ".ytdl")
has_part_file = os.path.exists(filepath + ".part")
# Frag 파일 존재 여부 확인 (같은 폴더에 Frag* 파일이 있으면 미완성) if os.path.exists(filepath) and not (is_ytdlp or has_ytdl_file):
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
entity.ffmpeg_status_kor = "파일 있음" entity.ffmpeg_status_kor = "파일 있음"
entity.ffmpeg_percent = 100 entity.ffmpeg_percent = 100
entity.download_completed()
entity.refresh_status() entity.refresh_status()
continue continue
dirname = os.path.dirname(filepath) dirname = os.path.dirname(filepath)
@@ -606,22 +586,8 @@ class FfmpegQueue(object):
ret["ret"] = "refresh" ret["ret"] = "refresh"
elif cmd == "reset": elif cmd == "reset":
if self.download_queue is not None: if self.download_queue is not None:
# 큐 비우기 (표준 Queue와 gevent Queue 모두 호환)
try:
# 표준 Queue의 경우
if hasattr(self.download_queue, 'mutex'):
with self.download_queue.mutex: with self.download_queue.mutex:
self.download_queue.queue.clear() 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}")
for _ in self.entity_list: for _ in self.entity_list:
# 다운로드중 상태인 경우에만 중지 시도 # 다운로드중 상태인 경우에만 중지 시도
if _.ffmpeg_status == 5: if _.ffmpeg_status == 5:
+11 -18
View File
@@ -32,8 +32,10 @@ def download(url, file_name):
def read_file(filename): def read_file(filename):
try: try:
import codecs import codecs
with codecs.open(filename, 'r', encoding='utf8') as ifp: ifp = codecs.open(filename, 'r', encoding='utf8')
return ifp.read() data = ifp.read()
ifp.close()
return data
except Exception as exception: except Exception as exception:
logger.error('Exception:%s', exception) logger.error('Exception:%s', exception)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -56,29 +58,20 @@ class Util(object):
@staticmethod @staticmethod
def change_text_for_use_filename(text): def change_text_for_use_filename(text):
# 1. Remove/replace Windows-forbidden characters # text = text.replace('/', '')
text = re.sub('[\\/:*?"<>|]', ' ', text) # 2021-07-31 X:X
# text = text.replace(':', ' ')
# 2. Remove consecutive dots (.. → .) text = re.sub('[\\/:*?\"<>|]', ' ', text).strip()
text = re.sub(r'\.{2,}', '.', text) text = re.sub("\s{2,}", ' ', text)
# 3. Remove leading/trailing dots and spaces
text = text.strip('. ')
# 4. Collapse multiple spaces to single space
text = re.sub(r'\s{2,}', ' ', text)
# 5. Remove any remaining trailing dots (after space collapse)
text = text.rstrip('.')
return text return text
@staticmethod @staticmethod
def write_file(data, filename): def write_file(data, filename):
try: try:
import codecs import codecs
with codecs.open(filename, 'w', encoding='utf8') as ofp: ofp = codecs.open(filename, 'w', encoding='utf8')
ofp.write(data) ofp.write(data)
ofp.close()
except Exception as exception: except Exception as exception:
logger.debug('Exception:%s', exception) logger.debug('Exception:%s', exception)
logger.debug(traceback.format_exc()) logger.debug(traceback.format_exc())
+11 -9
View File
@@ -172,11 +172,17 @@ class YtdlpDownloader:
# 주의: --external-downloader aria2c는 HLS 프래그먼트에서 오버헤드가 크므로 제거함 # 주의: --external-downloader aria2c는 HLS 프래그먼트에서 오버헤드가 크므로 제거함
# 1.5 브라우저 위장 설정 (User-Agent) # 1.5 환경별 브라우저 위장 설정 (Impersonate)
# --impersonate 옵션은 curl-impersonate 라이브러리가 필요하므로 수동 UA 사용 # 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') 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] cmd += ['--user-agent', user_agent]
logger.debug(f"Using manual User-Agent: {user_agent}") logger.debug(f"Using manual User-Agent on {platform.system()}: {user_agent}")
# 2. 프록시 설정 # 2. 프록시 설정
if self.proxy: if self.proxy:
@@ -336,14 +342,10 @@ class YtdlpDownloader:
elapsed = time.time() - self.start_time elapsed = time.time() - self.start_time
self.elapsed_time = self.format_time(elapsed) self.elapsed_time = self.format_time(elapsed)
# [최적화] 10% 단위로만 로그 출력 (로그 부하 감소) # [최적화] 진행률이 1% 이상 차이나거나, 100%인 경우에만 콜백 호출 (로그 부하 감소)
if self.callback and (int(new_percent) > int(self.percent) or new_percent >= 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 self.percent = new_percent
# 10% 단위가 변경되었거나 100%일 때만 로그 출력 logger.info(f"[yt-dlp progress] {int(self.percent)}% speed={self.current_speed}")
if new_tens > old_tens or new_percent >= 100:
logger.info(f"[yt-dlp] {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) self.callback(percent=int(self.percent), current=int(self.percent), total=100, speed=self.current_speed, elapsed=self.elapsed_time)
else: else:
self.percent = new_percent self.percent = new_percent
-531
View File
@@ -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()
-197
View File
@@ -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}))
+135 -740
View File
File diff suppressed because it is too large Load Diff
+2 -157
View File
@@ -1,14 +1,10 @@
from flask import render_template, request, jsonify from flask import render_template, request, jsonify
from plugin import PluginModuleBase from flaskfarm.lib.plugin import PluginModuleBase
import framework import framework
import os, traceback, time, json import os, traceback, time, json
from datetime import datetime from datetime import datetime
class AnimeModuleBase(PluginModuleBase): class AnimeModuleBase(PluginModuleBase):
# 업데이트 체크 캐싱 (클래스 레벨)
_last_update_check = 0
_latest_version = None
def __init__(self, P, setup_default=None, **kwargs): def __init__(self, P, setup_default=None, **kwargs):
super(AnimeModuleBase, self).__init__(P, **kwargs) super(AnimeModuleBase, self).__init__(P, **kwargs)
self.P = P # Ensure P is available via self.P self.P = P # Ensure P is available via self.P
@@ -135,59 +131,6 @@ class AnimeModuleBase(PluginModuleBase):
arg3 = request.form.get('arg3') or request.args.get('arg3') arg3 = request.form.get('arg3') or request.args.get('arg3')
return self.process_command(command, arg1, arg2, arg3, req) return self.process_command(command, arg1, arg2, arg3, req)
elif sub == 'self_update':
# 자가 업데이트 (Git Pull) 및 모듈 리로드
try:
import subprocess
plugin_path = os.path.dirname(__file__)
self.P.logger.info(f"애니 다운로더 자가 업데이트 시작: {plugin_path}")
# 먼저 변경될 파일 목록 확인 (model 파일 변경 감지)
diff_cmd = ['git', '-C', plugin_path, 'diff', '--name-only', 'HEAD', 'origin/main']
subprocess.run(['git', '-C', plugin_path, 'fetch'], capture_output=True) # fetch first
diff_result = subprocess.run(diff_cmd, capture_output=True, text=True)
changed_files = diff_result.stdout.strip().split('\n') if diff_result.stdout.strip() else []
# 모델 파일 변경 여부 확인
model_patterns = ['model', 'db', 'migration']
needs_restart = any(
any(pattern in f.lower() for pattern in model_patterns)
for f in changed_files if f
)
# Git Pull 실행
cmd = ['git', '-C', plugin_path, 'pull']
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
stdout, stderr = process.communicate()
if process.returncode != 0:
raise Exception(f"Git pull 실패: {stderr}")
self.P.logger.info(f"Git pull 결과: {stdout}")
# 모델 변경 없으면 리로드 시도
if not needs_restart:
self.reload_plugin()
msg = f"업데이트 완료! 새로고침하세요.<br><pre>{stdout}</pre>"
else:
self.P.logger.warning("모델 파일 변경 감지 - 서버 재시작 필요")
msg = f"<strong>모델 변경 감지!</strong> 서버 재시작이 필요합니다.<br><pre>{stdout}</pre>"
return jsonify({
'ret': 'success',
'msg': msg,
'data': stdout,
'needs_restart': needs_restart
})
except Exception as e:
self.P.logger.error(f"자가 업데이트 중 오류: {str(e)}")
self.P.logger.error(traceback.format_exc())
return jsonify({'ret': 'danger', 'msg': f"업데이트 실패: {str(e)}"})
elif sub == 'check_update':
force = req.form.get('force') == 'true'
return jsonify({'ret': 'success', 'data': self.get_update_info(force=force)})
return jsonify({'ret': 'fail', 'log': f"Unknown sub: {sub}"}) return jsonify({'ret': 'fail', 'log': f"Unknown sub: {sub}"})
except Exception as e: except Exception as e:
@@ -234,7 +177,7 @@ class AnimeModuleBase(PluginModuleBase):
namespace = f"/{self.P.package_name}/{self.name}/queue" namespace = f"/{self.P.package_name}/{self.name}/queue"
# 큐 페이지 소켓에 직접 emit # 큐 페이지 소켓에 직접 emit
socketio.emit(refresh_type, data, namespace=namespace) socketio.emit(refresh_type, data, namespace=namespace, broadcast=True)
except Exception as e: except Exception as e:
self.P.logger.error(f"socketio_callback error: {e}") self.P.logger.error(f"socketio_callback error: {e}")
@@ -255,101 +198,3 @@ class AnimeModuleBase(PluginModuleBase):
except Exception as e: except Exception as e:
return {'ret': 'fail', 'msg': str(e)} return {'ret': 'fail', 'msg': str(e)}
def get_update_info(self, force=False):
"""GitHub에서 최신 버전 정보 가져오기 (캐싱 활용)"""
import requests
now = time.time()
# 실제 로컬 파일에서 현재 버전 읽기
current_version = self.P.plugin_info.get('version', '0.0.0')
try:
info_path = os.path.join(os.path.dirname(__file__), 'info.yaml')
if os.path.exists(info_path):
import yaml
with open(info_path, 'r', encoding='utf-8') as f:
local_info = yaml.safe_load(f)
current_version = str(local_info.get('version', current_version))
except: pass
# 1시간마다 체크 (force=True면 즉시)
if not force and AnimeModuleBase._latest_version and (now - AnimeModuleBase._last_update_check < 3600):
return {
'current': current_version,
'latest': AnimeModuleBase._latest_version,
'has_update': self._is_newer(AnimeModuleBase._latest_version, current_version)
}
try:
url = "https://raw.githubusercontent.com/projectdx75/anime_downloader/master/info.yaml"
res = requests.get(url, timeout=5)
if res.status_code == 200:
import yaml
data = yaml.safe_load(res.text)
AnimeModuleBase._latest_version = str(data.get('version', ''))
AnimeModuleBase._last_update_check = now
return {
'current': current_version,
'latest': AnimeModuleBase._latest_version,
'has_update': self._is_newer(AnimeModuleBase._latest_version, current_version)
}
except Exception as e:
self.P.logger.error(f"Update check failed: {e}")
return {
'current': current_version,
'latest': AnimeModuleBase._latest_version or current_version,
'has_update': False
}
def _is_newer(self, latest, current):
"""버전 비교 (0.7.8 vs 0.7.7)"""
if not latest or not current: return False
try:
l_parts = [int(p) for p in latest.split('.')]
c_parts = [int(p) for p in current.split('.')]
return l_parts > c_parts
except:
return latest != current
def reload_plugin(self):
"""플러그인 모듈 핫 리로드"""
import sys
import importlib
try:
package_name = self.P.package_name
self.P.logger.info(f"플러그인 리로드 시작: {package_name}")
# 리로드에서 제외할 패턴 (모델/DB 관련 - SQLAlchemy 충돌 방지)
skip_patterns = ['model', 'db', 'migration', 'setup', 'create_plugin']
# 관련 모듈 찾기 및 리로드
modules_to_reload = []
for module_name in list(sys.modules.keys()):
if module_name.startswith(package_name):
# 모델 관련 모듈은 건너뛰기
should_skip = any(pattern in module_name.lower() for pattern in skip_patterns)
if not should_skip:
modules_to_reload.append(module_name)
# 의존성 역순으로 정렬 (깊은 모듈 먼저)
modules_to_reload.sort(key=lambda x: x.count('.'), reverse=True)
reloaded_count = 0
for module_name in modules_to_reload:
try:
module = sys.modules[module_name]
importlib.reload(module)
self.P.logger.debug(f"Reloaded: {module_name}")
reloaded_count += 1
except Exception as e:
self.P.logger.warning(f"Skip reload {module_name}: {e}")
self.P.logger.info(f"플러그인 [{package_name}] 리로드 완료: {reloaded_count}개 모듈")
self.P.logger.info("템플릿/정적 파일은 새로고침 시 자동 적용됩니다.")
return True
except Exception as e:
self.P.logger.error(f"모듈 리로드 중 실패: {str(e)}")
self.P.logger.error(traceback.format_exc())
return False
+106 -793
View File
File diff suppressed because it is too large Load Diff
+225 -1799
View File
File diff suppressed because it is too large Load Diff
-40
View File
@@ -114,7 +114,6 @@ class AnimeQueueEntity(FfmpegQueueEntity):
self.ffmpeg_status = 7 self.ffmpeg_status = 7
self.ffmpeg_status_kor = "완료" self.ffmpeg_status_kor = "완료"
self.end_time = datetime.now() self.end_time = datetime.now()
self._update_db_status()
return return
if self.filepath and os.path.exists(self.filepath): if self.filepath and os.path.exists(self.filepath):
@@ -127,50 +126,11 @@ class AnimeQueueEntity(FfmpegQueueEntity):
self.ffmpeg_status = 7 self.ffmpeg_status = 7
self.ffmpeg_status_kor = "완료" self.ffmpeg_status_kor = "완료"
self.end_time = datetime.now() self.end_time = datetime.now()
self._update_db_status()
except Exception as e: except Exception as e:
self.P.logger.error(f"Download completed error: {e}") self.P.logger.error(f"Download completed error: {e}")
self.ffmpeg_status = 4 self.ffmpeg_status = 4
self.ffmpeg_status_kor = "이동 실패" 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): def info_dict(self, tmp):
"""Default valid implementation""" """Default valid implementation"""
return tmp return tmp
+124
View File
@@ -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 등 다양한 기술 사용
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from flaskfarm.lib.plugin import *
+102
View File
@@ -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()
+810
View File
@@ -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>&laquo; &laquo; &laquo;</button>
<button id="page-prev" disabled>&lsaquo; </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> &rsaquo;</button>
<button id="page-last" disabled> &raquo; &raquo; &raquo;</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;'>&#128512;</span> </br> <strong>메인</strong></a> </li>
<li> <a href="https://linkkf.live/anime-list/" rel="home"style="
height: 30px;
"> <span style='font-size:13px;'>&#128528;</span> </br> <strong>신작</strong></a> </li>
<li> <a href="https://linkkf.live/menulist" rel="home"style="
height: 30px;
"> <span style='font-size:13px;'>&#128566;</span> </br> <strong>장르</strong></a> </li>
<li> <a href="https://linkkf.live/topanime" rel="home"style="
height: 30px;
"> <span style='font-size:13px;'>&#127775;</span> </br> <strong>TOP</strong></a> </li>
<li> <a href="https://linkkf.tv" rel="home"style="
height: 30px;
"> <span style='font-size:13px;'> &#128518; </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>
+1 -43
View File
@@ -112,9 +112,6 @@ setting = {
from plugin import * from plugin import *
import os import os
import importlib
import subprocess
import sys
import traceback import traceback
from flask import render_template from flask import render_template
@@ -146,46 +143,6 @@ class LogicGuide(PluginModuleBase):
DEFINE_DEV = True DEFINE_DEV = True
P = create_plugin_instance(setting) 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: try:
if DEFINE_DEV: if DEFINE_DEV:
from .mod_ohli24 import LogicOhli24 from .mod_ohli24 import LogicOhli24
@@ -209,3 +166,4 @@ try:
except Exception as e: except Exception as e:
P.logger.error(f'Exception: {str(e)}') P.logger.error(f'Exception: {str(e)}')
P.logger.error(traceback.format_exc()) P.logger.error(traceback.format_exc())
-44
View File
@@ -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;
}
-81
View File
@@ -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;
}
}
-286
View File
@@ -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;
}
}
-665
View File
@@ -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;
}
}
-377
View File
@@ -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;
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

-459
View File
@@ -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">&times;</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 %} {% 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 id="preloader">
<div class='demo'> <div class='demo'>
<!-- <div class="loader-inner">--> <!-- <div class="loader-inner">-->
@@ -920,6 +917,20 @@
opacity: 0.5; 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> </style>
{% endblock %} {% endblock %}
+37 -4
View File
@@ -2,8 +2,6 @@
{% block content %} {% block content %}
<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"/>
<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> <style>
body { body {
@@ -330,10 +328,45 @@
font-size: 11px !important; 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> </style>
<div class="content-cloak"> <div class="content-cloak">
<form id="form_search" class="form-inline" style="text-align:left; width:100%;"> <form id="form_search" class="form-inline" style="text-align:left; width:100%;">
<div class="search-container"> <div class="search-container">
File diff suppressed because it is too large Load Diff
+69 -85
View File
@@ -1,24 +1,24 @@
{% extends "base.html" %} {% block content %} {% 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> <style>
/* Cosmic Violet 배경 (즉시 적용) */ /* Global Container Margin Overrides */
body {
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 40%, #4c1d95 100%) !important;
background-attachment: fixed !important;
}
/* Global Container Margin Overrides - mobile_custom.css와 통합 */
#main_container { #main_container {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 15px !important;
padding-right: 15px !important;
margin-left: 0 !important;
margin-right: 0 !important;
}
.container, .container-fluid {
width: 100% !important;
max-width: 100% !important;
padding-left: 0 !important;
padding-right: 0 !important;
} }
</style> </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 id="preloader" class="loader">
<div class="loader-inner"> <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>
@@ -30,7 +30,7 @@ body {
</div> </div>
<form id="program_list"> <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"> <div class="form-group mb-0">
<label for="code" class="text-white font-weight-bold mb-2" style="color: #c4b5fd !important;"> <label for="code" class="text-white font-weight-bold mb-2" style="color: #c4b5fd !important;">
<i class="fa fa-search mr-2" style="color: #a78bfa;"></i>작품 Code <i class="fa fa-search mr-2" style="color: #a78bfa;"></i>작품 Code
@@ -40,13 +40,14 @@ body {
placeholder="URL 또는 코드를 입력하세요" placeholder="URL 또는 코드를 입력하세요"
style="background: rgba(30, 27, 75, 0.8); color: #e0e7ff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); border-radius: 12px 0 0 12px;"> style="background: rgba(30, 27, 75, 0.8); color: #e0e7ff; box-shadow: inset 0 2px 4px rgba(0,0,0,0.3); border-radius: 12px 0 0 12px;">
<div class="input-group-append"> <div class="input-group-append">
<button id="analysis_btn" class="btn px-3 font-weight-bold" type="button" <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); min-width: 80px;"> 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> 분석 <i class="fa fa-cogs mr-1"></i> 분석
</button> </button>
<button id="go_anilife_btn" class="btn px-3 font-weight-bold" type="button" <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; min-width: 80px;"> 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;">
GO <span class="d-none d-md-inline">Go 애니라이프</span>
<span class="d-md-none">Go</span>
</button> </button>
</div> </div>
</div> </div>
@@ -127,14 +128,11 @@ body {
// window.addEventListener("DOMContentLoaded", wait_seconds); // window.addEventListener("DOMContentLoaded", wait_seconds);
function analyze(wr_id, bo_table) { function analyze(wr_id, bo_table) {
// 분석 시작 시 preloader 표시 // e.preventDefault();
const preloader = document.getElementById('preloader'); // e.stopPropagation()
if (preloader) { // e.preventDefault();
preloader.style.display = 'block';
preloader.style.opacity = '1';
}
const code = document.getElementById("code").value const code = document.getElementById("code").value
// console.log(code)
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/analysis', url: '/' + package_name + '/ajax/' + sub + '/analysis',
type: "POST", type: "POST",
@@ -142,29 +140,14 @@ body {
data: {code: code, wr_id: wr_id, bo_table: bo_table}, data: {code: code, wr_id: wr_id, bo_table: bo_table},
dataType: "json", dataType: "json",
success: function (ret) { success: function (ret) {
// 분석 완료 시 preloader 숨기기
if (preloader) {
preloader.style.opacity = '0';
setTimeout(function() {
preloader.style.display = 'none';
}, 300);
}
if (ret.ret === 'success' && ret.data != null) { if (ret.ret === 'success' && ret.data != null) {
// {#// console.log(ret.code)#}
// console.log(ret.data)
make_program(ret.data) make_program(ret.data)
$("#loader").css("display", 'none') $("#loader").css("display", 'none')
} else { } else {
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'}); $.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,13 +322,9 @@ body {
}) })
$(document).ready(function () { $(document).ready(function () {
// content-cloak visible 클래스 추가 (즉시) // DOM 로딩 완료 후 preloader 숨기기
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
// URL에 code가 없으면 preloader 숨기기 (있으면 analyze 함수에서 처리)
const hasAutoAnalysis = params.code || "{{arg['anilife_current_code']}}";
if (!hasAutoAnalysis) {
const preloader = document.getElementById('preloader'); const preloader = document.getElementById('preloader');
setTimeout(function() { setTimeout(function() {
if (preloader) { if (preloader) {
preloader.style.opacity = '0'; preloader.style.opacity = '0';
@@ -353,20 +332,11 @@ body {
preloader.style.display = 'none'; preloader.style.display = 'none';
}, 300); }, 300);
} }
}, 100); }, 500);
}
$("#loader").css("display", 'none'); $("#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) { $("#analysis_btn").unbind("click").bind('click', function (e) {
e.preventDefault(); e.preventDefault();
e.stopPropagation() e.stopPropagation()
@@ -428,10 +398,8 @@ body {
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'}); $.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
} else if (data.ret == 'db_completed') { } else if (data.ret == 'db_completed') {
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'}); $.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
} else if (data.ret == 'file_exists') {
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
} else { } 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 { .episode-list-container {
margin-top: 20px; margin-top: 20px;
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 10px; gap: 10px;
} }
@media (max-width: 600px) {
.episode-list-container {
grid-template-columns: 1fr !important;
}
}
/* 에피소드 카드 */ /* 에피소드 카드 */
.episode-card { .episode-card {
display: flex; display: flex;
@@ -770,12 +732,9 @@ body {
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */ /* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
@media (max-width: 768px) { @media (max-width: 768px) {
/* Ensure breadcrumb span doesn't force width */ /* 상단 서브메뉴가 SJVA 메인 navbar에 가려지지 않도록 여백 추가 */
#menu_module_div span.nav-link { ul.nav.nav-pills.bg-light {
overflow: hidden; margin-top: 60px !important;
text-overflow: ellipsis;
max-width: 150px;
white-space: nowrap !important;
} }
/* 입력창 크기 최적화 */ /* 입력창 크기 최적화 */
@@ -807,8 +766,8 @@ body {
.row, form, #program_list, #program_auto_form, #episode_list { .row, form, #program_list, #program_auto_form, #episode_list {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 4px !important; /* 10px -> 4px */ padding-left: 10px !important;
padding-right: 4px !important; /* 10px -> 4px */ padding-right: 10px !important;
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
@@ -866,8 +825,8 @@ body {
align-items: center !important; align-items: center !important;
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding: 8px 6px !important; /* 좌우 패딩 대폭 축소 */ padding: 10px 12px !important;
gap: 6px !important; /* 요소 간 간격 축소 */ gap: 10px !important;
margin: 0 !important; margin: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }
@@ -1071,7 +1030,8 @@ body {
} }
.loader { .loader {
background: transparent; background: rgb(0, 0, 0, 0.8);
background: radial-gradient(#222, #000);
bottom: 0; bottom: 0;
left: 0; left: 0;
overflow: hidden; overflow: hidden;
@@ -1138,35 +1098,35 @@ body {
} }
.loader-line-wrap:nth-child(1) .loader-line { .loader-line-wrap:nth-child(1) .loader-line {
border-color: #a78bfa; /* Light Violet */ border-color: hsl(0, 80%, 60%);
height: 90px; height: 90px;
width: 90px; width: 90px;
top: 7px; top: 7px;
} }
.loader-line-wrap:nth-child(2) .loader-line { .loader-line-wrap:nth-child(2) .loader-line {
border-color: #8b5cf6; /* Violet */ border-color: hsl(60, 80%, 60%);
height: 76px; height: 76px;
width: 76px; width: 76px;
top: 14px; top: 14px;
} }
.loader-line-wrap:nth-child(3) .loader-line { .loader-line-wrap:nth-child(3) .loader-line {
border-color: #7c3aed; /* Purple */ border-color: hsl(120, 80%, 60%);
height: 62px; height: 62px;
width: 62px; width: 62px;
top: 21px; top: 21px;
} }
.loader-line-wrap:nth-child(4) .loader-line { .loader-line-wrap:nth-child(4) .loader-line {
border-color: #6d28d9; /* Deep Purple */ border-color: hsl(180, 80%, 60%);
height: 48px; height: 48px;
width: 48px; width: 48px;
top: 28px; top: 28px;
} }
.loader-line-wrap:nth-child(5) .loader-line { .loader-line-wrap:nth-child(5) .loader-line {
border-color: #5b21b6; /* Dark Violet */ border-color: hsl(240, 80%, 60%);
height: 34px; height: 34px;
width: 34px; width: 34px;
top: 35px; top: 35px;
@@ -1305,6 +1265,30 @@ $(document).ready(function(){
}, 100); }, 100);
}); });
</script> </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> </style>
{% endblock %} {% endblock %}
+45 -100
View File
@@ -1,8 +1,4 @@
{% extends "base.html" %} {% block content %} {% 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 id="preloader" class="content-cloak">
<div class='demo'> <div class='demo'>
<!-- <div class="loader-inner">--> <!-- <div class="loader-inner">-->
@@ -197,10 +193,7 @@
tmp += '</div>'; tmp += '</div>';
tmp += '<div class=\"card-body\">' tmp += '<div class=\"card-body\">'
tmp += '<h5 class=\"card-title\">' + data.anime_list[i].title + '</h5>'; tmp += '<h5 class=\"card-title\">' + data.anime_list[i].title + '</h5>';
tmp += '<div class=\"card-actions\">'; tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\">' + data.anime_list[i].title + '</a>';
tmp += '<a href=\"./request?code=' + data.anime_list[i].code + '\" class=\"btn btn-primary cut-text\"><i class="fa fa-info-circle"></i> 상세</a>';
tmp += '<button type=\"button\" class=\"btn btn-sch btn-add-schedule\" data-code=\"' + data.anime_list[i].code + '\" data-title=\"' + data.anime_list[i].title.replace(/"/g, '&quot;') + '\"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
@@ -265,10 +258,7 @@
tmp += '</div>'; tmp += '</div>';
tmp += '<div class="card-body">' tmp += '<div class="card-body">'
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>'; tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
tmp += '<div class="card-actions">'; tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>';
tmp += '<a href="' + request_url + '" class="btn btn-primary cut-text"><i class="fa fa-info-circle"></i> 상세</a>';
tmp += '<button type="button" class="btn btn-sch btn-add-schedule" data-code="' + data.anime_list[i].code + '" data-title="' + data.anime_list[i].title.replace(/"/g, '&quot;') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
@@ -320,10 +310,7 @@
tmp += '</div>'; tmp += '</div>';
tmp += '<div class="card-body">' tmp += '<div class="card-body">'
tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>'; tmp += '<h5 class="card-title">' + data.anime_list[i].title + '</h5>';
tmp += '<div class="card-actions">'; tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text">' + data.anime_list[i].title + '</a>';
tmp += '<a href="./request?code=' + data.anime_list[i].code + '" class="btn btn-primary cut-text"><i class="fa fa-info-circle"></i> 상세</a>';
tmp += '<button type="button" class="btn btn-sch btn-add-schedule" data-code="' + data.anime_list[i].code + '" data-title="' + data.anime_list[i].title.replace(/"/g, '&quot;') + '"><i class="fa fa-calendar-plus-o"></i> 스케쥴</button>';
tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
tmp += '</div>'; tmp += '</div>';
@@ -587,38 +574,6 @@
}; };
document.addEventListener("scroll", debounce(onScroll, 300)); document.addEventListener("scroll", debounce(onScroll, 300));
// ================================
// 스케쥴 등록 버튼 핸들러
// ================================
$('body').on('click', '.btn-add-schedule', function(e) {
e.preventDefault();
var code = $(this).data('code');
var title = $(this).data('title');
var btn = $(this);
btn.prop('disabled', true).html('<i class="fa fa-spinner fa-spin"></i>');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_schedule',
type: 'POST',
data: { code: code, title: title },
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success' || ret.ret === 'exist') {
$.notify('<strong>' + (ret.ret === 'exist' ? '이미 등록됨' : '스케쥴 등록 완료') + '</strong>', { type: ret.ret === 'exist' ? 'info' : 'success' });
} else {
$.notify('<strong>등록 실패: ' + (ret.msg || ret.ret) + '</strong>', { type: 'warning' });
}
},
error: function() {
$.notify('<strong>스케쥴 등록 중 오류</strong>', { type: 'danger' });
},
complete: function() {
btn.prop('disabled', false).html('<i class="fa fa-calendar-plus-o"></i> 스케쥴');
}
});
});
</script> </script>
<style> <style>
button.code-button { button.code-button {
@@ -847,7 +802,6 @@
line-height: 1.3; line-height: 1.3;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -1262,57 +1216,6 @@
color: #fff !important; color: #fff !important;
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4);
} }
/* Card Actions Layout */
.card-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
}
.card-actions .btn {
font-size: 13px;
padding: 8px 12px;
border-radius: 8px;
}
.card-actions .btn-sch {
background: linear-gradient(135deg, #f472b6 0%, #ec4899 100%) !important;
border: none !important;
color: white !important;
}
.card-actions .btn-sch:hover {
background: linear-gradient(135deg, #ec4899 0%, #db2777 100%) !important;
transform: translateY(-1px);
}
/* Reduced Wrapper Margins */
#yommi_wrapper {
max-width: 100% !important;
padding: 10px 8px !important;
margin: 0 auto;
}
@media (min-width: 1200px) {
#yommi_wrapper {
max-width: 95% !important;
padding: 20px 15px !important;
}
}
@media (max-width: 768px) {
.row.infinite-scroll > [class*="col-"] {
padding: 6px !important;
}
.card-body {
padding: 10px !important;
}
.card-title {
font-size: 0.85rem !important;
}
.card-actions .btn {
font-size: 12px;
padding: 6px 10px;
}
}
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -1323,5 +1226,47 @@ $(document).ready(function(){
}, 100); }, 100);
}); });
</script> </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 %} {% endblock %}
+14 -386
View File
@@ -1,20 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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 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="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> <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', '설정 저장']])}} {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
</div> </div>
</div>
{{ macros.m_row_start('5') }} {{ macros.m_row_start('5') }}
{{ macros.m_row_end() }} {{ macros.m_row_end() }}
@@ -22,7 +14,7 @@
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('normal', '일반', true) }} {{ macros.m_tab_head('normal', '일반', true) }}
{{ macros.m_tab_head('auto', '자동등록', false) }} {{ macros.m_tab_head('auto', '홈화면 자동', false) }}
{{ macros.m_tab_head('action', '기타', false) }} {{ macros.m_tab_head('action', '기타', false) }}
{{ macros.m_tab_head_end() }} {{ macros.m_tab_head_end() }}
</nav> </nav>
@@ -31,8 +23,6 @@
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
{{ macros.m_tab_content_start('normal', true) }} {{ macros.m_tab_content_start('normal', true) }}
{{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }} {{ macros.setting_input_text_and_buttons('anilife_url', '애니라이프 URL', [['go_btn', 'GO']], value=arg['anilife_url']) }}
{{ macros.setting_input_text('anilife_proxy_url', '프록시 URL', col='4', value=arg.get('anilife_proxy_url', ''), desc='차단 시 프록시 서버를 입력하세요. 예: http://IP:PORT') }}
{{ macros.setting_input_int('anilife_cache_ttl', 'HTTP 캐시 TTL (초)', value=arg.get('anilife_cache_ttl', 300), desc='HTTP 응답 캐시 유지 시간 (초 단위, 기본: 300초 = 5분)') }}
<!-- 저장 폴더 (탐색 버튼 포함) --> <!-- 저장 폴더 (탐색 버튼 포함) -->
<div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;"> <div class="row" style="padding-top: 10px; padding-bottom:10px; align-items: center;">
@@ -66,73 +56,16 @@
{{ macros.setting_checkbox('anilife_auto_make_season_folder', '시즌 폴더 생성', value=arg['anilife_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }} {{ macros.setting_checkbox('anilife_auto_make_season_folder', '시즌 폴더 생성', value=arg['anilife_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }}
</div> </div>
{{ macros.setting_checkbox('anilife_uncompleted_auto_enqueue', '자동으로 다시 받기', value=arg['anilife_uncompleted_auto_enqueue'], desc=['On : 플러그인 로딩시 미완료인 항목은 자동으로 다시 받습니다.']) }} {{ macros.setting_checkbox('anilife_uncompleted_auto_enqueue', '자동으로 다시 받기', value=arg['anilife_uncompleted_auto_enqueue'], desc=['On : 플러그인 로딩시 미완료인 항목은 자동으로 다시 받습니다.']) }}
{{ macros.setting_select('anilife_cache_minutes', 'HTML 캐시 시간', [['0', '캐시 없음'], ['5', '5분'], ['10', '10분'], ['15', '15분'], ['30', '30분'], ['60', '1시간']], value=arg.get('anilife_cache_minutes', '5'), desc=['브라우징(요청, 검색) 페이지의 HTML을 캐시합니다.', '0으로 설정하면 캐시를 사용하지 않습니다.']) }}
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
{{ macros.m_tab_content_start('auto', false) }} {{ macros.m_tab_content_start('auto', false) }}
{{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }}
{{ macros.setting_input_text('anilife_interval', '스케쥴링 실행 정보', value=arg['anilife_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }} {{ macros.setting_input_text('anilife_interval', '스케쥴링 실행 정보', value=arg['anilife_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }}
{{ macros.setting_checkbox('anilife_auto_start', '시작시 자동실행', value=arg['anilife_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }} {{ macros.setting_checkbox('anilife_auto_start', '시작시 자동실행', value=arg['anilife_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
<!-- 자동 다운로드 작품 코드 - Tag Chips UI --> {{ macros.setting_input_textarea('anilife_auto_code_list', '자동 다운로드 작품 코드', desc=['all 입력시 모두 받기', '구분자 | 또는 엔터'], value=arg['anilife_auto_code_list'], row='10') }}
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left">
<strong>자동 다운로드할 작품 코드</strong>
</div>
<div class="col-sm-9">
<input type="hidden" id="anilife_auto_code_list" name="anilife_auto_code_list" value="{{arg['anilife_auto_code_list']}}">
<div id="tag_chips_container" class="tag-chips-wrapper mb-2"></div>
<div class="input-group input-group-sm">
<input type="text" id="new_tag_input" class="form-control" placeholder="작품명 입력 후 Enter (all: 모두 받기)">
<div class="input-group-append">
<button type="button" class="btn btn-outline-primary" id="add_tag_btn"><i class="bi bi-plus-lg"></i> 추가</button>
</div>
</div>
<div style="padding-top:5px;"><em class="text-muted">Enter로 추가, X로 삭제, 드래그 순서변경 | all 입력시 모두 받기</em></div>
</div>
</div>
{{ macros.setting_checkbox('anilife_auto_mode_all', '에피소드 모두 받기', value=arg['anilife_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }} {{ macros.setting_checkbox('anilife_auto_mode_all', '에피소드 모두 받기', value=arg['anilife_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
{{ macros.m_tab_content_start('action', false) }}
<div class="p-3" style="background: rgba(0,0,0,0.2); border-radius: 8px;">
<h5 class="text-info mb-3"><i class="bi bi-lightning-charge-fill mr-2"></i>Actions</h5>
{{ macros.setting_buttons([['global_one_execute_btn', '1회 실행']], left='1회 실행' ) }}
<hr style="border-color: rgba(255,255,255,0.1);">
{{ macros.setting_buttons([['global_reset_db_btn', 'DB 초기화']], left='DB정리' ) }}
<hr style="border-color: rgba(255,255,255,0.1);">
<h5 class="text-info mb-3"><i class="bi bi-cpu-fill mr-2"></i>시스템 상태 및 의존성</h5>
<div id="system_check_result" class="mb-3 p-3 rounded" style="background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.05);">
<div class="d-flex align-items-center mb-2">
<span class="mr-2">Chromium/Chrome:</span>
<span id="browser_status_badge" class="badge badge-secondary">확인 중...</span>
</div>
<div id="browser_path_display" class="small text-muted mb-2" style="font-family: monospace;"></div>
<div id="install_guide_section" style="display:none;">
<p class="small text-warning mb-2"><i class="bi bi-exclamation-triangle-fill mr-1"></i>브라우저가 발견되지 않았습니다. Zendriver 기능을 위해 설치가 필요합니다.</p>
<div id="auto_install_div" style="display:none;">
<button type="button" id="auto_install_btn" class="btn btn-sm btn-outline-info mb-2">
<i class="bi bi-download mr-1"></i>자동 설치 (Ubuntu/Docker)
</button>
</div>
<div class="mt-2">
<small class="d-block text-muted mb-1">수동 설치 명령어:</small>
<div class="input-group input-group-sm">
<input type="text" id="manual_install_cmd" class="form-control form-control-sm bg-dark border-secondary text-info" readonly value="apt-get update && apt-get install -y chromium-browser">
<div class="input-group-append">
<button class="btn btn-outline-secondary" type="button" id="copy_cmd_btn"><i class="bi bi-clipboard"></i></button>
</div>
</div>
</div>
</div>
</div>
<hr style="border-color: rgba(255,255,255,0.1);">
<h5 class="text-info mb-3"><i class="bi bi-browser-chrome mr-2"></i>Zendriver 설정</h5>
{{ macros.setting_input_text('anilife_zendriver_browser_path', '브라우저 경로', value=arg.get('anilife_zendriver_browser_path', ''), desc=['Zendriver가 사용할 Chrome/Chromium 실행 파일 경로입니다.', '위의 시스템 상태에서 자동으로 찾은 경우 비워두셔도 됩니다 (수동 설정 시 우선 적용).']) }}
</div>
{{ macros.m_tab_content_end() }}
</div><!--tab-content--> </div><!--tab-content-->
</form> </form>
</div> </div>
@@ -375,124 +308,23 @@
background: rgba(59, 130, 246, 0.3) !important; background: rgba(59, 130, 246, 0.3) !important;
} }
/* Tag Chips Styles */ /* Mobile Responsive */
.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;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.anilife-setting-header { body { padding-top: 10px !important; }
flex-direction: column !important; ul.nav.nav-pills.bg-light {
align-items: flex-start !important; margin-top: 50px !important;
gap: 12px; margin-bottom: 10px !important;
width: 100% !important;
display: flex !important;
border-radius: 12px !important;
} }
ul.nav.nav-pills .nav-link {
.anilife-setting-actions { padding: 6px 12px !important;
display: flex; font-size: 13px;
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;
} }
} }
.tag-chip .tag-remove:hover { background: rgba(239, 68, 68, 0.9); }
.tag-chip .tag-index {
width: 20px; height: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
font-size: 0.7rem;
color: #94a3b8;
display: flex; align-items: center; justify-content: center;
}
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
var package_name = "{{arg['package_name'] }}"; var package_name = "{{arg['package_name'] }}";
var sub = "{{arg['sub'] }}"; 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; } function escapeHtml(text) { var div = document.createElement('div'); div.appendChild(document.createTextNode(text)); return div.innerHTML; }
// ======================================
// 1회 실행 버튼
// ======================================
$("body").on('click', '#global_one_execute_btn', function(e){
e.preventDefault();
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/immediately_execute',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('스케줄러 1회 실행을 시작합니다.', {type:'success'});
} else {
$.notify(ret.msg || '실행 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// ======================================
// DB 초기화 버튼
// ======================================
$("body").on('click', '#global_reset_db_btn', function(e){
e.preventDefault();
if (!confirm('정말 DB를 초기화하시겠습니까?')) return;
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/reset_db',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('DB가 초기화되었습니다.', {type:'success'});
} else {
$.notify(ret.msg || '초기화 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// ======================================
// 시스템 체크 및 브라우저 설치
// ======================================
function runSystemCheck() {
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/system_check',
type: 'POST',
success: function(ret) {
if (ret.browser_found) {
$('#browser_status_badge').removeClass('badge-secondary badge-danger badge-warning').addClass('badge-success').text('발견됨');
$('#browser_path_display').text('경로: ' + ret.browser_path);
$('#install_guide_section').hide();
} else {
if (ret.snap_error) {
$('#browser_status_badge').removeClass('badge-secondary badge-success badge-danger').addClass('badge-warning').text('스냅 오류');
$('#browser_path_display').html('<span class="text-warning">발견되었으나 Snap 버전입니다. 도커에서 작동하지 않습니다.</span>');
} else {
$('#browser_status_badge').removeClass('badge-secondary badge-success badge-warning').addClass('badge-danger').text('미설치');
$('#browser_path_display').text('');
}
$('#install_guide_section').show();
$('#manual_install_cmd').val(ret.install_cmd);
if (ret.can_install) {
$('#auto_install_div').show();
} else {
$('#auto_install_div').hide();
}
}
}
});
}
// 자동 설치 버튼
$('#auto_install_btn').on('click', function() {
if (!confirm('시스템 브라우저 설치를 시작하시겠습니까?\n(Ubuntu/Debian 기반 도커 환경에서만 작동합니다)')) return;
var btn = $(this);
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin mr-1"></i>설치 중 (최대 10분 소요)...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/install_browser',
type: 'POST',
success: function(ret) {
if (ret.ret === 'success') {
$.notify(ret.msg, {type: 'success'});
if (ret.path) {
$('#anilife_zendriver_browser_path').val(ret.path);
}
runSystemCheck();
} else {
$.notify(ret.msg, {type: 'danger'});
}
},
error: function() {
$.notify('설치 요청 중 오류가 발생했습니다.', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-download mr-1"></i>자동 설치 (Ubuntu/Docker)');
}
});
});
// 명령어 복사 버튼
$('#copy_cmd_btn').on('click', function() {
var copyText = document.getElementById("manual_install_cmd");
copyText.select();
copyText.setSelectionRange(0, 99999);
document.execCommand("copy");
$.notify('명령어가 복사되었습니다.', {type: 'info'});
});
// 초기 실행 - Action 탭 로드시 시스템 체크
$(document).ready(function() {
runSystemCheck();
initTagChips();
});
// ======================================
// Tag Chips 기능
// ======================================
function initTagChips() {
var value = $('#anilife_auto_code_list').val().trim();
if (value) {
var items = value.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0);
items.forEach(function(item, index) { addTagChip(item, index); });
}
updateTagIndices();
}
function addTagChip(text, index) {
var chip = $('<div class="tag-chip" draggable="true" data-value="'+escapeHtml(text)+'"><span class="tag-index">'+(index+1)+'</span><span class="tag-text" title="'+escapeHtml(text)+'">'+escapeHtml(text)+'</span><span class="tag-remove"><i class="bi bi-x"></i></span></div>');
$('#tag_chips_container').append(chip);
}
function updateHiddenField() {
var values = [];
$('#tag_chips_container .tag-chip').each(function() { values.push($(this).data('value')); });
$('#anilife_auto_code_list').val(values.join('|'));
}
function updateTagIndices() {
$('#tag_chips_container .tag-chip').each(function(i) { $(this).find('.tag-index').text(i+1); });
}
$('#tag_chips_container').on('click', '.tag-remove', function(e) {
e.stopPropagation();
var chip = $(this).closest('.tag-chip');
chip.fadeOut(200, function() { $(this).remove(); updateHiddenField(); updateTagIndices(); });
});
$('#add_tag_btn').on('click', function() { addNewTag(); });
$('#new_tag_input').on('keypress', function(e) { if (e.which === 13) { e.preventDefault(); addNewTag(); } });
function addNewTag() {
var text = $('#new_tag_input').val().trim();
if (!text) { $.notify('작품명을 입력하세요', {type:'warning'}); return; }
var exists = false;
$('#tag_chips_container .tag-chip').each(function() { if ($(this).data('value') === text) exists = true; });
if (exists) { $.notify('이미 등록된 작품입니다', {type:'warning'}); return; }
addTagChip(text, $('#tag_chips_container .tag-chip').length);
updateHiddenField();
$('#new_tag_input').val('');
$.notify('"'+text+'" 추가됨', {type:'success'});
}
var draggedChip = null;
$('#tag_chips_container').on('dragstart', '.tag-chip', function(e) { draggedChip = this; $(this).addClass('dragging'); });
$('#tag_chips_container').on('dragend', '.tag-chip', function() { $(this).removeClass('dragging'); draggedChip = null; updateHiddenField(); updateTagIndices(); });
$('#tag_chips_container').on('dragover', function(e) { e.preventDefault(); var after = getDragAfterElement(this, e.originalEvent.clientX); if (!after) this.appendChild(draggedChip); else this.insertBefore(draggedChip, after); });
function getDragAfterElement(container, x) {
return [...container.querySelectorAll('.tag-chip:not(.dragging)')].reduce((c, el) => { var box = el.getBoundingClientRect(); var offset = x - box.left - box.width/2; return (offset < 0 && offset > c.offset) ? {offset, element: el} : c; }, {offset: Number.NEGATIVE_INFINITY}).element;
}
// ======================================
// 자가 업데이트 기능
// ======================================
$('#btn-self-update').on('click', function() {
if (!confirm('최신 코드를 다운로드하고 플러그인을 리로드하시겠습니까?')) return;
var btn = $(this);
var originalHTML = btn.html();
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/self_update',
type: 'POST',
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success') {
$.notify('<strong>업데이트 완료!</strong> 페이지를 새로고침합니다.', {type: 'success'});
setTimeout(function() { location.reload(); }, 1500);
} else {
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type: 'danger'});
}
},
error: function() {
$.notify('<strong>업데이트 중 오류 발생</strong>', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html(originalHTML);
}
});
});
</script> </script>
<style> <style>
@@ -1,7 +1,4 @@
{% extends "base.html" %} {% block content %} {% 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"> <div class="input-group mb-2">
<input <input
+139 -9
View File
@@ -1,13 +1,67 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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> <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 { .search-container {
background: rgba(6, 78, 59, 0.3); background: rgba(6, 78, 59, 0.3);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
@@ -348,6 +402,52 @@ $(document).ready(function(){
--accent-amber: #fbbf24; --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 */
.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> </style>
<script type="text/javascript"> <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-play" data-path="' + item.filepath + '" data-filename="' + item.filename + '"><i class="fa fa-play"></i> 재생</button>';
str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>'; str += '<button class="action-btn btn-merge-sub" data-id="' + item.id + '" data-filename="' + item.filename + '"><i class="fa fa-cc"></i> 자막합침</button>';
} }
// [보기] 버튼 추가 - JSON 버튼 왼쪽에 배치
if (item.content_code) {
str += '<button class="action-btn" onclick="location.href=\'/' + package_name + '/' + sub + '/request?code=' + item.content_code + '\'"><i class="fa fa-eye"></i> 보기</button>';
}
str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>'; str += '<button class="action-btn" onclick="m_modal(current_data.list[' + i + '])"><i class="fa fa-code"></i> JSON</button>';
str += '<button class="action-btn" onclick="search_item(\'' + (item.title || '') + '\')"><i class="fa fa-search"></i> 검색</button>'; str += '<button class="action-btn" onclick="search_item(\'' + (item.title || '') + '\')"><i class="fa fa-search"></i> 검색</button>';
str += '<button class="action-btn" onclick="db_remove(' + item.id + ')"><i class="fa fa-trash"></i> 삭제</button>'; str += '<button class="action-btn" onclick="db_remove(' + item.id + ')"><i class="fa fa-trash"></i> 삭제</button>';
+61 -69
View File
@@ -1,9 +1,60 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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> <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 { .queue-header-container {
display: flex; justify-content: space-between; align-items: flex-end; 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; 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); }); socket.on('last', function(data){ status_html(data); button_html(data); });
} }
// GDM 전용 소켓 추가 핸들링 (전역 업데이트 수신용)
var gdmSocket = null;
try {
gdmSocket = io.connect(socketUrl + '/gommi_downloader_manager');
gdmSocket.on('status', function(data) {
// 이 모듈과 관련된 작업만 처리
if (data.caller_plugin === PACKAGE_NAME + '_' + MODULE_NAME) {
// GDM 데이터를 큐 형식으로 변환하여 UI 즉시 업데이트
silentFetchList(function(newList) {
smartSyncList(newList);
});
}
});
} catch (e) {
console.error('GDM socket error:', e);
}
on_start(); on_start();
refreshIntervalId = setInterval(autoRefreshList, 3000); refreshIntervalId = setInterval(autoRefreshList, 3000);
}); });
@@ -187,13 +221,15 @@ function silentFetchList(callback) {
function autoRefreshList() { function autoRefreshList() {
silentFetchList(function(data) { silentFetchList(function(data) {
// 무조건 스마트 싱크 호출 (내부에서 변경 사항이 있을 때만 업데이트됨) if (data.length !== current_list_length) {
smartSyncList(data); current_list_length = data.length;
renderList(data);
}
var hasActiveDownload = false; var hasActiveDownload = false;
if (data && data.length > 0) { if (data && data.length > 0) {
for (var j = 0; j < data.length; j++) { for (var j = 0; j < data.length; j++) {
if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED' || data[j].status_str === 'ANALYZING') { if (data[j].status_str === 'DOWNLOADING' || data[j].status_str === 'WAITING' || data[j].status_str === 'STARTED') {
hasActiveDownload = true; hasActiveDownload = true;
break; 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) { function renderList(data) {
$("#list").html(''); $("#list").html('');
if (!data || data.length == 0) { 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>'; str += '<td id="status_'+data.idx+'" style="text-align:center;"><span class="badge-status ' + status_class + '">'+ data.status_kor + '</span></td>';
// 진행률 표시: 0%이더라도 다운로드 중이면 바를 표시 (깜빡임 서프레스) var visi = (parseInt(data.percent) > 0) ? 'visible' : 'hidden';
str += '<td><div class="progress custom-progress"><div id="progress_'+data.idx+'" class="progress-bar" style="width:'+data.percent+'%">'+data.percent +'%</div></div></td>'; str += '<td><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 style="text-align:center;">'+ data.duration_str + '</td>';
str += '<td id="current_pf_count_'+data.idx+'" style="text-align:center; color: #f87171;">'+ data.current_pf_count + '</td>'; str += '<td id="current_pf_count_'+data.idx+'" style="text-align:center; color: #f87171;">'+ data.current_pf_count + '</td>';
@@ -394,6 +385,7 @@ function status_html(data) {
progress.style.width = data.percent+ '%'; progress.style.width = data.percent+ '%';
progress.innerHTML = data.percent+ '%'; progress.innerHTML = data.percent+ '%';
progress.style.visibility = 'visible';
var statusEl = document.getElementById("status_" + data.idx); var statusEl = document.getElementById("status_" + data.idx);
if (statusEl) { if (statusEl) {
+55 -75
View File
@@ -1,7 +1,4 @@
{% extends "base.html" %} {% block content %} {% 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="anime_downloader_wrapper" class="content-cloak" style="max-width: 100%;">
<div id="preloader"> <div id="preloader">
<div class='demo'> <div class='demo'>
@@ -49,7 +46,7 @@
const package_name = "{{arg['package_name'] }}"; const package_name = "{{arg['package_name'] }}";
const sub = "{{arg['sub'] }}"; const sub = "{{arg['sub'] }}";
const ohli24_url = "{{arg['ohli24_url']}}"; const ohli24_url = "{{arg['ohli24_url']}}";
var current_data = null; // let current_data = '';
const params = new Proxy(new URLSearchParams(window.location.search), { const params = new Proxy(new URLSearchParams(window.location.search), {
get: (searchParams, prop) => searchParams.get(prop), get: (searchParams, prop) => searchParams.get(prop),
@@ -195,29 +192,45 @@
} }
$(function () { $(function () {
// URL 파라미터 처리 (code) // console.log(params.wr_id)
const urlCode = params.code || findGetParameter('code'); // console.log(findGetParameter('wr_id'))
const currentCode = "{{arg['linkkf_current_code']}}"; // console.log(params.code)
if (params.code === '') {
const targetCode = urlCode || currentCode; } else {
document.getElementById("code").value = params.code
if (targetCode) { // {#document.getElementById("analysis_btn").click();#}
document.getElementById("code").value = targetCode;
// wr_id, bo_table 등이 있으면 같이 전달
analyze(params.wr_id || findGetParameter('wr_id'), params.bo_table || findGetParameter('bo_table'));
} }
});
if ("{{arg['linkkf_current_code']}}" !== "") {
if (params.code === null) {
// console.log('params.code === null')
document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else if (params.code === '') {
document.getElementById("code").value = "{{arg['linkkf_current_code']}}";
} else {
// console.log('params code exist')
// console.log(params.code)
document.getElementById("code").value = params.code
analyze(params.wr_id, params.bo_table)
// document.getElementById("analysis_btn").click();
// $('#analysis_btn').trigger('click')
}
// 값이 공백이 아니면 분석 버튼 계속 누름
// {#document.getElementById("analysis_btn").click();#}
} else {
}
})
$(document).ready(function () { $(document).ready(function () {
// console.log('wr_id::', params.wr_id)
});
// Enter 키로 검색 트리거 // console.log('wr_id::', params.wr_id)
$("#code").on('keypress', function (e) {
if (e.which === 13) { // Enter key
e.preventDefault();
$("#analysis_btn").click();
}
}); });
$("#analysis_btn").unbind("click").bind('click', function (e) { $("#analysis_btn").unbind("click").bind('click', function (e) {
@@ -288,29 +301,24 @@
$("body").on('click', '#add_queue_btn', function (e) { $("body").on('click', '#add_queue_btn', function (e) {
e.preventDefault(); e.preventDefault();
let episode_data = current_data.episode[$(this).data('idx')]; data = current_data.episode[$(this).data('idx')];
// console.log('episode_data:::>', episode_data) // console.log('data:::>', data)
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_queue', url: '/' + package_name + '/ajax/' + sub + '/add_queue',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(episode_data)}, data: {data: JSON.stringify(data)},
dataType: "json", dataType: "json",
success: function (ret) { success: function (data) {
// console.log('#add_queue_btn::ret >>', ret) // console.log('#add_queue_btn::data >>', data)
if (ret.ret == 'enqueue_db_append' || ret.ret == 'enqueue_db_exist' || ret.ret == 'enqueue_gdm_success') { if (data.ret == 'enqueue_db_append' || data.ret == 'enqueue_db_exist') {
$.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'}); $.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
} else if (ret.ret == 'queue_exist') { } else if (data.ret == 'queue_exist') {
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'}); $.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
} else if (ret.ret == 'db_completed') { } else if (data.ret == 'db_completed') {
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'}); $.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 { } else {
const msg = ret.log || '알 수 없는 이유로 추가에 실패하였습니다.'; $.notify('<strong>추가 실패</strong><br>' + ret.log, {type: 'warning'});
$.notify('<strong>추가 실패</strong><br>' + msg, {type: 'warning'});
} }
} }
}); });
@@ -319,14 +327,16 @@
$("body").on('click', '#check_download_btn', function (e) { $("body").on('click', '#check_download_btn', function (e) {
e.preventDefault(); e.preventDefault();
let selected_data = []; all = $('input[id^="checkbox_"]');
$('input[id^="checkbox_"]').each(function() { let data = [];
if ($(this).prop('checked')) { let idx;
let idx = parseInt($(this).attr('id').split('_')[1]); for (let i in all) {
selected_data.push(current_data.episode[idx]); 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'}); $.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return; return;
} }
@@ -334,43 +344,13 @@
url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list', url: '/' + package_name + '/ajax/' + sub + '/add_queue_checked_list',
type: "POST", type: "POST",
cache: false, cache: false,
data: {data: JSON.stringify(selected_data)}, data: {data: JSON.stringify(data)},
dataType: "json", dataType: "json",
success: function (ret) { success: function (data) {
$.notify('<strong>백그라운드로 작업을 추가합니다.</strong>', {type: 'success'}); $.notify('<strong>백그라운드로 작업을 추가합니다.</strong>', {type: 'success'});
} }
}); });
}); });
$("body").on('click', '#down_subtitle_btn', function (e) {
e.preventDefault();
let selected_data = [];
$('input[id^="checkbox_"]').each(function() {
if ($(this).prop('checked')) {
let idx = parseInt($(this).attr('id').split('_')[1]);
selected_data.push(current_data.episode[idx]);
}
});
if (selected_data.length == 0) {
$.notify('<strong>선택하세요.</strong>', {type: 'warning'});
return;
}
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/add_sub_queue_checked_list',
type: "POST",
cache: false,
data: {data: JSON.stringify(selected_data)},
dataType: "json",
success: function (ret) {
if (ret.ret == "success") {
$.notify('<strong>백그라운드로 자막 다운로드를 시작합니다.</strong>', {type: 'success'});
} else {
const msg = ret.log || '알 수 없는 이유로 요청에 실패하였습니다.';
$.notify('<strong>자막 다운로드 요청 실패: ' + msg + '</strong>', {type: 'warning'});
}
}
});
});
</script> </script>
<style> <style>
#anime_downloader_wrapper { #anime_downloader_wrapper {
+80 -6
View File
@@ -1,9 +1,6 @@
{% extends "base.html" %} {% block content %} {% 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="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 id="preloader" class="loader">
<div class="loader-inner"> <div class="loader-inner">
<div class="loader-line-wrap"> <div class="loader-line-wrap">
@@ -981,7 +978,6 @@
padding: 0 !important; padding: 0 !important;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
@@ -1147,8 +1143,46 @@
color: #fff; 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> </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" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.1/font/bootstrap-icons.css" />
<style> <style>
@@ -1175,6 +1209,46 @@
opacity: 1; opacity: 1;
transition-delay: 300ms; 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> </style>
<script type="text/javascript"> <script type="text/javascript">
+3 -393
View File
@@ -1,20 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% 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 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="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> <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', '설정 저장']])}} {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
</div> </div>
</div>
{{ macros.m_row_start('5') }} {{ macros.m_row_start('5') }}
{{ macros.m_row_end() }} {{ macros.m_row_end() }}
@@ -22,7 +14,7 @@
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('normal', '일반', true) }} {{ macros.m_tab_head('normal', '일반', true) }}
{{ macros.m_tab_head('auto', '자동등록', false) }} {{ macros.m_tab_head('auto', '홈화면 자동', false) }}
{{ macros.m_tab_head('action', '기타', false) }} {{ macros.m_tab_head('action', '기타', false) }}
{{ macros.m_tab_head_end() }} {{ macros.m_tab_head_end() }}
</nav> </nav>
@@ -68,107 +60,8 @@
{{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }}
{{ macros.setting_input_text('linkkf_interval', '스케쥴링 실행 정보', value=arg['linkkf_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }} {{ macros.setting_input_text('linkkf_interval', '스케쥴링 실행 정보', value=arg['linkkf_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }}
{{ macros.setting_checkbox('linkkf_auto_start', '시작시 자동실행', value=arg['linkkf_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }} {{ macros.setting_checkbox('linkkf_auto_start', '시작시 자동실행', value=arg['linkkf_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
<!-- 자동 다운로드 작품 코드 - Tag Chips UI --> {{ macros.setting_input_textarea('linkkf_auto_code_list', '자동 다운로드 작품 코드', desc=['all 입력시 모두 받기', '구분자 | 또는 엔터'], value=arg['linkkf_auto_code_list'], row='10') }}
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left"><strong>자동 다운로드할 작품 코드</strong></div>
<div class="col-sm-9">
<input type="hidden" id="linkkf_auto_code_list" name="linkkf_auto_code_list" value="{{arg['linkkf_auto_code_list']}}">
<div id="tag_chips_container" class="tag-chips-wrapper mb-2"></div>
<div class="input-group input-group-sm">
<input type="text" id="new_tag_input" class="form-control" placeholder="작품명 입력 후 Enter (all: 모두 받기)">
<div class="input-group-append"><button type="button" class="btn btn-outline-primary" id="add_tag_btn"><i class="bi bi-plus-lg"></i> 추가</button></div>
</div>
<div style="padding-top:5px;"><em class="text-muted">Enter로 추가, X로 삭제, 드래그 순서변경 | all 입력시 모두 받기</em></div>
</div>
</div>
{{ macros.setting_checkbox('linkkf_auto_mode_all', '에피소드 모두 받기', value=arg['linkkf_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }} {{ macros.setting_checkbox('linkkf_auto_mode_all', '에피소드 모두 받기', value=arg['linkkf_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
{{ macros.setting_checkbox('linkkf_auto_download_new', '새 에피소드 자동 다운로드', value=arg['linkkf_auto_download_new'], desc=['On : 새 에피소드 감지 시 자동으로 큐에 추가합니다.', 'Off : 알림만 보내고 다운로드는 수동으로 합니다.']) }}
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left"><strong>모니터링 주기</strong></div>
<div class="col-sm-9">
<select class="form-control form-control-sm col-sm-3" id="linkkf_monitor_interval" name="linkkf_monitor_interval">
<option value="5" {% if arg.get('linkkf_monitor_interval', '10') == '5' %}selected{% endif %}>5분</option>
<option value="10" {% if arg.get('linkkf_monitor_interval', '10') == '10' or not arg.get('linkkf_monitor_interval') %}selected{% endif %}>10분 (기본)</option>
<option value="15" {% if arg.get('linkkf_monitor_interval', '10') == '15' %}selected{% endif %}>15분</option>
<option value="30" {% if arg.get('linkkf_monitor_interval', '10') == '30' %}selected{% endif %}>30분</option>
<option value="60" {% if arg.get('linkkf_monitor_interval', '10') == '60' %}selected{% endif %}>1시간</option>
</select>
<div style="padding-top:5px;"><em class="text-muted">'all' 모드 사용 시 사이트를 확인하는 주기입니다.</em></div>
</div>
</div>
{{ macros.m_tab_content_end() }}
{{ macros.m_tab_content_start('action', false) }}
<div class="row mb-3">
<div class="col-sm-12">
<h5 class="text-white mb-3"><i class="bi bi-lightning-fill mr-2"></i>수동 작업</h5>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-3 set-left"><strong>스케줄러 1회 실행</strong></div>
<div class="col-sm-9">
<button type="button" class="btn btn-outline-success btn-sm" id="global_one_execute_btn">
<i class="bi bi-play-circle mr-1"></i> 1회 실행
</button>
<div style="padding-top:5px;"><em class="text-muted">자동 다운로드 스케줄러를 즉시 1회 실행합니다.</em></div>
</div>
</div>
<div class="row mb-4">
<div class="col-sm-3 set-left"><strong>DB 초기화</strong></div>
<div class="col-sm-9">
<button type="button" class="btn btn-outline-danger btn-sm" id="global_reset_db_btn">
<i class="bi bi-trash mr-1"></i> DB 초기화
</button>
<div style="padding-top:5px;"><em class="text-muted">다운로드 기록 DB를 초기화합니다.</em></div>
</div>
</div>
<hr style="border-color: rgba(255,255,255,0.1); margin: 30px 0;">
<div class="row mb-3">
<div class="col-sm-12">
<h5 class="text-white mb-3"><i class="bi bi-bell-fill mr-2"></i>알림 설정</h5>
</div>
</div>
{{ macros.setting_checkbox('linkkf_notify_enabled', '알림 활성화', value=arg['linkkf_notify_enabled'], desc='새 에피소드가 큐에 추가되면 알림을 보냅니다.') }}
<div class="row mb-3">
<div class="col-sm-12">
<h6 class="text-info mb-2"><i class="bi bi-discord mr-1"></i> Discord</h6>
</div>
</div>
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left"><strong>Discord Webhook URL</strong></div>
<div class="col-sm-9">
<div class="input-group">
<input type="text" class="form-control form-control-sm" id="linkkf_discord_webhook_url" name="linkkf_discord_webhook_url" value="{{arg['linkkf_discord_webhook_url']}}">
<div class="input-group-append">
<button type="button" class="btn btn-sm btn-outline-secondary" id="copy_discord_url_btn" title="URL 복사">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div style="padding-top:5px;"><em class="text-muted">Discord 서버 설정 → 연동 → 웹훅에서 URL을 복사하세요.</em></div>
</div>
</div>
<div class="row mb-3 mt-4">
<div class="col-sm-12">
<h6 class="text-info mb-2"><i class="bi bi-telegram mr-1"></i> Telegram</h6>
</div>
</div>
{{ macros.setting_input_text('linkkf_telegram_bot_token', 'Telegram Bot Token', col='9', value=arg['linkkf_telegram_bot_token'], desc='@BotFather에서 생성한 봇 토큰입니다.') }}
{{ macros.setting_input_text('linkkf_telegram_chat_id', 'Telegram Chat ID', col='4', value=arg['linkkf_telegram_chat_id'], desc='알림을 받을 채팅방 ID (개인: 숫자, 그룹: -숫자)') }}
<div class="row mb-3 mt-3">
<div class="col-sm-3"></div>
<div class="col-sm-9">
<button type="button" class="btn btn-outline-info btn-sm" id="test_notify_btn">
<i class="bi bi-send mr-1"></i> 테스트 알림 전송
</button>
</div>
</div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
</div><!--tab-content--> </div><!--tab-content-->
@@ -217,44 +110,6 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
<style> <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 */ /* Global Background */
body { body {
font-family: 'NamumSquareNeo', system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Noto Sans, Liberation Sans, Arial, sans-serif; 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 { .folder-item.selected {
background: rgba(16, 185, 129, 0.3) !important; background: rgba(16, 185, 129, 0.3) !important;
} }
/* Tag Chips Styles */
.tag-chips-wrapper { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; min-height: 60px; background: rgba(0,0,0,0.2); border: 1px dashed rgba(255,255,255,0.15); border-radius: 8px; }
.tag-chips-wrapper:empty::before { content: '작품이 없습니다.'; color: #64748b; font-style: italic; }
.tag-chip { display: inline-flex; align-items: center; gap: 8px; padding: 8px 12px; background: linear-gradient(135deg, rgba(16,185,129,0.3), rgba(5,150,105,0.4)); border: 1px solid rgba(16,185,129,0.4); border-radius: 20px; font-size: 0.9rem; color: #e2e8f0; cursor: grab; transition: all 0.2s ease; }
.tag-chip:hover { background: linear-gradient(135deg, rgba(16,185,129,0.5), rgba(5,150,105,0.6)); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(16,185,129,0.3); }
.tag-chip .tag-text { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tag-chip .tag-remove { width: 18px; height: 18px; background: rgba(239,68,68,0.5); border-radius: 50%; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 0.75rem; }
.tag-chip .tag-remove:hover { background: rgba(239,68,68,0.9); }
.tag-chip .tag-index { width: 20px; height: 20px; background: rgba(0,0,0,0.3); border-radius: 50%; font-size: 0.7rem; color: #94a3b8; display: flex; align-items: center; justify-content: center; }
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -496,94 +342,6 @@ $("body").on('click', '#go_btn', function(e){
window.open(url, "_blank"); window.open(url, "_blank");
}); });
// 1회 실행 버튼
$(document).on('click', '#global_one_execute_btn', function(e){
e.preventDefault();
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/immediately_execute',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('스케줄러 1회 실행을 시작합니다.', {type:'success'});
} else {
$.notify(ret.msg || '실행 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// DB 초기화 버튼
$(document).on('click', '#global_reset_db_btn', function(e){
e.preventDefault();
if (!confirm('정말 DB를 초기화하시겠습니까?')) return;
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/reset_db',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('DB가 초기화되었습니다.', {type:'success'});
} else {
$.notify(ret.msg || '초기화 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
}
});
});
// Discord Webhook URL 복사 버튼
$(document).on('click', '#copy_discord_url_btn', function(e){
e.preventDefault();
var url = $('#linkkf_discord_webhook_url').val();
if (!url) {
$.notify('복사할 URL이 없습니다.', {type:'warning'});
return;
}
navigator.clipboard.writeText(url).then(function() {
$.notify('URL이 클립보드에 복사되었습니다.', {type:'success'});
}).catch(function() {
// Fallback for older browsers
var temp = $('<input>').val(url).appendTo('body').select();
document.execCommand('copy');
temp.remove();
$.notify('URL이 클립보드에 복사되었습니다.', {type:'success'});
});
});
// 테스트 알림 버튼
$(document).on('click', '#test_notify_btn', function(e){
e.preventDefault();
var btn = $(this);
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin mr-1"></i> 전송 중...');
$.ajax({
url: '/'+package_name+'/ajax/'+sub+'/test_notification',
type: "POST",
cache: false,
dataType: "json",
success: function(ret) {
if (ret.ret == 'success') {
$.notify('테스트 알림을 전송했습니다!', {type:'success'});
} else {
$.notify(ret.msg || '알림 전송 실패', {type:'danger'});
}
},
error: function(xhr, status, error) {
$.notify('에러: ' + error, {type:'danger'});
},
complete: function() {
btn.prop('disabled', false).html('<i class="bi bi-send mr-1"></i> 테스트 알림 전송');
}
});
});
// ====================================== // ======================================
// 폴더 탐색 기능 // 폴더 탐색 기능
// ====================================== // ======================================
@@ -722,154 +480,6 @@ $(document).ready(function(){
setTimeout(function() { setTimeout(function() {
$('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible'); $('.content-cloak, #menu_module_div, #menu_page_div').addClass('visible');
}, 100); }, 100);
initTagChips();
});
// Tag Chips 기능
function initTagChips() {
var value = $('#linkkf_auto_code_list').val().trim();
if (value) {
var items = value.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0);
items.forEach(function(item, index) { addTagChip(item, index); });
}
updateTagIndices();
}
function addTagChip(text, index) {
var chip = $('<div class="tag-chip" draggable="true" data-value="'+escapeHtml(text)+'"><span class="tag-index">'+(index+1)+'</span><span class="tag-text" title="'+escapeHtml(text)+'">'+escapeHtml(text)+'</span><span class="tag-remove"><i class="bi bi-x"></i></span></div>');
$('#tag_chips_container').append(chip);
}
function updateHiddenField() {
var values = [];
$('#tag_chips_container .tag-chip').each(function() { values.push($(this).data('value')); });
$('#linkkf_auto_code_list').val(values.join('|'));
}
function updateTagIndices() {
$('#tag_chips_container .tag-chip').each(function(i) { $(this).find('.tag-index').text(i+1); });
}
$('#tag_chips_container').on('click', '.tag-remove', function(e) {
e.stopPropagation();
var chip = $(this).closest('.tag-chip');
chip.fadeOut(200, function() { $(this).remove(); updateHiddenField(); updateTagIndices(); });
});
$('#add_tag_btn').on('click', function() { addNewTag(); });
$('#new_tag_input').on('keypress', function(e) { if (e.which === 13) { e.preventDefault(); addNewTag(); } });
function addNewTag() {
var text = $('#new_tag_input').val().trim();
if (!text) { $.notify('작품명을 입력하세요', {type:'warning'}); return; }
var exists = false;
$('#tag_chips_container .tag-chip').each(function() { if ($(this).data('value') === text) exists = true; });
if (exists) { $.notify('이미 등록된 작품입니다', {type:'warning'}); return; }
addTagChip(text, $('#tag_chips_container .tag-chip').length);
updateHiddenField();
$('#new_tag_input').val('');
$.notify('"'+text+'" 추가됨', {type:'success'});
}
var draggedChip = null;
$('#tag_chips_container').on('dragstart', '.tag-chip', function(e) { draggedChip = this; $(this).addClass('dragging'); });
$('#tag_chips_container').on('dragend', '.tag-chip', function() { $(this).removeClass('dragging'); draggedChip = null; updateHiddenField(); updateTagIndices(); });
$('#tag_chips_container').on('dragover', function(e) { e.preventDefault(); var after = getDragAfterElement(this, e.originalEvent.clientX); if (!after) this.appendChild(draggedChip); else this.insertBefore(draggedChip, after); });
function getDragAfterElement(container, x) {
return [...container.querySelectorAll('.tag-chip:not(.dragging)')].reduce((c, el) => { var box = el.getBoundingClientRect(); var offset = x - box.left - box.width/2; return (offset < 0 && offset > c.offset) ? {offset, element: el} : c; }, {offset: Number.NEGATIVE_INFINITY}).element;
}
// ======================================
// 자가 업데이트 기능
// ======================================
$(document).on('click', '#btn-self-update', function() {
$('#updateConfirmModal').modal('show');
});
// 실제 업데이트 실행 (모달에서 확인 버튼 클릭 시)
$(document).on('click', '#confirmUpdateBtn', function() {
$('#updateConfirmModal').modal('hide');
var btn = $('#btn-self-update');
var originalHTML = btn.html();
btn.prop('disabled', true).html('<i class="bi bi-arrow-repeat spin"></i> 업데이트 중...');
$.ajax({
url: '/' + package_name + '/ajax/' + sub + '/self_update',
type: 'POST',
dataType: 'json',
success: function(ret) {
if (ret.ret === 'success') {
$.notify('<strong>업데이트 완료!</strong> 페이지를 새로고침합니다.', {type: 'success'});
setTimeout(function() { location.reload(); }, 1500);
} else {
$.notify('<strong>업데이트 실패: ' + ret.msg + '</strong>', {type: 'danger'});
}
},
error: function() {
$.notify('<strong>업데이트 중 오류 발생</strong>', {type: 'danger'});
},
complete: function() {
btn.prop('disabled', false).html(originalHTML);
}
});
}); });
</script> </script>
<!-- Update Confirmation Modal (Linkkf Green Theme) -->
<div class="modal fade" id="updateConfirmModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content animate__animated animate__zoomIn" style="background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(16, 185, 129, 0.3); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);">
<div class="modal-body text-center" style="padding: 40px 30px;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, rgba(16, 185, 129, 0.2) 0%, rgba(5, 150, 105, 0.2) 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; border: 2px solid rgba(16, 185, 129, 0.3);">
<i class="bi bi-arrow-repeat" style="color: #10b981; font-size: 36px;"></i>
</div>
<h4 style="color: #f1f5f9; font-weight: 700; margin-bottom: 12px;">플러그인 업데이트</h4>
<p style="color: #94a3b8; font-size: 15px; margin-bottom: 8px;">최신 코드를 다운로드하고 플러그인을 리로드합니다.</p>
<p style="color: #64748b; font-size: 13px; margin-bottom: 32px;"><i class="bi bi-info-circle"></i> 서버 재시작 없이 즉시 적용됩니다.</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button type="button" class="btn" data-dismiss="modal" style="width: 120px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #94a3b8; border-radius: 10px; padding: 12px 24px; font-weight: 600;">취소</button>
<button type="button" id="confirmUpdateBtn" class="btn" style="width: 140px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; border-radius: 10px; padding: 12px 24px; font-weight: 600; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.4);">
<i class="bi bi-download"></i> 업데이트
</button>
</div>
</div>
</div>
</div>
</div>
<style>
/* Update Button Enhanced Visibility (Linkkf Green) */
#btn-self-update {
background: linear-gradient(135deg, #10b981 0%, #059669 100%) !important;
border: none !important;
color: white !important;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
transition: all 0.2s ease;
}
#btn-self-update:hover:not(:disabled) {
background: linear-gradient(135deg, #059669 0%, #047857 100%) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4);
}
#btn-self-update:disabled {
background: linear-gradient(135deg, #475569 0%, #334155 100%) !important;
color: #94a3b8 !important;
cursor: not-allowed;
box-shadow: none;
opacity: 0.7;
}
#btn-self-update .bi-arrow-repeat.spin,
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Animate.css for modal */
.animate__animated { animation-duration: 0.3s; }
.animate__zoomIn { animation-name: zoomIn; }
@keyframes zoomIn {
from { opacity: 0; transform: scale3d(0.3, 0.3, 0.3); }
50% { opacity: 1; }
}
</style>
{% endblock %} {% endblock %}
+217 -144
View File
@@ -1,159 +1,193 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/>
<style> <style>
/* Match gds_dviewer Log Page Design */ /* Premium Dark Theme Variables */
body { :root {
background: linear-gradient(145deg, #0f172a, #1e293b) !important; --bg-color: #0f172a; /* Slate 900 */
color: #f8fafc; --card-bg: #1e293b; /* Slate 800 */
font-family: 'Inter', -apple-system, sans-serif; --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 { /* Global Override */
background: linear-gradient(145deg, rgba(20, 30, 48, 0.95), rgba(36, 59, 85, 0.9)); body {
border: 1px solid rgba(100, 150, 180, 0.25); 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; border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
overflow: hidden; overflow: hidden;
margin-top: 20px; margin-top: 20px;
} }
.log-card-header {
background: transparent;
border-bottom: 1px solid rgba(100, 150, 180, 0.2);
padding: 16px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.log-card-header h5 {
margin: 0;
color: #e2e8f0;
font-weight: 600;
}
.log-card-header h5 i {
color: #7dd3fc;
margin-right: 8px;
}
.btn-log {
background: linear-gradient(180deg, rgba(45, 55, 72, 0.95), rgba(35, 45, 60, 0.98));
border: 1px solid rgba(100, 150, 180, 0.25);
color: #7dd3fc;
padding: 6px 14px;
font-size: 12px;
cursor: pointer;
border-radius: 6px;
margin-left: 8px;
}
.btn-log:hover {
background: linear-gradient(180deg, rgba(55, 65, 82, 0.95), rgba(45, 55, 70, 0.98));
color: #fff;
}
.btn-log.danger {
background: rgba(239, 68, 68, 0.15);
border: 1px solid rgba(239, 68, 68, 0.4);
color: #fca5a5;
}
.btn-log.danger:hover {
background: rgba(239, 68, 68, 0.3);
}
.log-container {
height: calc(100vh - 200px);
min-height: 400px;
overflow-y: auto;
padding: 16px;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
font-size: 12px;
line-height: 1.6;
background: rgba(0, 0, 0, 0.3);
color: #94a3b8;
}
.log-line-error { color: #f87171; }
.log-line-warning { color: #fbbf24; }
.log-line-info { color: #5eead4; }
.log-line-debug { color: #94a3b8; }
/* Tabs Styling */ /* Tabs Styling */
.nav-tabs { .nav-tabs {
border-bottom: 1px solid rgba(100, 150, 180, 0.2); border-bottom: 1px solid var(--border-color);
background: rgba(0, 0, 0, 0.2); background-color: rgba(0,0,0,0.2);
padding: 10px 10px 0 10px; padding: 10px 10px 0 10px;
} }
.nav-tabs .nav-link { .nav-tabs .nav-link {
color: #94a3b8 !important; color: var(--text-muted) !important;
border: none !important; border: none !important;
border-radius: 8px 8px 0 0 !important; border-radius: 8px 8px 0 0 !important;
padding: 10px 20px; padding: 10px 20px;
font-weight: 500; font-weight: 500;
transition: all 0.2s ease;
background: transparent; background: transparent;
} }
.nav-tabs .nav-link:hover { .nav-tabs .nav-link:hover {
color: #e2e8f0 !important; color: var(--text-color) !important;
background: rgba(255, 255, 255, 0.05); background-color: rgba(255,255,255,0.05);
} }
.nav-tabs .nav-link.active { .nav-tabs .nav-link.active {
color: #7dd3fc !important; color: var(--accent-color) !important;
background: rgba(20, 30, 48, 0.95) !important; background-color: var(--card-bg) !important;
border-bottom: 2px solid #7dd3fc !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 { .controls-bar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
padding: 12px 20px; padding: 12px 20px;
background: rgba(0, 0, 0, 0.2); background-color: rgba(0,0,0,0.2);
border-top: 1px solid rgba(100, 150, 180, 0.2); border-top: 1px solid var(--border-color);
gap: 12px; }
/* Toggle Switch */
.form-check-input {
background-color: #334155;
border-color: #475569;
cursor: pointer;
} }
.form-check-input:checked { .form-check-input:checked {
background-color: #7dd3fc; background-color: var(--accent-color);
border-color: #7dd3fc; border-color: var(--accent-color);
} }
.form-check-label { .form-check-label {
color: #94a3b8; color: var(--text-muted);
font-weight: 500; font-weight: 500;
margin-right: 12px;
user-select: none;
} }
@media (max-width: 768px) { /* Buttons */
.log-container { .btn-action {
height: calc(100vh - 180px); background-color: transparent;
min-height: 300px; border: 1px solid var(--border-color);
padding: 12px; color: var(--text-color);
} border-radius: 6px;
.log-card-header { padding: 6px 16px;
flex-direction: column; font-size: 14px;
gap: 12px; font-weight: 500;
align-items: flex-start; transition: all 0.2s;
} margin-left: 12px;
} }
/* Smooth Load */ .btn-action:hover {
.content-cloak { background-color: var(--accent-color);
opacity: 0; border-color: var(--accent-color);
transition: opacity 0.5s ease-out; color: white;
}
.content-cloak.visible {
opacity: 1;
} }
</style> </style>
<div class="container-fluid content-cloak" id="main_container" style="max-width: 1400px;"> <div class="container-fluid content-cloak" id="main_container">
<div class="log-card"> <!-- 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> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('old', 'History', true) }} {{ macros.m_tab_head('old', 'History', true) }}
@@ -162,20 +196,27 @@
</nav> </nav>
<div class="tab-content" id="nav-tabContent"> <div class="tab-content" id="nav-tabContent">
<!-- History Logs --> <!-- Old Logs -->
{{ macros.m_tab_content_start('old', true) }} {{ 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() }} {{ macros.m_tab_content_end() }}
<!-- Real-time Logs --> <!-- New Logs -->
{{ macros.m_tab_content_start('new', false) }} {{ 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"> <div class="controls-bar">
<div class="d-flex align-items-center">
<label class="form-check-label" for="auto_scroll">Auto Scroll</label> <label class="form-check-label" for="auto_scroll">Auto Scroll</label>
<div class="form-check form-switch mb-0"> <div class="form-check form-switch mb-0">
<input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked> <input id="auto_scroll" name="auto_scroll" class="form-check-input" type="checkbox" checked>
</div> </div>
<button id="clear" class="btn-log">Clear</button> <button id="clear" class="btn btn-action">Clear Console</button>
</div>
</div> </div>
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
</div> </div>
@@ -183,58 +224,90 @@
</div> </div>
<script type="text/javascript"> <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() { $(document).ready(function() {
// Force fluid layout
$("#main_container").removeClass("container").addClass("container-fluid"); $("#main_container").removeClass("container").addClass("container-fluid");
$('#loading').show(); $('#loading').show();
setTimeout(function() { ResizeTextAreaLog()
$('.content-cloak').addClass('visible'); })
}, 100);
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 protocol = window.location.protocol;
var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log"); var socket = io.connect(protocol + "//" + document.domain + ":" + location.port + "/log");
socket.emit("start", {'package':'{{package}}'} ); socket.emit("start", {'package':'{{package}}'} );
socket.on('on_start', function(data){ socket.on('on_start', function(data){
var container = document.getElementById("log-history"); var logEl = document.getElementById("log");
var lines = data.data.split('\n'); logEl.innerHTML += data.data;
var html = ''; logEl.scrollTop = logEl.scrollHeight;
lines.forEach(function(line) { logEl.style.visibility = 'visible';
html += formatLogLine(line);
});
container.innerHTML = html || '<div class="text-muted text-center">로그가 비어 있습니다.</div>';
container.scrollTop = container.scrollHeight;
$('#loading').hide(); $('#loading').hide();
}); });
socket.on('add', function(data){ socket.on('add', function(data){
if (data.package == "{{package}}") { if (data.package == "{{package}}") {
var chk = $('#auto_scroll').is(":checked"); var chk = $('#auto_scroll').is(":checked");
var container = document.getElementById("log-realtime"); var addEl = document.getElementById("add");
container.innerHTML += formatLogLine(data.data); addEl.innerHTML += data.data;
if (chk) container.scrollTop = container.scrollHeight; if (chk) addEl.scrollTop = addEl.scrollHeight;
} }
}); });
$("#clear").click(function(e) { $("#clear").click(function(e) {
e.preventDefault(); e.preventDefault();
document.getElementById("log-realtime").innerHTML = ''; 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> </script>
{% endblock %} {% endblock %}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+133 -232
View File
@@ -1,8 +1,4 @@
{% extends "base.html" %} {% block content %} {% 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 id="preloader">
<div class="demo"> <div class="demo">
<!-- <div class="loader-inner">--> <!-- <div class="loader-inner">-->
@@ -24,28 +20,16 @@
<!-- </div>--> <!-- </div>-->
</div> </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"> <form id="program_list">
<div class="glass-card p-4 mb-4"> <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="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="form-group mb-0"> <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> <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"> <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);"> <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"> <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> <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>
<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="go_ohli24_btn" class="btn btn-outline-light px-3">Go OHLI24</button>
</div> </div>
</div> </div>
<div class="d-flex align-items-center mt-2 text-muted small"> <div class="d-flex align-items-center mt-2 text-muted small">
@@ -60,9 +44,6 @@
</form> </form>
</div> </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"> <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> <script src="{{ url_for('.static', filename='js/sjva_ui14.js') }}"></script>
@@ -70,7 +51,7 @@
const package_name = "{{arg['package_name'] }}"; const package_name = "{{arg['package_name'] }}";
const sub = "{{arg['sub'] }}"; const sub = "{{arg['sub'] }}";
const ohli24_url = "{{arg['ohli24_url']}}"; const ohli24_url = "{{arg['ohli24_url']}}";
// let current_data = ''; {#let current_data = '';#}
let accessibleCount = 1; let accessibleCount = 1;
@@ -78,39 +59,6 @@
get: (searchParams, prop) => searchParams.get(prop), 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 loader = document.getElementById("preloader");
const dismissLoadingScreen = function () { const dismissLoadingScreen = function () {
@@ -140,7 +88,7 @@
} }
function analyze(wr_id, bo_table) { function analyze(wr_id, bo_table) {
// e.preventDefault(); {#e.preventDefault();#}
const code = document.getElementById("code").value const code = document.getElementById("code").value
// // console.log(code) // // console.log(code)
@@ -170,13 +118,12 @@
function make_program(data) { function make_program(data) {
current_data = data; current_data = data;
updateGoButtonHref(data.code || $('#code').val() || params.code);
// // console.log(data); // // console.log(data);
let str = ""; let str = "";
// 1. Info Card (Top) // 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">`; str += `<div class="row">`;
// Poster // Poster
@@ -226,14 +173,14 @@
// Standard Actions // Standard Actions
if (data.first_exist_filepath) { 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="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 ohli24-secondary-action">전체 선택</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 ohli24-muted-action">해제</button>`; str += `<button id="all_check_off_btn" class="btn btn-outline-secondary btn-sm">해제</button>`;
// Scheduling (Heart) // 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 str += `</div>`; // End Action Toolbar
@@ -247,14 +194,9 @@
let epThumbSrc = data.episode[i].thumbnail || ''; let epThumbSrc = data.episode[i].thumbnail || '';
let epTitle = data.episode[i].title || ''; let epTitle = data.episode[i].title || '';
// 에피소드 번호 추출: 백엔드 epi_no 우선, 없으면 정규식, 마지막으로 인덱스 // 에피소드 번호 추출 (title에서 "N화" 패턴 찾기)
let epNumText = ''; let epNumMatch = epTitle.match(/(\d+)화/);
if (data.episode[i].epi_no !== undefined && data.episode[i].epi_no !== null) { let epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화';
epNumText = data.episode[i].epi_no + '화';
} else {
let epNumMatch = epTitle.match(/(\d+(?:\.\d+)?)[\s\.\…화회]*$/);
epNumText = epNumMatch ? epNumMatch[1] + '화' : (parseInt(i) + 1) + '화';
}
str += '<div class="episode-card">'; str += '<div class="episode-card">';
str += '<div class="episode-thumb">'; str += '<div class="episode-thumb">';
@@ -279,17 +221,11 @@
} }
str += '</div>'; str += '</div>';
// 먼저 HTML을 삽입하고 레이아웃이 완료된 후 bootstrapToggle 적용
document.getElementById("episode_list").innerHTML = str; document.getElementById("episode_list").innerHTML = str;
$('input[id^="checkbox_"]').bootstrapToggle()
// 다음 프레임에서 bootstrapToggle 적용 (모든 카드가 먼저 표시됨)
requestAnimationFrame(function() {
$('input[id^="checkbox_"]').bootstrapToggle();
});
} }
$(function () { $(function () {
updateGoButtonHref(params.code || $('#code').val());
// // console.log(params.wr_id) // // console.log(params.wr_id)
// // console.log(findGetParameter('wr_id')) // // console.log(findGetParameter('wr_id'))
// // console.log(params.code) // // console.log(params.code)
@@ -329,7 +265,7 @@
// // console.log("{{ arg['code'] }}") // // console.log("{{ arg['code'] }}")
// // console.log('wr_id::', params.wr_id) // // console.log('wr_id::', params.wr_id)
if (document.getElementById("code").value !== "") { if (document.getElementById("code").value !== "") {
// document.getElementById("analysis_btn").click() {#document.getElementById("analysis_btn").click()#}
$('#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) { $("#analysis_btn").unbind("click").bind('click', function (e) {
e.preventDefault(); e.preventDefault();
@@ -365,9 +294,8 @@
dataType: "json", dataType: "json",
success: function (ret) { success: function (ret) {
if (ret.ret === 'success' && ret.data != null) { if (ret.ret === 'success' && ret.data != null) {
// console.log(ret.code) // {#// // console.log(ret.code)#}
// // console.log(ret.data) // // console.log(ret.data)
updateGoButtonHref((ret.data && ret.data.code) || code);
make_program(ret.data) make_program(ret.data)
} else { } else {
$.notify('<strong>분석 실패</strong><br>' + ret.log, {type: 'warning'}); $.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) { $("body").on('click', '#all_check_on_btn', function (e) {
e.preventDefault(); e.preventDefault();
$('input[id^="checkbox_"]').bootstrapToggle('on') $('input[id^="checkbox_"]').bootstrapToggle('on')
@@ -432,16 +365,14 @@
dataType: "json", dataType: "json",
success: function (data) { success: function (data) {
// // console.log('#add_queue_btn::data >>', 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'}); $.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
} else if (data.ret == 'queue_exist') { } else if (data.ret == 'queue_exist') {
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'}); $.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
} else if (data.ret == 'db_completed') { } else if (data.ret == 'db_completed') {
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'}); $.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
} else if (data.ret == 'file_exists') {
$.notify('<strong>파일이 이미 존재합니다.</strong>', {type: 'warning'});
} else { } 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 { .row, form, #program_list, #program_auto_form, #episode_list {
width: 100% !important; width: 100% !important;
max-width: 100% !important; max-width: 100% !important;
padding-left: 2px !important; padding-left: 4px !important;
padding-right: 2px !important; padding-right: 4px !important;
margin-left: 0 !important; margin-left: 0 !important;
margin-right: 0 !important; margin-right: 0 !important;
box-sizing: border-box !important; box-sizing: border-box !important;
@@ -803,16 +734,16 @@
} }
/* ===== 상단 정보 카드 ===== */ /* ===== 상단 정보 카드 ===== */
.glass-card { .card.p-lg-5, .card.border-light {
width: calc(100% - 4px) !important; width: calc(100% - 10px) !important;
max-width: 100% !important; max-width: 100% !important;
padding: 8px !important; padding: 6px !important; /* Reduced from 15px */
margin: 2px !important; margin: 5px !important;
border-radius: 12px !important; border-radius: 12px !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }
.glass-card > .row { .card.p-lg-5 > .row {
display: flex !important; display: flex !important;
flex-direction: column !important; flex-direction: column !important;
width: 100% !important; width: 100% !important;
@@ -1375,51 +1306,40 @@ $(document).ready(function(){
}, 100); }, 100);
}); });
</script> </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 fade" id="videoModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl" role="document"> <div class="modal-dialog modal-lg" style="max-width: 90%; margin: 1.75rem auto;">
<div class="modal-content" style="background: #0f172a; border-radius: 12px; border: 1px solid rgba(255,255,255,0.1);"> <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;"> <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;"> <h5 class="modal-title" style="color: #ecfdf5; font-weight: 700; display: flex; align-items: center; gap: 10px;">
<i class="fa fa-play-circle" style="color: #3b82f6;"></i> <i class="fa fa-youtube-play" style="color: #10b981;"></i>
<span id="current-video-title">Video Player</span> <span id="current-video-title">Video Player</span>
</h5> </h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close" style="color: #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">&times;</span> <span aria-hidden="true">&times;</span>
</button> </button>
</div> </div>
<div class="modal-body p-0"> <div class="modal-body p-0" style="display: flex; flex-direction: column; height: 80vh;">
<div class="video-container" style="position: relative; overflow: hidden; background: #000;"> <div style="flex-grow: 1; background: black; position: relative;">
<video id="video-player" class="video-js vjs-big-play-centered vjs-theme-fantasy" controls preload="auto" playsinline webkit-playsinline style="width: 100%; height: auto; max-height: 80vh;"> <video id="video-player" class="video-js vjs-big-play-centered vjs-theme-forest" controls preload="auto" style="width: 100%; height: 100%;"></video>
<p class="vjs-no-js">JavaScript가 필요합니다.</p> </div>
</video>
<!-- 화면 꽉 채우기 토글 버튼 --> <!-- Playlist Control Bar -->
<button id="btn-video-zoom" class="video-zoom-btn" title="화면 비율 조절"> <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;">
<i class="fa fa-expand"></i> <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> </button>
</div> </div>
<!-- 플레이리스트 컨트롤 UI --> <!-- Playlist Drawer -->
<div class="playlist-controls"> <div id="playlist-list-container" style="height: 0px; background: rgba(2, 44, 34, 0.95); transition: height 0.3s ease; overflow-y: auto; border-top: 1px solid rgba(16, 185, 129, 0.1); display: none;">
<div class="playlist-header"> <div id="playlist-list" style="padding: 10px;">
<button id="btn-prev-ep" class="nav-btn" title="이전 에피소드"> <!-- JS generated items -->
<i class="fa fa-chevron-left"></i>
</button>
<div class="playing-info">
<div id="playlist-progress" class="progress-text"></div>
</div>
<button id="btn-next-ep" class="nav-btn" title="다음 에피소드">
<i class="fa fa-chevron-right"></i>
</button>
<div class="control-group">
<button id="btn-toggle-playlist" class="action-btn active" title="목록 토글">
<i class="fa fa-list-ul"></i>
</button>
</div>
</div>
<div id="playlist-list-container" class="playlist-drawer" style="display: block;">
<div id="playlist-list" class="playlist-grid"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1427,96 +1347,86 @@ $(document).ready(function(){
</div> </div>
</div> </div>
<link href="https://vjs.zencdn.net/8.10.0/video-js.css" rel="stylesheet" /> <link href="https://vjs.zencdn.net/7.20.3/video-js.css" rel="stylesheet" />
<script src="https://vjs.zencdn.net/8.10.0/video.min.js"></script> <script src="https://vjs.zencdn.net/7.20.3/video.min.js"></script>
<style> <style>
/* Video Player Enhancements */ /* VideoJS Customization */
.playlist-controls { .vjs-theme-forest { --vjs-theme-forest--emerald: #10b981; }
padding: 16px; .video-js.vjs-theme-forest .vjs-big-play-button { background-color: #10b981; border-color: #34d399; }
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); .playlist-nav-btn {
border-bottom-left-radius: 12px; width: 40px; height: 40px; background: linear-gradient(135deg, #10b981, #059669);
border-bottom-right-radius: 12px; 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-header { display: flex; align-items: center; gap: 15px; } .playlist-toggle-btn {
.playing-info { flex: 1; min-width: 0; } padding: 8px 14px; background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2);
.progress-text { color: #94a3b8; font-size: 13px; font-weight: 600; } border-radius: 8px; color: #6ee7b7; font-size: 13px; cursor: pointer; transition: all 0.2s ease;
.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; } .playlist-toggle-btn:hover { background: rgba(16, 185, 129, 0.2); color: #ecfdf5; }
.action-btn.active { background: #3b82f6; box-shadow: 0 0 15px rgba(59, 130, 246, 0.4); } .playlist-toggle-btn.active { background: rgba(16, 185, 129, 0.3); border-color: #10b981; color: #10b981; }
.playlist-drawer {
margin-top: 15px;
background: rgba(0, 0, 0, 0.3);
border-radius: 10px;
padding: 10px;
max-height: 250px;
overflow-y: auto;
}
.playlist-grid { display: flex; flex-direction: column; gap: 4px; }
.playlist-item { .playlist-item {
padding: 10px 15px; padding: 10px 12px; border-radius: 6px; cursor: pointer; transition: all 0.2s;
background: rgba(255, 255, 255, 0.03); color: #d1fae5; font-size: 13px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid rgba(16, 185, 129, 0.05);
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;
} }
.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) { @media (max-width: 768px) {
#videoModal .modal-dialog { width: 100% !important; margin: 0 !important; max-width: 100% !important; } /* 모달 크기 조정 */
#videoModal .modal-content { border-radius: 0 !important; height: 100vh; display: flex; flex-direction: column; } #videoModal .modal-dialog {
.video-container { flex: 1; display: flex; align-items: center; } margin: 10px auto !important;
.playlist-controls { padding-bottom: 25px; } max-width: calc(100vw - 20px) !important;
.video-zoom-btn { opacity: 0.8; top: 10px; right: 10px; } }
/* 모달 바디 높이 조정 */
#videoModal .modal-body {
height: auto !important;
max-height: 75vh !important;
}
/* 비디오 높이 제한 */
#video-player, .video-js {
max-height: 40vh !important;
}
/* 플레이리스트 높이 제한 */
#playlist-list-container {
max-height: 20vh !important;
}
/* 플레이리스트 컨트롤 간소화 */
.playlist-nav-btn {
width: 32px !important;
height: 32px !important;
font-size: 12px !important;
}
.playlist-toggle-btn {
padding: 6px 10px !important;
font-size: 11px !important;
}
#playlist-progress {
font-size: 12px !important;
}
}
/* 초소형 모바일 (400px 미만) */
@media (max-width: 400px) {
#video-player, .video-js {
max-height: 35vh !important;
}
#playlist-list-container {
max-height: 15vh !important;
}
} }
</style> </style>
@@ -1552,21 +1462,6 @@ function play_video(path, filename) {
playVideoAtIndex(currentPlaylistIndex + 1); playVideoAtIndex(currentPlaylistIndex + 1);
} }
}); });
// 비디오 줌/확장 처리
$('#btn-video-zoom').click(function() {
const $video = $('#video-player_html5_api');
$(this).toggleClass('active');
if ($(this).hasClass('active')) {
$video.css({'object-fit': 'cover', 'height': '100%'});
$(this).find('i').removeClass('fa-expand').addClass('fa-compress');
$.notify('화면을 꽉 채웠습니다.', {type: 'info', delay: 1000});
} else {
$video.css({'object-fit': 'contain', 'height': 'auto'});
$(this).find('i').removeClass('fa-compress').addClass('fa-expand');
$.notify('원래 비율로 복원되었습니다.', {type: 'info', delay: 1000});
}
});
} }
playVideoAtIndex(currentPlaylistIndex); playVideoAtIndex(currentPlaylistIndex);
@@ -1662,7 +1557,13 @@ $('#btn-next-ep').click(function() { playVideoAtIndex(currentPlaylistIndex + 1);
$('#btn-toggle-playlist').click(function() { $('#btn-toggle-playlist').click(function() {
$(this).toggleClass('active'); $(this).toggleClass('active');
var container = $('#playlist-list-container'); var container = $('#playlist-list-container');
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> </script>
{% endblock %} {% endblock %}
+148 -194
View File
@@ -1,42 +1,30 @@
{% extends "base.html" %} {% block content %} {% 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="loader">
<!-- Modern UI Loader --> <div class="loader-inner">
<div id="preloader" class="loader-bg"> <div class="loader-line-wrap">
<div class="modern-loader"> <div class="loader-line"></div>
<div class="spinner-ring"></div> </div>
<div class="spinner-ring"></div> <div class="loader-line-wrap">
<div class="spinner-ring"></div> <div class="loader-line"></div>
<div class="loader-text">Loading Ohli24</div> </div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div>
<div class="loader-line-wrap">
<div class="loader-line"></div>
</div> </div>
</div> </div>
<!-- AJAX Loading Spinner (Global) -->
<div id="ajax_loader" class="ajax-loader-container" style="display: none;">
<div class="ajax-spinner"></div>
</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 --> <!-- Search Section -->
<div class="glass-card p-4 mb-4"> <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="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="row align-items-center"> <div class="row align-items-center">
<div class="col-md-8 mx-auto"> <div class="col-md-8 mx-auto">
<div class="input-group input-group-responsive"> <div class="input-group input-group-lg">
<input <input
id="input_search" id="input_search"
type="search" type="search"
@@ -56,23 +44,10 @@
<!-- Category Buttons --> <!-- Category Buttons -->
<div class="row mt-4 justify-content-center"> <div class="row mt-4 justify-content-center">
<div id="anime_category" class="d-flex flex-wrap justify-content-center" role="group"> <div id="anime_category" class="d-flex flex-wrap justify-content-center gap-2" role="group">
<button id="ing" type="button" class="btn btn-success btn-pill px-4 mx-1 active active-glow mb-2">방영중</button> <button id="ing" type="button" class="btn btn-outline-success btn-pill px-4 mx-1 active-glow">방영중</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">극장판</button>
<button id="theater" type="button" class="btn btn-outline-primary btn-pill px-4 mx-1 active-glow mb-2">극장판</button> <button id="complete_anilist" type="button" class="btn btn-outline-light btn-pill px-4 mx-1 active-glow">완결</button>
</div>
</div>
<!-- Year Selection (Sub-category) -->
<div id="year_filter_row" class="row mt-3 justify-content-center animate__animated animate__fadeIn" style="display: none;">
<div id="year_category" class="d-flex flex-wrap justify-content-center" 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> </div>
</div> </div>
</div> </div>
@@ -82,7 +57,7 @@
<div id="airing_list"></div> <div id="airing_list"></div>
</form> </form>
<form id="screen_movie_list_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>
<form id="program_auto_form"> <form id="program_auto_form">
@@ -107,8 +82,7 @@
// let current_data = null; // let current_data = null;
let page = 1; let page = 1;
let next_page = Number let next_page = Number
let current_cate = 'ing' let current_cate = ''
let current_sca = ''
let current_query = '' let current_query = ''
const loader = document.getElementById("preloader"); const loader = document.getElementById("preloader");
@@ -119,7 +93,7 @@
const wait3seconds = function () { const wait3seconds = function () {
// REFERENCE: https://www.w3schools.com/jsref/met_win_settimeout.asp // REFERENCE: https://www.w3schools.com/jsref/met_win_settimeout.asp
const result = setTimeout(dismissLoadingScreen, 1000); const result = setTimeout(dismissLoadingScreen, 2000);
}; };
window.addEventListener("load", wait3seconds); window.addEventListener("load", wait3seconds);
@@ -134,12 +108,10 @@
observer.observe(); observer.observe();
const get_anime_list = (type, page, sca = '') => { const get_anime_list = (type, page) => {
// console.log(`type: ${type}, page: ${page}, sca: ${sca}`) // console.log(`type: ${type}, page: ${page}`)
let url = '' let url = ''
let data = {"page": page, "type": type} let data = {"page": page, "type": type}
if (sca) data.sca = sca;
current_sca = sca;
switch (type) { switch (type) {
case 'ing': case 'ing':
@@ -162,38 +134,31 @@
break; break;
} }
$('#ajax_loader').show();
$.ajax({ $.ajax({
url: url, url: url,
type: "POST", type: "POST",
data: data, data: data,
cache: false, cache: false,
global: false,
dataType: "json", dataType: "json",
complete: () => {
$('#ajax_loader').hide();
},
success: (ret) => { success: (ret) => {
let current_screen_movie_data = ret let current_screen_movie_data = ret
// console.log('ret::>', ret) // console.log('ret::>', ret)
if (ret.ret === 'success') { if (current_screen_movie_data !== '') {
if (type === "ing") { if (type === "ing") {
make_airing_list(ret.data, page) make_airing_list(ret.data, page)
// observer.observe(); {#observer.observe();#}
} else if (type === "fin") { } else if (type === "fin") {
make_screen_movie_list(ret.data, page) make_screen_movie_list(ret.data, page)
// observer.observe(); {#observer.observe();#}
} else if (type === "theater") { } else if (type === "theater") {
make_screen_movie_list(ret.data, page) make_screen_movie_list(ret.data, page)
// observer.observe(); {#observer.observe();#}
} else { } else {
make_screen_movie_list(ret.data, page) make_screen_movie_list(ret.data, page)
} }
// div_visible = true {#div_visible = true#}
// // console.log(div_visible) {#// console.log(div_visible)#}
} else {
$.notify('<strong>데이터를 불러오지 못했습니다.</strong><br>' + (ret.log || ''), {type: 'danger'});
} }
next_page = page + 1 next_page = page + 1
} }
@@ -234,11 +199,6 @@
} }
function make_airing_list(data, page) { 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 = '' let str = ''
str += '<div class="d-flex justify-content-between align-items-center mb-3">'; str += '<div class="d-flex justify-content-between align-items-center mb-3">';
@@ -274,11 +234,6 @@
} }
function make_search_result_list(data, page) { 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 str = ''
let list = data.anime_list || []; let list = data.anime_list || [];
@@ -319,15 +274,10 @@
} }
function make_screen_movie_list(data, page) { 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">'; str += '<div class="d-flex justify-content-between align-items-center mb-3">';
let title = current_cate === 'movie' ? '극장판' : (current_cate === 'theater' ? '극장판(구)' : '완결 애니메이션'); let title = current_cate === 'movie' ? '극장판' : (current_cate === 'theater' ? '극장판(구)' : '완결 애니메이션');
if (current_sca) title += ' (' + current_sca + ')';
str += '<h5 class="text-white font-weight-bold border-left pl-3" style="border-width: 4px !important; border-color: #ffd700 !important;">' + title + '</h5>'; str += '<h5 class="text-white font-weight-bold border-left pl-3" style="border-width: 4px !important; border-color: #ffd700 !important;">' + title + '</h5>';
str += '<button type="button" class="btn btn-sm btn-dark rounded-pill px-3">Page <span class="badge badge-warning ml-1">' + page + '</span></button>'; str += '<button type="button" class="btn btn-sm btn-dark rounded-pill px-3">Page <span class="badge badge-warning ml-1">' + page + '</span></button>';
str += '</div>'; str += '</div>';
@@ -398,18 +348,13 @@
return false; return false;
} }
$('#ajax_loader').show();
$.ajax({ $.ajax({
url: "/" + package_name + "/ajax/" + sub + "/search", url: "/" + package_name + "/ajax/" + sub + "/search",
type: "POST", type: "POST",
cache: false, cache: false,
global: false,
data: {query: query, type: current_cate, page: page}, data: {query: query, type: current_cate, page: page},
// dataType: "json", // dataType: "json",
contentType: "application/x-www-form-urlencoded; charset=UTF-8", contentType: "application/x-www-form-urlencoded; charset=UTF-8",
complete: () => {
$('#ajax_loader').hide();
},
success: function (ret) { success: function (ret) {
if (ret.ret) { if (ret.ret) {
// console.log('ret:::', ret) // console.log('ret:::', ret)
@@ -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 () { $('#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) get_anime_list("ing", 1)
}) })
$('#anime_category #fin').on("click", function () { $('#anime_category #complete_anilist').on("click", function () {
updateActiveButton('fin'); // {#// console.log(this.id)#}
$('#year_filter_row').fadeIn(); // let spinner = document.getElementById('spinner');
get_anime_list("fin", 1, current_sca) // 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 () { $('#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) get_anime_list("theater", 1)
}) })
@@ -470,17 +395,12 @@
e.preventDefault(); e.preventDefault();
const code = document.getElementById("code").value const code = document.getElementById("code").value
// console.log(code) // console.log(code)
$('#ajax_loader').show();
$.ajax({ $.ajax({
url: '/' + package_name + '/ajax/' + sub + '/analysis', url: '/' + package_name + '/ajax/' + sub + '/analysis',
type: "POST", type: "POST",
cache: false, cache: false,
global: false,
data: {code: code}, data: {code: code},
dataType: "json", dataType: "json",
complete: () => {
$('#ajax_loader').hide();
},
success: function (ret) { success: function (ret) {
if (ret.ret === 'success' && ret.data != null) { if (ret.ret === 'success' && ret.data != null) {
// // console.log(ret.code) // // console.log(ret.code)
@@ -546,14 +466,14 @@
data: {data: JSON.stringify(data)}, data: {data: JSON.stringify(data)},
dataType: "json", dataType: "json",
success: function (data) { 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'}); $.notify('<strong>다운로드 작업을 추가 하였습니다.</strong>', {type: 'success'});
} else if (data.ret == 'queue_exist') { } else if (data.ret == 'queue_exist') {
$.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'}); $.notify('<strong>이미 큐에 있습니다. 삭제 후 추가하세요.</strong>', {type: 'warning'});
} else if (data.ret == 'db_completed') { } else if (data.ret == 'db_completed') {
$.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'}); $.notify('<strong>DB에 완료 기록이 있습니다.</strong>', {type: 'warning'});
} else { } 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 // const observer = lozad(el); // passing a `NodeList` (e.g. `document.querySelectorAll()`) is also valid
// observer.observe(); // observer.observe();
const loadNextAnimes = (cate, page) => { const loadNextAnimes = (cate, page) => {
// spinner.style.display = "block"; {#spinner.style.display = "block";#}
let data = {type: cate, page: String(page)}; let data = {type: cate, page: String(page)};
if (current_sca) data.sca = current_sca;
let url = '' let url = ''
switch (cate) { switch (cate) {
case 'ing': case 'ing':
@@ -593,7 +512,6 @@
break; break;
} }
$('#ajax_loader').show();
fetch(url, { fetch(url, {
method: "POST", method: "POST",
cache: "no-cache", cache: "no-cache",
@@ -617,10 +535,7 @@
page++; page++;
next_page++; next_page++;
}) })
.catch((error) => console.error("Error:", error)) .catch((error) => console.error("Error:", error));
.finally(() => {
$('#ajax_loader').hide();
});
}; };
@@ -628,7 +543,7 @@
console.dir(e.target.scrollingElement.scrollHeight); console.dir(e.target.scrollingElement.scrollHeight);
const {scrollTop, scrollHeight, clientHeight} = e.target.scrollingElement; const {scrollTop, scrollHeight, clientHeight} = e.target.scrollingElement;
if (Math.round(scrollHeight - scrollTop) <= clientHeight) { if (Math.round(scrollHeight - scrollTop) <= clientHeight) {
// document.getElementById("spinner").style.display = "block"; {#document.getElementById("spinner").style.display = "block";#}
// console.log("loading"); // console.log("loading");
// console.log("now page::> ", page); // console.log("now page::> ", page);
// console.log("next_page::> ", String(next_page)); // console.log("next_page::> ", String(next_page));
@@ -647,7 +562,12 @@
document.addEventListener("scroll", debounce(onScroll, 300)); document.addEventListener("scroll", debounce(onScroll, 300));
</script> </script>
<style> <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 */ /* Responsive Grid System */
.anime-grid { .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 { .glass-card:hover {
transform: translateY(-5px); transform: translateY(-5px);
@@ -731,67 +700,19 @@
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
} }
/* Modern Premium Preloader */ /* Preloader (Original but styled) */
.loader-bg { #preloader {
background-color: #0f172a;
position: fixed; position: fixed;
top: 0; left: 0; width: 100%; height: 100%; top: 0; left: 0; right: 0; bottom: 0;
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%);
z-index: 9999; z-index: 9999;
background: rgba(15, 23, 42, 0.8); display: flex;
backdrop-filter: blur(8px); align-items: center;
padding: 25px; justify-content: center;
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
} }
.ajax-spinner { .loader-inner {
width: 50px; height: 50px; position: relative;
border: 4px solid rgba(59, 130, 246, 0.1);
border-left-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
} }
/* Scrollbar */ /* Scrollbar */
@@ -854,6 +775,39 @@
transition-delay: 300ms; 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> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -868,7 +822,7 @@ $(document).ready(function(){
/* Mobile Margin Fix */ /* Mobile Margin Fix */
@media (max-width: 768px) { @media (max-width: 768px) {
body { overflow-x: hidden !important; padding: 0 !important; margin: 0 !important; } 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; width: 100% !important; max-width: 100% !important;
padding-left: 4px !important; padding-right: 4px !important; padding-left: 4px !important; padding-right: 4px !important;
margin-left: 0 !important; margin-right: 0 !important; margin-left: 0 !important; margin-right: 0 !important;
+104 -500
View File
@@ -1,28 +1,12 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<link rel="stylesheet" href="{{ url_for('.static', filename='css/mobile_custom.css') }}"/> <div id="ohli24_setting_wrapper" class="container-fluid mt-4 mx-auto content-cloak" style="max-width: 100%;">
<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 class="glass-card p-4"> <div class="glass-card p-4">
<div class="ohli24-header"> <div class="d-flex justify-content-between align-items-center mb-4">
<div class="ohli24-header-left"> <h2 class="text-white font-weight-bold"><i class="bi bi-gear-fill mr-2"></i>Ohli24 설정</h2>
<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', '설정 저장']])}} {{ macros.m_button_group([['globalSettingSaveBtn', '설정 저장']])}}
</div> </div>
</div>
{{ macros.m_row_start('5') }} {{ macros.m_row_start('5') }}
{{ macros.m_row_end() }} {{ macros.m_row_end() }}
@@ -30,7 +14,7 @@
<nav> <nav>
{{ macros.m_tab_head_start() }} {{ macros.m_tab_head_start() }}
{{ macros.m_tab_head('normal', '일반', true) }} {{ macros.m_tab_head('normal', '일반', true) }}
{{ macros.m_tab_head('auto', '자동등록', false) }} {{ macros.m_tab_head('auto', '홈화면 자동', false) }}
{{ macros.m_tab_head('action', '기타', false) }} {{ macros.m_tab_head('action', '기타', false) }}
{{ macros.m_tab_head_end() }} {{ macros.m_tab_head_end() }}
</nav> </nav>
@@ -75,41 +59,13 @@
{{ macros.setting_checkbox('ohli24_auto_make_season_folder', '시즌 폴더 생성', value=arg['ohli24_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }} {{ macros.setting_checkbox('ohli24_auto_make_season_folder', '시즌 폴더 생성', value=arg['ohli24_auto_make_season_folder'], desc=['On : Season 번호 폴더를 만듭니다.']) }}
</div> </div>
{{ macros.setting_checkbox('ohli24_uncompleted_auto_enqueue', '자동으로 다시 받기', value=arg['ohli24_uncompleted_auto_enqueue'], desc=['On : 플러그인 로딩시 미완료인 항목은 자동으로 다시 받습니다.']) }} {{ 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_end() }}
{{ macros.m_tab_content_start('auto', false) }} {{ macros.m_tab_content_start('auto', false) }}
{{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }} {{ macros.global_setting_scheduler_button(arg['scheduler'], arg['is_running']) }}
{{ macros.setting_input_text('ohli24_interval', '스케쥴링 실행 정보', value=arg['ohli24_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }} {{ macros.setting_input_text('ohli24_interval', '스케쥴링 실행 정보', value=arg['ohli24_interval'], col='3', desc=['Inverval(minute 단위)이나 Cron 설정']) }}
{{ macros.setting_checkbox('ohli24_auto_start', '시작시 자동실행', value=arg['ohli24_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }} {{ macros.setting_checkbox('ohli24_auto_start', '시작시 자동실행', value=arg['ohli24_auto_start'], desc='On : 시작시 자동으로 스케쥴러에 등록됩니다.') }}
<!-- 자동 다운로드 작품 코드 - Tag Chips UI --> {{ macros.setting_input_textarea('ohli24_auto_code_list', '자동 다운로드 작품 코드', desc=['구분자 | 또는 엔터'], value=arg['ohli24_auto_code_list'], row='10') }}
<div class="row" style="padding-top: 10px; padding-bottom:10px;">
<div class="col-sm-3 set-left">
<strong>자동 다운로드할 작품 코드</strong>
</div>
<div class="col-sm-9">
<!-- 숨겨진 실제 값 필드 (DB 저장용, | 구분) -->
<input type="hidden" id="ohli24_auto_code_list" name="ohli24_auto_code_list" value="{{arg['ohli24_auto_code_list']}}">
<!-- Tag Chips 컨테이너 -->
<div id="tag_chips_container" class="tag-chips-wrapper mb-2">
<!-- 태그들이 여기에 동적으로 추가됨 -->
</div>
<!-- 새 태그 입력 -->
<div class="input-group input-group-sm">
<input type="text" id="new_tag_input" class="form-control" placeholder="작품명 입력 후 Enter 또는 추가 버튼">
<div class="input-group-append">
<button type="button" class="btn btn-outline-primary" id="add_tag_btn">
<i class="bi bi-plus-lg"></i> 추가
</button>
</div>
</div>
<div style="padding-top:5px;">
<em class="text-muted">Enter로 추가, 태그 X로 삭제, 드래그로 순서 변경 가능</em>
</div>
</div>
</div>
{{ macros.setting_checkbox('ohli24_auto_mode_all', '에피소드 모두 받기', value=arg['ohli24_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }} {{ macros.setting_checkbox('ohli24_auto_mode_all', '에피소드 모두 받기', value=arg['ohli24_auto_mode_all'], desc=['On : 이전 에피소드를 모두 받습니다.', 'Off : 최신 에피소드만 받습니다.']) }}
{{ macros.m_tab_content_end() }} {{ macros.m_tab_content_end() }}
@@ -119,37 +75,6 @@
{{ macros.setting_buttons([['global_one_execute_btn', '1회 실행']], left='1회 실행' ) }} {{ macros.setting_buttons([['global_one_execute_btn', '1회 실행']], left='1회 실행' ) }}
<hr style="border-color: rgba(255,255,255,0.1);"> <hr style="border-color: rgba(255,255,255,0.1);">
{{ macros.setting_buttons([['global_reset_db_btn', 'DB 초기화']], left='DB정리' ) }} {{ 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> </div>
{{ macros.m_tab_content_end() }} {{ 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"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css">
<style> <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 { .nav-tabs .nav-link.active {
color: #60a5fa !important; color: #60a5fa !important;
background: rgba(30, 41, 59, 0.8) !important; background: rgba(30, 41, 59, 0.8) !important;
@@ -335,93 +359,6 @@
.folder-item i { .folder-item i {
font-size: 1.1rem; font-size: 1.1rem;
} }
/* Tag Chips Styles */
.tag-chips-wrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px;
min-height: 60px;
background: rgba(0, 0, 0, 0.2);
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 8px;
transition: all 0.3s ease;
}
.tag-chips-wrapper:empty::before {
content: '작품이 없습니다. 아래에서 추가하세요.';
color: #64748b;
font-style: italic;
}
.tag-chips-wrapper.drag-over {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: linear-gradient(135deg, rgba(59, 130, 246, 0.3) 0%, rgba(37, 99, 235, 0.4) 100%);
border: 1px solid rgba(96, 165, 250, 0.4);
border-radius: 20px;
font-size: 0.9rem;
color: #e2e8f0;
cursor: grab;
transition: all 0.2s ease;
user-select: none;
}
.tag-chip:hover {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.5) 0%, rgba(37, 99, 235, 0.6) 100%);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
}
.tag-chip.dragging {
opacity: 0.5;
cursor: grabbing;
}
.tag-chip .tag-text {
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-chip .tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: rgba(239, 68, 68, 0.5);
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
font-size: 0.75rem;
}
.tag-chip .tag-remove:hover {
background: rgba(239, 68, 68, 0.9);
transform: scale(1.1);
}
.tag-chip .tag-index {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 50%;
font-size: 0.7rem;
color: #94a3b8;
}
</style> </style>
<script type="text/javascript"> <script type="text/javascript">
@@ -440,9 +377,6 @@ $(document).ready(function(){
}, 100); }, 100);
use_collapse('ohli24_auto_make_folder'); use_collapse('ohli24_auto_make_folder');
// Tag Chips 초기화
initTagChips();
}); });
$('#ani365_auto_make_folder').change(function() { $('#ani365_auto_make_folder').change(function() {
@@ -627,336 +561,6 @@ function escapeHtml(text) {
return div.innerHTML; 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> </script>
<!-- Update Confirmation Modal -->
<div class="modal fade" id="updateConfirmModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content animate__animated animate__zoomIn" style="background: linear-gradient(145deg, #1e293b 0%, #0f172a 100%); border: 1px solid rgba(59, 130, 246, 0.3); border-radius: 20px; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);">
<div class="modal-body text-center" style="padding: 40px 30px;">
<div style="width: 80px; height: 80px; background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%); border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 24px; border: 2px solid rgba(59, 130, 246, 0.3);">
<i class="bi bi-arrow-repeat" style="color: #3b82f6; font-size: 36px;"></i>
</div>
<h4 style="color: #f1f5f9; font-weight: 700; margin-bottom: 12px;">플러그인 업데이트</h4>
<p style="color: #94a3b8; font-size: 15px; margin-bottom: 8px;">최신 코드를 다운로드하고 플러그인을 리로드합니다.</p>
<p style="color: #64748b; font-size: 13px; margin-bottom: 32px;"><i class="bi bi-info-circle"></i> 서버 재시작 없이 즉시 적용됩니다.</p>
<div style="display: flex; gap: 12px; justify-content: center;">
<button type="button" class="btn" data-dismiss="modal" style="width: 120px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); color: #94a3b8; border-radius: 10px; padding: 12px 24px; font-weight: 600;">취소</button>
<button type="button" id="confirmUpdateBtn" class="btn" style="width: 140px; background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); border: none; color: white; border-radius: 10px; padding: 12px 24px; font-weight: 600; box-shadow: 0 4px 15px rgba(59, 130, 246, 0.4);">
<i class="bi bi-download"></i> 업데이트
</button>
</div>
</div>
</div>
</div>
</div>
<style>
/* Update Button Enhanced Visibility */
#btn-self-update {
background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%) !important;
border: none !important;
color: white !important;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(14, 165, 233, 0.3);
transition: all 0.2s ease;
}
#btn-self-update:hover:not(:disabled) {
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%) !important;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(14, 165, 233, 0.4);
}
#btn-self-update:disabled {
background: linear-gradient(135deg, #475569 0%, #334155 100%) !important;
color: #94a3b8 !important;
cursor: not-allowed;
box-shadow: none;
opacity: 0.7;
}
#btn-self-update .bi-arrow-repeat.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Animate.css for modal */
.animate__zoomIn {
animation-duration: 0.3s;
}
</style>
{% endblock %} {% endblock %}
+360
View File
@@ -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
}