feat: Add CDNDania downloader, enhance mobile responsiveness for anime request templates, and refine Ohli24 episode numbering.
This commit is contained in:
477
lib/cdndania_downloader.py
Normal file
477
lib/cdndania_downloader.py
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
"""
|
||||||
|
cdndania.com CDN 전용 다운로더 (curl_cffi 사용)
|
||||||
|
- 동일한 세션(TLS 핑거프린트)으로 m3u8 추출과 세그먼트 다운로드 수행
|
||||||
|
- CDN 보안 검증 우회
|
||||||
|
- subprocess로 분리 실행하여 Flask 블로킹 방지
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CdndaniaDownloader:
|
||||||
|
"""cdndania.com 전용 다운로더 (세션 기반 보안 우회)"""
|
||||||
|
|
||||||
|
def __init__(self, iframe_src, output_path, referer_url=None, callback=None, proxy=None):
|
||||||
|
self.iframe_src = iframe_src # cdndania.com 플레이어 iframe URL
|
||||||
|
self.output_path = output_path
|
||||||
|
self.referer_url = referer_url or "https://ani.ohli24.com/"
|
||||||
|
self.callback = callback
|
||||||
|
self.proxy = proxy
|
||||||
|
self.cancelled = False
|
||||||
|
|
||||||
|
# 진행 상황 추적
|
||||||
|
self.start_time = None
|
||||||
|
self.total_bytes = 0
|
||||||
|
self.current_speed = 0
|
||||||
|
self.process = None
|
||||||
|
|
||||||
|
def download(self):
|
||||||
|
"""subprocess로 다운로드 실행 (Flask 블로킹 방지)"""
|
||||||
|
try:
|
||||||
|
# 현재 파일 경로 (subprocess에서 실행할 스크립트)
|
||||||
|
script_path = os.path.abspath(__file__)
|
||||||
|
|
||||||
|
# 진행 상황 파일
|
||||||
|
progress_file = tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False)
|
||||||
|
progress_path = progress_file.name
|
||||||
|
progress_file.close()
|
||||||
|
|
||||||
|
# subprocess 실행
|
||||||
|
cmd = [
|
||||||
|
sys.executable, script_path,
|
||||||
|
self.iframe_src,
|
||||||
|
self.output_path,
|
||||||
|
self.referer_url or "",
|
||||||
|
self.proxy or "",
|
||||||
|
progress_path
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Starting download subprocess: {self.iframe_src}")
|
||||||
|
logger.info(f"Output: {self.output_path}")
|
||||||
|
logger.info(f"Progress file: {progress_path}")
|
||||||
|
|
||||||
|
# subprocess 시작 (non-blocking)
|
||||||
|
self.process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.start_time = time.time()
|
||||||
|
last_callback_time = 0
|
||||||
|
|
||||||
|
# 진행 상황 모니터링 (별도 스레드 불필요, 메인에서 폴링)
|
||||||
|
while self.process.poll() is None:
|
||||||
|
if self.cancelled:
|
||||||
|
self.process.terminate()
|
||||||
|
try:
|
||||||
|
os.unlink(progress_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return False, "Cancelled by user"
|
||||||
|
|
||||||
|
# 진행 상황 읽기 (0.5초마다)
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - last_callback_time >= 0.5:
|
||||||
|
last_callback_time = current_time
|
||||||
|
try:
|
||||||
|
if os.path.exists(progress_path):
|
||||||
|
with open(progress_path, 'r') as f:
|
||||||
|
content = f.read().strip()
|
||||||
|
if content:
|
||||||
|
progress = json.loads(content)
|
||||||
|
if self.callback and progress.get('percent', 0) > 0:
|
||||||
|
self.callback(
|
||||||
|
percent=progress.get('percent', 0),
|
||||||
|
current=progress.get('current', 0),
|
||||||
|
total=progress.get('total', 0),
|
||||||
|
speed=progress.get('speed', ''),
|
||||||
|
elapsed=progress.get('elapsed', '')
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
time.sleep(0.1) # CPU 사용률 줄이기
|
||||||
|
|
||||||
|
# 프로세스 종료 후 결과 확인
|
||||||
|
stdout, stderr = self.process.communicate()
|
||||||
|
|
||||||
|
# 진행 상황 파일 삭제
|
||||||
|
try:
|
||||||
|
os.unlink(progress_path)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.process.returncode == 0:
|
||||||
|
# 출력 파일 확인
|
||||||
|
if os.path.exists(self.output_path):
|
||||||
|
file_size = os.path.getsize(self.output_path)
|
||||||
|
if file_size > 10000: # 10KB 이상
|
||||||
|
logger.info(f"Download completed: {self.output_path} ({file_size / 1024 / 1024:.1f}MB)")
|
||||||
|
return True, "Download completed"
|
||||||
|
else:
|
||||||
|
logger.error(f"Output file too small: {file_size}B")
|
||||||
|
return False, f"Output file too small: {file_size}B"
|
||||||
|
else:
|
||||||
|
logger.error(f"Output file not found: {self.output_path}")
|
||||||
|
return False, "Output file not created"
|
||||||
|
else:
|
||||||
|
# stderr에서 에러 메시지 추출
|
||||||
|
error_msg = stderr.strip() if stderr else f"Process exited with code {self.process.returncode}"
|
||||||
|
logger.error(f"Download failed: {error_msg}")
|
||||||
|
return False, error_msg
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"CdndaniaDownloader error: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return False, str(e)
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
"""다운로드 취소"""
|
||||||
|
self.cancelled = True
|
||||||
|
if self.process:
|
||||||
|
self.process.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
def _download_worker(iframe_src, output_path, referer_url, proxy, progress_path):
|
||||||
|
"""실제 다운로드 작업 (subprocess에서 실행)"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
# 로깅 설정 (subprocess용)
|
||||||
|
import logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='[%(asctime)s|%(levelname)s|%(name)s] %(message)s',
|
||||||
|
handlers=[logging.StreamHandler(sys.stderr)]
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def update_progress(percent, current, total, speed, elapsed):
|
||||||
|
"""진행 상황을 파일에 저장"""
|
||||||
|
try:
|
||||||
|
with open(progress_path, 'w') as f:
|
||||||
|
json.dump({
|
||||||
|
'percent': percent,
|
||||||
|
'current': current,
|
||||||
|
'total': total,
|
||||||
|
'speed': speed,
|
||||||
|
'elapsed': elapsed
|
||||||
|
}, f)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def format_speed(bytes_per_sec):
|
||||||
|
if bytes_per_sec < 1024:
|
||||||
|
return f"{bytes_per_sec:.0f} B/s"
|
||||||
|
elif bytes_per_sec < 1024 * 1024:
|
||||||
|
return f"{bytes_per_sec / 1024:.1f} KB/s"
|
||||||
|
else:
|
||||||
|
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
|
||||||
|
|
||||||
|
def format_time(seconds):
|
||||||
|
seconds = int(seconds)
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds}초"
|
||||||
|
elif seconds < 3600:
|
||||||
|
return f"{seconds // 60}분 {seconds % 60}초"
|
||||||
|
else:
|
||||||
|
return f"{seconds // 3600}시간 {(seconds % 3600) // 60}분"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# curl_cffi 임포트
|
||||||
|
try:
|
||||||
|
from curl_cffi import requests as cffi_requests
|
||||||
|
except ImportError:
|
||||||
|
subprocess.run([sys.executable, "-m", "pip", "install", "curl_cffi", "-q"],
|
||||||
|
timeout=120, check=True)
|
||||||
|
from curl_cffi import requests as cffi_requests
|
||||||
|
|
||||||
|
# 세션 생성 (Chrome 120 TLS 핑거프린트 사용)
|
||||||
|
session = cffi_requests.Session(impersonate="chrome120")
|
||||||
|
|
||||||
|
proxies = None
|
||||||
|
if proxy:
|
||||||
|
proxies = {"http": proxy, "https": proxy}
|
||||||
|
|
||||||
|
# 1. iframe URL에서 video_id 추출
|
||||||
|
video_id = None
|
||||||
|
if "/video/" in iframe_src:
|
||||||
|
video_id = iframe_src.split("/video/")[1].split("?")[0].split("&")[0]
|
||||||
|
elif "/v/" in iframe_src:
|
||||||
|
video_id = iframe_src.split("/v/")[1].split("?")[0].split("&")[0]
|
||||||
|
|
||||||
|
if not video_id:
|
||||||
|
print(f"Could not extract video ID from: {iframe_src}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info(f"Extracted video_id: {video_id}")
|
||||||
|
|
||||||
|
# 2. 플레이어 페이지 먼저 방문 (세션/쿠키 획득)
|
||||||
|
headers = {
|
||||||
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
|
||||||
|
"accept-language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||||
|
"referer": referer_url,
|
||||||
|
"sec-ch-ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
|
||||||
|
"sec-ch-ua-mobile": "?0",
|
||||||
|
"sec-ch-ua-platform": '"macOS"',
|
||||||
|
"sec-fetch-dest": "iframe",
|
||||||
|
"sec-fetch-mode": "navigate",
|
||||||
|
"sec-fetch-site": "cross-site",
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f"Visiting iframe page: {iframe_src}")
|
||||||
|
resp = session.get(iframe_src, headers=headers, proxies=proxies, timeout=30)
|
||||||
|
log.info(f"Iframe page status: {resp.status_code}")
|
||||||
|
|
||||||
|
# 3. getVideo API 호출
|
||||||
|
api_url = f"https://cdndania.com/player/index.php?data={video_id}&do=getVideo"
|
||||||
|
api_headers = {
|
||||||
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"x-requested-with": "XMLHttpRequest",
|
||||||
|
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||||
|
"referer": iframe_src,
|
||||||
|
"origin": "https://cdndania.com",
|
||||||
|
"accept": "application/json, text/javascript, */*; q=0.01",
|
||||||
|
}
|
||||||
|
post_data = {
|
||||||
|
"hash": video_id,
|
||||||
|
"r": referer_url
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f"Calling video API: {api_url}")
|
||||||
|
api_resp = session.post(api_url, headers=api_headers, data=post_data,
|
||||||
|
proxies=proxies, timeout=30)
|
||||||
|
|
||||||
|
if api_resp.status_code != 200:
|
||||||
|
print(f"API request failed: HTTP {api_resp.status_code}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = api_resp.json()
|
||||||
|
except:
|
||||||
|
print(f"Failed to parse API response: {api_resp.text[:200]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
video_url = data.get("videoSource") or data.get("securedLink")
|
||||||
|
if not video_url:
|
||||||
|
print(f"No video URL in API response: {data}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info(f"Got video URL: {video_url}")
|
||||||
|
|
||||||
|
# 4. m3u8 다운로드 (동일 세션 유지!)
|
||||||
|
m3u8_headers = {
|
||||||
|
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
|
"referer": iframe_src,
|
||||||
|
"origin": "https://cdndania.com",
|
||||||
|
"accept": "*/*",
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(f"Fetching m3u8: {video_url}")
|
||||||
|
m3u8_resp = session.get(video_url, headers=m3u8_headers, proxies=proxies, timeout=30)
|
||||||
|
|
||||||
|
if m3u8_resp.status_code != 200:
|
||||||
|
print(f"m3u8 fetch failed: HTTP {m3u8_resp.status_code}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
m3u8_content = m3u8_resp.text
|
||||||
|
|
||||||
|
# Master playlist 확인
|
||||||
|
if "#EXT-X-STREAM-INF" in m3u8_content:
|
||||||
|
# 가장 높은 품질의 미디어 플레이리스트 URL 추출
|
||||||
|
base = video_url.rsplit('/', 1)[0] + '/'
|
||||||
|
last_url = None
|
||||||
|
for line in m3u8_content.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
if line.startswith('http'):
|
||||||
|
last_url = line
|
||||||
|
else:
|
||||||
|
last_url = urljoin(base, line)
|
||||||
|
|
||||||
|
if last_url:
|
||||||
|
log.info(f"Following media playlist: {last_url}")
|
||||||
|
m3u8_resp = session.get(last_url, headers=m3u8_headers, proxies=proxies, timeout=30)
|
||||||
|
m3u8_content = m3u8_resp.text
|
||||||
|
video_url = last_url
|
||||||
|
|
||||||
|
# 5. 세그먼트 URL 파싱
|
||||||
|
base = video_url.rsplit('/', 1)[0] + '/'
|
||||||
|
segments = []
|
||||||
|
for line in m3u8_content.strip().split('\n'):
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
if line.startswith('http'):
|
||||||
|
segments.append(line)
|
||||||
|
else:
|
||||||
|
segments.append(urljoin(base, line))
|
||||||
|
|
||||||
|
if not segments:
|
||||||
|
print("No segments found in m3u8", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info(f"Found {len(segments)} segments")
|
||||||
|
|
||||||
|
# 6. 세그먼트 다운로드
|
||||||
|
start_time = time.time()
|
||||||
|
last_speed_time = start_time
|
||||||
|
total_bytes = 0
|
||||||
|
last_bytes = 0
|
||||||
|
current_speed = 0
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as temp_dir:
|
||||||
|
segment_files = []
|
||||||
|
total_segments = len(segments)
|
||||||
|
|
||||||
|
log.info(f"Temp directory: {temp_dir}")
|
||||||
|
|
||||||
|
for i, segment_url in enumerate(segments):
|
||||||
|
segment_path = os.path.join(temp_dir, f"segment_{i:05d}.ts")
|
||||||
|
|
||||||
|
# 매 20개마다 또는 첫 5개 로그
|
||||||
|
if i < 5 or i % 20 == 0:
|
||||||
|
log.info(f"Downloading segment {i+1}/{total_segments}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
seg_resp = session.get(segment_url, headers=m3u8_headers,
|
||||||
|
proxies=proxies, timeout=120)
|
||||||
|
|
||||||
|
if seg_resp.status_code != 200:
|
||||||
|
time.sleep(0.5)
|
||||||
|
seg_resp = session.get(segment_url, headers=m3u8_headers,
|
||||||
|
proxies=proxies, timeout=120)
|
||||||
|
|
||||||
|
segment_data = seg_resp.content
|
||||||
|
|
||||||
|
if len(segment_data) < 100:
|
||||||
|
print(f"CDN security block: segment {i} returned {len(segment_data)}B", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(segment_path, 'wb') as f:
|
||||||
|
f.write(segment_data)
|
||||||
|
|
||||||
|
segment_files.append(f"segment_{i:05d}.ts")
|
||||||
|
total_bytes += len(segment_data)
|
||||||
|
|
||||||
|
# 속도 계산
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - last_speed_time >= 1.0:
|
||||||
|
bytes_diff = total_bytes - last_bytes
|
||||||
|
time_diff = current_time - last_speed_time
|
||||||
|
current_speed = bytes_diff / time_diff if time_diff > 0 else 0
|
||||||
|
last_speed_time = current_time
|
||||||
|
last_bytes = total_bytes
|
||||||
|
|
||||||
|
# 진행률 업데이트
|
||||||
|
percent = int(((i + 1) / total_segments) * 100)
|
||||||
|
elapsed = format_time(current_time - start_time)
|
||||||
|
update_progress(percent, i + 1, total_segments, format_speed(current_speed), elapsed)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Segment {i} download error: {e}")
|
||||||
|
print(f"Segment {i} download failed: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 7. ffmpeg로 합치기
|
||||||
|
log.info("Concatenating segments with ffmpeg...")
|
||||||
|
concat_file = os.path.join(temp_dir, "concat.txt")
|
||||||
|
with open(concat_file, 'w') as f:
|
||||||
|
for seg_file in segment_files:
|
||||||
|
f.write(f"file '{seg_file}'\n")
|
||||||
|
|
||||||
|
# 출력 디렉토리 생성
|
||||||
|
output_dir = os.path.dirname(output_path)
|
||||||
|
if output_dir and not os.path.exists(output_dir):
|
||||||
|
os.makedirs(output_dir)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'ffmpeg', '-y',
|
||||||
|
'-f', 'concat',
|
||||||
|
'-safe', '0',
|
||||||
|
'-i', 'concat.txt',
|
||||||
|
'-c', 'copy',
|
||||||
|
os.path.abspath(output_path)
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True,
|
||||||
|
timeout=600, cwd=temp_dir)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
print(f"FFmpeg concat failed: {result.stderr[:200]}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 출력 파일 확인
|
||||||
|
if not os.path.exists(output_path):
|
||||||
|
print("Output file not created", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
file_size = os.path.getsize(output_path)
|
||||||
|
if file_size < 10000:
|
||||||
|
print(f"Output file too small: {file_size}B", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
log.info(f"Download completed: {output_path} ({file_size / 1024 / 1024:.1f}MB)")
|
||||||
|
update_progress(100, total_segments, total_segments, "", format_time(time.time() - start_time))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
traceback.print_exc(file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
# CLI 및 subprocess 엔트리포인트
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) >= 6:
|
||||||
|
# subprocess 모드
|
||||||
|
iframe_url = sys.argv[1]
|
||||||
|
output_path = sys.argv[2]
|
||||||
|
referer = sys.argv[3] if sys.argv[3] else None
|
||||||
|
proxy = sys.argv[4] if sys.argv[4] else None
|
||||||
|
progress_path = sys.argv[5]
|
||||||
|
|
||||||
|
_download_worker(iframe_url, output_path, referer, proxy, progress_path)
|
||||||
|
elif len(sys.argv) >= 3:
|
||||||
|
# CLI 테스트 모드
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
|
iframe_url = sys.argv[1]
|
||||||
|
output_path = sys.argv[2]
|
||||||
|
referer = sys.argv[3] if len(sys.argv) > 3 else None
|
||||||
|
proxy = sys.argv[4] if len(sys.argv) > 4 else None
|
||||||
|
|
||||||
|
def progress_callback(percent, current, total, speed, elapsed):
|
||||||
|
print(f"\r[{percent:3d}%] {current}/{total} segments - {speed} - {elapsed}", end="", flush=True)
|
||||||
|
|
||||||
|
downloader = CdndaniaDownloader(
|
||||||
|
iframe_src=iframe_url,
|
||||||
|
output_path=output_path,
|
||||||
|
referer_url=referer,
|
||||||
|
callback=progress_callback,
|
||||||
|
proxy=proxy
|
||||||
|
)
|
||||||
|
|
||||||
|
success, message = downloader.download()
|
||||||
|
print()
|
||||||
|
print(f"Result: {'SUCCESS' if success else 'FAILED'} - {message}")
|
||||||
|
else:
|
||||||
|
print("Usage: python cdndania_downloader.py <iframe_url> <output_path> [referer_url] [proxy]")
|
||||||
|
sys.exit(1)
|
||||||
@@ -258,10 +258,10 @@ class FfmpegQueue(object):
|
|||||||
# 다운로드 방법 설정 확인
|
# 다운로드 방법 설정 확인
|
||||||
download_method = P.ModelSetting.get(f"{self.name}_download_method")
|
download_method = P.ModelSetting.get(f"{self.name}_download_method")
|
||||||
|
|
||||||
# cdndania.com 감지 시 YtdlpDownloader 사용 (CDN 세션 쿠키 + Impersonate로 보안 우회)
|
# cdndania.com 감지 시 CdndaniaDownloader 사용 (curl_cffi로 세션 기반 보안 우회)
|
||||||
if 'cdndania.com' in video_url:
|
if 'cdndania.com' in video_url:
|
||||||
logger.info("Detected cdndania.com URL - forcing YtdlpDownloader with cookies (CDN security bypass)")
|
logger.info("Detected cdndania.com URL - using CdndaniaDownloader (curl_cffi session)")
|
||||||
download_method = "ytdlp"
|
download_method = "cdndania"
|
||||||
|
|
||||||
logger.info(f"Download method: {download_method}")
|
logger.info(f"Download method: {download_method}")
|
||||||
|
|
||||||
@@ -283,7 +283,24 @@ class FfmpegQueue(object):
|
|||||||
entity_ref.download_time = elapsed
|
entity_ref.download_time = elapsed
|
||||||
entity_ref.refresh_status()
|
entity_ref.refresh_status()
|
||||||
|
|
||||||
if method == "ytdlp":
|
if method == "cdndania":
|
||||||
|
# cdndania.com 전용 다운로더 사용 (curl_cffi 세션 기반)
|
||||||
|
from .cdndania_downloader import CdndaniaDownloader
|
||||||
|
logger.info("Using CdndaniaDownloader (curl_cffi session-based)...")
|
||||||
|
# 엔티티에서 원본 iframe_src 가져오기
|
||||||
|
_iframe_src = getattr(entity_ref, 'iframe_src', None)
|
||||||
|
if not _iframe_src:
|
||||||
|
# 폴백: headers의 Referer에서 가져오기
|
||||||
|
_iframe_src = getattr(entity_ref, 'headers', {}).get('Referer', video_url)
|
||||||
|
logger.info(f"CdndaniaDownloader iframe_src: {_iframe_src}")
|
||||||
|
downloader = CdndaniaDownloader(
|
||||||
|
iframe_src=_iframe_src,
|
||||||
|
output_path=output_file_ref,
|
||||||
|
referer_url="https://ani.ohli24.com/",
|
||||||
|
callback=progress_callback,
|
||||||
|
proxy=_proxy
|
||||||
|
)
|
||||||
|
elif method == "ytdlp":
|
||||||
# yt-dlp 사용
|
# yt-dlp 사용
|
||||||
from .ytdlp_downloader import YtdlpDownloader
|
from .ytdlp_downloader import YtdlpDownloader
|
||||||
logger.info("Using yt-dlp downloader...")
|
logger.info("Using yt-dlp downloader...")
|
||||||
|
|||||||
@@ -979,7 +979,7 @@ class LogicOhli24(PluginModuleBase):
|
|||||||
for en in self.queue.entity_list:
|
for en in self.queue.entity_list:
|
||||||
if en.info["_id"] == info["_id"]:
|
if en.info["_id"] == info["_id"]:
|
||||||
return True
|
return True
|
||||||
# return False
|
return False
|
||||||
|
|
||||||
def callback_function(self, **args):
|
def callback_function(self, **args):
|
||||||
logger.debug("callback_function============")
|
logger.debug("callback_function============")
|
||||||
@@ -1207,6 +1207,7 @@ class Ohli24QueueEntity(FfmpegQueueEntity):
|
|||||||
self.url = video_url
|
self.url = video_url
|
||||||
self.srt_url = vtt_url
|
self.srt_url = vtt_url
|
||||||
self.cookies_file = cookies_file # yt-dlp용 세션 쿠키 파일
|
self.cookies_file = cookies_file # yt-dlp용 세션 쿠키 파일
|
||||||
|
self.iframe_src = iframe_src # CdndaniaDownloader용 원본 iframe URL
|
||||||
logger.info(f"Video URL: {self.url}")
|
logger.info(f"Video URL: {self.url}")
|
||||||
if self.srt_url:
|
if self.srt_url:
|
||||||
logger.info(f"Subtitle URL: {self.srt_url}")
|
logger.info(f"Subtitle URL: {self.srt_url}")
|
||||||
|
|||||||
@@ -501,8 +501,10 @@
|
|||||||
.episode-info {
|
.episode-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,11 +516,15 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-date {
|
.episode-date {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 에피소드 액션 버튼 */
|
/* 에피소드 액션 버튼 */
|
||||||
@@ -539,10 +545,153 @@
|
|||||||
transform: scale(0.85);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 반응형: 작은 화면에서는 1열 */
|
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* 전체 페이지 기본 설정 */
|
||||||
|
body {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모든 컨테이너/row 폭 100% 강제 */
|
||||||
|
.container, .container-fluid, .container-sm, .container-md, .container-lg,
|
||||||
|
.row, form, #program_list, #program_auto_form, #episode_list {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-left: 10px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* form-group 및 모든 col 클래스 */
|
||||||
|
.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 마진 제거 */
|
||||||
|
.row {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 그룹 */
|
||||||
|
.form-inline {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline .btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 시리즈 정보 박스 */
|
||||||
|
.series-info-box {
|
||||||
|
padding: 15px !important;
|
||||||
|
line-height: 1.8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 목록 - 화면 폭에 꽉 차게 */
|
||||||
.episode-list-container {
|
.episode-list-container {
|
||||||
grid-template-columns: 1fr;
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 15px 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
gap: 10px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-thumb {
|
||||||
|
width: 50px !important;
|
||||||
|
min-width: 50px !important;
|
||||||
|
height: 38px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 12px !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-date {
|
||||||
|
font-size: 10px !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
margin-left: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-top: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 10px !important;
|
||||||
|
padding: 4px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .toggle {
|
||||||
|
transform: scale(0.85) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 더 작은 화면 (400px 이하) */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.episode-thumb {
|
||||||
|
width: 40px !important;
|
||||||
|
min-width: 40px !important;
|
||||||
|
height: 30px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-num {
|
||||||
|
font-size: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 9px !important;
|
||||||
|
padding: 3px 8px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -491,8 +491,10 @@
|
|||||||
.episode-info {
|
.episode-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,6 +503,8 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.3;
|
line-height: 1.3;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-filename {
|
.episode-filename {
|
||||||
@@ -509,6 +513,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 에피소드 액션 버튼 */
|
/* 에피소드 액션 버튼 */
|
||||||
@@ -529,10 +535,225 @@
|
|||||||
transform: scale(0.85);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 반응형 */
|
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* 전체 페이지 기본 설정 */
|
||||||
|
body {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모든 컨테이너/row 폭 100% 강제 */
|
||||||
|
.container, .container-fluid, .container-sm, .container-md, .container-lg,
|
||||||
|
.row, form, #program_list, #program_auto_form, #episode_list {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-left: 10px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* form-group 및 모든 col 클래스 */
|
||||||
|
.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 마진 제거 */
|
||||||
|
.row {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 상단 정보 카드 */
|
||||||
|
.card.p-lg-5, .card.border-light {
|
||||||
|
width: calc(100% - 20px) !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
margin: 10px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.p-lg-5 > .row {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 메인 썸네일 - 중앙 정렬 */
|
||||||
|
.card.p-lg-5 > .row > [class*="col-"]:first-child {
|
||||||
|
flex: 0 0 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
margin-bottom: 15px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.p-lg-5 > .row > [class*="col-"]:first-child img {
|
||||||
|
width: 70% !important;
|
||||||
|
max-width: 220px !important;
|
||||||
|
height: auto !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 테이블 각 행 - 카드 스타일 */
|
||||||
|
.card.p-lg-5 .row .row {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 0 6px 0 !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 라벨 */
|
||||||
|
.card.p-lg-5 .row .row > [class*="col-"]:first-child {
|
||||||
|
flex: 0 0 60px !important;
|
||||||
|
max-width: 60px !important;
|
||||||
|
min-width: 60px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
text-align: left !important;
|
||||||
|
padding: 0 5px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 값 */
|
||||||
|
.card.p-lg-5 .row .row > [class*="col-"]:last-child {
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
text-align: left !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 그룹 */
|
||||||
|
.form-inline {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline .btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline input.form-control {
|
||||||
|
width: 100% !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 목록 - 화면 폭에 꽉 차게 */
|
||||||
.episode-list-container {
|
.episode-list-container {
|
||||||
grid-template-columns: 1fr;
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 15px 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
gap: 10px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-thumb {
|
||||||
|
width: 40px !important;
|
||||||
|
min-width: 40px !important;
|
||||||
|
height: 40px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 12px !important;
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-filename {
|
||||||
|
font-size: 10px !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
max-width: 35% !important;
|
||||||
|
margin-left: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-top: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 10px !important;
|
||||||
|
padding: 4px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .toggle {
|
||||||
|
transform: scale(0.85) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 더 작은 화면 (400px 이하) */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.episode-thumb {
|
||||||
|
width: 35px !important;
|
||||||
|
min-width: 35px !important;
|
||||||
|
height: 35px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-num {
|
||||||
|
font-size: 9px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-filename {
|
||||||
|
max-width: 30% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 9px !important;
|
||||||
|
padding: 3px 8px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -149,19 +149,26 @@
|
|||||||
str += '<div class="episode-list-container">';
|
str += '<div class="episode-list-container">';
|
||||||
for (let i in data.episode) {
|
for (let i in data.episode) {
|
||||||
let epThumbSrc = data.episode[i].thumbnail || '';
|
let epThumbSrc = data.episode[i].thumbnail || '';
|
||||||
|
let epTitle = data.episode[i].title || '';
|
||||||
|
|
||||||
|
// 에피소드 번호 추출 (title에서 "N화" 패턴 찾기)
|
||||||
|
let epNumMatch = epTitle.match(/(\d+)화/);
|
||||||
|
let 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">';
|
||||||
if (epThumbSrc) {
|
if (epThumbSrc) {
|
||||||
str += '<img src="' + epThumbSrc + '" loading="lazy" onerror="this.style.display=\'none\'">';
|
str += '<img src="' + epThumbSrc + '" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||||
}
|
}
|
||||||
str += '<span class="episode-num">' + (parseInt(i) + 1) + '화</span>';
|
str += '<span class="episode-num">' + epNumText + '</span>';
|
||||||
str += '</div>';
|
str += '</div>';
|
||||||
str += '<div class="episode-info">';
|
str += '<div class="episode-info">';
|
||||||
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
|
str += '<div class="episode-info-row">';
|
||||||
|
str += '<div class="episode-title">' + epTitle + '</div>';
|
||||||
if (data.episode[i].date) {
|
if (data.episode[i].date) {
|
||||||
str += '<div class="episode-date">' + data.episode[i].date + '</div>';
|
str += '<div class="episode-date">' + data.episode[i].date + '</div>';
|
||||||
}
|
}
|
||||||
|
str += '</div>';
|
||||||
str += '<div class="episode-actions">';
|
str += '<div class="episode-actions">';
|
||||||
str += '<input id="checkbox_' + i + '" name="checkbox_' + i + '" type="checkbox" checked data-toggle="toggle" data-on="선택" data-off="-" data-onstyle="success" data-offstyle="secondary" data-size="small">';
|
str += '<input id="checkbox_' + i + '" name="checkbox_' + i + '" type="checkbox" checked data-toggle="toggle" data-on="선택" data-off="-" data-onstyle="success" data-offstyle="secondary" data-size="small">';
|
||||||
str += m_button('add_queue_btn', '다운로드', [{'key': 'idx', 'value': i}]);
|
str += m_button('add_queue_btn', '다운로드', [{'key': 'idx', 'value': i}]);
|
||||||
@@ -465,19 +472,22 @@
|
|||||||
border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important;
|
border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 에피소드 목록 컨테이너 */
|
/* 에피소드 목록 컨테이너 - 반응형 그리드 */
|
||||||
.episode-list-container {
|
.episode-list-container {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
padding: 0 5px;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 에피소드 카드 */
|
/* 에피소드 카드 */
|
||||||
.episode-card {
|
.episode-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 8px 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -524,15 +534,25 @@
|
|||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 에피소드 정보 */
|
/* 에피소드 정보 - 2줄 레이아웃 */
|
||||||
.episode-info {
|
.episode-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: 2px;
|
align-items: center;
|
||||||
|
gap: 4px 10px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 제목과 날짜를 담는 첫번째 줄 */
|
||||||
|
.episode-info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.episode-title {
|
.episode-title {
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -541,19 +561,28 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-date {
|
.episode-date {
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 에피소드 액션 버튼 */
|
/* 에피소드 액션 버튼 - 선택 좌측, 다운로드 우측 */
|
||||||
.episode-actions {
|
.episode-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 4px;
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.episode-actions .btn {
|
.episode-actions .btn {
|
||||||
@@ -566,10 +595,261 @@
|
|||||||
transform: scale(0.85);
|
transform: scale(0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 반응형 */
|
/* 모바일 반응형 - Bootstrap 모든 레이아웃 강제 덮어쓰기 */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
/* 전체 페이지 기본 설정 */
|
||||||
|
body {
|
||||||
|
overflow-x: hidden !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 모든 컨테이너/row 폭 100% 강제 */
|
||||||
|
.container, .container-fluid, .container-sm, .container-md, .container-lg,
|
||||||
|
.row, form, #program_list, #program_auto_form, #episode_list {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding-left: 10px !important;
|
||||||
|
padding-right: 10px !important;
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* form-group 및 모든 col 클래스 */
|
||||||
|
.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 마진 제거 */
|
||||||
|
.row {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 입력 폼 스타일 */
|
||||||
|
#program_list .form-group {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#program_list input.form-control {
|
||||||
|
width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#program_list .btn {
|
||||||
|
width: auto !important;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 버튼 그룹 */
|
||||||
|
.form-inline {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-bottom: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-inline .btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 상단 정보 카드 ===== */
|
||||||
|
.card.p-lg-5, .card.border-light {
|
||||||
|
width: calc(100% - 20px) !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 15px !important;
|
||||||
|
margin: 10px !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.p-lg-5 > .row {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 메인 썸네일 - 중앙 정렬 */
|
||||||
|
.card.p-lg-5 > .row > [class*="col-"]:first-child {
|
||||||
|
flex: 0 0 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
text-align: center !important;
|
||||||
|
margin-bottom: 15px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.p-lg-5 > .row > [class*="col-"]:first-child img {
|
||||||
|
width: 70% !important;
|
||||||
|
max-width: 220px !important;
|
||||||
|
height: auto !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 컬럼 - 전체 폭 */
|
||||||
|
.card.p-lg-5 > .row > [class*="col-"]:last-child {
|
||||||
|
flex: 0 0 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 테이블 각 행 - 카드 스타일 */
|
||||||
|
.card.p-lg-5 .row .row {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
flex-wrap: nowrap !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 0 6px 0 !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
background: rgba(255, 255, 255, 0.06) !important;
|
||||||
|
border-radius: 8px !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 라벨 (제목, 원제 등) */
|
||||||
|
.card.p-lg-5 .row .row > [class*="col-"]:first-child {
|
||||||
|
flex: 0 0 60px !important;
|
||||||
|
max-width: 60px !important;
|
||||||
|
min-width: 60px !important;
|
||||||
|
font-size: 11px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
text-align: left !important;
|
||||||
|
padding: 0 5px 0 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 정보 값 */
|
||||||
|
.card.p-lg-5 .row .row > [class*="col-"]:last-child {
|
||||||
|
flex: 1 1 auto !important;
|
||||||
|
max-width: none !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
word-break: break-word !important;
|
||||||
|
text-align: left !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 에피소드 목록 ===== */
|
||||||
.episode-list-container {
|
.episode-list-container {
|
||||||
grid-template-columns: 1fr;
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
gap: 8px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 15px 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
padding: 10px 12px !important;
|
||||||
|
gap: 10px !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-thumb {
|
||||||
|
width: 55px !important;
|
||||||
|
min-width: 55px !important;
|
||||||
|
height: 42px !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 12px !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
text-overflow: ellipsis !important;
|
||||||
|
line-height: 1.3 !important;
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-date {
|
||||||
|
font-size: 10px !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
flex-shrink: 0 !important;
|
||||||
|
margin-left: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions {
|
||||||
|
display: flex !important;
|
||||||
|
flex-wrap: wrap !important;
|
||||||
|
gap: 6px !important;
|
||||||
|
margin-top: 5px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 10px !important;
|
||||||
|
padding: 4px 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .toggle {
|
||||||
|
transform: scale(0.85) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 더 작은 화면 (400px 이하) */
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.card.p-lg-5 > .row > [class*="col-"]:first-child img {
|
||||||
|
width: 60% !important;
|
||||||
|
max-width: 180px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.p-lg-5 .row .row > [class*="col-"]:first-child {
|
||||||
|
flex: 0 0 50px !important;
|
||||||
|
max-width: 50px !important;
|
||||||
|
min-width: 50px !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card.p-lg-5 .row .row > [class*="col-"]:last-child {
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-thumb {
|
||||||
|
width: 45px !important;
|
||||||
|
min-width: 45px !important;
|
||||||
|
height: 34px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-num {
|
||||||
|
font-size: 8px !important;
|
||||||
|
padding: 1px 3px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 9px !important;
|
||||||
|
padding: 3px 8px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user