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:
|
||||
# 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()
|
||||
|
||||
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["start_time"] = self.created_time
|
||||
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["duration_str"] = ""
|
||||
tmp["duration"] = ""
|
||||
|
||||
@@ -26,6 +26,7 @@ class YtdlpDownloader:
|
||||
self.cancelled = False
|
||||
self.process = None
|
||||
self.error_output = [] # 에러 메시지 저장
|
||||
self.total_duration_seconds = 0 # 전체 영상 길이 (초)
|
||||
|
||||
# 속도 및 시간 계산용
|
||||
self.start_time = None
|
||||
@@ -59,9 +60,53 @@ class YtdlpDownloader:
|
||||
else:
|
||||
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):
|
||||
"""yt-dlp CLI를 통한 브라우저 흉내(Impersonate) 방식 다운로드 수행"""
|
||||
try:
|
||||
# yt-dlp 설치 확인
|
||||
if not self._ensure_ytdlp_installed():
|
||||
return False, "yt-dlp installation failed"
|
||||
|
||||
self.start_time = time.time()
|
||||
|
||||
# 출력 디렉토리 생성
|
||||
@@ -76,14 +121,26 @@ class YtdlpDownloader:
|
||||
concat_char = '&' if '?' in current_url else '?'
|
||||
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 = [
|
||||
'yt-dlp',
|
||||
'--newline',
|
||||
'--no-playlist',
|
||||
'--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',
|
||||
'--progress',
|
||||
'--verbose', # 디버깅용 상세 로그
|
||||
@@ -121,6 +178,17 @@ class YtdlpDownloader:
|
||||
cmd += ['--referer', 'https://cdndania.com/']
|
||||
cmd += ['--add-header', 'Origin:https://cdndania.com']
|
||||
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)
|
||||
|
||||
@@ -136,13 +204,23 @@ class YtdlpDownloader:
|
||||
)
|
||||
|
||||
# 여러 진행률 형식 매칭
|
||||
# [download] 10.5% of ~100.00MiB at 2.45MiB/s
|
||||
# [download] 10.5% of 100.00MiB at 2.45MiB/s ETA 00:30
|
||||
# [download] 100% of 100.00MiB
|
||||
# yt-dlp native: [download] 10.5% of ~100.00MiB at 2.45MiB/s
|
||||
# yt-dlp native: [download] 10.5% of 100.00MiB at 2.45MiB/s ETA 00:30
|
||||
# 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 = [
|
||||
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\.]+)%'),
|
||||
# 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:
|
||||
if self.cancelled:
|
||||
@@ -152,11 +230,60 @@ class YtdlpDownloader:
|
||||
line = line.strip()
|
||||
if not line: continue
|
||||
|
||||
# 디버깅: 모든 출력 로깅 (너무 많으면 주석 해제)
|
||||
if '[download]' in line or 'fragment' in line.lower():
|
||||
logger.debug(f"yt-dlp: {line}")
|
||||
# ffmpeg Duration 파싱 (전체 길이 확인용)
|
||||
if 'Duration:' in line and self.total_duration_seconds == 0:
|
||||
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)
|
||||
if match:
|
||||
try:
|
||||
@@ -168,8 +295,10 @@ class YtdlpDownloader:
|
||||
elapsed = time.time() - self.start_time
|
||||
self.elapsed_time = self.format_time(elapsed)
|
||||
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)
|
||||
except: pass
|
||||
except Exception as cb_err:
|
||||
logger.warning(f"Callback error: {cb_err}")
|
||||
break # 한 패턴이 매칭되면 중단
|
||||
|
||||
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))
|
||||
|
||||
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):
|
||||
try:
|
||||
if sub == "analysis":
|
||||
@@ -1204,11 +1232,64 @@ class AniLifeQueueEntity(FfmpegQueueEntity):
|
||||
provider_html = 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 - 봇 감지 우회)
|
||||
try:
|
||||
import subprocess
|
||||
import json as json_module
|
||||
|
||||
# Camoufox 설치 확인
|
||||
if not ensure_camoufox_installed():
|
||||
logger.error("Camoufox installation failed. Cannot proceed.")
|
||||
return
|
||||
|
||||
# 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 sub = "{{arg['sub'] }}";
|
||||
|
||||
function on_start() {
|
||||
function on_start(silent = false) {
|
||||
$.ajax({
|
||||
url: '/' + package_name + '/ajax/' + sub + '/entity_list',
|
||||
type: "POST",
|
||||
cache: false,
|
||||
data: {},
|
||||
dataType: "json",
|
||||
global: !silent,
|
||||
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 () {
|
||||
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) {
|
||||
on_start();
|
||||
});
|
||||
|
||||
socket.on('list_refresh', function (data) {
|
||||
on_start()
|
||||
});
|
||||
|
||||
// 3초마다 자동 새로고침 폴백 (인디케이터 없이 조용히)
|
||||
setInterval(function() {
|
||||
on_start(true);
|
||||
}, 3000);
|
||||
|
||||
socket.on('status', function (data) {
|
||||
console.log(data);
|
||||
on_status(data)
|
||||
console.log("Status update received:", data);
|
||||
status_html(data);
|
||||
});
|
||||
|
||||
socket.on('on_start', function (data) {
|
||||
@@ -79,10 +113,6 @@
|
||||
button_html(data);
|
||||
});
|
||||
|
||||
socket.on('status', function (data) {
|
||||
status_html(data);
|
||||
});
|
||||
|
||||
socket.on('last', function (data) {
|
||||
status_html(data);
|
||||
button_html(data);
|
||||
@@ -194,6 +224,7 @@
|
||||
|
||||
function status_html(data) {
|
||||
var progress = document.getElementById("progress_" + data.idx);
|
||||
if (!progress) return;
|
||||
progress.style.width = data.percent + '%';
|
||||
progress.innerHTML = data.percent + '%';
|
||||
progress.style.visibility = 'visible';
|
||||
|
||||
@@ -154,39 +154,25 @@
|
||||
|
||||
// str += m_hr_black();
|
||||
str += "</div>"
|
||||
|
||||
// 에피소드 카드 그리드 레이아웃
|
||||
str += '<div class="episode-list-container">';
|
||||
for (i in data.episode) {
|
||||
str += m_row_start();
|
||||
// tmp = '<img src="' + data.episode[i].image + '" class="img-fluid">'
|
||||
// str += m_col(3, tmp)
|
||||
tmp = "<strong>" + data.episode[i].title + "</strong><span>. </span>";
|
||||
{#tmp += "<br>";#}
|
||||
tmp += data.episode[i].filename + "<br><p></p>";
|
||||
|
||||
tmp += '<div class="form-inline">';
|
||||
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"> ';
|
||||
// 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 class="episode-card">';
|
||||
str += '<div class="episode-thumb">';
|
||||
str += '<span class="episode-num">' + (parseInt(i) + 1) + '화</span>';
|
||||
str += '</div>';
|
||||
str += '<div class="episode-info">';
|
||||
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
|
||||
str += '<div class="episode-filename">' + data.episode[i].filename + '</div>';
|
||||
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 += m_button("add_queue_btn", "다운로드", [{key: "idx", value: i}]);
|
||||
str += '</div>';
|
||||
str += '</div>';
|
||||
str += '</div>';
|
||||
}
|
||||
str += '</div>';
|
||||
document.getElementById("episode_list").innerHTML = str;
|
||||
$('input[id^="checkbox_"]').bootstrapToggle();
|
||||
}
|
||||
@@ -451,6 +437,105 @@
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -145,32 +145,31 @@
|
||||
str += "</div>"
|
||||
{#str += m_hr_black();#}
|
||||
|
||||
// 에피소드 카드 그리드 레이아웃
|
||||
str += '<div class="episode-list-container">';
|
||||
for (let i in data.episode) {
|
||||
str += m_row_start();
|
||||
tmp = '';
|
||||
if (data.episode[i].thumbnail)
|
||||
tmp = '<img src="' + data.episode[i].thumbnail + '" class="img-fluid">'
|
||||
str += m_col(3, tmp)
|
||||
tmp = '<strong>' + data.episode[i].title + '</strong>';
|
||||
tmp += '<br>';
|
||||
tmp += data.episode[i].date + '<br>';
|
||||
|
||||
tmp += '<div class="form-inline">'
|
||||
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"> '
|
||||
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 += '</div>'
|
||||
str += m_col(9, tmp)
|
||||
str += m_row_end();
|
||||
{#if (i != data.length - 1) str += m_hr(0);#}
|
||||
let epThumbSrc = data.episode[i].thumbnail || '';
|
||||
|
||||
str += '<div class="episode-card">';
|
||||
str += '<div class="episode-thumb">';
|
||||
if (epThumbSrc) {
|
||||
str += '<img src="' + epThumbSrc + '" loading="lazy" onerror="this.style.display=\'none\'">';
|
||||
}
|
||||
str += '<span class="episode-num">' + (parseInt(i) + 1) + '화</span>';
|
||||
str += '</div>';
|
||||
str += '<div class="episode-info">';
|
||||
str += '<div class="episode-title">' + data.episode[i].title + '</div>';
|
||||
if (data.episode[i].date) {
|
||||
str += '<div class="episode-date">' + data.episode[i].date + '</div>';
|
||||
}
|
||||
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 += m_button('add_queue_btn', '다운로드', [{'key': 'idx', 'value': i}]);
|
||||
str += '</div>';
|
||||
str += '</div>';
|
||||
str += '</div>';
|
||||
}
|
||||
str += '</div>';
|
||||
document.getElementById("episode_list").innerHTML = str;
|
||||
$('input[id^="checkbox_"]').bootstrapToggle()
|
||||
}
|
||||
@@ -466,6 +465,114 @@
|
||||
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 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user