feat: Enhance yt-dlp downloader with auto-installation, adaptive HLS download strategies, CDN-specific headers, and improved ffmpeg progress tracking.
This commit is contained in:
@@ -31,7 +31,12 @@ def extract_aldata(detail_url: str, episode_num: str) -> dict:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Camoufox 시작 (자동 fingerprint 생성)
|
# Camoufox 시작 (자동 fingerprint 생성)
|
||||||
with Camoufox(headless=False) as browser:
|
# Docker/서버 환경에서는 DISPLAY가 없으므로 headless 모드 사용
|
||||||
|
import os
|
||||||
|
has_display = os.environ.get('DISPLAY') is not None
|
||||||
|
use_headless = not has_display
|
||||||
|
|
||||||
|
with Camoufox(headless=use_headless) as browser:
|
||||||
page = browser.new_page()
|
page = browser.new_page()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -88,7 +88,15 @@ class FfmpegQueueEntity(abc.ABCMeta("ABC", (object,), {"__slots__": ()})):
|
|||||||
tmp["callback_id"] = getattr(self, 'name', 'anilife') if hasattr(self, 'name') else 'anilife'
|
tmp["callback_id"] = getattr(self, 'name', 'anilife') if hasattr(self, 'name') else 'anilife'
|
||||||
tmp["start_time"] = self.created_time
|
tmp["start_time"] = self.created_time
|
||||||
tmp["status_kor"] = self.ffmpeg_status_kor
|
tmp["status_kor"] = self.ffmpeg_status_kor
|
||||||
tmp["status_str"] = str(self.ffmpeg_status) if self.ffmpeg_status != -1 else "WAITING"
|
# status_str: 템플릿에서 문자열 비교에 사용 (DOWNLOADING, COMPLETED, WAITING)
|
||||||
|
status_map = {
|
||||||
|
0: "WAITING",
|
||||||
|
1: "STARTED",
|
||||||
|
5: "DOWNLOADING",
|
||||||
|
7: "COMPLETED",
|
||||||
|
-1: "FAILED"
|
||||||
|
}
|
||||||
|
tmp["status_str"] = status_map.get(self.ffmpeg_status, "WAITING")
|
||||||
tmp["percent"] = self.ffmpeg_percent
|
tmp["percent"] = self.ffmpeg_percent
|
||||||
tmp["duration_str"] = ""
|
tmp["duration_str"] = ""
|
||||||
tmp["duration"] = ""
|
tmp["duration"] = ""
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class YtdlpDownloader:
|
|||||||
self.cancelled = False
|
self.cancelled = False
|
||||||
self.process = None
|
self.process = None
|
||||||
self.error_output = [] # 에러 메시지 저장
|
self.error_output = [] # 에러 메시지 저장
|
||||||
|
self.total_duration_seconds = 0 # 전체 영상 길이 (초)
|
||||||
|
|
||||||
# 속도 및 시간 계산용
|
# 속도 및 시간 계산용
|
||||||
self.start_time = None
|
self.start_time = None
|
||||||
@@ -59,9 +60,53 @@ class YtdlpDownloader:
|
|||||||
else:
|
else:
|
||||||
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
|
return f"{bytes_per_sec / (1024 * 1024):.2f} MB/s"
|
||||||
|
|
||||||
|
def time_to_seconds(self, time_str):
|
||||||
|
"""HH:MM:SS.ms 형식을 초로 변환"""
|
||||||
|
try:
|
||||||
|
if not time_str:
|
||||||
|
return 0
|
||||||
|
parts = time_str.split(':')
|
||||||
|
if len(parts) != 3:
|
||||||
|
return 0
|
||||||
|
h = float(parts[0])
|
||||||
|
m = float(parts[1])
|
||||||
|
s = float(parts[2])
|
||||||
|
return h * 3600 + m * 60 + s
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _ensure_ytdlp_installed(self):
|
||||||
|
"""yt-dlp가 설치되어 있는지 확인하고, 없으면 자동 설치"""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
# yt-dlp binary가 PATH에 있는지 확인
|
||||||
|
if shutil.which('yt-dlp') is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("yt-dlp not found in PATH. Installing via pip...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", "yt-dlp", "-q"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Failed to install yt-dlp: {result.stderr}")
|
||||||
|
return False
|
||||||
|
logger.info("yt-dlp installed successfully")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"yt-dlp installation error: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
def download(self):
|
def download(self):
|
||||||
"""yt-dlp CLI를 통한 브라우저 흉내(Impersonate) 방식 다운로드 수행"""
|
"""yt-dlp CLI를 통한 브라우저 흉내(Impersonate) 방식 다운로드 수행"""
|
||||||
try:
|
try:
|
||||||
|
# yt-dlp 설치 확인
|
||||||
|
if not self._ensure_ytdlp_installed():
|
||||||
|
return False, "yt-dlp installation failed"
|
||||||
|
|
||||||
self.start_time = time.time()
|
self.start_time = time.time()
|
||||||
|
|
||||||
# 출력 디렉토리 생성
|
# 출력 디렉토리 생성
|
||||||
@@ -76,14 +121,26 @@ class YtdlpDownloader:
|
|||||||
concat_char = '&' if '?' in current_url else '?'
|
concat_char = '&' if '?' in current_url else '?'
|
||||||
current_url = f"{current_url}{concat_char}dummy=.m3u8"
|
current_url = f"{current_url}{concat_char}dummy=.m3u8"
|
||||||
|
|
||||||
# 1. 기본 명령어 구성 (Impersonate & HLS 강제)
|
# 1. 기본 명령어 구성 (Impersonate & HLS 옵션)
|
||||||
|
# hlz CDN (linkkf)은 .jpg 확장자로 위장된 TS 세그먼트를 사용
|
||||||
|
# ffmpeg 8.0에서 이를 인식하지 못하므로 native HLS 다운로더 사용
|
||||||
|
use_native_hls = 'hlz' in current_url and '.top/' in current_url
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
'yt-dlp',
|
'yt-dlp',
|
||||||
'--newline',
|
'--newline',
|
||||||
'--no-playlist',
|
'--no-playlist',
|
||||||
'--no-part',
|
'--no-part',
|
||||||
'--hls-prefer-ffmpeg',
|
]
|
||||||
'--hls-use-mpegts',
|
|
||||||
|
if use_native_hls:
|
||||||
|
# hlz CDN: native HLS 다운로더 사용 (ffmpeg의 확장자 제한 우회)
|
||||||
|
cmd += ['--hls-prefer-native']
|
||||||
|
else:
|
||||||
|
# 기타 CDN: ffmpeg 사용 (더 안정적)
|
||||||
|
cmd += ['--hls-prefer-ffmpeg', '--hls-use-mpegts']
|
||||||
|
|
||||||
|
cmd += [
|
||||||
'--no-check-certificate',
|
'--no-check-certificate',
|
||||||
'--progress',
|
'--progress',
|
||||||
'--verbose', # 디버깅용 상세 로그
|
'--verbose', # 디버깅용 상세 로그
|
||||||
@@ -121,6 +178,17 @@ class YtdlpDownloader:
|
|||||||
cmd += ['--referer', 'https://cdndania.com/']
|
cmd += ['--referer', 'https://cdndania.com/']
|
||||||
cmd += ['--add-header', 'Origin:https://cdndania.com']
|
cmd += ['--add-header', 'Origin:https://cdndania.com']
|
||||||
cmd += ['--add-header', 'X-Requested-With:XMLHttpRequest']
|
cmd += ['--add-header', 'X-Requested-With:XMLHttpRequest']
|
||||||
|
|
||||||
|
# linkkf CDN (hlz3.top, hlz2.top 등) 헤더 보강
|
||||||
|
if 'hlz' in current_url and '.top/' in current_url:
|
||||||
|
# hlz CDN은 자체 도메인을 Referer로 요구함
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
parsed = urlparse(current_url)
|
||||||
|
cdn_origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
if not has_referer:
|
||||||
|
cmd += ['--referer', cdn_origin + '/']
|
||||||
|
cmd += ['--add-header', f'Origin:{cdn_origin}']
|
||||||
|
cmd += ['--add-header', 'Accept:*/*']
|
||||||
|
|
||||||
cmd.append(current_url)
|
cmd.append(current_url)
|
||||||
|
|
||||||
@@ -136,13 +204,23 @@ class YtdlpDownloader:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 여러 진행률 형식 매칭
|
# 여러 진행률 형식 매칭
|
||||||
# [download] 10.5% of ~100.00MiB at 2.45MiB/s
|
# yt-dlp native: [download] 10.5% of ~100.00MiB at 2.45MiB/s
|
||||||
# [download] 10.5% of 100.00MiB at 2.45MiB/s ETA 00:30
|
# yt-dlp native: [download] 10.5% of 100.00MiB at 2.45MiB/s ETA 00:30
|
||||||
# [download] 100% of 100.00MiB
|
# yt-dlp native: [download] 100% of 100.00MiB
|
||||||
|
# ffmpeg: frame= 1234 fps= 30 size= 12345kB time=00:01:23.45 bitrate=1234.5kbits/s
|
||||||
|
# ffmpeg: size= 123456kB time=00:01:23.45
|
||||||
prog_patterns = [
|
prog_patterns = [
|
||||||
re.compile(r'\[download\]\s+(?P<percent>[\d\.]+)%\s+of\s+.*?(?:\s+at\s+(?P<speed>[\d\.]+\s*\w+/s))?'),
|
re.compile(r'\[download\]\s+(?P<percent>[\d\.]+)%\s+of\s+.*?(?:\s+at\s+(?P<speed>[\d\.]+\s*\w+/s))?'),
|
||||||
re.compile(r'\[download\]\s+(?P<percent>[\d\.]+)%'),
|
re.compile(r'\[download\]\s+(?P<percent>[\d\.]+)%'),
|
||||||
|
# ffmpeg time 출력 파싱 (time=HH:MM:SS.ms)
|
||||||
|
re.compile(r'time=(?P<time>\d+:\d+:\d+\.\d+)'),
|
||||||
|
# ffmpeg size 출력 파싱
|
||||||
|
re.compile(r'size=\s*(?P<size>\d+)kB'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ffmpeg time-based progress tracking
|
||||||
|
last_time_str = ""
|
||||||
|
ffmpeg_progress_count = 0
|
||||||
|
|
||||||
for line in self.process.stdout:
|
for line in self.process.stdout:
|
||||||
if self.cancelled:
|
if self.cancelled:
|
||||||
@@ -152,11 +230,60 @@ class YtdlpDownloader:
|
|||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line: continue
|
if not line: continue
|
||||||
|
|
||||||
# 디버깅: 모든 출력 로깅 (너무 많으면 주석 해제)
|
# ffmpeg Duration 파싱 (전체 길이 확인용)
|
||||||
if '[download]' in line or 'fragment' in line.lower():
|
if 'Duration:' in line and self.total_duration_seconds == 0:
|
||||||
logger.debug(f"yt-dlp: {line}")
|
dur_match = re.search(r'Duration:\s*(?P<duration>\d+:\d+:\d+\.\d+)', line)
|
||||||
|
if dur_match:
|
||||||
|
self.total_duration_seconds = self.time_to_seconds(dur_match.group('duration'))
|
||||||
|
logger.info(f"[ffmpeg] Total duration detected: {dur_match.group('duration')} ({self.total_duration_seconds}s)")
|
||||||
|
|
||||||
|
# ffmpeg time/size 출력 특별 처리
|
||||||
|
# ffmpeg는 [download] X% 형식을 사용하지 않으므로 time으로 진행 상황 추정
|
||||||
|
if 'time=' in line:
|
||||||
|
ffmpeg_progress_count += 1
|
||||||
|
# 매 5번째 출력마다 UI 업데이트 (너무 자주 업데이트 방지)
|
||||||
|
if ffmpeg_progress_count % 5 == 0 and self.callback:
|
||||||
|
# time= 파싱
|
||||||
|
time_match = re.search(r'time=(?P<time>\d+:\d+:\d+\.\d+)', line)
|
||||||
|
speed_match = re.search(r'bitrate=\s*([\d\.]+\w+)', line)
|
||||||
|
|
||||||
|
time_str = time_match.group('time') if time_match else ""
|
||||||
|
bitrate = speed_match.group(1) if speed_match else ""
|
||||||
|
|
||||||
|
if self.start_time:
|
||||||
|
elapsed = time.time() - self.start_time
|
||||||
|
self.elapsed_time = self.format_time(elapsed)
|
||||||
|
|
||||||
|
# 비디오 시간 위치 표시 (시:분:초)
|
||||||
|
current_seconds = self.time_to_seconds(time_str)
|
||||||
|
if time_str:
|
||||||
|
# "00:05:30.45" -> "5분 30초"
|
||||||
|
parts = time_str.split(':')
|
||||||
|
hours = int(parts[0])
|
||||||
|
mins = int(parts[1])
|
||||||
|
secs = int(float(parts[2]))
|
||||||
|
if hours > 0:
|
||||||
|
video_time = f"{hours}시간 {mins}분"
|
||||||
|
else:
|
||||||
|
video_time = f"{mins}분 {secs}초"
|
||||||
|
else:
|
||||||
|
video_time = ""
|
||||||
|
|
||||||
|
self.current_speed = bitrate if bitrate else ""
|
||||||
|
|
||||||
|
# % 계산 (전체 길이를 알면 정확하게, 모르면 카운터 기반 99% 제한)
|
||||||
|
if self.total_duration_seconds > 0:
|
||||||
|
self.percent = (current_seconds / self.total_duration_seconds) * 100
|
||||||
|
self.percent = min(100.0, self.percent)
|
||||||
|
else:
|
||||||
|
self.percent = min(99.0, ffmpeg_progress_count)
|
||||||
|
|
||||||
|
logger.info(f"[ffmpeg progress] {self.percent:.1f}% time={video_time} bitrate={bitrate}")
|
||||||
|
self.callback(percent=int(self.percent), current=int(current_seconds), total=int(self.total_duration_seconds), speed=video_time, elapsed=self.elapsed_time)
|
||||||
|
continue
|
||||||
|
|
||||||
for prog_re in prog_patterns:
|
# 일반 [download] X% 형식 처리 (yt-dlp native 다운로더용)
|
||||||
|
for prog_re in prog_patterns[:2]: # 첫 두 패턴만 사용 (download 형식)
|
||||||
match = prog_re.search(line)
|
match = prog_re.search(line)
|
||||||
if match:
|
if match:
|
||||||
try:
|
try:
|
||||||
@@ -168,8 +295,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)
|
||||||
if self.callback:
|
if self.callback:
|
||||||
|
logger.info(f"[yt-dlp progress] Calling callback: {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)
|
||||||
except: pass
|
except Exception as cb_err:
|
||||||
|
logger.warning(f"Callback error: {cb_err}")
|
||||||
break # 한 패턴이 매칭되면 중단
|
break # 한 패턴이 매칭되면 중단
|
||||||
|
|
||||||
if 'error' in line.lower() or 'security' in line.lower() or 'unable' in line.lower():
|
if 'error' in line.lower() or 'security' in line.lower() or 'unable' in line.lower():
|
||||||
|
|||||||
@@ -456,6 +456,34 @@ class LogicAniLife(PluginModuleBase):
|
|||||||
)
|
)
|
||||||
return render_template("sample.html", title="%s - %s" % (P.package_name, sub))
|
return render_template("sample.html", title="%s - %s" % (P.package_name, sub))
|
||||||
|
|
||||||
|
def socketio_callback(self, refresh_type, data):
|
||||||
|
"""
|
||||||
|
socketio를 통해 클라이언트에 상태 업데이트 전송
|
||||||
|
refresh_type: 'add', 'status', 'last', 'list_refresh' 등
|
||||||
|
data: entity.as_dict() 데이터 또는 리스트 갱신용 빈 문자열
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from flaskfarm.lib.framework.init_main import socketio
|
||||||
|
|
||||||
|
# /package_name/module_name/queue 네임스페이스로 emit
|
||||||
|
namespace = f"/{P.package_name}/{self.name}/queue"
|
||||||
|
|
||||||
|
# 큐 페이지 소켓에 직접 emit
|
||||||
|
socketio.emit(refresh_type, data, namespace=namespace, broadcast=True)
|
||||||
|
|
||||||
|
# 진행 상태인 경우 /framework 네임스페이스로 전역 알림(옵션)
|
||||||
|
if refresh_type == "status" and isinstance(data, dict):
|
||||||
|
percent = data.get('percent', 0)
|
||||||
|
if percent > 0 and percent % 10 == 0: # 10% 단위로 전역 알림
|
||||||
|
notify_data = {
|
||||||
|
"type": "info",
|
||||||
|
"msg": f"[Anilife] 다운로드중 {percent}% - {data.get('filename', '')}",
|
||||||
|
}
|
||||||
|
socketio.emit("notify", notify_data, namespace="/framework", broadcast=True)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"socketio_callback error: {e}")
|
||||||
|
|
||||||
def process_ajax(self, sub, req):
|
def process_ajax(self, sub, req):
|
||||||
try:
|
try:
|
||||||
if sub == "analysis":
|
if sub == "analysis":
|
||||||
@@ -1204,11 +1232,64 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
|||||||
provider_html = None
|
provider_html = None
|
||||||
aldata_value = None
|
aldata_value = None
|
||||||
|
|
||||||
|
# Camoufox 설치 확인 및 자동 설치
|
||||||
|
def ensure_camoufox_installed():
|
||||||
|
"""Camoufox가 설치되어 있는지 확인하고, 없으면 자동 설치
|
||||||
|
|
||||||
|
Note: Docker 환경에서 import camoufox 시 trio/epoll 문제가 발생할 수 있으므로
|
||||||
|
실제 import 대신 importlib.util.find_spec으로 패키지 존재만 확인
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
# 패키지 존재 여부만 확인 (import 하지 않음)
|
||||||
|
if importlib.util.find_spec("camoufox") is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info("Camoufox not installed. Installing...")
|
||||||
|
try:
|
||||||
|
import subprocess as sp
|
||||||
|
|
||||||
|
# pip로 camoufox[geoip] 설치
|
||||||
|
pip_result = sp.run(
|
||||||
|
[sys.executable, "-m", "pip", "install", "camoufox[geoip]", "-q"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
if pip_result.returncode != 0:
|
||||||
|
logger.error(f"Failed to install camoufox: {pip_result.stderr}")
|
||||||
|
return False
|
||||||
|
logger.info("Camoufox package installed successfully")
|
||||||
|
|
||||||
|
# Camoufox 브라우저 바이너리 다운로드
|
||||||
|
logger.info("Downloading Camoufox browser binary...")
|
||||||
|
fetch_result = sp.run(
|
||||||
|
[sys.executable, "-m", "camoufox", "fetch"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300 # 브라우저 다운로드는 시간이 걸릴 수 있음
|
||||||
|
)
|
||||||
|
if fetch_result.returncode != 0:
|
||||||
|
logger.warning(f"Camoufox browser fetch warning: {fetch_result.stderr}")
|
||||||
|
# fetch 실패해도 이미 있을 수 있으므로 계속 진행
|
||||||
|
else:
|
||||||
|
logger.info("Camoufox browser binary installed successfully")
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as install_err:
|
||||||
|
logger.error(f"Failed to install Camoufox: {install_err}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Camoufox를 subprocess로 실행 (스텔스 Firefox - 봇 감지 우회)
|
# Camoufox를 subprocess로 실행 (스텔스 Firefox - 봇 감지 우회)
|
||||||
try:
|
try:
|
||||||
import subprocess
|
import subprocess
|
||||||
import json as json_module
|
import json as json_module
|
||||||
|
|
||||||
|
# Camoufox 설치 확인
|
||||||
|
if not ensure_camoufox_installed():
|
||||||
|
logger.error("Camoufox installation failed. Cannot proceed.")
|
||||||
|
return
|
||||||
|
|
||||||
# camoufox_anilife.py 스크립트 경로
|
# camoufox_anilife.py 스크립트 경로
|
||||||
script_path = os.path.join(os.path.dirname(__file__), "lib", "camoufox_anilife.py")
|
script_path = os.path.join(os.path.dirname(__file__), "lib", "camoufox_anilife.py")
|
||||||
|
|
||||||
|
|||||||
@@ -27,34 +27,68 @@
|
|||||||
const package_name = "{{arg['package_name'] }}";
|
const package_name = "{{arg['package_name'] }}";
|
||||||
const sub = "{{arg['sub'] }}";
|
const sub = "{{arg['sub'] }}";
|
||||||
|
|
||||||
function on_start() {
|
function on_start(silent = false) {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/' + package_name + '/ajax/' + sub + '/entity_list',
|
url: '/' + package_name + '/ajax/' + sub + '/entity_list',
|
||||||
type: "POST",
|
type: "POST",
|
||||||
cache: false,
|
cache: false,
|
||||||
data: {},
|
data: {},
|
||||||
dataType: "json",
|
dataType: "json",
|
||||||
|
global: !silent,
|
||||||
success: function (data) {
|
success: function (data) {
|
||||||
make_download_list(data)
|
// entity_list 응답을 처리
|
||||||
|
current_data = data;
|
||||||
|
|
||||||
|
// 목록 개수가 변했거나 데이터가 없을 때만 전체 갱신 (반짝임 방지)
|
||||||
|
const list_body = $("#list");
|
||||||
|
if (data.length == 0) {
|
||||||
|
list_body.html("<tr><td colspan='11'><h4>작업이 없습니다.</h4><td><tr>");
|
||||||
|
} else if (list_body.children().length !== data.length * 2) { // make_item이 행 2개를 생성하므로
|
||||||
|
str = ''
|
||||||
|
for (i in data) {
|
||||||
|
str += make_item(data[i]);
|
||||||
|
}
|
||||||
|
list_body.html(str);
|
||||||
|
} else {
|
||||||
|
// 개수가 같으면 각 항목의 상태만 보강 업데이트
|
||||||
|
for (i in data) {
|
||||||
|
status_html(data[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
const socket = io.connect(window.location.href);
|
const socket_url = window.location.protocol + "//" + document.domain + ":" + location.port + "/anime_downloader/anilife/queue";
|
||||||
|
console.log("Connecting to socket:", socket_url);
|
||||||
|
const socket = io.connect(socket_url);
|
||||||
|
|
||||||
{#socket = io.connect(window.location.protocol + "//" + document.domain + ":" + location.port + "/" + package_name + '/' + sub);#}
|
socket.on('connect', function() {
|
||||||
|
console.log('Socket connected to anilife queue!');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 이벤트 모니터링 (디버깅용)
|
||||||
|
socket.onAny((event, ...args) => {
|
||||||
|
console.log(`[Socket event: ${event}]`, args);
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('start', function (data) {
|
socket.on('start', function (data) {
|
||||||
on_start();
|
on_start();
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('list_refresh', function (data) {
|
socket.on('list_refresh', function (data) {
|
||||||
on_start()
|
on_start()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 3초마다 자동 새로고침 폴백 (인디케이터 없이 조용히)
|
||||||
|
setInterval(function() {
|
||||||
|
on_start(true);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
socket.on('status', function (data) {
|
socket.on('status', function (data) {
|
||||||
console.log(data);
|
console.log("Status update received:", data);
|
||||||
on_status(data)
|
status_html(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('on_start', function (data) {
|
socket.on('on_start', function (data) {
|
||||||
@@ -79,10 +113,6 @@
|
|||||||
button_html(data);
|
button_html(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('status', function (data) {
|
|
||||||
status_html(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.on('last', function (data) {
|
socket.on('last', function (data) {
|
||||||
status_html(data);
|
status_html(data);
|
||||||
button_html(data);
|
button_html(data);
|
||||||
@@ -194,6 +224,7 @@
|
|||||||
|
|
||||||
function status_html(data) {
|
function status_html(data) {
|
||||||
var progress = document.getElementById("progress_" + data.idx);
|
var progress = document.getElementById("progress_" + data.idx);
|
||||||
|
if (!progress) return;
|
||||||
progress.style.width = data.percent + '%';
|
progress.style.width = data.percent + '%';
|
||||||
progress.innerHTML = data.percent + '%';
|
progress.innerHTML = data.percent + '%';
|
||||||
progress.style.visibility = 'visible';
|
progress.style.visibility = 'visible';
|
||||||
|
|||||||
@@ -154,39 +154,25 @@
|
|||||||
|
|
||||||
// str += m_hr_black();
|
// str += m_hr_black();
|
||||||
str += "</div>"
|
str += "</div>"
|
||||||
|
|
||||||
|
// 에피소드 카드 그리드 레이아웃
|
||||||
|
str += '<div class="episode-list-container">';
|
||||||
for (i in data.episode) {
|
for (i in data.episode) {
|
||||||
str += m_row_start();
|
str += '<div class="episode-card">';
|
||||||
// tmp = '<img src="' + data.episode[i].image + '" class="img-fluid">'
|
str += '<div class="episode-thumb">';
|
||||||
// str += m_col(3, tmp)
|
str += '<span class="episode-num">' + (parseInt(i) + 1) + '화</span>';
|
||||||
tmp = "<strong>" + data.episode[i].title + "</strong><span>. </span>";
|
str += '</div>';
|
||||||
{#tmp += "<br>";#}
|
str += '<div class="episode-info">';
|
||||||
tmp += data.episode[i].filename + "<br><p></p>";
|
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
|
||||||
|
str += '<div class="episode-filename">' + data.episode[i].filename + '</div>';
|
||||||
tmp += '<div class="form-inline">';
|
str += '<div class="episode-actions">';
|
||||||
tmp +=
|
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">';
|
||||||
'<input id="checkbox_' +
|
str += m_button("add_queue_btn", "다운로드", [{key: "idx", value: i}]);
|
||||||
i +
|
str += '</div>';
|
||||||
'" name="checkbox_' +
|
str += '</div>';
|
||||||
i +
|
str += '</div>';
|
||||||
'" type="checkbox" checked data-toggle="toggle" data-on="선 택" data-off="-" data-onstyle="success" data-offstyle="danger" data-size="small"> ';
|
|
||||||
// tmp += m_button('add_queue_btn', '다운로드 추가', [{'key': 'code', 'value': data.episode[i].code}])
|
|
||||||
tmp += m_button("add_queue_btn", "다운로드 추가", [
|
|
||||||
{key: "idx", value: i},
|
|
||||||
]);
|
|
||||||
tmp += j_button('insert_download_btn', '다운로드 추가', {
|
|
||||||
code: data.episode[i]._id,
|
|
||||||
});
|
|
||||||
tmp += j_button(
|
|
||||||
'force_insert_download_btn',
|
|
||||||
'다운로드 추가 (DB무시)',
|
|
||||||
{code: data.episode[i]._id}
|
|
||||||
);
|
|
||||||
// tmp += '<button id="play_video" name="play_video" class="btn btn-sm btn-outline-primary" data-idx="'+i+'">바로보기</button>';
|
|
||||||
tmp += "</div>";
|
|
||||||
str += m_col(12, tmp);
|
|
||||||
str += m_row_end();
|
|
||||||
if (i != data.length - 1) str += m_hr(0);
|
|
||||||
}
|
}
|
||||||
|
str += '</div>';
|
||||||
document.getElementById("episode_list").innerHTML = str;
|
document.getElementById("episode_list").innerHTML = str;
|
||||||
$('input[id^="checkbox_"]').bootstrapToggle();
|
$('input[id^="checkbox_"]').bootstrapToggle();
|
||||||
}
|
}
|
||||||
@@ -451,6 +437,105 @@
|
|||||||
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 {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 카드 */
|
||||||
|
.episode-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(51, 65, 85, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||||
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(96, 165, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 썸네일 */
|
||||||
|
.episode-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 번호 배지 */
|
||||||
|
.episode-num {
|
||||||
|
color: white;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 정보 */
|
||||||
|
.episode-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-filename {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 액션 버튼 */
|
||||||
|
.episode-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .toggle {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.episode-list-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#airing_list {
|
#airing_list {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,32 +145,31 @@
|
|||||||
str += "</div>"
|
str += "</div>"
|
||||||
{#str += m_hr_black();#}
|
{#str += m_hr_black();#}
|
||||||
|
|
||||||
|
// 에피소드 카드 그리드 레이아웃
|
||||||
|
str += '<div class="episode-list-container">';
|
||||||
for (let i in data.episode) {
|
for (let i in data.episode) {
|
||||||
str += m_row_start();
|
let epThumbSrc = data.episode[i].thumbnail || '';
|
||||||
tmp = '';
|
|
||||||
if (data.episode[i].thumbnail)
|
str += '<div class="episode-card">';
|
||||||
tmp = '<img src="' + data.episode[i].thumbnail + '" class="img-fluid">'
|
str += '<div class="episode-thumb">';
|
||||||
str += m_col(3, tmp)
|
if (epThumbSrc) {
|
||||||
tmp = '<strong>' + data.episode[i].title + '</strong>';
|
str += '<img src="' + epThumbSrc + '" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||||
tmp += '<br>';
|
}
|
||||||
tmp += data.episode[i].date + '<br>';
|
str += '<span class="episode-num">' + (parseInt(i) + 1) + '화</span>';
|
||||||
|
str += '</div>';
|
||||||
tmp += '<div class="form-inline">'
|
str += '<div class="episode-info">';
|
||||||
tmp += '<input id="checkbox_' + i + '" name="checkbox_' + i + '" type="checkbox" checked data-toggle="toggle" data-on="선 택" data-off="-" data-onstyle="success" data-offstyle="danger" data-size="small"> '
|
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
|
||||||
tmp += m_button('add_queue_btn', '다운로드 추가', [{'key': 'idx', 'value': i}])
|
if (data.episode[i].date) {
|
||||||
tmp += j_button('insert_download_btn', '다운로드 추가', {
|
str += '<div class="episode-date">' + data.episode[i].date + '</div>';
|
||||||
code: data.episode[i]._id,
|
}
|
||||||
});
|
str += '<div class="episode-actions">';
|
||||||
tmp += j_button(
|
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">';
|
||||||
'force_insert_download_btn',
|
str += m_button('add_queue_btn', '다운로드', [{'key': 'idx', 'value': i}]);
|
||||||
'다운로드 추가 (DB무시)',
|
str += '</div>';
|
||||||
{code: data.episode[i]._id}
|
str += '</div>';
|
||||||
);
|
str += '</div>';
|
||||||
tmp += '</div>'
|
|
||||||
str += m_col(9, tmp)
|
|
||||||
str += m_row_end();
|
|
||||||
{#if (i != data.length - 1) str += m_hr(0);#}
|
|
||||||
}
|
}
|
||||||
|
str += '</div>';
|
||||||
document.getElementById("episode_list").innerHTML = str;
|
document.getElementById("episode_list").innerHTML = str;
|
||||||
$('input[id^="checkbox_"]').bootstrapToggle()
|
$('input[id^="checkbox_"]').bootstrapToggle()
|
||||||
}
|
}
|
||||||
@@ -466,6 +465,114 @@
|
|||||||
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 {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 카드 */
|
||||||
|
.episode-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card:hover {
|
||||||
|
background: linear-gradient(135deg, rgba(51, 65, 85, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%);
|
||||||
|
border-color: rgba(96, 165, 250, 0.5);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(96, 165, 250, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 썸네일 */
|
||||||
|
.episode-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 56px;
|
||||||
|
min-width: 56px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, rgba(0, 0, 0, 0.3) 0%, rgba(30, 41, 59, 0.5) 100%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 번호 배지 */
|
||||||
|
.episode-num {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2px;
|
||||||
|
left: 2px;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);
|
||||||
|
color: white;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 정보 */
|
||||||
|
.episode-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.3;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-date {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 에피소드 액션 버튼 */
|
||||||
|
.episode-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-actions .toggle {
|
||||||
|
transform: scale(0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.episode-list-container {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#airing_list {
|
#airing_list {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user