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:
2025-12-28 23:22:36 +09:00
parent 028562ea18
commit bb4f2797c1
7 changed files with 524 additions and 78 deletions

View File

@@ -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:

View File

@@ -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"] = ""

View File

@@ -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():

View File

@@ -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")

View File

@@ -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';

View File

@@ -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">&nbsp;&nbsp;&nbsp;&nbsp;';
// 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;
} }

View File

@@ -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">&nbsp;&nbsp;&nbsp;&nbsp;' 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;
} }